Compare commits

..

No commits in common. "develop" and "release/5.2.0" have entirely different histories.

2746 changed files with 150025 additions and 174164 deletions

View File

@ -1,6 +1,5 @@
{ {
"extends": "standard-with-typescript", "extends": "standard-with-typescript",
"root": true,
"rules": { "rules": {
"eol-last": [ "eol-last": [
"error", "error",
@ -127,20 +126,18 @@
] ]
}, },
"ignorePatterns": [ "ignorePatterns": [
"node_modules", "node_modules/",
"packages/tests/fixtures", "server/tests/fixtures"
"apps/**/dist",
"packages/**/dist",
"server/dist",
"packages/types-generator/tests",
"*.js",
"/client",
"/dist"
], ],
"parserOptions": { "parserOptions": {
"EXPERIMENTAL_useSourceOfProjectReferenceRedirect": true,
"project": [ "project": [
"./tsconfig.eslint.json" "./tsconfig.json",
], "./shared/tsconfig.json",
"EXPERIMENTAL_useSourceOfProjectReferenceRedirect": true "./scripts/tsconfig.json",
"./server/tsconfig.json",
"./server/tools/tsconfig.json",
"./packages/peertube-runner/tsconfig.json"
]
} }
} }

View File

@ -53,25 +53,13 @@ interested in, user interface, design, decentralized architecture...
You can help to write the documentation of the REST API, code, architecture, You can help to write the documentation of the REST API, code, architecture,
demonstrations. demonstrations.
### User documentation For the REST API you can see the documentation in [/support/doc/api](https://github.com/Chocobozzz/PeerTube/tree/develop/support/doc/api) directory.
Then, you can just open the `openapi.yaml` file in a special editor like [http://editor.swagger.io/](http://editor.swagger.io/) to easily see and edit the documentation. You can also use [redoc-cli](https://github.com/Redocly/redoc/blob/master/cli/README.md) and run `redoc-cli serve --watch support/doc/api/openapi.yaml` to see the final result.
The official user documentation is available on https://docs.joinpeertube.org/
You can update it by writing markdown files in the following repository: https://framagit.org/framasoft/peertube/documentation/
### REST API documentation
The [REST API documentation](https://docs.joinpeertube.org/api-rest-reference.html) is generated from `support/doc/api/openapi.yaml` file.
To quickly get a preview of your changes, you can generate the documentation *on the fly* using the following command:
```
npx @redocly/cli preview-docs ./support/doc/api/openapi.yaml
```
Some hints: Some hints:
* Routes are defined in [/server/core/controllers/](https://github.com/Chocobozzz/PeerTube/tree/develop/server/core/controllers) directory * Routes are defined in [/server/controllers/](https://github.com/Chocobozzz/PeerTube/tree/develop/server/controllers) directory
* Parameters validators are defined in [/server/core/middlewares/validators](https://github.com/Chocobozzz/PeerTube/tree/develop/server/core/middlewares/validators) directory * Parameters validators are defined in [/server/middlewares/validators](https://github.com/Chocobozzz/PeerTube/tree/develop/server/middlewares/validators) directory
* Models sent/received by the controllers are defined in [/packages/models](https://github.com/Chocobozzz/PeerTube/tree/develop/packages/models) directory * Models sent/received by the controllers are defined in [/shared/models](https://github.com/Chocobozzz/PeerTube/tree/develop/shared/models) directory
## Improve the website ## Improve the website
@ -254,6 +242,15 @@ To test emails with PeerTube:
* Run [mailslurper](http://mailslurper.com/) * Run [mailslurper](http://mailslurper.com/)
* Run PeerTube using mailslurper SMTP port: `NODE_CONFIG='{ "smtp": { "hostname": "localhost", "port": 2500, "tls": false } }' NODE_ENV=dev node dist/server` * Run PeerTube using mailslurper SMTP port: `NODE_CONFIG='{ "smtp": { "hostname": "localhost", "port": 2500, "tls": false } }' NODE_ENV=dev node dist/server`
### OpenAPI documentation
The [REST API documentation](https://docs.joinpeertube.org/api-rest-reference.html) is generated from `support/doc/api/openapi.yaml` file.
To quickly get a preview of your changes, you can generate the documentation *on the fly* using the following command:
```
npx @redocly/cli preview-docs ./support/doc/api/openapi.yaml
```
### Environment variables ### Environment variables
PeerTube can be configured using environment variables. PeerTube can be configured using environment variables.

View File

@ -32,12 +32,4 @@ runs:
- name: Install peertube runner dependencies - name: Install peertube runner dependencies
shell: bash shell: bash
run: cd apps/peertube-runner && yarn install --frozen-lockfile run: cd packages/peertube-runner && yarn install --frozen-lockfile
- name: Install peertube CLI dependencies
shell: bash
run: cd apps/peertube-cli && yarn install --frozen-lockfile
- name: Display PeerTube dependencies
shell: bash
run: ls -l node_modules/@peertube

View File

@ -35,14 +35,13 @@ jobs:
- uses: './.github/actions/reusable-prepare-peertube-build' - uses: './.github/actions/reusable-prepare-peertube-build'
with: with:
node-version: '18.x' node-version: '16.x'
- uses: './.github/actions/reusable-prepare-peertube-run' - uses: './.github/actions/reusable-prepare-peertube-run'
- name: Build - name: Build
run: | run: |
startClient=`date +%s` startClient=`date +%s`
npm run build:server
npm run build:client npm run build:client
endClient=`date +%s` endClient=`date +%s`
clientBuildTime=$((endClient-startClient)) clientBuildTime=$((endClient-startClient))
@ -72,7 +71,7 @@ jobs:
- name: Run benchmark - name: Run benchmark
run: | run: |
npm run benchmark-server -- -o benchmark.json node dist/scripts/benchmark.js -o benchmark.json
- name: Display result - name: Display result
run: | run: |

View File

@ -29,7 +29,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
language: [ 'javascript-typescript' ] language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://git.io/codeql-language-support # Learn more about CodeQL language support at https://git.io/codeql-language-support
@ -39,7 +39,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v2 uses: github/codeql-action/init@v1
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
config-file: ./.github/workflows/codeql/codeql-config.yml config-file: ./.github/workflows/codeql/codeql-config.yml
@ -51,7 +51,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v2 uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
@ -65,4 +65,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2 uses: github/codeql-action/analyze@v1

View File

@ -1,6 +1,4 @@
name: "PeerTube CodeQL config" name: "PeerTube CodeQL config"
paths-ignore: paths-ignore:
- packages/tests - server/tests
- packages/server-commands
- packages/types-generator

View File

@ -24,8 +24,8 @@ jobs:
# FIXME: https://github.com/actions/checkout/issues/290 # FIXME: https://github.com/actions/checkout/issues/290
git fetch --force --tags git fetch --force --tags
one="{ \"file\": \"./support/docker/production/Dockerfile.bookworm\", \"ref\": \"develop\", \"tags\": \"chocobozzz/peertube:develop-bookworm\" }" one="{ \"file\": \"./support/docker/production/Dockerfile.bullseye\", \"ref\": \"develop\", \"tags\": \"chocobozzz/peertube:develop-bullseye\" }"
two="{ \"file\": \"./support/docker/production/Dockerfile.bookworm\", \"ref\": \"master\", \"tags\": \"chocobozzz/peertube:production-bookworm,chocobozzz/peertube:$(git describe --abbrev=0)-bookworm\" }" two="{ \"file\": \"./support/docker/production/Dockerfile.bullseye\", \"ref\": \"master\", \"tags\": \"chocobozzz/peertube:production-bullseye,chocobozzz/peertube:$(git describe --abbrev=0)-bullseye\" }"
three="{ \"file\": \"./support/docker/production/Dockerfile.nginx\", \"ref\": \"master\", \"tags\": \"chocobozzz/peertube-webserver:latest\" }" three="{ \"file\": \"./support/docker/production/Dockerfile.nginx\", \"ref\": \"master\", \"tags\": \"chocobozzz/peertube-webserver:latest\" }"
matrix="[$one,$two,$three]" matrix="[$one,$two,$three]"

View File

@ -18,7 +18,7 @@ jobs:
- uses: './.github/actions/reusable-prepare-peertube-build' - uses: './.github/actions/reusable-prepare-peertube-build'
with: with:
node-version: '18.x' node-version: '16.x'
- name: Build - name: Build
run: npm run nightly run: npm run nightly

View File

@ -22,7 +22,7 @@ jobs:
- uses: './.github/actions/reusable-prepare-peertube-build' - uses: './.github/actions/reusable-prepare-peertube-build'
with: with:
node-version: '18.x' node-version: '16.x'
- name: Angular bundlewatch - name: Angular bundlewatch
uses: jackyef/bundlewatch-gh-action@master uses: jackyef/bundlewatch-gh-action@master
@ -36,12 +36,12 @@ jobs:
run: | run: |
wget "https://github.com/boyter/scc/releases/download/v3.0.0/scc-3.0.0-x86_64-unknown-linux.zip" wget "https://github.com/boyter/scc/releases/download/v3.0.0/scc-3.0.0-x86_64-unknown-linux.zip"
unzip "scc-3.0.0-x86_64-unknown-linux.zip" unzip "scc-3.0.0-x86_64-unknown-linux.zip"
./scc --format=json --exclude-dir .git,node_modules,client/node_modules,client/dist,dist,yarn.lock,client/yarn.lock,client/src/locale,test1,test2,test3,client/src/assets/images,config,storage,packages/tests/fixtures,support/openapi,.idea,.vscode,docker-volume,ffmpeg-3,ffmpeg-4 > ./scc.json ./scc --format=json --exclude-dir .git,node_modules,client/node_modules,client/dist,dist,yarn.lock,client/yarn.lock,client/src/locale,test1,test2,test3,client/src/assets/images,config,storage,server/tests/fixtures,support/openapi,.idea,.vscode,docker-volume,ffmpeg-3,ffmpeg-4 > ./scc.json
- name: PeerTube client stats - name: PeerTube client stats
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
run: | run: |
npm run client:build-stats > client-build-stats.json node dist/scripts/client-build-stats.js > client-build-stats.json
- name: PeerTube client lighthouse report - name: PeerTube client lighthouse report
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'

View File

@ -46,7 +46,6 @@ jobs:
PGHOST: localhost PGHOST: localhost
NODE_PENDING_JOB_WAIT: 250 NODE_PENDING_JOB_WAIT: 250
ENABLE_OBJECT_STORAGE_TESTS: true ENABLE_OBJECT_STORAGE_TESTS: true
ENABLE_FFMPEG_THUMBNAIL_PIXEL_COMPARISON_TESTS: true
OBJECT_STORAGE_SCALEWAY_KEY_ID: ${{ secrets.OBJECT_STORAGE_SCALEWAY_KEY_ID }} OBJECT_STORAGE_SCALEWAY_KEY_ID: ${{ secrets.OBJECT_STORAGE_SCALEWAY_KEY_ID }}
OBJECT_STORAGE_SCALEWAY_ACCESS_KEY: ${{ secrets.OBJECT_STORAGE_SCALEWAY_ACCESS_KEY }} OBJECT_STORAGE_SCALEWAY_ACCESS_KEY: ${{ secrets.OBJECT_STORAGE_SCALEWAY_ACCESS_KEY }}
YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN: ${{ secrets.GITHUB_TOKEN }} YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -56,7 +55,7 @@ jobs:
- uses: './.github/actions/reusable-prepare-peertube-build' - uses: './.github/actions/reusable-prepare-peertube-build'
with: with:
node-version: '18.x' node-version: '16.x'
- uses: './.github/actions/reusable-prepare-peertube-run' - uses: './.github/actions/reusable-prepare-peertube-run'

17
.gitignore vendored
View File

@ -1,8 +1,8 @@
# NPM instalation # NPM instalation
node_modules /node_modules/
/server/tools/node_modules
*npm-debug.log *npm-debug.log
yarn-error.log yarn-error.log
.yarn
# Testing # Testing
/test1/ /test1/
@ -11,8 +11,8 @@ yarn-error.log
/test4/ /test4/
/test5/ /test5/
/test6/ /test6/
/packages/tests/fixtures/video_high_bitrate_1080p.mp4 /server/tests/fixtures/video_high_bitrate_1080p.mp4
/packages/tests/fixtures/video_59fps.mp4 /server/tests/fixtures/video_59fps.mp4
# Production # Production
/storage /storage
@ -23,7 +23,6 @@ yarn-error.log
/ffmpeg-4/ /ffmpeg-4/
/thumbnails/ /thumbnails/
/torrents/ /torrents/
/web-videos/
/videos/ /videos/
/previews/ /previews/
/logs/ /logs/
@ -49,14 +48,12 @@ yarn-error.log
/*.tar.xz /*.tar.xz
/*.asc /*.asc
*.DS_Store *.DS_Store
/server/tools/import-mediacore.ts
/docker-volume/ /docker-volume/
/init.mp4 /init.mp4
# TypeScript # TypeScript
*.tsbuildinfo *.tsbuildinfo
# EsLint # Packages
.eslintcache /packages/types/dist/
# Compiled output
dist

View File

@ -1,10 +0,0 @@
process.env.ESBK_TSCONFIG_PATH = './packages/tests/tsconfig.json'
module.exports = {
"node-option": [
"loader=tsx",
"no-warnings",
"conditions=peertube:tsx"
],
"timeout": 30000
}

View File

@ -1,190 +1,5 @@
# Changelog # Changelog
## v6.0.1
### IMPORTANT NOTES
* If you upgrade from PeerTube **< v6.0.0**, please follow v6.0.0 IMPORTANT NOTES
* We've made some modifications in v6.0.0 IMPORTANT NOTES, so if you upgrade from PeerTube v6.0.0:
* Ensure `location = /api/v1/videos/upload-resumable {` has been replaced by `location ~ ^/api/v1/videos/(upload-resumable|([^/]+/source/replace-resumable))$ {` in your nginx configuration
* Ensure you updated `storage.web_videos` configuration value to use `web-videos/` directory name
* Ensure your directory name on filesystem is the same as `storage.web_videos` configuration value: directory on filesystem must be renamed from `videos/` to `web-videos/` to represent the value of `storage.web_videos`
### Bug fixes
* Fix CPU going to 100% on odd cpu count
* Increase storyboard generation job TTL
* Add missing `generate-video-storyboard` job type in admin jobs list
* Regenerate storyboard after studio job
## v6.0.0
### IMPORTANT NOTES
We have many important notes in this release. We know it's a pain for sysadmin, but consider each one as a major step forward for PeerTube quality!
#### Sysadmins important notes
* Remove NodeJS 16 support (see https://nodejs.org/fr/blog/announcements/nodejs16-eol):
* Please upgrade to NodeJS 18 before upgrading PeerTube
* If you use NodeSource repository, you may have to migrate to their new repository: https://github.com/nodesource/distributions/wiki/How-to-migrate-to-the-new-repository
* Check in `production.yaml` that you use `127.0.0.1` instead of `localhost` for `listen.hostname`, `database.hostname` and `redis.hostname` as Node 18 favours IPv6 for `localhost` resolution
* Remove WebTorrent support in player:
* "WebTorrent videos" are renamed to "Web Video". The video format is the same, we just stop to use P2P for these videos
* There is no "Auto" quality anymore for Web Videos. The viewer has to explicitly choose the video resolution
* We still use P2P with the HLS player, which is the recommended transcoding format since several versions
* See https://github.com/Chocobozzz/PeerTube/issues/5465 for more information
* Configuration key that you must update in your `production.yaml` if not automatically done by your upgrade script:
* `storage.videos` must be **renamed** to `storage.web_videos`: https://github.com/Chocobozzz/PeerTube/blob/develop/config/production.yaml.example#L151
* Configuration value of `storage.web_videos` must have the directory name to be **changed** from `videos/` to `web-videos/`: https://github.com/Chocobozzz/PeerTube/blob/develop/config/production.yaml.example#L151
* Directory on filesystem must be **renamed** from `videos/` to `web-videos/` to represent the value of `storage.web_videos`
* Classic installation: `sudo -u peertube mv '/var/www/peertube/storage/videos/' '/var/www/peertube/storage/web-videos/'`
* Docker installation: `mv '/path-to-docker-installation/docker-volume/data/videos/' '/path-to-docker-installation/docker-volume/data/web-videos/'`
* `transcoding.webtorrent` must be **renamed** to `transcoding.web_videos`: https://github.com/Chocobozzz/PeerTube/blob/develop/config/production.yaml.example#L532
* `object_storage.videos` must be **renamed** to `object_storage.web_videos`. The value of `object_storage.web_videos.bucket_name` doesn't need to be changed: https://github.com/Chocobozzz/PeerTube/blob/develop/config/production.yaml.example#L223
* `storage.storyboards` must be **added**: https://github.com/Chocobozzz/PeerTube/blob/develop/config/production.yaml.example#L157
* PeerTube Docker image now uses `bookworm`. `chocobozzz/peertube:production-bullseye` needs to be replaced by `chocobozzz/peertube:production-bookworm`
* Env configuration that your must update if you use Docker:
* `PEERTUBE_TRANSCODING_WEBTORRENT_ENABLED` must be **renamed** to `PEERTUBE_TRANSCODING_WEB_VIDEOS_ENABLED`
* `PEERTUBE_OBJECT_STORAGE_VIDEOS_BUCKET_NAME` must be **renamed** to `PEERTUBE_OBJECT_STORAGE_WEB_VIDEOS_BUCKET_NAME`
* `PEERTUBE_OBJECT_STORAGE_VIDEOS_PREFIX` must be **renamed** to `PEERTUBE_OBJECT_STORAGE_WEB_VIDEOS_PREFIX`
* `PEERTUBE_OBJECT_STORAGE_VIDEOS_BASE_URL` must be **renamed** to `PEERTUBE_OBJECT_STORAGE_WEB_VIDEOS_BASE_URL`
* You must update nginx configuration: https://github.com/Chocobozzz/PeerTube/blob/develop/support/nginx/peertube
* `location ~ ^/static/(thumbnails|avatars)/ {` block must be removed
* `location = /api/v1/videos/upload-resumable {` must be updated to `location ~ ^/api/v1/videos/(upload-resumable|([^/]+/source/replace-resumable))$ {`
* `location ~ ^(/static/(webseed|streaming-playlists)/private/)|^/download {` must be updated to `location ~ ^(/static/(webseed|web-videos|streaming-playlists)/private/)|^/download {`
* `location ~ ^/static/(webseed|redundancy|streaming-playlists)/ {` must be updated to `location ~ ^/static/(webseed|web-videos|redundancy|streaming-playlists)/ {`
* Tracing requires `--experimental-loader=@opentelemetry/instrumentation/hook.mjs` node option: https://github.com/Chocobozzz/PeerTube/blob/develop/config/production.yaml.example#L264
#### Developers important notes
* REST API breaking changes:
* Removed `webtorrentEnabled` from user response (deprecated since 4.1 in favour of `p2pEnabled`)
* Removed `avatar` and `banner` fields from account/channel responses (deprecated since 4.2 in favour of `avatars` and `banners`)
* Removed `filter` query when listing videos (deprecated since 4.0 in favour of `isLocal` and `include`)
* Deprecate `/api/v1/videos/:id/webtorrent` video file routes in favour of `/api/v1/videos/:id/web-videos` routes
* Deprecate `hasWebtorrentFiles` body video filter in favour of `hasWebVideoFiles` when listing videos
* Deprecate `webtorrent` `transcodingType` in favour of `web-video` in `/api/v1/videos/{id}/transcoding` route
* `currentTime` is now required to notify the user is watching the video using `/api/v1/videos/{id}/views` (introduced in 4.2)
* Static server paths breaking changes:
* `/static/webseed/...` is deprecated in favour of `/static/web-videos/...`
* `/object-storage-proxy/webseed/...` is deprecated in favour of `/object-storage-proxy/web-videos/...`
* `/static/thumbnails/...` is deprecated in favour of `/static/lazy-thumbnails/...`
* Plugin API breaking changes:
* Deprecated `webtorrent` key in `getFiles()` helper result. Use `webVideo` instead
### CLI tools
* Removed unmaintained `peertube-import-videos` (also aliased as `peertube import-videos` or `peertube import`) script
* PeerTube remote CLI is much more simpler to install using NPM: https://docs.joinpeertube.org/maintain/tools#remote-peertube-cli
* Support moving video files from object storage to filesystem: https://docs.joinpeertube.org/maintain/tools#move-video-files-from-object-storage-to-filesystem
### Features
* :tada: **Add "Password protected" video privacy** [#5836](https://github.com/Chocobozzz/PeerTube/pull/5836) :tada:
* A single password can be set using the web interface at video upload/import/update
* The [REST API](https://docs.joinpeertube.org/api-rest-reference.html#tag/Video-Passwords) can store as many passwords as you want, allowing developers to use this feature to easily give or revoke access to a video *on the fly*
* Developers that use PeerTube embeds can set the video password using [the embed API](https://docs.joinpeertube.org/api/embed-player#setvideopassword-promise-void)
* :tada: **Add video storyboard support** :tada:
* PeerTube automatically generates a storyboard on video upload/import
* Viewers can see the image around the targeted timecode when hovering the progress bar
* Storyboard of videos uploaded/imported before v6 can be generated by the admin using `npm run create-generate-storyboard-job` command: https://docs.joinpeertube.org/maintain/tools#generate-storyboard
* :tada: **Add ability for users to replace their video file** :tada:
* Has to be enabled by the PeerTube instance administrator
* The user can replace the video file in the *Update Video* page
* The *re-upload* date is displayed under the video player
* :tada: **Add video chapters support** :tada:
* Add chapters in the upload/import/update video page or let PeerTube automatically imports them from the video container/youtube-dl
* Markers are displayed in the player progress bar to symbolize a chapter
* Chapter title is displayed when hovering/touching the player progress bar
* Better video player:
* More efficient as we don't rebuild the player every time the played video changes
* The player keeps the current player settings (playback speed, fullscreen...) when the played video changes
* Automatically adjust the player size to match video ratio
* Improve SEO and video link sharing:
* Use short video/channel/account URLs in sitemap and for canonical tags
* Add JSON-LD tag in embed page
* Embed page does not forbid indexation anymore: we use a canonical tag instead that targets the watch page
* Forbid indexation of remote videos, accounts and channels (instead of providing an invalid canonical tag)
* Truncate OpenGraph/Twitter card link description
* Fix client accessibility and keyboard navigation:
* Fix links in bootstrap alerts color
* Better input placeholder contrast
* Fix video miniature link label
* Add ability to disable hotkeys
* Improve table overall accessibility
* Wrap icons that can lead to an action inside buttons
* Fix left menu admin/my-library menu accessibility
* And many more improvements!
* Improve remote runner management:
* Add ability to remove runner jobs
* Add runner job state quick filter
* Merge registration tokens and runners tables in same page
* Add copy button to copy registration token
* Add ability for admins to force transcoding on a specific video even if it's in broken state (stuck in *To Transcode* for example)
* Add an option to sign federated fetches (ActivityPub based software such as Mastodon may require it to access content)
* Download video file directly from S3 using pre signed URLs
* Lazy download remote video thumbnails to reduce storage
* Improve recommended videos when the watched video doesn't have tags set
* Add more rate limits in configuration (`plugins`, `well-known`, `feeds`, `activity_pub` and `client` endpoints)
* Add ability to reset video *Originally published at* attribute
* Add ability for admins to set the default user channel name [#6000](https://github.com/Chocobozzz/PeerTube/pull/6000)
* Server now uses [ESM modules](https://nodejs.org/api/esm.html)
* Add worker threads Prometheus metrics
* Performance:
* Process unicast HTTP job in worker threads
* Sign ActivityPub requests in worker threads
* Optimize recommended videos HTTP request
* Optimize videos SQL queries when filtering on lives or tags
* Optimize `/videos/{id}/views` endpoint with many viewers
* Add ability to disable PeerTube HTTP logs
* Optimize homepage videos HTTP queries
### Bug fixes
* Don't cache upload response if the video has been deleted
* Fix broken upgrade script when using custom database port
* Prevent duplicate runner names
* Avoid runner job update error
* Notify remote runners there are available jobs when a job is aborted/errored
* Fix updating P2P settings in left menu
* Fix 500 HTTP error on invalid short UUID conversion
* Don't display admin email in `security.txt` well-known endpoint
* Optimize `update-host` script to fix out of memory error
* Fix error log when using an unconventional distribution of FFmpeg with a non-standard version string [#5917](https://github.com/Chocobozzz/PeerTube/pull/5917)
* Fix live replay REST API breaking change: `replaySettings.privacy` is not required anymore
* Fix broken live replay when updating replay privacy
* More robust *About* page when getting category from server
* Fix `ERR_HTTP_HEADERS_SENT` crash
* Avoid illegal characters in torrent filename
* Avoid federation error log with remote `Like` on `Note`
* Fix atom feed with *Science & Technology* category
* Support empty value returned by `filter:api.video.get.result` hook
* Prevent remote subscribe on accounts (not yet supported by PeerTube)
* Fix feed audio file mimetype
* Fix video quality on high video resolution/fps
* Fix disabling Object Storage ACL using Docker env `PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PUBLIC` and `PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PRIVATE` in `.env`
* Correctly end live session on ffprobe error
* Fix video stats X axis with old videos
* Fix empty master playlist upload on s3
* Correctly generate `production.yaml.new` that should merge your current `production.yaml` with new keys defined by PeerTube
* Fix card font color theme
* Respect "transcode original resolution" setting when using remote runners
* Prevent player mobile buttons flickering
* Fix graph zooming end date
## v5.2.1 ## v5.2.1
### Bug fixes ### Bug fixes
@ -201,7 +16,7 @@ We have many important notes in this release. We know it's a pain for sysadmin,
* **Important** Remove NodeJS 14 support * **Important** Remove NodeJS 14 support
* **Important** You must update your nginx configuration to support remote runners: https://github.com/Chocobozzz/PeerTube/blob/develop/support/nginx/peertube#L101 * **Important** You must update your nginx configuration to support remote runners: https://github.com/Chocobozzz/PeerTube/blob/develop/support/nginx/peertube#L101
* Add `storage.tmp_persistent` directory in configuration file. **You must configure it in your production.yaml**: https://github.com/Chocobozzz/PeerTube/blob/develop/config/production.yaml.example#L148 * Add `storage.tmp_persistent` directory in configuration file. **You must configure it in your production.yaml**: https://github.com/Chocobozzz/PeerTube/blob/develop/config/production.yaml.example#L128
* PeerTube requires **Docker Compose >= v2** for Docker compose installation * PeerTube requires **Docker Compose >= v2** for Docker compose installation
### Maintenance ### Maintenance

View File

@ -11,30 +11,30 @@
* Filip Bengtsson * Filip Bengtsson
* Ihor Hordiichuk * Ihor Hordiichuk
* Jeff Huang * Jeff Huang
* Payman Moghadam
* Simon Brosdetzko * Simon Brosdetzko
* kontrollanten * kontrollanten
* Jiri Podhorecky * Jiri Podhorecky
* Payman Moghadam
* Phongpanot * Phongpanot
* hecko * hecko
* Milo Ivir
* Laurent Ettouati * Laurent Ettouati
* Milo Ivir
* kimsible * kimsible
* Zet * Zet
* GunChleoc * GunChleoc
* Clemens Schielicke * Clemens Schielicke
* Racida S * Racida S
* Sveinn í Felli
* Ewout van Mansom
* Marcin Mikołajczak * Marcin Mikołajczak
* Ewout van Mansom
* Eivind Ødegård * Eivind Ødegård
* Sveinn í Felli
* Tirifto * Tirifto
* Kim * Kim
* Wicklow
* Armin * Armin
* Hannes Ylä-Jääski * Hannes Ylä-Jääski
* Vodoyo Kamal
* Mohamad Reza * Mohamad Reza
* Vodoyo Kamal
* Wicklow
* John Livingston * John Livingston
* Kimsible * Kimsible
* Besnik Bleta * Besnik Bleta
@ -71,7 +71,6 @@
* jan Seli * jan Seli
* lutangar * lutangar
* 李奕寯 * 李奕寯
* Blood Axe
* Martin Hoefler * Martin Hoefler
* Porrumentzio * Porrumentzio
* Poslovitch * Poslovitch
@ -95,10 +94,12 @@
* Ms Kimsible * Ms Kimsible
* Thomas Citharel * Thomas Citharel
* Benjamin Bouvier * Benjamin Bouvier
* Blood Axe
* Joe Bill * Joe Bill
* Kemal Oktay Aktoğan * Kemal Oktay Aktoğan
* Lucas Declercq * Lucas Declercq
* Sirxy * Sirxy
* chris@famichiki.tube
* matograine * matograine
* Alexander Ivanov * Alexander Ivanov
* Daniel Santos * Daniel Santos
@ -150,8 +151,8 @@
* Benjamin Seitz * Benjamin Seitz
* Bob Oob * Bob Oob
* Booteille * Booteille
* Chris Sakura 佐倉くりす on Youtube
* DontUseGithub * DontUseGithub
* Farooq Karimi Zadeh
* I_Automne * I_Automne
* Iñigo * Iñigo
* Joan Montané * Joan Montané
@ -195,6 +196,7 @@
* Eder Etxebarria * Eder Etxebarria
* Ehsan Gholami * Ehsan Gholami
* Elga Ahmad Prayoga * Elga Ahmad Prayoga
* Farooq Karimi Zadeh
* Girish Ramakrishnan * Girish Ramakrishnan
* Hakim Oubouali * Hakim Oubouali
* Hans Meiser * Hans Meiser
@ -204,7 +206,6 @@
* Jocelyn Jaubert * Jocelyn Jaubert
* Johan Fleury * Johan Fleury
* Jurij Podgoršek * Jurij Podgoršek
* Kindred La Boneta
* Kiro * Kiro
* Leopere * Leopere
* Linus * Linus
@ -234,7 +235,6 @@
* Ömer Faruk Çakmak * Ömer Faruk Çakmak
* AQR_Rastiq * AQR_Rastiq
* Al-Hassan Abdel-Raouf * Al-Hassan Abdel-Raouf
* Alecks Gates
* Amos Tamam * Amos Tamam
* Andrew Morgan * Andrew Morgan
* Andy Khit * Andy Khit
@ -246,12 +246,10 @@
* Average Dude * Average Dude
* BitTube * BitTube
* Boo Teille * Boo Teille
* Branislav Pavelka
* Dashie * Dashie
* David Luís Pereira Pires * David Luís Pereira Pires
* David Marzal * David Marzal
* EndoGai * EndoGai
* Ettore Atalan
* Fatih Özsoy * Fatih Özsoy
* FediverseTV * FediverseTV
* Florent Fayolle * Florent Fayolle
@ -267,7 +265,6 @@
* HybridGlucose * HybridGlucose
* J C Worm * J C Worm
* Jan Marsalek * Jan Marsalek
* José M
* Joël Galeran * Joël Galeran
* Julien Lemaire * Julien Lemaire
* Lucas Teixeira * Lucas Teixeira
@ -302,7 +299,6 @@
* libertas * libertas
* merty * merty
* plr20 * plr20
* q_h
* qwerty * qwerty
* spf * spf
* taziden * taziden
@ -320,6 +316,7 @@
* Agron * Agron
* Aitozl * Aitozl
* Alberto Mardegan * Alberto Mardegan
* Alecks Gates
* Alejandro Criado-Pérez * Alejandro Criado-Pérez
* Aleksandr Sokolov * Aleksandr Sokolov
* Alexander F. Rødseth * Alexander F. Rødseth
@ -345,6 +342,7 @@
* Cadence Ember * Cadence Ember
* Cale * Cale
* Charles de Lacombe * Charles de Lacombe
* Chris Sakura 佐倉くりす on Youtube - 日本語は第二言語やけ、間違っとったら思いっきり叩いてくださいw つたない日本語ばっかりやけど頑張りまーす♪
* Christoph Geschwind * Christoph Geschwind
* Chronos * Chronos
* Claude * Claude
@ -384,7 +382,6 @@
* Iván Cabaleiro * Iván Cabaleiro
* J Webb * J Webb
* Jacen * Jacen
* Jackson Chen
* Jacob * Jacob
* Jacques Foucry * Jacques Foucry
* Jagannath Bhat * Jagannath Bhat
@ -494,7 +491,6 @@
* Vagelis F * Vagelis F
* Varik Valefor * Varik Valefor
* Vegard Fjeldberg * Vegard Fjeldberg
* Victor Hampel
* Vik * Vik
* Vincent Stakenburg * Vincent Stakenburg
* WhiredPlanck * WhiredPlanck
@ -544,6 +540,7 @@
* philippe lhardy * philippe lhardy
* pitchum * pitchum
* potedeo * potedeo
* q_h
* rdxuan * rdxuan
* retiolus * retiolus
* ruvilonix * ruvilonix

View File

@ -116,7 +116,7 @@ Be it as a user or an instance administrator, you can decide what your experienc
<h3 align="right">Communities that help each other</h3> <h3 align="right">Communities that help each other</h3>
<p align="right"> <p align="right">
In addition to visitors using P2P with WebRTC to share the load among them, instances can help each other by caching one another's videos. This way even small instances have a way to show content to a wider audience, as they will be shouldered by friend instances (more about that in our <a href="https://docs.joinpeertube.org/contribute/architecture#redundancy-between-instances">redundancy guide</a>). In addition to visitors using WebTorrent to share the load among them, instances can help each other by caching one another's videos. This way even small instances have a way to show content to a wider audience, as they will be shouldered by friend instances (more about that in our <a href="https://docs.joinpeertube.org/contribute/architecture#redundancy-between-instances">redundancy guide</a>).
</p> </p>
<p align="right"> <p align="right">
Content creators can get help from their viewers in the simplest way possible: a support button showing a message linking to their donation accounts or really anything else. No more pay-per-view and advertisements that hurt visitors and alter creativity (more about that in our <a href="https://github.com/Chocobozzz/PeerTube/blob/develop/FAQ.md">FAQ</a>). Content creators can get help from their viewers in the simplest way possible: a support button showing a message linking to their donation accounts or really anything else. No more pay-per-view and advertisements that hurt visitors and alter creativity (more about that in our <a href="https://github.com/Chocobozzz/PeerTube/blob/develop/FAQ.md">FAQ</a>).

View File

@ -1,4 +0,0 @@
src
meta.json
tsconfig.json
scripts

View File

@ -1,43 +0,0 @@
# PeerTube CLI
## Usage
See https://docs.joinpeertube.org/maintain/tools#remote-tools
## Dev
## Install dependencies
```bash
cd peertube-root
yarn install --pure-lockfile
cd apps/peertube-cli && yarn install --pure-lockfile
```
## Develop
```bash
cd peertube-root
npm run dev:peertube-cli
```
## Build
```bash
cd peertube-root
npm run build:peertube-cli
```
## Run
```bash
cd peertube-root
node apps/peertube-cli/dist/peertube-cli.js --help
```
## Publish on NPM
```bash
cd peertube-root
(cd apps/peertube-cli && npm version patch) && npm run build:peertube-cli && (cd apps/peertube-cli && npm publish --access=public)
```

View File

@ -1,19 +0,0 @@
{
"name": "@peertube/peertube-cli",
"version": "1.0.1",
"type": "module",
"main": "dist/peertube.js",
"bin": "dist/peertube.js",
"engines": {
"node": ">=16.x"
},
"scripts": {},
"license": "AGPL-3.0",
"private": false,
"devDependencies": {
"application-config": "^2.0.0",
"cli-table3": "^0.6.0",
"netrc-parser": "^3.1.6"
},
"dependencies": {}
}

View File

@ -1,27 +0,0 @@
import * as esbuild from 'esbuild'
import { readFileSync } from 'fs'
const packageJSON = JSON.parse(readFileSync(new URL('../package.json', import.meta.url)))
export const esbuildOptions = {
entryPoints: [ './src/peertube.ts' ],
bundle: true,
platform: 'node',
format: 'esm',
target: 'node16',
external: [
'./lib-cov/fluent-ffmpeg',
'pg-hstore'
],
outfile: './dist/peertube.js',
banner: {
js: `const require = (await import("node:module")).createRequire(import.meta.url);` +
`const __filename = (await import("node:url")).fileURLToPath(import.meta.url);` +
`const __dirname = (await import("node:path")).dirname(__filename);`
},
define: {
'process.env.PACKAGE_VERSION': `'${packageJSON.version}'`
}
}
await esbuild.build(esbuildOptions)

View File

@ -1,7 +0,0 @@
import * as esbuild from 'esbuild'
import { esbuildOptions } from './build.js'
const context = await esbuild.context(esbuildOptions)
// Enable watch mode
await context.watch()

View File

@ -1,171 +0,0 @@
import CliTable3 from 'cli-table3'
import prompt from 'prompt'
import { Command } from '@commander-js/extra-typings'
import { assignToken, buildServer, getNetrc, getSettings, writeSettings } from './shared/index.js'
export function defineAuthProgram () {
const program = new Command()
.name('auth')
.description('Register your accounts on remote instances to use them with other commands')
program
.command('add')
.description('remember your accounts on remote instances for easier use')
.option('-u, --url <url>', 'Server url')
.option('-U, --username <username>', 'Username')
.option('-p, --password <token>', 'Password')
.option('--default', 'add the entry as the new default')
.action(options => {
/* eslint-disable no-import-assign */
prompt.override = options
prompt.start()
prompt.get({
properties: {
url: {
description: 'instance url',
conform: value => isURLaPeerTubeInstance(value),
message: 'It should be an URL (https://peertube.example.com)',
required: true
},
username: {
conform: value => typeof value === 'string' && value.length !== 0,
message: 'Name must be only letters, spaces, or dashes',
required: true
},
password: {
hidden: true,
replace: '*',
required: true
}
}
}, async (_, result) => {
// Check credentials
try {
// Strip out everything after the domain:port.
// See https://github.com/Chocobozzz/PeerTube/issues/3520
result.url = stripExtraneousFromPeerTubeUrl(result.url)
const server = buildServer(result.url)
await assignToken(server, result.username, result.password)
} catch (err) {
console.error(err.message)
process.exit(-1)
}
await setInstance(result.url, result.username, result.password, options.default)
process.exit(0)
})
})
program
.command('del <url>')
.description('Unregisters a remote instance')
.action(async url => {
await delInstance(url)
process.exit(0)
})
program
.command('list')
.description('List registered remote instances')
.action(async () => {
const [ settings, netrc ] = await Promise.all([ getSettings(), getNetrc() ])
const table = new CliTable3({
head: [ 'instance', 'login' ],
colWidths: [ 30, 30 ]
}) as any
settings.remotes.forEach(element => {
if (!netrc.machines[element]) return
table.push([
element,
netrc.machines[element].login
])
})
console.log(table.toString())
process.exit(0)
})
program
.command('set-default <url>')
.description('Set an existing entry as default')
.action(async url => {
const settings = await getSettings()
const instanceExists = settings.remotes.includes(url)
if (instanceExists) {
settings.default = settings.remotes.indexOf(url)
await writeSettings(settings)
process.exit(0)
} else {
console.log('<url> is not a registered instance.')
process.exit(-1)
}
})
program.addHelpText('after', '\n\n Examples:\n\n' +
' $ peertube auth add -u https://peertube.cpy.re -U "PEERTUBE_USER" --password "PEERTUBE_PASSWORD"\n' +
' $ peertube auth add -u https://peertube.cpy.re -U root\n' +
' $ peertube auth list\n' +
' $ peertube auth del https://peertube.cpy.re\n'
)
return program
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function delInstance (url: string) {
const [ settings, netrc ] = await Promise.all([ getSettings(), getNetrc() ])
const index = settings.remotes.indexOf(url)
settings.remotes.splice(index)
if (settings.default === index) settings.default = -1
await writeSettings(settings)
delete netrc.machines[url]
await netrc.save()
}
async function setInstance (url: string, username: string, password: string, isDefault: boolean) {
const [ settings, netrc ] = await Promise.all([ getSettings(), getNetrc() ])
if (settings.remotes.includes(url) === false) {
settings.remotes.push(url)
}
if (isDefault || settings.remotes.length === 1) {
settings.default = settings.remotes.length - 1
}
await writeSettings(settings)
netrc.machines[url] = { login: username, password }
await netrc.save()
}
function isURLaPeerTubeInstance (url: string) {
return url.startsWith('http://') || url.startsWith('https://')
}
function stripExtraneousFromPeerTubeUrl (url: string) {
// Get everything before the 3rd /.
const urlLength = url.includes('/', 8)
? url.indexOf('/', 8)
: url.length
return url.substring(0, urlLength)
}

View File

@ -1,39 +0,0 @@
import { Command } from '@commander-js/extra-typings'
import { assignToken, buildServer } from './shared/index.js'
export function defineGetAccessProgram () {
const program = new Command()
.name('get-access-token')
.description('Get a peertube access token')
.alias('token')
program
.option('-u, --url <url>', 'Server url')
.option('-n, --username <username>', 'Username')
.option('-p, --password <token>', 'Password')
.action(async options => {
try {
if (
!options.url ||
!options.username ||
!options.password
) {
if (!options.url) console.error('--url field is required.')
if (!options.username) console.error('--username field is required.')
if (!options.password) console.error('--password field is required.')
process.exit(-1)
}
const server = buildServer(options.url)
await assignToken(server, options.username, options.password)
console.log(server.accessToken)
} catch (err) {
console.error('Cannot get access token: ' + err.message)
process.exit(-1)
}
})
return program
}

View File

@ -1,167 +0,0 @@
import CliTable3 from 'cli-table3'
import { isAbsolute } from 'path'
import { Command } from '@commander-js/extra-typings'
import { PluginType, PluginType_Type } from '@peertube/peertube-models'
import { assignToken, buildServer, CommonProgramOptions, getServerCredentials } from './shared/index.js'
export function definePluginsProgram () {
const program = new Command()
program
.name('plugins')
.description('Manage instance plugins/themes')
.alias('p')
program
.command('list')
.description('List installed plugins')
.option('-u, --url <url>', 'Server url')
.option('-U, --username <username>', 'Username')
.option('-p, --password <token>', 'Password')
.option('-t, --only-themes', 'List themes only')
.option('-P, --only-plugins', 'List plugins only')
.action(async options => {
try {
await pluginsListCLI(options)
} catch (err) {
console.error('Cannot list plugins: ' + err.message)
process.exit(-1)
}
})
program
.command('install')
.description('Install a plugin or a theme')
.option('-u, --url <url>', 'Server url')
.option('-U, --username <username>', 'Username')
.option('-p, --password <token>', 'Password')
.option('-P --path <path>', 'Install from a path')
.option('-n, --npm-name <npmName>', 'Install from npm')
.option('--plugin-version <pluginVersion>', 'Specify the plugin version to install (only available when installing from npm)')
.action(async options => {
try {
await installPluginCLI(options)
} catch (err) {
console.error('Cannot install plugin: ' + err.message)
process.exit(-1)
}
})
program
.command('update')
.description('Update a plugin or a theme')
.option('-u, --url <url>', 'Server url')
.option('-U, --username <username>', 'Username')
.option('-p, --password <token>', 'Password')
.option('-P --path <path>', 'Update from a path')
.option('-n, --npm-name <npmName>', 'Update from npm')
.action(async options => {
try {
await updatePluginCLI(options)
} catch (err) {
console.error('Cannot update plugin: ' + err.message)
process.exit(-1)
}
})
program
.command('uninstall')
.description('Uninstall a plugin or a theme')
.option('-u, --url <url>', 'Server url')
.option('-U, --username <username>', 'Username')
.option('-p, --password <token>', 'Password')
.option('-n, --npm-name <npmName>', 'NPM plugin/theme name')
.action(async options => {
try {
await uninstallPluginCLI(options)
} catch (err) {
console.error('Cannot uninstall plugin: ' + err.message)
process.exit(-1)
}
})
return program
}
// ----------------------------------------------------------------------------
async function pluginsListCLI (options: CommonProgramOptions & { onlyThemes?: true, onlyPlugins?: true }) {
const { url, username, password } = await getServerCredentials(options)
const server = buildServer(url)
await assignToken(server, username, password)
let pluginType: PluginType_Type
if (options.onlyThemes) pluginType = PluginType.THEME
if (options.onlyPlugins) pluginType = PluginType.PLUGIN
const { data } = await server.plugins.list({ start: 0, count: 100, sort: 'name', pluginType })
const table = new CliTable3({
head: [ 'name', 'version', 'homepage' ],
colWidths: [ 50, 20, 50 ]
}) as any
for (const plugin of data) {
const npmName = plugin.type === PluginType.PLUGIN
? 'peertube-plugin-' + plugin.name
: 'peertube-theme-' + plugin.name
table.push([
npmName,
plugin.version,
plugin.homepage
])
}
console.log(table.toString())
}
async function installPluginCLI (options: CommonProgramOptions & { path?: string, npmName?: string, pluginVersion?: string }) {
if (!options.path && !options.npmName) {
throw new Error('You need to specify the npm name or the path of the plugin you want to install.')
}
if (options.path && !isAbsolute(options.path)) {
throw new Error('Path should be absolute.')
}
const { url, username, password } = await getServerCredentials(options)
const server = buildServer(url)
await assignToken(server, username, password)
await server.plugins.install({ npmName: options.npmName, path: options.path, pluginVersion: options.pluginVersion })
console.log('Plugin installed.')
}
async function updatePluginCLI (options: CommonProgramOptions & { path?: string, npmName?: string }) {
if (!options.path && !options.npmName) {
throw new Error('You need to specify the npm name or the path of the plugin you want to update.')
}
if (options.path && !isAbsolute(options.path)) {
throw new Error('Path should be absolute.')
}
const { url, username, password } = await getServerCredentials(options)
const server = buildServer(url)
await assignToken(server, username, password)
await server.plugins.update({ npmName: options.npmName, path: options.path })
console.log('Plugin updated.')
}
async function uninstallPluginCLI (options: CommonProgramOptions & { npmName?: string }) {
if (!options.npmName) {
throw new Error('You need to specify the npm name of the plugin/theme you want to uninstall.')
}
const { url, username, password } = await getServerCredentials(options)
const server = buildServer(url)
await assignToken(server, username, password)
await server.plugins.uninstall({ npmName: options.npmName })
console.log('Plugin uninstalled.')
}

View File

@ -1,186 +0,0 @@
import bytes from 'bytes'
import CliTable3 from 'cli-table3'
import { URL } from 'url'
import { Command } from '@commander-js/extra-typings'
import { forceNumber, uniqify } from '@peertube/peertube-core-utils'
import { HttpStatusCode, VideoRedundanciesTarget } from '@peertube/peertube-models'
import { assignToken, buildServer, CommonProgramOptions, getServerCredentials } from './shared/index.js'
export function defineRedundancyProgram () {
const program = new Command()
.name('redundancy')
.description('Manage instance redundancies')
.alias('r')
program
.command('list-remote-redundancies')
.description('List remote redundancies on your videos')
.option('-u, --url <url>', 'Server url')
.option('-U, --username <username>', 'Username')
.option('-p, --password <token>', 'Password')
.action(async options => {
try {
await listRedundanciesCLI({ target: 'my-videos', ...options })
} catch (err) {
console.error('Cannot list remote redundancies: ' + err.message)
process.exit(-1)
}
})
program
.command('list-my-redundancies')
.description('List your redundancies of remote videos')
.option('-u, --url <url>', 'Server url')
.option('-U, --username <username>', 'Username')
.option('-p, --password <token>', 'Password')
.action(async options => {
try {
await listRedundanciesCLI({ target: 'remote-videos', ...options })
} catch (err) {
console.error('Cannot list redundancies: ' + err.message)
process.exit(-1)
}
})
program
.command('add')
.description('Duplicate a video in your redundancy system')
.option('-u, --url <url>', 'Server url')
.option('-U, --username <username>', 'Username')
.option('-p, --password <token>', 'Password')
.requiredOption('-v, --video <videoId>', 'Video id to duplicate', parseInt)
.action(async options => {
try {
await addRedundancyCLI(options)
} catch (err) {
console.error('Cannot duplicate video: ' + err.message)
process.exit(-1)
}
})
program
.command('remove')
.description('Remove a video from your redundancies')
.option('-u, --url <url>', 'Server url')
.option('-U, --username <username>', 'Username')
.option('-p, --password <token>', 'Password')
.requiredOption('-v, --video <videoId>', 'Video id to remove from redundancies', parseInt)
.action(async options => {
try {
await removeRedundancyCLI(options)
} catch (err) {
console.error('Cannot remove redundancy: ' + err)
process.exit(-1)
}
})
return program
}
// ----------------------------------------------------------------------------
async function listRedundanciesCLI (options: CommonProgramOptions & { target: VideoRedundanciesTarget }) {
const { target } = options
const { url, username, password } = await getServerCredentials(options)
const server = buildServer(url)
await assignToken(server, username, password)
const { data } = await server.redundancy.listVideos({ start: 0, count: 100, sort: 'name', target })
const table = new CliTable3({
head: [ 'video id', 'video name', 'video url', 'files', 'playlists', 'by instances', 'total size' ]
}) as any
for (const redundancy of data) {
const webVideoFiles = redundancy.redundancies.files
const streamingPlaylists = redundancy.redundancies.streamingPlaylists
let totalSize = ''
if (target === 'remote-videos') {
const tmp = webVideoFiles.concat(streamingPlaylists)
.reduce((a, b) => a + b.size, 0)
// FIXME: don't use external dependency to stringify bytes: we already have the functions in the client
totalSize = bytes(tmp)
}
const instances = uniqify(
webVideoFiles.concat(streamingPlaylists)
.map(r => r.fileUrl)
.map(u => new URL(u).host)
)
table.push([
redundancy.id.toString(),
redundancy.name,
redundancy.url,
webVideoFiles.length,
streamingPlaylists.length,
instances.join('\n'),
totalSize
])
}
console.log(table.toString())
}
async function addRedundancyCLI (options: { video: number } & CommonProgramOptions) {
const { url, username, password } = await getServerCredentials(options)
const server = buildServer(url)
await assignToken(server, username, password)
if (!options.video || isNaN(options.video)) {
throw new Error('You need to specify the video id to duplicate and it should be a number.')
}
try {
await server.redundancy.addVideo({ videoId: options.video })
console.log('Video will be duplicated by your instance!')
} catch (err) {
if (err.message.includes(HttpStatusCode.CONFLICT_409)) {
throw new Error('This video is already duplicated by your instance.')
}
if (err.message.includes(HttpStatusCode.NOT_FOUND_404)) {
throw new Error('This video id does not exist.')
}
throw err
}
}
async function removeRedundancyCLI (options: CommonProgramOptions & { video: number }) {
const { url, username, password } = await getServerCredentials(options)
const server = buildServer(url)
await assignToken(server, username, password)
if (!options.video || isNaN(options.video)) {
throw new Error('You need to specify the video id to remove from your redundancies')
}
const videoId = forceNumber(options.video)
const myVideoRedundancies = await server.redundancy.listVideos({ target: 'my-videos' })
let videoRedundancy = myVideoRedundancies.data.find(r => videoId === r.id)
if (!videoRedundancy) {
const remoteVideoRedundancies = await server.redundancy.listVideos({ target: 'remote-videos' })
videoRedundancy = remoteVideoRedundancies.data.find(r => videoId === r.id)
}
if (!videoRedundancy) {
throw new Error('Video redundancy not found.')
}
const ids = videoRedundancy.redundancies.files
.concat(videoRedundancy.redundancies.streamingPlaylists)
.map(r => r.id)
for (const id of ids) {
await server.redundancy.removeVideo({ redundancyId: id })
}
console.log('Video redundancy removed!')
}

View File

@ -1,167 +0,0 @@
import { access, constants } from 'fs/promises'
import { isAbsolute } from 'path'
import { inspect } from 'util'
import { Command } from '@commander-js/extra-typings'
import { VideoPrivacy } from '@peertube/peertube-models'
import { PeerTubeServer } from '@peertube/peertube-server-commands'
import { assignToken, buildServer, getServerCredentials, listOptions } from './shared/index.js'
type UploadOptions = {
url?: string
username?: string
password?: string
thumbnail?: string
preview?: string
file?: string
videoName?: string
category?: string
licence?: string
language?: string
tags?: string
nsfw?: true
videoDescription?: string
privacy?: number
channelName?: string
noCommentsEnabled?: true
support?: string
noWaitTranscoding?: true
noDownloadEnabled?: true
}
export function defineUploadProgram () {
const program = new Command('upload')
.description('Upload a video on a PeerTube instance')
.alias('up')
program
.option('-u, --url <url>', 'Server url')
.option('-U, --username <username>', 'Username')
.option('-p, --password <token>', 'Password')
.option('-b, --thumbnail <thumbnailPath>', 'Thumbnail path')
.option('-v, --preview <previewPath>', 'Preview path')
.option('-f, --file <file>', 'Video absolute file path')
.option('-n, --video-name <name>', 'Video name')
.option('-c, --category <category_number>', 'Category number')
.option('-l, --licence <licence_number>', 'Licence number')
.option('-L, --language <language_code>', 'Language ISO 639 code (fr or en...)')
.option('-t, --tags <tags>', 'Video tags', listOptions)
.option('-N, --nsfw', 'Video is Not Safe For Work')
.option('-d, --video-description <description>', 'Video description')
.option('-P, --privacy <privacy_number>', 'Privacy', parseInt)
.option('-C, --channel-name <channel_name>', 'Channel name')
.option('--no-comments-enabled', 'Disable video comments')
.option('-s, --support <support>', 'Video support text')
.option('--no-wait-transcoding', 'Do not wait transcoding before publishing the video')
.option('--no-download-enabled', 'Disable video download')
.option('-v, --verbose <verbose>', 'Verbosity, from 0/\'error\' to 4/\'debug\'', 'info')
.action(async options => {
try {
const { url, username, password } = await getServerCredentials(options)
if (!options.videoName || !options.file) {
if (!options.videoName) console.error('--video-name is required.')
if (!options.file) console.error('--file is required.')
process.exit(-1)
}
if (isAbsolute(options.file) === false) {
console.error('File path should be absolute.')
process.exit(-1)
}
await run({ ...options, url, username, password })
} catch (err) {
console.error('Cannot upload video: ' + err.message)
process.exit(-1)
}
})
return program
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function run (options: UploadOptions) {
const { url, username, password } = options
const server = buildServer(url)
await assignToken(server, username, password)
await access(options.file, constants.F_OK)
console.log('Uploading %s video...', options.videoName)
const baseAttributes = await buildVideoAttributesFromCommander(server, options)
const attributes = {
...baseAttributes,
fixture: options.file,
thumbnailfile: options.thumbnail,
previewfile: options.preview
}
try {
await server.videos.upload({ attributes })
console.log(`Video ${options.videoName} uploaded.`)
process.exit(0)
} catch (err) {
const message = err.message || ''
if (message.includes('413')) {
console.error('Aborted: user quota is exceeded or video file is too big for this PeerTube instance.')
} else {
console.error(inspect(err))
}
process.exit(-1)
}
}
async function buildVideoAttributesFromCommander (server: PeerTubeServer, options: UploadOptions, defaultAttributes: any = {}) {
const defaultBooleanAttributes = {
nsfw: false,
commentsEnabled: true,
downloadEnabled: true,
waitTranscoding: true
}
const booleanAttributes: { [id in keyof typeof defaultBooleanAttributes]: boolean } | {} = {}
for (const key of Object.keys(defaultBooleanAttributes)) {
if (options[key] !== undefined) {
booleanAttributes[key] = options[key]
} else if (defaultAttributes[key] !== undefined) {
booleanAttributes[key] = defaultAttributes[key]
} else {
booleanAttributes[key] = defaultBooleanAttributes[key]
}
}
const videoAttributes = {
name: options.videoName || defaultAttributes.name,
category: options.category || defaultAttributes.category || undefined,
licence: options.licence || defaultAttributes.licence || undefined,
language: options.language || defaultAttributes.language || undefined,
privacy: options.privacy || defaultAttributes.privacy || VideoPrivacy.PUBLIC,
support: options.support || defaultAttributes.support || undefined,
description: options.videoDescription || defaultAttributes.description || undefined,
tags: options.tags || defaultAttributes.tags || undefined
}
Object.assign(videoAttributes, booleanAttributes)
if (options.channelName) {
const videoChannel = await server.channels.get({ channelName: options.channelName })
Object.assign(videoAttributes, { channelId: videoChannel.id })
if (!videoAttributes.support && videoChannel.support) {
Object.assign(videoAttributes, { support: videoChannel.support })
}
}
return videoAttributes
}

View File

@ -1 +0,0 @@
export * from './cli.js'

View File

@ -1,15 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist",
"rootDir": "src",
"tsBuildInfoFile": "./dist/.tsbuildinfo"
},
"references": [
{ "path": "../../packages/core-utils" },
{ "path": "../../packages/models" },
{ "path": "../../packages/node-utils" },
{ "path": "../../packages/server-commands" }
]
}

View File

@ -1,4 +0,0 @@
src
meta.json
tsconfig.json
scripts

View File

@ -1,43 +0,0 @@
# PeerTube runner
Runner program to execute jobs (transcoding...) of remote PeerTube instances.
Commands below has to be run at the root of PeerTube git repository.
## Dev
### Install dependencies
```bash
cd peertube-root
yarn install --pure-lockfile
cd apps/peertube-runner && yarn install --pure-lockfile
```
### Develop
```bash
cd peertube-root
npm run dev:peertube-runner
```
### Build
```bash
cd peertube-root
npm run build:peertube-runner
```
### Run
```bash
cd peertube-root
node apps/peertube-runner/dist/peertube-runner.js --help
```
### Publish on NPM
```bash
cd peertube-root
(cd apps/peertube-runner && npm version patch) && npm run build:peertube-runner && (cd apps/peertube-runner && npm publish --access=public)
```

View File

@ -1,27 +0,0 @@
import * as esbuild from 'esbuild'
import { readFileSync } from 'fs'
const packageJSON = JSON.parse(readFileSync(new URL('../package.json', import.meta.url)))
export const esbuildOptions = {
entryPoints: [ './src/peertube-runner.ts' ],
bundle: true,
platform: 'node',
format: 'esm',
target: 'node16',
external: [
'./lib-cov/fluent-ffmpeg',
'pg-hstore'
],
outfile: './dist/peertube-runner.js',
banner: {
js: `const require = (await import("node:module")).createRequire(import.meta.url);` +
`const __filename = (await import("node:url")).fileURLToPath(import.meta.url);` +
`const __dirname = (await import("node:path")).dirname(__filename);`
},
define: {
'process.env.PACKAGE_VERSION': `'${packageJSON.version}'`
}
}
await esbuild.build(esbuildOptions)

View File

@ -1 +0,0 @@
export * from './register.js'

View File

@ -1 +0,0 @@
export * from './server.js'

View File

@ -1,2 +0,0 @@
export * from './shared/index.js'
export * from './process.js'

View File

@ -1,3 +0,0 @@
export * from './common.js'
export * from './process-vod.js'
export * from './transcoding-logger.js'

View File

@ -1,201 +0,0 @@
import { remove } from 'fs-extra/esm'
import { join } from 'path'
import {
RunnerJobVODAudioMergeTranscodingPayload,
RunnerJobVODHLSTranscodingPayload,
RunnerJobVODWebVideoTranscodingPayload,
VODAudioMergeTranscodingSuccess,
VODHLSTranscodingSuccess,
VODWebVideoTranscodingSuccess
} from '@peertube/peertube-models'
import { buildUUID } from '@peertube/peertube-node-utils'
import { ConfigManager } from '../../../shared/config-manager.js'
import { logger } from '../../../shared/index.js'
import { buildFFmpegVOD, downloadInputFile, ProcessOptions, scheduleTranscodingProgress } from './common.js'
export async function processWebVideoTranscoding (options: ProcessOptions<RunnerJobVODWebVideoTranscodingPayload>) {
const { server, job, runnerToken } = options
const payload = job.payload
let ffmpegProgress: number
let inputPath: string
const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`)
const updateProgressInterval = scheduleTranscodingProgress({
job,
server,
runnerToken,
progressGetter: () => ffmpegProgress
})
try {
logger.info(`Downloading input file ${payload.input.videoFileUrl} for web video transcoding job ${job.jobToken}`)
inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
logger.info(`Downloaded input file ${payload.input.videoFileUrl} for job ${job.jobToken}. Running web video transcoding.`)
const ffmpegVod = buildFFmpegVOD({
onJobProgress: progress => { ffmpegProgress = progress }
})
await ffmpegVod.transcode({
type: 'video',
inputPath,
outputPath,
inputFileMutexReleaser: () => {},
resolution: payload.output.resolution,
fps: payload.output.fps
})
const successBody: VODWebVideoTranscodingSuccess = {
videoFile: outputPath
}
await server.runnerJobs.success({
jobToken: job.jobToken,
jobUUID: job.uuid,
runnerToken,
payload: successBody
})
} finally {
if (inputPath) await remove(inputPath)
if (outputPath) await remove(outputPath)
if (updateProgressInterval) clearInterval(updateProgressInterval)
}
}
export async function processHLSTranscoding (options: ProcessOptions<RunnerJobVODHLSTranscodingPayload>) {
const { server, job, runnerToken } = options
const payload = job.payload
let ffmpegProgress: number
let inputPath: string
const uuid = buildUUID()
const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `${uuid}-${payload.output.resolution}.m3u8`)
const videoFilename = `${uuid}-${payload.output.resolution}-fragmented.mp4`
const videoPath = join(join(ConfigManager.Instance.getTranscodingDirectory(), videoFilename))
const updateProgressInterval = scheduleTranscodingProgress({
job,
server,
runnerToken,
progressGetter: () => ffmpegProgress
})
try {
logger.info(`Downloading input file ${payload.input.videoFileUrl} for HLS transcoding job ${job.jobToken}`)
inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
logger.info(`Downloaded input file ${payload.input.videoFileUrl} for job ${job.jobToken}. Running HLS transcoding.`)
const ffmpegVod = buildFFmpegVOD({
onJobProgress: progress => { ffmpegProgress = progress }
})
await ffmpegVod.transcode({
type: 'hls',
copyCodecs: false,
inputPath,
hlsPlaylist: { videoFilename },
outputPath,
inputFileMutexReleaser: () => {},
resolution: payload.output.resolution,
fps: payload.output.fps
})
const successBody: VODHLSTranscodingSuccess = {
resolutionPlaylistFile: outputPath,
videoFile: videoPath
}
await server.runnerJobs.success({
jobToken: job.jobToken,
jobUUID: job.uuid,
runnerToken,
payload: successBody
})
} finally {
if (inputPath) await remove(inputPath)
if (outputPath) await remove(outputPath)
if (videoPath) await remove(videoPath)
if (updateProgressInterval) clearInterval(updateProgressInterval)
}
}
export async function processAudioMergeTranscoding (options: ProcessOptions<RunnerJobVODAudioMergeTranscodingPayload>) {
const { server, job, runnerToken } = options
const payload = job.payload
let ffmpegProgress: number
let audioPath: string
let inputPath: string
const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`)
const updateProgressInterval = scheduleTranscodingProgress({
job,
server,
runnerToken,
progressGetter: () => ffmpegProgress
})
try {
logger.info(
`Downloading input files ${payload.input.audioFileUrl} and ${payload.input.previewFileUrl} ` +
`for audio merge transcoding job ${job.jobToken}`
)
audioPath = await downloadInputFile({ url: payload.input.audioFileUrl, runnerToken, job })
inputPath = await downloadInputFile({ url: payload.input.previewFileUrl, runnerToken, job })
logger.info(
`Downloaded input files ${payload.input.audioFileUrl} and ${payload.input.previewFileUrl} ` +
`for job ${job.jobToken}. Running audio merge transcoding.`
)
const ffmpegVod = buildFFmpegVOD({
onJobProgress: progress => { ffmpegProgress = progress }
})
await ffmpegVod.transcode({
type: 'merge-audio',
audioPath,
inputPath,
outputPath,
inputFileMutexReleaser: () => {},
resolution: payload.output.resolution,
fps: payload.output.fps
})
const successBody: VODAudioMergeTranscodingSuccess = {
videoFile: outputPath
}
await server.runnerJobs.success({
jobToken: job.jobToken,
jobUUID: job.uuid,
runnerToken,
payload: successBody
})
} finally {
if (audioPath) await remove(audioPath)
if (inputPath) await remove(inputPath)
if (outputPath) await remove(outputPath)
if (updateProgressInterval) clearInterval(updateProgressInterval)
}
}

View File

@ -1 +0,0 @@
export * from './supported-job.js'

View File

@ -1,3 +0,0 @@
export * from './config-manager.js'
export * from './http.js'
export * from './logger.js'

View File

@ -1,2 +0,0 @@
export * from './ipc-client.js'
export * from './ipc-server.js'

View File

@ -1,2 +0,0 @@
export * from './ipc-request.model.js'
export * from './ipc-response.model.js'

View File

@ -1,16 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist",
"rootDir": "src",
"tsBuildInfoFile": "./dist/.tsbuildinfo"
},
"references": [
{ "path": "../../packages/core-utils" },
{ "path": "../../packages/ffmpeg" },
{ "path": "../../packages/models" },
{ "path": "../../packages/node-utils" },
{ "path": "../../packages/server-commands" }
]
}

View File

@ -3,7 +3,7 @@
"ignorePatterns": [ "ignorePatterns": [
"projects/**/*", "projects/**/*",
"node_modules/", "node_modules/",
"src/standalone/embed-player-api/dist" "src/standalone/player/dist"
], ],
"overrides": [ "overrides": [
{ {
@ -14,7 +14,6 @@
"project": [ "project": [
"tsconfig.eslint.json" "tsconfig.eslint.json"
], ],
"EXPERIMENTAL_useSourceOfProjectReferenceRedirect": true,
"createDefaultProgram": false "createDefaultProgram": false
}, },
"extends": [ "extends": [

4
client/.gitignore vendored
View File

@ -12,5 +12,5 @@
/e2e/local.log /e2e/local.log
/e2e/browserstack.err /e2e/browserstack.err
/e2e/screenshots /e2e/screenshots
/src/standalone/embed-player-api/build /src/standalone/player/build
/src/standalone/embed-player-api/dist /src/standalone/player/dist

View File

@ -195,14 +195,11 @@
"path-browserify", "path-browserify",
"deep-merge", "deep-merge",
"escape-string-regexp", "escape-string-regexp",
"mousetrap",
"is-plain-object", "is-plain-object",
"parse-srcset", "parse-srcset",
"deepmerge", "deepmerge",
"core-js/features/reflect", "core-js/features/reflect"
"@formatjs/intl-locale/polyfill",
"@formatjs/intl-locale/should-polyfill",
"@formatjs/intl-pluralrules/polyfill-force",
"@formatjs/intl-pluralrules/should-polyfill"
], ],
"scripts": [], "scripts": [],
"vendorChunk": true, "vendorChunk": true,

View File

@ -1,4 +1,4 @@
import { browserSleep, go, isAndroid } from '../utils' import { go } from '../utils'
export class LoginPage { export class LoginPage {
@ -23,20 +23,12 @@ export class LoginPage {
await $('input#username').setValue(username) await $('input#username').setValue(username)
await $('input#password').setValue(password) await $('input#password').setValue(password)
await browserSleep(1000) await browser.pause(1000)
const submit = $('.login-form-and-externals > form input[type=submit]') await $('form input[type=submit]').click()
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) { if (this.isMobileDevice) {
const menuToggle = $('.top-left-block button') const menuToggle = $('.top-left-block span[role=button]')
await $('h2=Our content selection').waitForDisplayed() await $('h2=Our content selection').waitForDisplayed()
@ -87,7 +79,7 @@ export class LoginPage {
await logout.click() await logout.click()
await browser.waitUntil(() => { await browser.waitUntil(() => {
return $$('.login-buttons-block, my-error-page a[href="/login"]').some(e => e.isDisplayed()) return $('.login-buttons-block, my-error-page a[href="/login"]').isDisplayed()
}) })
} }

View File

@ -1,4 +1,4 @@
import { getCheckbox, go, selectCustomSelect } from '../utils' import { getCheckbox, go } from '../utils'
export class MyAccountPage { export class MyAccountPage {
@ -117,26 +117,6 @@ export class MyAccountPage {
return go(url) return go(url)
} }
async updatePlaylistPrivacy (playlistUUID: string, privacy: 'Public' | 'Private' | 'Unlisted') {
go('/my-library/video-playlists/update/' + playlistUUID)
await browser.waitUntil(async () => {
return (await $('form .video-playlist-title').getText() === 'PLAYLIST')
})
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 // My account Videos
private async getVideoElement (name: string) { private async getVideoElement (name: string) {

View File

@ -29,34 +29,29 @@ export class PlayerPage {
} }
async playAndPauseVideo (isAutoplay: boolean, waitUntilSec: number) { async playAndPauseVideo (isAutoplay: boolean, waitUntilSec: number) {
// Autoplay is disabled on mobile and Safari const videojsElem = () => $('div.video-js')
if (isIOS() || isSafari() || isMobileDevice() || isAutoplay === false) {
await this.playVideo()
}
await $('div.video-js.vjs-has-started').waitForExist() await videojsElem().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()) return
// Autoplay is disabled on iOS and Safari // Autoplay is disabled on iOS and Safari
if (isIOS() || isSafari() || isMobileDevice()) { if (isIOS() || isSafari() || isMobileDevice()) {
// We can't play the video if it is not muted // We can't play the video if it is not muted
await browser.execute(`document.querySelector('video').muted = true`) await browser.execute(`document.querySelector('video').muted = true`)
await this.clickOnPlayButton()
} else if (isAutoplay === false) {
await this.clickOnPlayButton()
} }
await browserSleep(2000)
await browser.waitUntil(async () => {
return (await this.getWatchVideoPlayerCurrentTime()) >= waitUntilSec
})
await videojsElem().click()
}
async playVideo () {
return this.clickOnPlayButton() return this.clickOnPlayButton()
} }
@ -66,15 +61,4 @@ export class PlayerPage {
await playButton().waitForClickable() await playButton().waitForClickable()
await playButton().click() 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()
}
} }

View File

@ -62,26 +62,4 @@ export class SignupPage {
await $('#displayName').setValue(options.displayName || `${options.name} channel display name`) await $('#displayName').setValue(options.displayName || `${options.name} channel display name`)
await $('#name').setValue(options.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()
}
} }

View File

@ -2,7 +2,7 @@ export class VideoSearchPage {
async search (search: string) { async search (search: string) {
await $('#search-video').setValue(search) await $('#search-video').setValue(search)
await $('.search-button').click() await $('my-header .icon-search').click()
await browser.waitUntil(() => { await browser.waitUntil(() => {
return $('my-video-miniature').isDisplayed() return $('my-video-miniature').isDisplayed()

View File

@ -64,16 +64,6 @@ export class VideoUploadPage {
return selectCustomSelect('privacy', 'Private') 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 () { private getSecondStepSubmitButton () {
return $('.submit-container my-button') return $('.submit-container my-button')
} }

View File

@ -9,12 +9,11 @@ export class VideoWatchPage {
waitWatchVideoName (videoName: string) { waitWatchVideoName (videoName: string) {
if (this.isSafari) return browserSleep(5000) if (this.isSafari) return browserSleep(5000)
// On mobile we display the first node, on desktop the second one // On mobile we display the first node, on desktop the second
const index = this.isMobileDevice ? 0 : 1 const index = this.isMobileDevice ? 0 : 1
return browser.waitUntil(async () => { return browser.waitUntil(async () => {
return await $('.video-info .video-info-name').isExisting() && return (await $$('.video-info .video-info-name')[index].getText()).includes(videoName)
(await $$('.video-info .video-info-name')[index].getText()).includes(videoName)
}) })
} }
@ -44,25 +43,19 @@ export class VideoWatchPage {
return $('my-privacy-concerns').isDisplayed() return $('my-privacy-concerns').isDisplayed()
} }
async goOnAssociatedEmbed (passwordProtected = false) { async goOnAssociatedEmbed () {
let url = await browser.getUrl() let url = await browser.getUrl()
url = url.replace('/w/', '/videos/embed/') url = url.replace('/w/', '/videos/embed/')
url = url.replace(':3333', ':9001') url = url.replace(':3333', ':9001')
await go(url) await go(url)
await this.waitEmbedForDisplayed()
if (passwordProtected) await this.waitEmbedForVideoPasswordForm()
else await this.waitEmbedForDisplayed()
} }
waitEmbedForDisplayed () { waitEmbedForDisplayed () {
return $('.vjs-big-play-button').waitForDisplayed() return $('.vjs-big-play-button').waitForDisplayed()
} }
waitEmbedForVideoPasswordForm () {
return $('#video-password-input').waitForDisplayed()
}
isEmbedWarningDisplayed () { isEmbedWarningDisplayed () {
return $('.peertube-dock-description').isDisplayed() return $('.peertube-dock-description').isDisplayed()
} }
@ -145,78 +138,4 @@ export class VideoWatchPage {
return elem() 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)
}
} }

View File

@ -31,8 +31,8 @@ describe('Private videos all workflow', () => {
return loginPage.loginOnPeerTube2() return loginPage.loginOnPeerTube2()
}) })
it('Should play an internal web video', async () => { it('Should play an internal webtorrent video', async () => {
await go(FIXTURE_URLS.INTERNAL_WEB_VIDEO) await go(FIXTURE_URLS.INTERNAL_WEBTORRENT_VIDEO)
await videoWatchPage.waitWatchVideoName(internalVideoName) await videoWatchPage.waitWatchVideoName(internalVideoName)
await checkCorrectlyPlay(playerPage) await checkCorrectlyPlay(playerPage)
@ -52,8 +52,8 @@ describe('Private videos all workflow', () => {
await checkCorrectlyPlay(playerPage) await checkCorrectlyPlay(playerPage)
}) })
it('Should play an internal Web Video in embed', async () => { it('Should play an internal WebTorrent video in embed', async () => {
await go(FIXTURE_URLS.INTERNAL_EMBED_WEB_VIDEO) await go(FIXTURE_URLS.INTERNAL_EMBED_WEBTORRENT_VIDEO)
await videoWatchPage.waitEmbedForDisplayed() await videoWatchPage.waitEmbedForDisplayed()
await checkCorrectlyPlay(playerPage) await checkCorrectlyPlay(playerPage)

View File

@ -89,7 +89,7 @@ describe('Videos all workflow', () => {
let videoNameToExcept = videoName let videoNameToExcept = videoName
if (isMobileDevice() || isSafari()) { if (isMobileDevice() || isSafari()) {
await go(FIXTURE_URLS.WEB_VIDEO) await go(FIXTURE_URLS.WEBTORRENT_VIDEO)
videoNameToExcept = 'E2E tests' videoNameToExcept = 'E2E tests'
} else { } else {
await videoListPage.clickOnVideo(videoName) await videoListPage.clickOnVideo(videoName)
@ -176,7 +176,7 @@ describe('Videos all workflow', () => {
await videoWatchPage.waitUntilVideoName(video2Name, 40 * 1000) await videoWatchPage.waitUntilVideoName(video2Name, 40 * 1000)
}) })
it('Should watch the WEB VIDEO playlist in the embed', async () => { it('Should watch the webtorrent playlist in the embed', async () => {
if (isUploadUnsupported()) return if (isUploadUnsupported()) return
const accessToken = await browser.execute(`return window.localStorage.getItem('access_token');`) const accessToken = await browser.execute(`return window.localStorage.getItem('access_token');`)

View File

@ -1,7 +1,7 @@
import { LoginPage } from '../po/login.po' import { LoginPage } from '../po/login.po'
import { VideoUploadPage } from '../po/video-upload.po' import { VideoUploadPage } from '../po/video-upload.po'
import { VideoWatchPage } from '../po/video-watch.po' import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, go, isMobileDevice, isSafari, waitServerUp } from '../utils' import { go, isMobileDevice, isSafari, waitServerUp } from '../utils'
describe('Custom server defaults', () => { describe('Custom server defaults', () => {
let videoUploadPage: VideoUploadPage let videoUploadPage: VideoUploadPage
@ -83,8 +83,4 @@ describe('Custom server defaults', () => {
await checkP2P(false) await checkP2P(false)
}) })
}) })
after(async () => {
await browser.saveScreenshot(getScreenshotPath('after-test.png'))
})
}) })

View File

@ -35,7 +35,7 @@ function checkEndMessage (options: {
} }
{ {
const checkEmail = 'Check your email' const checkEmail = 'Check your emails'
if (requiresEmailVerification) { if (requiresEmailVerification) {
expect(message).toContain(checkEmail) expect(message).toContain(checkEmail)

View File

@ -1,229 +0,0 @@
import { LoginPage } from '../po/login.po'
import { SignupPage } from '../po/signup.po'
import { PlayerPage } from '../po/player.po'
import { VideoUploadPage } from '../po/video-upload.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, go, isMobileDevice, isSafari, waitServerUp } from '../utils'
import { MyAccountPage } from '../po/my-account.po'
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 = /\/([a-f0-9-]+)$/i
const match = url.match(regex)
const uuid = match ? match[1] : null
await myAccountPage.updatePlaylistPrivacy(uuid, 'Public')
})
it('Should watch the playlist', async () => {
await myAccountPage.clickOnPlaylist(playlistName)
await myAccountPage.playPlaylist()
playlistUrl = await browser.getUrl()
await videoWatchPage.waitUntilVideoName(publicVideoName1, 40 * 1000)
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'))
})
})

View File

@ -8,12 +8,6 @@ function isMobileDevice () {
return platformName === 'android' || platformName === 'ios' return platformName === 'android' || platformName === 'ios'
} }
function isAndroid () {
const platformName = (browser.capabilities['platformName'] || '').toLowerCase()
return platformName === 'android'
}
function isSafari () { function isSafari () {
return browser.capabilities['browserName'] && return browser.capabilities['browserName'] &&
browser.capabilities['browserName'].toLowerCase() === 'safari' browser.capabilities['browserName'].toLowerCase() === 'safari'
@ -26,6 +20,7 @@ function isIOS () {
async function go (url: string) { async function go (url: string) {
await browser.url(url) await browser.url(url)
// Hide notifications that could fail tests when hiding buttons
await browser.execute(() => { await browser.execute(() => {
const style = document.createElement('style') const style = document.createElement('style')
style.innerHTML = 'p-toast { display: none }' style.innerHTML = 'p-toast { display: none }'
@ -46,7 +41,6 @@ export {
isMobileDevice, isMobileDevice,
isSafari, isSafari,
isIOS, isIOS,
isAndroid,
waitServerUp, waitServerUp,
go, go,
browserSleep browserSleep

View File

@ -93,14 +93,5 @@ function buildConfig (suiteFile: string = undefined) {
} }
} }
if (filename === 'video-password.e2e-spec.ts') {
return {
signup: {
enabled: true,
limit: -1
}
}
}
return {} return {}
} }

View File

@ -3,8 +3,6 @@ import { join, resolve } from 'path'
function runServer (appInstance: number, config: any = {}) { function runServer (appInstance: number, config: any = {}) {
const env = Object.create(process.env) const env = Object.create(process.env)
env['NODE_OPTIONS'] = ''
env['NODE_ENV'] = 'test' env['NODE_ENV'] = 'test'
env['NODE_APP_INSTANCE'] = appInstance + '' env['NODE_APP_INSTANCE'] = appInstance + ''
@ -45,10 +43,7 @@ function runServer (appInstance: number, config: any = {}) {
function runCommand (command: string) { function runCommand (command: string) {
return new Promise<void>((res, rej) => { return new Promise<void>((res, rej) => {
// Reset NODE_OPTIONS env set by webdriverio const p = exec(command, { cwd: getRootCWD() })
const env = { ...process.env, NODE_OPTIONS: '' }
const p = exec(command, { env, cwd: getRootCWD() })
p.stderr.on('data', data => console.error(data.toString())) p.stderr.on('data', data => console.error(data.toString()))
p.on('error', err => rej(err)) p.on('error', err => rej(err))

View File

@ -1,14 +1,14 @@
const FIXTURE_URLS = { const FIXTURE_URLS = {
INTERNAL_WEB_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?mode=web-video&start=0', INTERNAL_WEBTORRENT_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?mode=webtorrent&start=0',
INTERNAL_HLS_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?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_WEBTORRENT_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?mode=webtorrent&start=0',
INTERNAL_EMBED_HLS_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?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_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', 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', WEBTORRENT_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_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', HLS_PLAYLIST_EMBED: 'https://peertube2.cpy.re/video-playlists/embed/73804a40-da9a-40c2-b1eb-2c6d9eec8f0a',

View File

@ -6,10 +6,6 @@
"esModuleInterop": true, "esModuleInterop": true,
"module": "commonjs", "module": "commonjs",
"target": "es5", "target": "es5",
"typeRoots": [
"../node_modules/@types",
"../node_modules"
],
"types": [ "types": [
"node", "node",
"@wdio/globals/types", "@wdio/globals/types",

View File

@ -17,32 +17,18 @@ function buildMainOptions (sessionName: string) {
} }
} }
function buildBStackDesktopOptions (options: { function buildBStackDesktopOptions (sessionName: string, resolution: string, os?: string) {
sessionName: string
resolution: string
os?: string
osVersion?: string
}) {
const { sessionName, resolution, os, osVersion } = options
return { return {
'bstack:options': { 'bstack:options': {
...buildMainOptions(sessionName), ...buildMainOptions(sessionName),
os, os,
osVersion,
resolution resolution
} }
} }
} }
function buildBStackMobileOptions (options: { function buildBStackMobileOptions (sessionName: string, deviceName: string, osVersion: string) {
sessionName: string
deviceName: string
osVersion: string
}) {
const { sessionName, deviceName, osVersion } = options
return { return {
'bstack:options': { 'bstack:options': {
...buildMainOptions(sessionName), ...buildMainOptions(sessionName),
@ -67,45 +53,45 @@ module.exports = {
{ {
browserName: 'Chrome', browserName: 'Chrome',
...buildBStackDesktopOptions({ sessionName: 'Latest Chrome Desktop', resolution: '1280x1024', os: 'Windows', osVersion: '8' }) ...buildBStackDesktopOptions('Latest Chrome Desktop', '1280x1024')
}, },
{ {
browserName: 'Firefox', browserName: 'Firefox',
browserVersion: '78', // Very old ESR browserVersion: '78', // Very old ESR
...buildBStackDesktopOptions({ sessionName: 'Firefox ESR Desktop', resolution: '1280x1024', os: 'Windows', osVersion: '8' }) ...buildBStackDesktopOptions('Firefox ESR Desktop', '1280x1024', 'Windows')
}, },
{ {
browserName: 'Safari', browserName: 'Safari',
browserVersion: '12.1', browserVersion: '12.1',
...buildBStackDesktopOptions({ sessionName: 'Safari Desktop', resolution: '1280x1024' }) ...buildBStackDesktopOptions('Safari Desktop', '1280x1024')
}, },
{ {
browserName: 'Firefox', browserName: 'Firefox',
...buildBStackDesktopOptions({ sessionName: 'Firefox Latest', resolution: '1280x1024', os: 'Windows', osVersion: '8' }) ...buildBStackDesktopOptions('Firefox Latest', '1280x1024')
}, },
{ {
browserName: 'Edge', browserName: 'Edge',
...buildBStackDesktopOptions({ sessionName: 'Edge Latest', resolution: '1280x1024' }) ...buildBStackDesktopOptions('Edge Latest', '1280x1024')
}, },
{ {
browserName: 'Chrome', browserName: 'Chrome',
...buildBStackMobileOptions({ sessionName: 'Latest Chrome Android', deviceName: 'Samsung Galaxy S8', osVersion: '7.0' }) ...buildBStackMobileOptions('Latest Chrome Android', 'Samsung Galaxy S8', '7.0')
}, },
{ {
browserName: 'Safari', browserName: 'Safari',
...buildBStackMobileOptions({ sessionName: 'Safari iPhone', deviceName: 'iPhone 8 Plus', osVersion: '12.4' }) ...buildBStackMobileOptions('Safari iPhone', 'iPhone 8 Plus', '12.4')
}, },
{ {
browserName: 'Safari', browserName: 'Safari',
...buildBStackMobileOptions({ sessionName: 'Safari iPad', deviceName: 'iPad 7th', osVersion: '13' }) ...buildBStackMobileOptions('Safari iPad', 'iPad 7th', '13')
} }
], ],

View File

@ -28,7 +28,7 @@ module.exports = {
'browserName': 'chrome', 'browserName': 'chrome',
'acceptInsecureCerts': true, 'acceptInsecureCerts': true,
'goog:chromeOptions': { 'goog:chromeOptions': {
args: [ '--headless', '--disable-gpu', windowSizeArg ], args: [ '--disable-gpu', windowSizeArg ],
prefs prefs
} }
}, },
@ -43,7 +43,7 @@ module.exports = {
} }
], ],
services: [ 'shared-store' ], services: [ 'chromedriver', 'geckodriver', 'shared-store' ],
beforeSession: beforeLocalSession, beforeSession: beforeLocalSession,
beforeSuite: beforeLocalSuite, beforeSuite: beforeLocalSuite,

View File

@ -22,7 +22,6 @@ module.exports = {
{ {
'browserName': 'chrome', 'browserName': 'chrome',
'goog:chromeOptions': { 'goog:chromeOptions': {
binary: '/usr/bin/google-chrome-stable',
args: [ '--headless', '--disable-gpu', windowSizeArg ], args: [ '--headless', '--disable-gpu', windowSizeArg ],
prefs prefs
} }
@ -38,7 +37,7 @@ module.exports = {
} }
], ],
services: [ 'shared-store' ], services: [ 'chromedriver', 'geckodriver', 'shared-store' ],
beforeSession: beforeLocalSession, beforeSession: beforeLocalSession,
beforeSuite: beforeLocalSuite, beforeSuite: beforeLocalSuite,

View File

@ -59,7 +59,7 @@ export const config = {
// with `/`, the base url gets prepended, not including the path portion of your baseUrl. // 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 // If your `url` parameter starts without a scheme or `/` (like `some/path`), the base url
// gets prepended directly. // gets prepended directly.
baseUrl: 'http://127.0.0.1:9001', baseUrl: 'http://localhost:9001',
// //
// Default timeout for all waitFor* commands. // Default timeout for all waitFor* commands.
waitforTimeout: 5000, waitforTimeout: 5000,
@ -80,7 +80,7 @@ export const config = {
framework: 'mocha', framework: 'mocha',
// //
// The number of times to retry the entire specfile when it fails as a whole // The number of times to retry the entire specfile when it fails as a whole
specFileRetries: 2, specFileRetries: 1,
// //
// Delay in seconds between the spec file retry attempts // Delay in seconds between the spec file retry attempts
// specFileRetriesDelay: 0, // specFileRetriesDelay: 0,
@ -107,6 +107,14 @@ export const config = {
tsNodeOpts: { tsNodeOpts: {
project: require('path').join(__dirname, './tsconfig.json') project: require('path').join(__dirname, './tsconfig.json')
},
tsConfigPathsOpts: {
baseUrl: './',
paths: {
'@server/*': [ '../../server/*' ],
'@shared/*': [ '../../shared/*' ]
}
} }
}, },

View File

@ -1,6 +1,6 @@
{ {
"name": "peertube-client", "name": "peertube-client",
"version": "6.0.1", "version": "5.2.1",
"private": true, "private": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"author": { "author": {
@ -14,7 +14,7 @@
}, },
"scripts": { "scripts": {
"lint": "npm run lint-ts && npm run lint-scss", "lint": "npm run lint-ts && npm run lint-scss",
"lint-ts": "eslint --cache --ext .ts src/standalone/**/*.ts && npm run ng lint", "lint-ts": "eslint --ext .ts src/standalone/**/*.ts && npm run ng lint",
"lint-scss": "stylelint 'src/**/*.scss'", "lint-scss": "stylelint 'src/**/*.scss'",
"webpack": "webpack", "webpack": "webpack",
"eslint": "eslint", "eslint": "eslint",
@ -24,9 +24,6 @@
"ngx-extractor": "ngx-extractor", "ngx-extractor": "ngx-extractor",
"stylelint": "stylelint" "stylelint": "stylelint"
}, },
"workspaces": [
"../packages/*"
],
"typings": "*.d.ts", "typings": "*.d.ts",
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^16.0.2", "@angular-devkit/build-angular": "^16.0.2",
@ -50,18 +47,14 @@
"@angular/service-worker": "^16.0.2", "@angular/service-worker": "^16.0.2",
"@babel/core": "^7.18.5", "@babel/core": "^7.18.5",
"@babel/preset-env": "^7.18.2", "@babel/preset-env": "^7.18.2",
"@formatjs/intl-locale": "^3.3.1", "@ng-bootstrap/ng-bootstrap": "^14.0.1",
"@formatjs/intl-pluralrules": "^5.2.2", "@ng-select/ng-select": "^10.0.3",
"@ng-bootstrap/ng-bootstrap": "^15.1.1",
"@ng-select/ng-select": "^11.2.0",
"@ngx-loading-bar/core": "^6.0.0", "@ngx-loading-bar/core": "^6.0.0",
"@ngx-loading-bar/http-client": "^6.0.0", "@ngx-loading-bar/http-client": "^6.0.0",
"@ngx-loading-bar/router": "^6.0.0", "@ngx-loading-bar/router": "^6.0.0",
"@peertube/maildev": "^1.2.0", "@peertube/maildev": "^1.2.0",
"@peertube/p2p-media-loader-core": "^1.0.15", "@peertube/p2p-media-loader-core": "^1.0.14",
"@peertube/p2p-media-loader-hlsjs": "^1.0.15", "@peertube/p2p-media-loader-hlsjs": "^1.0.14",
"@peertube/peertube-core-utils": "*",
"@peertube/peertube-models": "*",
"@peertube/videojs-contextmenu": "^5.5.0", "@peertube/videojs-contextmenu": "^5.5.0",
"@peertube/xliffmerge": "^2.0.3", "@peertube/xliffmerge": "^2.0.3",
"@popperjs/core": "^2.11.5", "@popperjs/core": "^2.11.5",
@ -71,48 +64,58 @@
"@types/jschannel": "^1.0.0", "@types/jschannel": "^1.0.0",
"@types/linkifyjs": "^2.1.2", "@types/linkifyjs": "^2.1.2",
"@types/lodash-es": "^4.17.0", "@types/lodash-es": "^4.17.0",
"@types/markdown-it": "^13.0.2", "@types/markdown-it": "^12.0.1",
"@types/node": "^18.13.0", "@types/node": "^18.13.0",
"@types/sanitize-html": "2.9.2", "@types/sanitize-html": "2.6.2",
"@types/sha.js": "^2.4.0", "@types/sha.js": "^2.4.0",
"@types/video.js": "^7.3.40", "@types/video.js": "^7.3.40",
"@typescript-eslint/eslint-plugin": "^6.7.5", "@types/webtorrent": "^0.109.0",
"@typescript-eslint/parser": "^6.7.5", "@typescript-eslint/eslint-plugin": "^5.43.0",
"@typescript-eslint/parser": "^5.43.0",
"@wdio/browserstack-service": "^8.10.5", "@wdio/browserstack-service": "^8.10.5",
"@wdio/cli": "^8.10.5", "@wdio/cli": "^8.10.5",
"@wdio/local-runner": "^8.10.5", "@wdio/local-runner": "^8.10.5",
"@wdio/mocha-framework": "^8.10.4", "@wdio/mocha-framework": "^8.10.4",
"@wdio/shared-store-service": "^8.10.5", "@wdio/shared-store-service": "^8.10.5",
"@wdio/spec-reporter": "^8.10.5", "@wdio/spec-reporter": "^8.10.5",
"angular2-hotkeys": "^13.1.0",
"angularx-qrcode": "16.0.0", "angularx-qrcode": "16.0.0",
"babel-loader": "^9.1.0", "babel-loader": "^9.1.0",
"bootstrap": "^5.1.3", "bootstrap": "^5.1.3",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"cache-chunk-store": "^3.0.0",
"chart.js": "^4.3.0", "chart.js": "^4.3.0",
"chartjs-plugin-zoom": "~2.0.1", "chartjs-plugin-zoom": "~2.0.1",
"chromedriver": "^113.0.0",
"core-js": "^3.22.8", "core-js": "^3.22.8",
"css-loader": "^6.2.0", "css-loader": "^6.2.0",
"debug": "^4.3.1", "debug": "^4.3.1",
"dexie": "^3.2.2",
"eslint": "^8.28.0", "eslint": "^8.28.0",
"eslint-plugin-import": "2.28.1", "eslint-plugin-import": "2.27.5",
"eslint-plugin-jsdoc": "^46.8.2", "eslint-plugin-jsdoc": "^44.2.4",
"eslint-plugin-prefer-arrow": "latest", "eslint-plugin-prefer-arrow": "latest",
"expect-webdriverio": "^4.2.3", "expect-webdriverio": "^4.2.3",
"focus-visible": "^5.0.2", "focus-visible": "^5.0.2",
"geckodriver": "^4.0.0",
"hls.js": "~1.3", "hls.js": "~1.3",
"html-loader": "^4.1.0", "html-loader": "^4.1.0",
"html-webpack-plugin": "^5.3.1", "html-webpack-plugin": "^5.3.1",
"https-browserify": "^1.0.0",
"intl-messageformat": "^10.1.0", "intl-messageformat": "^10.1.0",
"jschannel": "^1.0.2", "jschannel": "^1.0.2",
"linkify-html": "^4.0.2", "linkify-html": "^4.0.2",
"linkifyjs": "^4.0.2", "linkifyjs": "^4.0.2",
"lodash-es": "^4.17.4", "lodash-es": "^4.17.4",
"markdown-it": "13.0.2", "markdown-it": "13.0.1",
"mini-css-extract-plugin": "^2.2.0", "mini-css-extract-plugin": "^2.2.0",
"ngx-uploadx": "^6.1.0", "ngx-uploadx": "^6.1.0",
"path-browserify": "^1.0.0", "path-browserify": "^1.0.0",
"postcss": "^8.4.14", "postcss": "^8.4.14",
"primeng": "^16.0.0-rc.2", "primeng": "^16.0.0-rc.2",
"process": "^0.11.10",
"purify-css": "^1.2.5",
"querystring": "^0.2.1",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"rxjs": "^7.3.0", "rxjs": "^7.3.0",
"sanitize-html": "^2.1.2", "sanitize-html": "^2.1.2",
@ -120,17 +123,23 @@
"sass-loader": "^13.2.0", "sass-loader": "^13.2.0",
"sha.js": "^2.4.11", "sha.js": "^2.4.11",
"socket.io-client": "^4.5.4", "socket.io-client": "^4.5.4",
"stream-browserify": "^3.0.0",
"stream-http": "^3.0.0",
"stylelint": "^15.1.0", "stylelint": "^15.1.0",
"stylelint-config-sass-guidelines": "^10.0.0", "stylelint-config-sass-guidelines": "^10.0.0",
"tinykeys": "^2.1.0",
"ts-loader": "^9.3.0", "ts-loader": "^9.3.0",
"ts-node": "^10.9.1",
"tslib": "^2.4.0", "tslib": "^2.4.0",
"typescript": "~5.1.0", "typescript": "~4.9.5",
"url": "^0.11.0",
"video.js": "^7.19.2", "video.js": "^7.19.2",
"videostream": "~3.2.1",
"wdio-chromedriver-service": "^8.1.1",
"wdio-geckodriver-service": "^5.0.1",
"webpack": "^5.73.0", "webpack": "^5.73.0",
"webpack-bundle-analyzer": "^4.4.2", "webpack-bundle-analyzer": "^4.4.2",
"webpack-cli": "^5.0.1", "webpack-cli": "^5.0.1",
"webtorrent": "1.8.26",
"whatwg-fetch": "^3.0.0",
"zone.js": "~0.13.0" "zone.js": "~0.13.0"
}, },
"dependencies": {} "dependencies": {}

View File

@ -2,7 +2,7 @@ import { SortMeta } from 'primeng/api'
import { Component, OnInit } from '@angular/core' import { Component, OnInit } from '@angular/core'
import { ComponentPagination, hasMoreItems, Notifier, RestService, ServerService } from '@app/core' import { ComponentPagination, hasMoreItems, Notifier, RestService, ServerService } from '@app/core'
import { InstanceFollowService } from '@app/shared/shared-instance' import { InstanceFollowService } from '@app/shared/shared-instance'
import { Actor } from '@peertube/peertube-models' import { Actor } from '@shared/models/actors'
@Component({ @Component({
selector: 'my-about-follows', selector: 'my-about-follows',

View File

@ -3,8 +3,8 @@ import { AfterViewChecked, Component, ElementRef, OnInit, ViewChild } from '@ang
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { Notifier, ServerService } from '@app/core' import { Notifier, ServerService } from '@app/core'
import { AboutHTML } from '@app/shared/shared-instance' import { AboutHTML } from '@app/shared/shared-instance'
import { HTMLServerConfig, ServerStats } from '@peertube/peertube-models'
import { copyToClipboard } from '@root-helpers/utils' import { copyToClipboard } from '@root-helpers/utils'
import { HTMLServerConfig, ServerStats } from '@shared/models/server'
import { ResolverData } from './about-instance.resolver' import { ResolverData } from './about-instance.resolver'
import { ContactAdminModalComponent } from './contact-admin-modal.component' import { ContactAdminModalComponent } from './contact-admin-modal.component'

View File

@ -4,7 +4,7 @@ import { Injectable } from '@angular/core'
import { ServerService } from '@app/core' import { ServerService } from '@app/core'
import { CustomMarkupService } from '@app/shared/shared-custom-markup' import { CustomMarkupService } from '@app/shared/shared-custom-markup'
import { AboutHTML, InstanceService } from '@app/shared/shared-instance' import { AboutHTML, InstanceService } from '@app/shared/shared-instance'
import { About, ServerStats } from '@peertube/peertube-models' import { About, ServerStats } from '@shared/models/server'
export type ResolverData = { export type ResolverData = {
serverStats: ServerStats serverStats: ServerStats

View File

@ -1,10 +1,7 @@
<ng-template #modal> <ng-template #modal>
<div class="modal-header"> <div class="modal-header">
<h1 i18n class="modal-title">Contact the administrator(s)<p class="modal-subtitle">{{ instanceName }}</p></h1> <h1 i18n class="modal-title">Contact the administrator(s)<p class="modal-subtitle">{{ instanceName }}</p></h1>
<my-global-icon iconName="cross" aria-label="Close" tabindex="0" role="button" (click)="hide()" (keydown.enter)="hide()"></my-global-icon>
<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>
<div class="modal-body"> <div class="modal-body">
@ -15,9 +12,8 @@
<input <input
type="text" id="fromName" class="form-control" type="text" id="fromName" class="form-control"
formControlName="fromName" [ngClass]="{ 'input-error': formErrors.fromName }" formControlName="fromName" [ngClass]="{ 'input-error': formErrors.fromName }"
autocomplete="name"
> >
<div *ngIf="formErrors.fromName" class="form-error" role="alert">{{ formErrors.fromName }}</div> <div *ngIf="formErrors.fromName" class="form-error">{{ formErrors.fromName }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -25,9 +21,8 @@
<input <input
type="text" id="fromEmail" class="form-control" type="text" id="fromEmail" class="form-control"
formControlName="fromEmail" [ngClass]="{ 'input-error': formErrors['fromEmail'] }" 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 *ngIf="formErrors.fromEmail" class="form-error">{{ formErrors.fromEmail }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -36,14 +31,14 @@
type="text" id="subject" class="form-control" type="text" id="subject" class="form-control"
formControlName="subject" [ngClass]="{ 'input-error': formErrors['subject'] }" formControlName="subject" [ngClass]="{ 'input-error': formErrors['subject'] }"
> >
<div *ngIf="formErrors.subject" class="form-error" role="alert">{{ formErrors.subject }}</div> <div *ngIf="formErrors.subject" class="form-error">{{ formErrors.subject }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label i18n for="body">Your message</label> <label i18n for="body">Your message</label>
<textarea id="body" formControlName="body" class="form-control" [ngClass]="{ 'input-error': formErrors['body'] }"> <textarea id="body" formControlName="body" class="form-control" [ngClass]="{ 'input-error': formErrors['body'] }">
</textarea> </textarea>
<div *ngIf="formErrors.body" class="form-error" role="alert">{{ formErrors.body }}</div> <div *ngIf="formErrors.body" class="form-error">{{ formErrors.body }}</div>
</div> </div>
<div *ngIf="error" class="alert alert-danger">{{ error }}</div> <div *ngIf="error" class="alert alert-danger">{{ error }}</div>

View File

@ -11,7 +11,7 @@ import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { InstanceService } from '@app/shared/shared-instance' import { InstanceService } from '@app/shared/shared-instance'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
import { HTMLServerConfig, HttpStatusCode } from '@peertube/peertube-models' import { HTMLServerConfig, HttpStatusCode } from '@shared/models'
type Prefill = { type Prefill = {
subject?: string subject?: string

View File

@ -1,5 +1,5 @@
import { Component, Input } from '@angular/core' import { Component, Input } from '@angular/core'
import { ServerStats } from '@peertube/peertube-models' import { ServerStats } from '@shared/models/server'
@Component({ @Component({
selector: 'my-instance-statistics', selector: 'my-instance-statistics',

View File

@ -2,10 +2,10 @@ import { from, Subject, Subscription } from 'rxjs'
import { concatMap, map, switchMap, tap } from 'rxjs/operators' import { concatMap, map, switchMap, tap } from 'rxjs/operators'
import { Component, OnDestroy, OnInit } from '@angular/core' import { Component, OnDestroy, OnInit } from '@angular/core'
import { ComponentPagination, hasMoreItems, MarkdownService, User, UserService } from '@app/core' import { ComponentPagination, hasMoreItems, MarkdownService, User, UserService } from '@app/core'
import { SimpleMemoize } from '@app/helpers'
import { Account, AccountService, Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main' import { Account, AccountService, Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature' import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
import { NSFWPolicyType, VideoSortField } from '@peertube/peertube-models' import { NSFWPolicyType, VideoSortField } from '@shared/models'
import { SimpleMemoize } from '@app/helpers'
@Component({ @Component({
selector: 'my-account-video-channels', selector: 'my-account-video-channels',
@ -98,7 +98,7 @@ export class AccountVideoChannelsComponent implements OnInit, OnDestroy {
videoChannel, videoChannel,
videoPagination: this.videosPagination, videoPagination: this.videosPagination,
sort: this.videosSort, sort: this.videosSort,
nsfw: this.videoService.nsfwPolicyToParam(this.nsfwPolicy) nsfwPolicy: this.nsfwPolicy
} }
return this.videoService.getVideoChannelVideos(options) return this.videoService.getVideoChannelVideos(options)

View File

@ -4,7 +4,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'
import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core' import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core'
import { Account, AccountService, VideoService } from '@app/shared/shared-main' import { Account, AccountService, VideoService } from '@app/shared/shared-main'
import { VideoFilters } from '@app/shared/shared-video-miniature' import { VideoFilters } from '@app/shared/shared-video-miniature'
import { VideoSortField } from '@peertube/peertube-models' import { VideoSortField } from '@shared/models'
@Component({ @Component({
selector: 'my-account-videos', selector: 'my-account-videos',

View File

@ -18,18 +18,18 @@
(userChanged)="onUserChanged()" (userDeleted)="onUserDeleted()" (userChanged)="onUserChanged()" (userDeleted)="onUserDeleted()"
></my-user-moderation-dropdown> ></my-user-moderation-dropdown>
<span *ngIf="accountUser?.blocked" tabindex="0" [ngbTooltip]="accountUser.blockedReason" class="pt-badge badge-danger" i18n>Banned</span> <span *ngIf="accountUser?.blocked" [ngbTooltip]="accountUser.blockedReason" class="pt-badge badge-danger" i18n>Banned</span>
<my-account-block-badges [account]="account"></my-account-block-badges> <my-account-block-badges [account]="account"></my-account-block-badges>
</div> </div>
<div class="actor-handle"> <div class="actor-handle">
<span>@{{ account.nameWithHost }}</span> <span>@{{ account.nameWithHost }}</span>
<button [cdkCopyToClipboard]="account.nameWithHostForced" (click)="activateCopiedMessage()"
<my-copy-button class="btn btn-outline-secondary btn-sm copy-button" title="Copy account handle" i18n-title
[value]="account.nameWithHostForced" i18n-notification notification="Username copied" >
title="Copy account handle" i18n-title <my-global-icon iconName="copy"></my-global-icon>
></my-copy-button> </button>
</div> </div>
<div class="actor-counters"> <div class="actor-counters">

View File

@ -28,8 +28,14 @@
} }
} }
my-copy-button { .copy-button {
@include margin-left(3px); @include margin-left(3px);
border: 0;
my-global-icon {
width: 15px;
}
} }
.account-info { .account-info {

View File

@ -13,7 +13,7 @@ import {
VideoService VideoService
} from '@app/shared/shared-main' } from '@app/shared/shared-main'
import { AccountReportComponent, BlocklistService } from '@app/shared/shared-moderation' import { AccountReportComponent, BlocklistService } from '@app/shared/shared-moderation'
import { HttpStatusCode, User, UserRight } from '@peertube/peertube-models' import { HttpStatusCode, User, UserRight } from '@shared/models'
@Component({ @Component({
templateUrl: './accounts.component.html', templateUrl: './accounts.component.html',
@ -115,6 +115,10 @@ export class AccountsComponent implements OnInit, OnDestroy {
this.redirectService.redirectToHomepage() this.redirectService.redirectToHomepage()
} }
activateCopiedMessage () {
this.notifier.success($localize`Username copied`)
}
searchChanged (search: string) { searchChanged (search: string) {
const queryParams = { search } const queryParams = { search }

View File

@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core'
import { AuthService, ScreenService, ServerService } from '@app/core' import { AuthService, ScreenService, ServerService } from '@app/core'
import { ListOverflowItem } from '@app/shared/shared-main' import { ListOverflowItem } from '@app/shared/shared-main'
import { TopMenuDropdownParam } from '@app/shared/shared-main/misc/top-menu-dropdown.component' import { TopMenuDropdownParam } from '@app/shared/shared-main/misc/top-menu-dropdown.component'
import { UserRight } from '@peertube/peertube-models' import { UserRight } from '@shared/models'
@Component({ @Component({
templateUrl: './admin.component.html', templateUrl: './admin.component.html',

View File

@ -1,7 +1,7 @@
import { Routes } from '@angular/router' import { Routes } from '@angular/router'
import { EditCustomConfigComponent } from '@app/+admin/config/edit-custom-config' import { EditCustomConfigComponent } from '@app/+admin/config/edit-custom-config'
import { UserRightGuard } from '@app/core' import { UserRightGuard } from '@app/core'
import { UserRight } from '@peertube/peertube-models' import { UserRight } from '@shared/models'
export const ConfigRoutes: Routes = [ export const ConfigRoutes: Routes = [
{ {

View File

@ -22,7 +22,7 @@
<span i18n>{getCacheSize('previews'), plural, =1 {cached image} other {cached images}}</span> <span i18n>{getCacheSize('previews'), plural, =1 {cached image} other {cached images}}</span>
</div> </div>
<div *ngIf="formErrors.cache.previews.size" class="form-error" role="alert">{{ formErrors.cache.previews.size }}</div> <div *ngIf="formErrors.cache.previews.size" class="form-error">{{ formErrors.cache.previews.size }}</div>
</div> </div>
<div class="form-group" formGroupName="captions"> <div class="form-group" formGroupName="captions">
@ -36,7 +36,7 @@
<span i18n>{getCacheSize('captions'), plural, =1 {cached caption} other {cached captions}}</span> <span i18n>{getCacheSize('captions'), plural, =1 {cached caption} other {cached captions}}</span>
</div> </div>
<div *ngIf="formErrors.cache.captions.size" class="form-error" role="alert">{{ formErrors.cache.captions.size }}</div> <div *ngIf="formErrors.cache.captions.size" class="form-error">{{ formErrors.cache.captions.size }}</div>
</div> </div>
<div class="form-group" formGroupName="torrents"> <div class="form-group" formGroupName="torrents">
@ -50,21 +50,7 @@
<span i18n>{getCacheSize('torrents'), plural, =1 {cached torrent} other {cached torrents}}</span> <span i18n>{getCacheSize('torrents'), plural, =1 {cached torrent} other {cached torrents}}</span>
</div> </div>
<div *ngIf="formErrors.cache.torrents.size" class="form-error" role="alert">{{ formErrors.cache.torrents.size }}</div> <div *ngIf="formErrors.cache.torrents.size" class="form-error">{{ 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> </div>
</ng-container> </ng-container>
@ -88,18 +74,17 @@
<my-help> <my-help>
<ng-template ptTemplate="customHtml"> <ng-template ptTemplate="customHtml">
<ng-container i18n> <ng-container i18n>
<p class="mb-2">Write JavaScript code directly. Example:</p> Write JavaScript code directly.<br />Example: <pre>console.log('my instance is amazing');</pre>
<pre>console.log('my instance is amazing');</pre>
</ng-container> </ng-container>
</ng-template> </ng-template>
</my-help> </my-help>
<textarea <textarea
id="customizationJavascript" formControlName="javascript" class="form-control" dir="ltr" id="customizationJavascript" formControlName="javascript" class="form-control"
[ngClass]="{ 'input-error': formErrors['instance.customizations.javascript'] }" [ngClass]="{ 'input-error': formErrors['instance.customizations.javascript'] }"
></textarea> ></textarea>
<div *ngIf="formErrors.instance.customizations.javascript" class="form-error" role="alert">{{ formErrors.instance.customizations.javascript }}</div> <div *ngIf="formErrors.instance.customizations.javascript" class="form-error">{{ formErrors.instance.customizations.javascript }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -108,16 +93,16 @@
<my-help> <my-help>
<ng-template ptTemplate="customHtml"> <ng-template ptTemplate="customHtml">
<ng-container i18n> <ng-container i18n>
<p class="mb-2">Write CSS code directly. Example:</p> Write CSS code directly. Example:<br /><br />
<pre> <pre>
#custom-css {{ '{' }} #custom-css {{ '{' }}
color: red; color: red;
{{ '}' }} {{ '}' }}
</pre> </pre>
<p class="mb-2">Prepend with <em>#custom-css</em> to override styles. Example:</p> Prepend with <em>#custom-css</em> to override styles. Example:<br /><br />
<pre> <pre>
#custom-css .logged-in-email {{ '{' }} #custom-css .logged-in-email {{ '{' }}
color: red; color: red;
{{ '}' }} {{ '}' }}
</pre> </pre>
</ng-container> </ng-container>
@ -125,10 +110,10 @@
</my-help> </my-help>
<textarea <textarea
id="customizationCSS" formControlName="css" class="form-control" dir="ltr" id="customizationCSS" formControlName="css" class="form-control"
[ngClass]="{ 'input-error': formErrors['instance.customizations.css'] }" [ngClass]="{ 'input-error': formErrors['instance.customizations.css'] }"
></textarea> ></textarea>
<div *ngIf="formErrors.instance.customizations.css" class="form-error" role="alert">{{ formErrors.instance.customizations.css }}</div> <div *ngIf="formErrors.instance.customizations.css" class="form-error">{{ formErrors.instance.customizations.css }}</div>
</div> </div>
</ng-container> </ng-container>
</ng-container> </ng-container>

View File

@ -10,7 +10,7 @@ export class EditAdvancedConfigurationComponent {
@Input() form: FormGroup @Input() form: FormGroup
@Input() formErrors: any @Input() formErrors: any
getCacheSize (type: 'captions' | 'previews' | 'torrents' | 'storyboards') { getCacheSize (type: 'captions' | 'previews' | 'torrents') {
return this.form.value['cache'][type]['size'] return this.form.value['cache'][type]['size']
} }
} }

View File

@ -34,7 +34,7 @@
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
<div *ngIf="formErrors.instance.defaultClientRoute" class="form-error" role="alert">{{ formErrors.instance.defaultClientRoute }}</div> <div *ngIf="formErrors.instance.defaultClientRoute" class="form-error">{{ formErrors.instance.defaultClientRoute }}</div>
</div> </div>
<div class="form-group" formGroupName="trending"> <div class="form-group" formGroupName="trending">
@ -51,7 +51,7 @@
</select> </select>
</div> </div>
<div *ngIf="formErrors.trending.videos.algorithms.default" class="form-error" role="alert">{{ formErrors.trending.videos.algorithms.default }}</div> <div *ngIf="formErrors.trending.videos.algorithms.default" class="form-error">{{ formErrors.trending.videos.algorithms.default }}</div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>
@ -126,7 +126,7 @@
</select> </select>
</div> </div>
<div *ngIf="formErrors.broadcastMessage.level" class="form-error" role="alert">{{ formErrors.broadcastMessage.level }}</div> <div *ngIf="formErrors.broadcastMessage.level" class="form-error">{{ formErrors.broadcastMessage.level }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -137,7 +137,7 @@
[formError]="formErrors['broadcastMessage.message']" markdownType="to-unsafe-html" [formError]="formErrors['broadcastMessage.message']" markdownType="to-unsafe-html"
></my-markdown-textarea> ></my-markdown-textarea>
<div *ngIf="formErrors.broadcastMessage.message" class="form-error" role="alert">{{ formErrors.broadcastMessage.message }}</div> <div *ngIf="formErrors.broadcastMessage.message" class="form-error">{{ formErrors.broadcastMessage.message }}</div>
</div> </div>
</ng-container> </ng-container>
@ -193,7 +193,7 @@
<span i18n>{form.value['signup']['limit'], plural, =1 {user} other {users}}</span> <span i18n>{form.value['signup']['limit'], plural, =1 {user} other {users}}</span>
</div> </div>
<div *ngIf="formErrors.signup.limit" class="form-error" role="alert">{{ formErrors.signup.limit }}</div> <div *ngIf="formErrors.signup.limit" class="form-error">{{ formErrors.signup.limit }}</div>
<small i18n *ngIf="hasUnlimitedSignup()" class="muted small">Signup won't be limited to a fixed number of users.</small> <small i18n *ngIf="hasUnlimitedSignup()" class="muted small">Signup won't be limited to a fixed number of users.</small>
</div> </div>
@ -209,7 +209,7 @@
<span i18n>{form.value['signup']['minimumAge'], plural, =1 {year old} other {years old}}</span> <span i18n>{form.value['signup']['minimumAge'], plural, =1 {year old} other {years old}}</span>
</div> </div>
<div *ngIf="formErrors.signup.minimumAge" class="form-error" role="alert">{{ formErrors.signup.minimumAge }}</div> <div *ngIf="formErrors.signup.minimumAge" class="form-error">{{ formErrors.signup.minimumAge }}</div>
</div> </div>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
@ -230,7 +230,7 @@
<my-user-real-quota-info [videoQuota]="getUserVideoQuota()"></my-user-real-quota-info> <my-user-real-quota-info [videoQuota]="getUserVideoQuota()"></my-user-real-quota-info>
<div *ngIf="formErrors.user.videoQuota" class="form-error" role="alert">{{ formErrors.user.videoQuota }}</div> <div *ngIf="formErrors.user.videoQuota" class="form-error">{{ formErrors.user.videoQuota }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -244,7 +244,7 @@
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
<div *ngIf="formErrors.user.videoQuotaDaily" class="form-error" role="alert">{{ formErrors.user.videoQuotaDaily }}</div> <div *ngIf="formErrors.user.videoQuotaDaily" class="form-error">{{ formErrors.user.videoQuotaDaily }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<ng-container formGroupName="history"> <ng-container formGroupName="history">
@ -282,7 +282,7 @@
<span i18n>jobs in parallel</span> <span i18n>jobs in parallel</span>
</div> </div>
<div *ngIf="formErrors.import.concurrency" class="form-error" role="alert">{{ formErrors.import.concurrency }}</div> <div *ngIf="formErrors.import.concurrency" class="form-error">{{ formErrors.import.concurrency }}</div>
</div> </div>
<div class="form-group" formGroupName="http"> <div class="form-group" formGroupName="http">
@ -313,7 +313,7 @@
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="importSynchronizationEnabled" formControlName="enabled" inputName="importSynchronizationEnabled" formControlName="enabled"
i18n-labelText labelText="Allow channel synchronization with channel of other platforms like YouTube" i18n-labelText labelText="Allow channel synchronization with channel of other platforms like YouTube (requires allowing import with HTTP URL)"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n [hidden]="isImportVideosHttpEnabled()"> <span i18n [hidden]="isImportVideosHttpEnabled()">
@ -345,18 +345,6 @@
</ng-container> </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>
</div> </div>
</div> </div>
@ -377,7 +365,7 @@
<span i18n>{form.value['videoChannels']['maxPerUser'], plural, =1 {channel} other {channels}}</span> <span i18n>{form.value['videoChannels']['maxPerUser'], plural, =1 {channel} other {channels}}</span>
</div> </div>
<div *ngIf="formErrors.videoChannels.maxPerUser" class="form-error" role="alert">{{ formErrors.videoChannels.maxPerUser }}</div> <div *ngIf="formErrors.videoChannels.maxPerUser" class="form-error">{{ formErrors.videoChannels.maxPerUser }}</div>
</div> </div>
</div> </div>
</div> </div>
@ -439,7 +427,7 @@
formControlName="url" [ngClass]="{ 'input-error': formErrors['search.searchIndex.url'] }" 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 *ngIf="formErrors.search.searchIndex.url" class="form-error">{{ formErrors.search.searchIndex.url }}</div>
</div> </div>
<div class="mt-3"> <div class="mt-3">
@ -537,7 +525,7 @@
type="text" id="followingsInstanceAutoFollowIndexUrl" class="form-control" type="text" id="followingsInstanceAutoFollowIndexUrl" class="form-control"
formControlName="indexUrl" [ngClass]="{ 'input-error': formErrors['followings.instance.autoFollowIndex.indexUrl'] }" 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 *ngIf="formErrors.followings.instance.autoFollowIndex.indexUrl" class="form-error">{{ formErrors.followings.instance.autoFollowIndex.indexUrl }}</div>
</div> </div>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
@ -565,7 +553,7 @@
formControlName="email" [ngClass]="{ 'input-error': formErrors['admin.email'] }" formControlName="email" [ngClass]="{ 'input-error': formErrors['admin.email'] }"
> >
<div *ngIf="formErrors.admin.email" class="form-error" role="alert">{{ formErrors.admin.email }}</div> <div *ngIf="formErrors.admin.email" class="form-error">{{ formErrors.admin.email }}</div>
</div> </div>
<div class="form-group" formGroupName="contactForm"> <div class="form-group" formGroupName="contactForm">
@ -600,27 +588,22 @@
formControlName="username" [ngClass]="{ 'input-error': formErrors['services.twitter.username'] }" 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 *ngIf="formErrors.services.twitter.username" class="form-error">{{ formErrors.services.twitter.username }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox inputName="servicesTwitterWhitelisted" formControlName="whitelisted"> <my-peertube-checkbox inputName="servicesTwitterWhitelisted" formControlName="whitelisted">
<ng-template ptTemplate="label"> <ng-template ptTemplate="label">
<ng-container i18n>Instance allowed by Twitter/X</ng-container> <ng-container i18n>Instance allowed by Twitter</ng-container>
</ng-template> </ng-template>
<ng-template ptTemplate="help"> <ng-template ptTemplate="help">
<ng-container i18n> <ng-container i18n>
<p class="mb-2"> If your instance is explicitly allowed by Twitter, a video player will be embedded in the Twitter feed on PeerTube video share.<br />
If your instance is explicitly allowed by Twitter/X, a video player will be embedded in the Twitter feed on PeerTube video share.<br /> If the instance is not, we use an image link card that will redirect to your PeerTube instance.<br /><br />
If the instance is not, we use an image link card that will redirect to your PeerTube instance. Check this checkbox, save the configuration and test with a video URL of your instance (https://example.com/w/blabla) on
</p> <a class="link-orange" target='_blank' rel='noopener noreferrer' href='https://cards-dev.twitter.com/validator'>https://cards-dev.twitter.com/validator</a>
to see if you instance is allowed.
<p>
Check this checkbox, save the configuration and test with a video URL of your instance (https://example.com/w/blabla) on
<a class="link-orange" target='_blank' rel='noopener noreferrer' href='https://cards-dev.twitter.com/validator'>https://cards-dev.twitter.com/validator</a>
to see if you instance is allowed.
</p>
</ng-container> </ng-container>
</ng-template> </ng-template>
</my-peertube-checkbox> </my-peertube-checkbox>

View File

@ -3,7 +3,7 @@ import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core' import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
import { FormGroup } from '@angular/forms' import { FormGroup } from '@angular/forms'
import { MenuService, ThemeService } from '@app/core' import { MenuService, ThemeService } from '@app/core'
import { HTMLServerConfig } from '@peertube/peertube-models' import { HTMLServerConfig } from '@shared/models'
import { ConfigService } from '../shared/config.service' import { ConfigService } from '../shared/config.service'
@Component({ @Component({

View File

@ -1,6 +1,6 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { FormGroup } from '@angular/forms' import { FormGroup } from '@angular/forms'
import { formatICU } from '@app/helpers' import { prepareIcu } from '@app/helpers'
export type ResolutionOption = { export type ResolutionOption = {
id: string id: string
@ -99,7 +99,10 @@ export class EditConfigurationService {
return { return {
value, value,
atMost: noneOnAuto, // auto switches everything to a least estimation since ffmpeg will take as many threads as possible 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 }) unit: prepareIcu($localize`{value, plural, =1 {thread} other {threads}}`)(
{ value },
$localize`threads`
)
} }
} }
} }

View File

@ -68,7 +68,7 @@
<div class="col-md-7 col-xl-5"></div> <div class="col-md-7 col-xl-5"></div>
<div class="col-md-5 col-xl-5"> <div class="col-md-5 col-xl-5">
<div role="alert" class="form-error submit-error" i18n *ngIf="!form.valid && isUpdateAllowed()"> <div class="form-error submit-error" i18n *ngIf="!form.valid && isUpdateAllowed()">
There are errors in the form: There are errors in the form:
<ul> <ul>
@ -78,7 +78,7 @@
</ul> </ul>
</div> </div>
<span role="alert" class="form-error submit-error" i18n *ngIf="!hasLiveAllowReplayConsistentOptions()"> <span class="form-error submit-error" i18n *ngIf="!hasLiveAllowReplayConsistentOptions()">
You cannot allow live replay if you don't enable transcoding. You cannot allow live replay if you don't enable transcoding.
</span> </span>

View File

@ -1,3 +1,4 @@
import omit from 'lodash-es/omit' import omit from 'lodash-es/omit'
import { forkJoin } from 'rxjs' import { forkJoin } from 'rxjs'
import { SelectOptionsItem } from 'src/types/select-options-item.model' import { SelectOptionsItem } from 'src/types/select-options-item.model'
@ -8,7 +9,8 @@ import { Notifier } from '@app/core'
import { ServerService } from '@app/core/server/server.service' import { ServerService } from '@app/core/server/server.service'
import { import {
ADMIN_EMAIL_VALIDATOR, ADMIN_EMAIL_VALIDATOR,
CACHE_SIZE_VALIDATOR, CACHE_CAPTIONS_SIZE_VALIDATOR,
CACHE_PREVIEWS_SIZE_VALIDATOR,
CONCURRENCY_VALIDATOR, CONCURRENCY_VALIDATOR,
INDEX_URL_VALIDATOR, INDEX_URL_VALIDATOR,
INSTANCE_NAME_VALIDATOR, INSTANCE_NAME_VALIDATOR,
@ -26,7 +28,7 @@ import {
import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators' import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { CustomPageService } from '@app/shared/shared-main/custom-page' import { CustomPageService } from '@app/shared/shared-main/custom-page'
import { CustomConfig, CustomPage, HTMLServerConfig } from '@peertube/peertube-models' import { CustomConfig, CustomPage, HTMLServerConfig } from '@shared/models'
import { EditConfigurationService } from './edit-configuration.service' import { EditConfigurationService } from './edit-configuration.service'
type ComponentCustomConfig = CustomConfig & { type ComponentCustomConfig = CustomConfig & {
@ -118,16 +120,13 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
}, },
cache: { cache: {
previews: { previews: {
size: CACHE_SIZE_VALIDATOR size: CACHE_PREVIEWS_SIZE_VALIDATOR
}, },
captions: { captions: {
size: CACHE_SIZE_VALIDATOR size: CACHE_CAPTIONS_SIZE_VALIDATOR
}, },
torrents: { torrents: {
size: CACHE_SIZE_VALIDATOR size: CACHE_CAPTIONS_SIZE_VALIDATOR
},
storyboards: {
size: CACHE_SIZE_VALIDATOR
} }
}, },
signup: { signup: {
@ -189,7 +188,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
hls: { hls: {
enabled: null enabled: null
}, },
webVideos: { webtorrent: {
enabled: null enabled: null
}, },
remoteRunners: { remoteRunners: {
@ -224,11 +223,6 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
enabled: null enabled: null
} }
}, },
videoFile: {
update: {
enabled: null
}
},
autoBlacklist: { autoBlacklist: {
videos: { videos: {
ofUsers: { ofUsers: {

View File

@ -19,10 +19,9 @@
name="instanceCustomHomepageContent" formControlName="content" name="instanceCustomHomepageContent" formControlName="content"
[customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500" [customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
[formError]="formErrors['instanceCustomHomepage.content']" [formError]="formErrors['instanceCustomHomepage.content']"
dir="ltr"
></my-markdown-textarea> ></my-markdown-textarea>
<div *ngIf="formErrors.instanceCustomHomepage.content" class="form-error" role="alert">{{ formErrors.instanceCustomHomepage.content }}</div> <div *ngIf="formErrors.instanceCustomHomepage.content" class="form-error">{{ formErrors.instanceCustomHomepage.content }}</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -16,7 +16,7 @@
formControlName="name" [ngClass]="{ 'input-error': formErrors.instance.name }" formControlName="name" [ngClass]="{ 'input-error': formErrors.instance.name }"
> >
<div *ngIf="formErrors.instance.name" class="form-error" role="alert">{{ formErrors.instance.name }}</div> <div *ngIf="formErrors.instance.name" class="form-error">{{ formErrors.instance.name }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -27,7 +27,7 @@
[ngClass]="{ 'input-error': formErrors['instance.shortDescription'] }" [ngClass]="{ 'input-error': formErrors['instance.shortDescription'] }"
></textarea> ></textarea>
<div *ngIf="formErrors.instance.shortDescription" class="form-error" role="alert">{{ formErrors.instance.shortDescription }}</div> <div *ngIf="formErrors.instance.shortDescription" class="form-error">{{ formErrors.instance.shortDescription }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -91,7 +91,7 @@
<ng-template ptTemplate="help"> <ng-template ptTemplate="help">
<ng-container i18n> <ng-container i18n>
Enabling it will allow other administrators to know that you are mainly federating sensitive content.<br /> Enabling it will allow other administrators to know that you are mainly federating sensitive content.<br /><br />
Moreover, the NSFW checkbox on video upload will be automatically checked by default. Moreover, the NSFW checkbox on video upload will be automatically checked by default.
</ng-container> </ng-container>
</ng-template> </ng-template>
@ -118,7 +118,7 @@
</select> </select>
</div> </div>
<div *ngIf="formErrors.instance.defaultNSFWPolicy" class="form-error" role="alert">{{ formErrors.instance.defaultNSFWPolicy }}</div> <div *ngIf="formErrors.instance.defaultNSFWPolicy" class="form-error">{{ formErrors.instance.defaultNSFWPolicy }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@ -54,7 +54,7 @@
<span i18n>{form.value['live']['maxInstanceLives'], plural, =1 {live} other {lives}}</span> <span i18n>{form.value['live']['maxInstanceLives'], plural, =1 {live} other {lives}}</span>
</div> </div>
<div *ngIf="formErrors.live.maxInstanceLives" class="form-error" role="alert">{{ formErrors.live.maxInstanceLives }}</div> <div *ngIf="formErrors.live.maxInstanceLives" class="form-error">{{ formErrors.live.maxInstanceLives }}</div>
</div> </div>
<div class="form-group" [ngClass]="getDisabledLiveClass()"> <div class="form-group" [ngClass]="getDisabledLiveClass()">
@ -66,7 +66,7 @@
<span i18n>{form.value['live']['maxUserLives'], plural, =1 {live} other {lives}}</span> <span i18n>{form.value['live']['maxUserLives'], plural, =1 {live} other {lives}}</span>
</div> </div>
<div *ngIf="formErrors.live.maxUserLives" class="form-error" role="alert">{{ formErrors.live.maxUserLives }}</div> <div *ngIf="formErrors.live.maxUserLives" class="form-error">{{ formErrors.live.maxUserLives }}</div>
</div> </div>
<div class="form-group" [ngClass]="getDisabledLiveClass()"> <div class="form-group" [ngClass]="getDisabledLiveClass()">
@ -77,7 +77,7 @@
bindLabel="label" bindValue="value" [clearable]="false" [searchable]="true" bindLabel="label" bindValue="value" [clearable]="false" [searchable]="true"
></my-select-options> ></my-select-options>
<div *ngIf="formErrors.live.maxDuration" class="form-error" role="alert">{{ formErrors.live.maxDuration }}</div> <div *ngIf="formErrors.live.maxDuration" class="form-error">{{ formErrors.live.maxDuration }}</div>
</div> </div>
</ng-container> </ng-container>
@ -178,7 +178,7 @@
formControlName="threads" formControlName="threads"
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
<div *ngIf="formErrors.live.transcoding.threads" class="form-error" role="alert">{{ formErrors.live.transcoding.threads }}</div> <div *ngIf="formErrors.live.transcoding.threads" class="form-error">{{ formErrors.live.transcoding.threads }}</div>
</div> </div>
<div class="form-group mt-4" [ngClass]="getDisabledLiveLocalTranscodingClass()"> <div class="form-group mt-4" [ngClass]="getDisabledLiveLocalTranscodingClass()">
@ -193,7 +193,7 @@
> >
</my-select-options> </my-select-options>
<div *ngIf="formErrors.live.transcoding.profile" class="form-error" role="alert">{{ formErrors.live.transcoding.profile }}</div> <div *ngIf="formErrors.live.transcoding.profile" class="form-error">{{ formErrors.live.transcoding.profile }}</div>
</div> </div>
</ng-container> </ng-container>

View File

@ -1,7 +1,8 @@
import { SelectOptionsItem } from 'src/types/select-options-item.model' import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core' import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
import { FormGroup } from '@angular/forms' import { FormGroup } from '@angular/forms'
import { HTMLServerConfig } from '@peertube/peertube-models' import { HTMLServerConfig } from '@shared/models'
import { ConfigService } from '../shared/config.service' import { ConfigService } from '../shared/config.service'
import { EditConfigurationService, ResolutionOption } from './edit-configuration.service' import { EditConfigurationService, ResolutionOption } from './edit-configuration.service'

View File

@ -67,11 +67,11 @@
<div class="callout callout-light pt-2 mt-2 pb-0"> <div class="callout callout-light pt-2 mt-2 pb-0">
<h3 class="callout-title" i18n>Output formats</h3> <h3 class="callout-title" i18n>Output formats</h3>
<ng-container formGroupName="webVideos"> <ng-container formGroupName="webtorrent">
<div class="form-group" [ngClass]="getTranscodingDisabledClass()"> <div class="form-group" [ngClass]="getTranscodingDisabledClass()">
<my-peertube-checkbox <my-peertube-checkbox
inputName="transcodingWebVideosEnabled" formControlName="enabled" inputName="transcodingWebTorrentEnabled" formControlName="enabled"
i18n-labelText labelText="Web Videos enabled" i18n-labelText labelText="WebTorrent enabled"
> >
<ng-template ptTemplate="help"> <ng-template ptTemplate="help">
<ng-container> <ng-container>
@ -93,14 +93,14 @@
<ng-container i18n> <ng-container i18n>
<strong>Requires ffmpeg >= 4.1</strong> <strong>Requires ffmpeg >= 4.1</strong>
<p>Generate HLS playlists and fragmented MP4 files resulting in a better playback than with Web Videos:</p> <p>Generate HLS playlists and fragmented MP4 files resulting in a better playback than with plain WebTorrent:</p>
<ul> <ul>
<li>Resolution change is smoother</li> <li>Resolution change is smoother</li>
<li>Faster playback especially with long videos</li> <li>Faster playback especially with long videos</li>
<li>More stable playback (less bugs/infinite loading)</li> <li>More stable playback (less bugs/infinite loading)</li>
</ul> </ul>
<p>If you also enabled Web Videos support, it will multiply videos storage by 2</p> <p>If you also enabled WebTorrent support, it will multiply videos storage by 2</p>
</ng-container> </ng-container>
</ng-template> </ng-template>
</my-peertube-checkbox> </my-peertube-checkbox>
@ -175,7 +175,7 @@
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
<div *ngIf="formErrors.transcoding.threads" class="form-error" role="alert">{{ formErrors.transcoding.threads }}</div> <div *ngIf="formErrors.transcoding.threads" class="form-error">{{ formErrors.transcoding.threads }}</div>
</div> </div>
<div class="form-group" [ngClass]="getLocalTranscodingDisabledClass()"> <div class="form-group" [ngClass]="getLocalTranscodingDisabledClass()">
@ -187,7 +187,7 @@
<span i18n>jobs in parallel</span> <span i18n>jobs in parallel</span>
</div> </div>
<div *ngIf="formErrors.transcoding.concurrency" class="form-error" role="alert">{{ formErrors.transcoding.concurrency }}</div> <div *ngIf="formErrors.transcoding.concurrency" class="form-error">{{ formErrors.transcoding.concurrency }}</div>
</div> </div>
<div class="form-group" [ngClass]="getLocalTranscodingDisabledClass()"> <div class="form-group" [ngClass]="getLocalTranscodingDisabledClass()">
@ -201,7 +201,7 @@
[clearable]="false" [clearable]="false"
></my-select-options> ></my-select-options>
<div *ngIf="formErrors.transcoding.profile" class="form-error" role="alert">{{ formErrors.transcoding.profile }}</div> <div *ngIf="formErrors.transcoding.profile" class="form-error">{{ formErrors.transcoding.profile }}</div>
</div> </div>
</ng-container> </ng-container>

View File

@ -1,7 +1,8 @@
import { SelectOptionsItem } from 'src/types/select-options-item.model' import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core' import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
import { FormGroup } from '@angular/forms' import { FormGroup } from '@angular/forms'
import { HTMLServerConfig } from '@peertube/peertube-models' import { HTMLServerConfig } from '@shared/models'
import { ConfigService } from '../shared/config.service' import { ConfigService } from '../shared/config.service'
import { EditConfigurationService, ResolutionOption } from './edit-configuration.service' import { EditConfigurationService, ResolutionOption } from './edit-configuration.service'
@ -89,9 +90,9 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
const transcodingControl = this.form.get('transcoding.enabled') const transcodingControl = this.form.get('transcoding.enabled')
const videoStudioControl = this.form.get('videoStudio.enabled') const videoStudioControl = this.form.get('videoStudio.enabled')
const hlsControl = this.form.get('transcoding.hls.enabled') const hlsControl = this.form.get('transcoding.hls.enabled')
const webVideosControl = this.form.get('transcoding.webVideos.enabled') const webtorrentControl = this.form.get('transcoding.webtorrent.enabled')
webVideosControl.valueChanges webtorrentControl.valueChanges
.subscribe(newValue => { .subscribe(newValue => {
if (newValue === false && !hlsControl.disabled) { if (newValue === false && !hlsControl.disabled) {
hlsControl.disable() hlsControl.disable()
@ -104,12 +105,12 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
hlsControl.valueChanges hlsControl.valueChanges
.subscribe(newValue => { .subscribe(newValue => {
if (newValue === false && !webVideosControl.disabled) { if (newValue === false && !webtorrentControl.disabled) {
webVideosControl.disable() webtorrentControl.disable()
} }
if (newValue === true && !webVideosControl.enabled) { if (newValue === true && !webtorrentControl.enabled) {
webVideosControl.enable() webtorrentControl.enable()
} }
}) })
@ -121,7 +122,7 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
}) })
transcodingControl.updateValueAndValidity() transcodingControl.updateValueAndValidity()
webVideosControl.updateValueAndValidity() webtorrentControl.updateValueAndValidity()
videoStudioControl.updateValueAndValidity() videoStudioControl.updateValueAndValidity()
hlsControl.updateValueAndValidity() hlsControl.updateValueAndValidity()
} }

View File

@ -2,7 +2,7 @@ import { catchError } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http' import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { RestExtractor } from '@app/core' import { RestExtractor } from '@app/core'
import { CustomConfig } from '@peertube/peertube-models' import { CustomConfig } from '@shared/models'
import { SelectOptionsItem } from '../../../../types/select-options-item.model' import { SelectOptionsItem } from '../../../../types/select-options-item.model'
import { environment } from '../../../../environments/environment' import { environment } from '../../../../environments/environment'

View File

@ -7,7 +7,8 @@
[value]="followers" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start" [value]="followers" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
[rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order"
[lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false"
[showCurrentPageReport]="true" [currentPageReportTemplate]="getPaginationTemplate()" [showCurrentPageReport]="true" i18n-currentPageReportTemplate
currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} followers"
[(selection)]="selectedRows" [(selection)]="selectedRows"
> >
<ng-template pTemplate="caption"> <ng-template pTemplate="caption">
@ -28,14 +29,14 @@
<ng-template pTemplate="header"> <ng-template pTemplate="header">
<tr> <tr>
<th scope="col" style="width: 40px"> <th style="width: 40px">
<p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox> <p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
</th> </th>
<th scope="col" style="width: 150px;" i18n>Actions</th> <th style="width: 150px;" i18n>Actions</th>
<th scope="col" i18n>Follower</th> <th i18n>Follower</th>
<th scope="col" style="width: 100px;" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th> <th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
<th scope="col" style="width: 100px;" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="score">Score <p-sortIcon field="score"></p-sortIcon></th> <th style="width: 100px;" i18n pSortableColumn="score">Score <p-sortIcon field="score"></p-sortIcon></th>
<th scope="col" style="width: 150px;" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
</tr> </tr>
</ng-template> </ng-template>

View File

@ -1,11 +1,11 @@
import { SortMeta } from 'primeng/api' import { SortMeta } from 'primeng/api'
import { Component, OnInit } from '@angular/core' import { Component, OnInit } from '@angular/core'
import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
import { formatICU } from '@app/helpers' import { prepareIcu } from '@app/helpers'
import { AdvancedInputFilter } from '@app/shared/shared-forms' import { AdvancedInputFilter } from '@app/shared/shared-forms'
import { InstanceFollowService } from '@app/shared/shared-instance' import { InstanceFollowService } from '@app/shared/shared-instance'
import { DropdownAction } from '@app/shared/shared-main' import { DropdownAction } from '@app/shared/shared-main'
import { ActorFollow } from '@peertube/peertube-models' import { ActorFollow } from '@shared/models'
@Component({ @Component({
selector: 'my-followers-list', selector: 'my-followers-list',
@ -63,9 +63,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O
.subscribe({ .subscribe({
next: () => { next: () => {
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
const message = formatICU( const message = prepareIcu($localize`Accepted {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)(
$localize`Accepted {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`, { count: follows.length, followerName: this.buildFollowerName(follows[0]) },
{ count: follows.length, followerName: this.buildFollowerName(follows[0]) } $localize`Follow requests accepted`
) )
this.notifier.success(message) this.notifier.success(message)
@ -78,9 +78,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O
async rejectFollower (follows: ActorFollow[]) { async rejectFollower (follows: ActorFollow[]) {
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
const message = formatICU( const message = prepareIcu($localize`Do you really want to reject {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`)(
$localize`Do you really want to reject {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`, { count: follows.length, followerName: this.buildFollowerName(follows[0]) },
{ count: follows.length, followerName: this.buildFollowerName(follows[0]) } $localize`Do you really want to reject these follow requests?`
) )
const res = await this.confirmService.confirm(message, $localize`Reject`) const res = await this.confirmService.confirm(message, $localize`Reject`)
@ -90,9 +90,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O
.subscribe({ .subscribe({
next: () => { next: () => {
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
const message = formatICU( const message = prepareIcu($localize`Rejected {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)(
$localize`Rejected {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`, { count: follows.length, followerName: this.buildFollowerName(follows[0]) },
{ count: follows.length, followerName: this.buildFollowerName(follows[0]) } $localize`Follow requests rejected`
) )
this.notifier.success(message) this.notifier.success(message)
@ -110,9 +110,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O
message += '<br /><br />' message += '<br /><br />'
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
message += formatICU( message += prepareIcu($localize`Do you really want to delete {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`)(
$localize`Do you really want to delete {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`, icuParams,
icuParams $localize`Do you really want to delete these follow requests?`
) )
const res = await this.confirmService.confirm(message, $localize`Delete`) const res = await this.confirmService.confirm(message, $localize`Delete`)
@ -122,9 +122,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O
.subscribe({ .subscribe({
next: () => { next: () => {
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
const message = formatICU( const message = prepareIcu($localize`Removed {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)(
$localize`Removed {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`, icuParams,
icuParams $localize`Follow requests removed`
) )
this.notifier.success(message) this.notifier.success(message)

View File

@ -2,9 +2,7 @@
<div class="modal-header"> <div class="modal-header">
<h4 i18n class="modal-title">Follow</h4> <h4 i18n class="modal-title">Follow</h4>
<button class="border-0 p-0" title="Close this modal" i18n-title (click)="hide()"> <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
<my-global-icon iconName="cross"></my-global-icon>
</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@ -17,7 +15,7 @@
class="form-control" [ngClass]="{ 'input-error': formErrors['hostsOrHandles'] }" ngbAutofocus class="form-control" [ngClass]="{ 'input-error': formErrors['hostsOrHandles'] }" ngbAutofocus
></textarea> ></textarea>
<div *ngIf="formErrors.hostsOrHandles" class="form-error" role="alert"> <div *ngIf="formErrors.hostsOrHandles" class="form-error">
{{ formErrors.hostsOrHandles }} {{ formErrors.hostsOrHandles }}
<div *ngIf="form.controls['hostsOrHandles'].errors.validHostsOrHandles"> <div *ngIf="form.controls['hostsOrHandles'].errors.validHostsOrHandles">

View File

@ -1,6 +1,6 @@
import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
import { Notifier } from '@app/core' import { Notifier } from '@app/core'
import { formatICU } from '@app/helpers' import { prepareIcu } from '@app/helpers'
import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators' import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators'
import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { InstanceFollowService } from '@app/shared/shared-instance' import { InstanceFollowService } from '@app/shared/shared-instance'
@ -62,9 +62,9 @@ export class FollowModalComponent extends FormReactive implements OnInit {
.subscribe({ .subscribe({
next: () => { next: () => {
this.notifier.success( this.notifier.success(
formatICU( prepareIcu($localize`{count, plural, =1 {Follow request sent!} other {Follow requests sent!}}`)(
$localize`{count, plural, =1 {Follow request sent!} other {Follow requests sent!}}`, { count: hostsOrHandles.length },
{ count: hostsOrHandles.length } $localize`Follow request(s) sent!`
) )
) )

View File

@ -7,7 +7,8 @@
[value]="following" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start" [value]="following" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
[rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order"
[lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false"
[showCurrentPageReport]="true" [currentPageReportTemplate]="getPaginationTemplate()" [showCurrentPageReport]="true" i18n-currentPageReportTemplate
currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} hosts"
[(selection)]="selectedRows" [(selection)]="selectedRows"
> >
<ng-template pTemplate="caption"> <ng-template pTemplate="caption">
@ -33,14 +34,14 @@
<ng-template pTemplate="header"> <ng-template pTemplate="header">
<tr> <tr>
<th scope="col" style="width: 40px"> <th style="width: 40px">
<p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox> <p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
</th> </th>
<th scope="col" style="width: 150px;" i18n>Action</th> <th style="width: 150px;" i18n>Action</th>
<th scope="col" i18n>Following</th> <th i18n>Following</th>
<th scope="col" style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th> <th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
<th scope="col" style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
<th scope="col" style="width: 160px;" i18n pSortableColumn="redundancyAllowed">Redundancy allowed <p-sortIcon field="redundancyAllowed"></p-sortIcon></th> <th style="width: 160px;" i18n pSortableColumn="redundancyAllowed">Redundancy allowed <p-sortIcon field="redundancyAllowed"></p-sortIcon></th>
</tr> </tr>
</ng-template> </ng-template>

View File

@ -3,10 +3,10 @@ import { Component, OnInit, ViewChild } from '@angular/core'
import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
import { AdvancedInputFilter } from '@app/shared/shared-forms' import { AdvancedInputFilter } from '@app/shared/shared-forms'
import { InstanceFollowService } from '@app/shared/shared-instance' import { InstanceFollowService } from '@app/shared/shared-instance'
import { ActorFollow } from '@peertube/peertube-models' import { ActorFollow } from '@shared/models'
import { FollowModalComponent } from './follow-modal.component' import { FollowModalComponent } from './follow-modal.component'
import { DropdownAction } from '@app/shared/shared-main' import { DropdownAction } from '@app/shared/shared-main'
import { formatICU } from '@app/helpers' import { prepareIcu } from '@app/helpers'
@Component({ @Component({
templateUrl: './following-list.component.html', templateUrl: './following-list.component.html',
@ -64,9 +64,9 @@ export class FollowingListComponent extends RestTable <ActorFollow> implements O
async removeFollowing (follows: ActorFollow[]) { async removeFollowing (follows: ActorFollow[]) {
const icuParams = { count: follows.length, entryName: this.buildFollowingName(follows[0]) } const icuParams = { count: follows.length, entryName: this.buildFollowingName(follows[0]) }
const message = formatICU( const message = prepareIcu($localize`Do you really want to unfollow {count, plural, =1 {{entryName}?} other {{count} entries?}}`)(
$localize`Do you really want to unfollow {count, plural, =1 {{entryName}?} other {{count} entries?}}`, icuParams,
icuParams $localize`Do you really want to unfollow these entries?`
) )
const res = await this.confirmService.confirm(message, $localize`Unfollow`) const res = await this.confirmService.confirm(message, $localize`Unfollow`)
@ -76,9 +76,9 @@ export class FollowingListComponent extends RestTable <ActorFollow> implements O
.subscribe({ .subscribe({
next: () => { next: () => {
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
const message = formatICU( const message = prepareIcu($localize`You are not following {count, plural, =1 {{entryName} anymore.} other {these {count} entries anymore.}}`)(
$localize`You are not following {count, plural, =1 {{entryName} anymore.} other {these {count} entries anymore.}}`, icuParams,
icuParams $localize`You are not following them anymore.`
) )
this.notifier.success(message) this.notifier.success(message)

View File

@ -1,7 +1,7 @@
import { Routes } from '@angular/router' import { Routes } from '@angular/router'
import { VideoRedundanciesListComponent } from '@app/+admin/follows/video-redundancies-list' import { VideoRedundanciesListComponent } from '@app/+admin/follows/video-redundancies-list'
import { UserRightGuard } from '@app/core' import { UserRightGuard } from '@app/core'
import { UserRight } from '@peertube/peertube-models' import { UserRight } from '@shared/models'
import { FollowersListComponent } from './followers-list' import { FollowersListComponent } from './followers-list'
import { FollowingListComponent } from './following-list/following-list.component' import { FollowingListComponent } from './following-list/following-list.component'

View File

@ -24,20 +24,18 @@
> >
<ng-template pTemplate="header"> <ng-template pTemplate="header">
<tr> <tr>
<th scope="col" style="width: 40px;"> <th style="width: 40px;"></th>
<span i18n class="visually-hidden">More information</span> <th style="width: 150px;" i18n>Action</th>
</th> <th style="width: 160px;" i18n *ngIf="isDisplayingRemoteVideos()">Strategy</th>
<th scope="col" style="width: 150px;" i18n>Action</th> <th i18n pSortableColumn="name">Video <p-sortIcon field="name"></p-sortIcon></th >
<th scope="col" style="width: 160px;" i18n *ngIf="isDisplayingRemoteVideos()">Strategy</th> <th style="width: 100px;" i18n *ngIf="isDisplayingRemoteVideos()">Total size</th>
<th scope="col" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="name">Video <p-sortIcon field="name"></p-sortIcon></th >
<th scope="col" style="width: 100px;" i18n *ngIf="isDisplayingRemoteVideos()">Total size</th>
</tr> </tr>
</ng-template> </ng-template>
<ng-template pTemplate="body" let-expanded="expanded" let-redundancy> <ng-template pTemplate="body" let-expanded="expanded" let-redundancy>
<tr> <tr>
<td class="expand-cell"> <td class="expand-cell" [pRowToggler]="redundancy">
<my-table-expander-icon [pRowToggler]="redundancy" [expanded]="expanded" i18n-tooltip tooltip="List redundancies"></my-table-expander-icon> <my-table-expander-icon i18n-ngbTooltip ngbTooltip="List redundancies" [expanded]="expanded"></my-table-expander-icon>
</td> </td>
<td class="action-cell"> <td class="action-cell">

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