aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/test.yml1
-rw-r--r--.gitignore1
-rw-r--r--README.md2
-rw-r--r--client/.eslintrc.json2
-rw-r--r--client/.gitignore4
-rw-r--r--client/angular.json6
-rw-r--r--client/e2e/src/suites-all/private-videos.e2e-spec.ts8
-rw-r--r--client/e2e/src/suites-all/videos.e2e-spec.ts4
-rw-r--r--client/e2e/src/utils/urls.ts6
-rw-r--r--client/e2e/wdio.local-test.conf.ts18
-rw-r--r--client/package.json14
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html14
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts2
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-configuration.service.ts7
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts14
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html10
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts14
-rw-r--r--client/src/app/+admin/follows/followers-list/followers-list.component.ts32
-rw-r--r--client/src/app/+admin/follows/following-list/follow-modal.component.ts8
-rw-r--r--client/src/app/+admin/follows/following-list/following-list.component.ts14
-rw-r--r--client/src/app/+admin/moderation/registration-list/registration-list.component.ts14
-rw-r--r--client/src/app/+admin/overview/comments/video-comment-list.component.ts8
-rw-r--r--client/src/app/+admin/overview/users/user-list/user-list.component.ts26
-rw-r--r--client/src/app/+admin/overview/videos/video-admin.service.ts14
-rw-r--r--client/src/app/+admin/overview/videos/video-list.component.html12
-rw-r--r--client/src/app/+admin/overview/videos/video-list.component.ts52
-rw-r--r--client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts9
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts2
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts2
-rw-r--r--client/src/app/+my-library/my-videos/my-videos.component.ts14
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.html9
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.ts26
-rw-r--r--client/src/app/+videos/+video-edit/video-update.component.ts12
-rw-r--r--client/src/app/+videos/+video-edit/video-update.resolver.ts20
-rw-r--r--client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html6
-rw-r--r--client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts14
-rw-r--r--client/src/app/+videos/+video-watch/shared/action-buttons/video-rate.component.ts5
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts10
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html2
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts1
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html3
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts5
-rw-r--r--client/src/app/+videos/+video-watch/shared/information/video-alert.component.html4
-rw-r--r--client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts8
-rw-r--r--client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts16
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.html9
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.ts546
-rw-r--r--client/src/app/app.module.ts10
-rw-r--r--client/src/app/core/confirm/confirm.service.ts12
-rw-r--r--client/src/app/core/users/user.model.ts2
-rw-r--r--client/src/app/helpers/i18n-utils.ts69
-rw-r--r--client/src/app/helpers/utils/object.ts2
-rw-r--r--client/src/app/modal/confirm.component.html6
-rw-r--r--client/src/app/modal/confirm.component.ts9
-rw-r--r--client/src/app/shared/form-validators/custom-config-validators.ts17
-rw-r--r--client/src/app/shared/form-validators/video-validators.ts9
-rw-r--r--client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts8
-rw-r--r--client/src/app/shared/shared-instance/instance-features-table.component.ts14
-rw-r--r--client/src/app/shared/shared-main/angular/from-now.pipe.ts17
-rw-r--r--client/src/app/shared/shared-main/shared-main.module.ts3
-rw-r--r--client/src/app/shared/shared-main/video-caption/video-caption.service.ts8
-rw-r--r--client/src/app/shared/shared-main/video/index.ts1
-rw-r--r--client/src/app/shared/shared-main/video/video-edit.model.ts8
-rw-r--r--client/src/app/shared/shared-main/video/video-file-token.service.ts11
-rw-r--r--client/src/app/shared/shared-main/video/video-password.service.ts29
-rw-r--r--client/src/app/shared/shared-main/video/video.model.ts20
-rw-r--r--client/src/app/shared/shared-main/video/video.service.ts85
-rw-r--r--client/src/app/shared/shared-moderation/user-ban-modal.component.ts14
-rw-r--r--client/src/app/shared/shared-moderation/video-block.component.ts8
-rw-r--r--client/src/app/shared/shared-share-modal/video-share.component.html4
-rw-r--r--client/src/app/shared/shared-share-modal/video-share.component.ts4
-rw-r--r--client/src/app/shared/shared-video-comment/video-comment.service.ts25
-rw-r--r--client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts12
-rw-r--r--client/src/app/shared/shared-video-miniature/video-download.component.ts15
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters-header.component.html2
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.html1
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.ts4
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-list.component.ts1
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html3
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts4
-rw-r--r--client/src/assets/player/index.ts2
-rw-r--r--client/src/assets/player/peertube-player-manager.ts266
-rw-r--r--client/src/assets/player/peertube-player.ts522
-rw-r--r--client/src/assets/player/shared/bezels/bezels-plugin.ts4
-rw-r--r--client/src/assets/player/shared/bezels/pause-bezel.ts49
-rw-r--r--client/src/assets/player/shared/control-bar/index.ts2
-rw-r--r--client/src/assets/player/shared/control-bar/next-previous-video-button.ts25
-rw-r--r--client/src/assets/player/shared/control-bar/p2p-info-button.ts103
-rw-r--r--client/src/assets/player/shared/control-bar/peertube-link-button.ts47
-rw-r--r--client/src/assets/player/shared/control-bar/peertube-live-display.ts8
-rw-r--r--client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts33
-rw-r--r--client/src/assets/player/shared/control-bar/storyboard-plugin.ts197
-rw-r--r--client/src/assets/player/shared/control-bar/theater-button.ts17
-rw-r--r--client/src/assets/player/shared/dock/peertube-dock-component.ts31
-rw-r--r--client/src/assets/player/shared/dock/peertube-dock-plugin.ts19
-rw-r--r--client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts2
-rw-r--r--client/src/assets/player/shared/manager-options/control-bar-options-builder.ts155
-rw-r--r--client/src/assets/player/shared/manager-options/index.ts1
-rw-r--r--client/src/assets/player/shared/manager-options/manager-options-builder.ts186
-rw-r--r--client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts47
-rw-r--r--client/src/assets/player/shared/metrics/metrics-plugin.ts80
-rw-r--r--client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts26
-rw-r--r--client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts50
-rw-r--r--client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts89
-rw-r--r--client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts60
-rw-r--r--client/src/assets/player/shared/p2p-media-loader/segment-validator.ts127
-rw-r--r--client/src/assets/player/shared/peertube/peertube-plugin.ts248
-rw-r--r--client/src/assets/player/shared/player-options-builder/control-bar-options-builder.ts136
-rw-r--r--client/src/assets/player/shared/player-options-builder/hls-options-builder.ts (renamed from client/src/assets/player/shared/manager-options/hls-options-builder.ts)85
-rw-r--r--client/src/assets/player/shared/player-options-builder/index.ts3
-rw-r--r--client/src/assets/player/shared/player-options-builder/web-video-options-builder.ts22
-rw-r--r--client/src/assets/player/shared/playlist/playlist-button.ts22
-rw-r--r--client/src/assets/player/shared/playlist/playlist-menu-item.ts33
-rw-r--r--client/src/assets/player/shared/playlist/playlist-menu.ts73
-rw-r--r--client/src/assets/player/shared/playlist/playlist-plugin.ts19
-rw-r--r--client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts37
-rw-r--r--client/src/assets/player/shared/settings/resolution-menu-button.ts77
-rw-r--r--client/src/assets/player/shared/settings/resolution-menu-item.ts38
-rw-r--r--client/src/assets/player/shared/settings/settings-dialog.ts12
-rw-r--r--client/src/assets/player/shared/settings/settings-menu-button.ts12
-rw-r--r--client/src/assets/player/shared/settings/settings-menu-item.ts86
-rw-r--r--client/src/assets/player/shared/stats/stats-card.ts45
-rw-r--r--client/src/assets/player/shared/stats/stats-plugin.ts16
-rw-r--r--client/src/assets/player/shared/upnext/end-card.ts46
-rw-r--r--client/src/assets/player/shared/upnext/upnext-plugin.ts24
-rw-r--r--client/src/assets/player/shared/web-video/web-video-plugin.ts186
-rw-r--r--client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts234
-rw-r--r--client/src/assets/player/shared/webtorrent/video-renderer.ts134
-rw-r--r--client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts663
-rw-r--r--client/src/assets/player/types/index.ts2
-rw-r--r--client/src/assets/player/types/manager-options.ts98
-rw-r--r--client/src/assets/player/types/peertube-player-options.ts117
-rw-r--r--client/src/assets/player/types/peertube-videojs-typings.ts144
-rw-r--r--client/src/root-helpers/video.ts13
-rw-r--r--client/src/sass/player/control-bar.scss11
-rw-r--r--client/src/sass/player/index.scss1
-rw-r--r--client/src/sass/player/mobile.scss28
-rw-r--r--client/src/sass/player/peertube-skin.scss4
-rw-r--r--client/src/sass/player/settings-menu.scss9
-rw-r--r--client/src/sass/player/storyboard.scss26
-rw-r--r--client/src/shims/http.ts1
-rw-r--r--client/src/shims/https.ts1
-rw-r--r--client/src/shims/stream.ts1
-rw-r--r--client/src/standalone/embed-player-api/.npmignore (renamed from client/src/standalone/player/.npmignore)0
-rw-r--r--client/src/standalone/embed-player-api/README.md (renamed from client/src/standalone/player/README.md)0
-rw-r--r--client/src/standalone/embed-player-api/definitions.ts (renamed from client/src/standalone/player/definitions.ts)0
-rw-r--r--client/src/standalone/embed-player-api/events.ts (renamed from client/src/standalone/player/events.ts)0
-rw-r--r--client/src/standalone/embed-player-api/package.json (renamed from client/src/standalone/player/package.json)0
-rw-r--r--client/src/standalone/embed-player-api/player.ts (renamed from client/src/standalone/player/player.ts)0
-rw-r--r--client/src/standalone/embed-player-api/tsconfig.json (renamed from client/src/standalone/player/tsconfig.json)0
-rw-r--r--client/src/standalone/embed-player-api/webpack.config.js (renamed from client/src/standalone/player/webpack.config.js)0
-rw-r--r--client/src/standalone/videos/embed-api.ts19
-rw-r--r--client/src/standalone/videos/embed.html19
-rw-r--r--client/src/standalone/videos/embed.scss43
-rw-r--r--client/src/standalone/videos/embed.ts276
-rw-r--r--client/src/standalone/videos/shared/auth-http.ts10
-rw-r--r--client/src/standalone/videos/shared/index.ts2
-rw-r--r--client/src/standalone/videos/shared/player-html.ts59
-rw-r--r--client/src/standalone/videos/shared/player-options-builder.ts (renamed from client/src/standalone/videos/shared/player-manager-options.ts)281
-rw-r--r--client/src/standalone/videos/shared/video-fetcher.ts35
-rw-r--r--client/src/standalone/videos/test-embed.ts4
-rw-r--r--client/src/types/index.ts1
-rw-r--r--client/src/types/server-error.model.ts11
-rw-r--r--client/tsconfig.json12
-rw-r--r--client/webpack/webpack.video-embed.js5
-rw-r--r--client/yarn.lock832
-rw-r--r--config/default.yaml19
-rw-r--r--config/production.yaml.example20
-rw-r--r--config/test-1.yaml3
-rw-r--r--config/test-2.yaml3
-rw-r--r--config/test-3.yaml3
-rw-r--r--config/test-4.yaml3
-rw-r--r--config/test-5.yaml3
-rw-r--r--config/test-6.yaml3
-rw-r--r--config/test.yaml4
-rw-r--r--package.json2
-rw-r--r--packages/peertube-runner/package.json2
-rw-r--r--packages/peertube-runner/server/process/shared/common.ts51
-rw-r--r--packages/peertube-runner/server/process/shared/process-studio.ts33
-rw-r--r--packages/peertube-runner/server/process/shared/process-vod.ts96
-rw-r--r--packages/peertube-runner/server/server.ts4
-rw-r--r--scripts/create-generate-storyboard-job.ts85
-rw-r--r--scripts/create-move-video-storage-job.ts8
-rwxr-xr-xscripts/i18n/create-custom-files.ts8
-rw-r--r--scripts/migrations/peertube-4.2.ts6
-rwxr-xr-xscripts/prune-storage.ts10
-rwxr-xr-xscripts/release-embed-api.sh2
-rwxr-xr-xscripts/upgrade.sh3
-rw-r--r--server.ts9
-rw-r--r--server/controllers/activitypub/client.ts13
-rw-r--r--server/controllers/activitypub/outbox.ts1
-rw-r--r--server/controllers/api/accounts.ts2
-rw-r--r--server/controllers/api/config.ts7
-rw-r--r--server/controllers/api/index.ts11
-rw-r--r--server/controllers/api/runners/jobs-files.ts4
-rw-r--r--server/controllers/api/search/search-videos.ts2
-rw-r--r--server/controllers/api/users/me.ts9
-rw-r--r--server/controllers/api/users/my-subscriptions.ts4
-rw-r--r--server/controllers/api/video-channel.ts2
-rw-r--r--server/controllers/api/video-playlist.ts18
-rw-r--r--server/controllers/api/videos/files.ts34
-rw-r--r--server/controllers/api/videos/import.ts7
-rw-r--r--server/controllers/api/videos/index.ts6
-rw-r--r--server/controllers/api/videos/live.ts11
-rw-r--r--server/controllers/api/videos/passwords.ts105
-rw-r--r--server/controllers/api/videos/storyboard.ts29
-rw-r--r--server/controllers/api/videos/token.ts16
-rw-r--r--server/controllers/api/videos/update.ts16
-rw-r--r--server/controllers/api/videos/upload.ts26
-rw-r--r--server/controllers/download.ts43
-rw-r--r--server/controllers/feeds/shared/video-feed-utils.ts2
-rw-r--r--server/controllers/lazy-static.ts118
-rw-r--r--server/controllers/misc.ts4
-rw-r--r--server/controllers/object-storage-proxy.ts15
-rw-r--r--server/controllers/static.ts16
-rw-r--r--server/helpers/custom-validators/activitypub/misc.ts3
-rw-r--r--server/helpers/custom-validators/activitypub/videos.ts39
-rw-r--r--server/helpers/custom-validators/metrics.ts3
-rw-r--r--server/helpers/custom-validators/video-transcoding.ts2
-rw-r--r--server/helpers/custom-validators/videos.ts57
-rw-r--r--server/helpers/express-utils.ts8
-rw-r--r--server/helpers/image-utils.ts2
-rw-r--r--server/helpers/promise-cache.ts22
-rw-r--r--server/helpers/query.ts3
-rw-r--r--server/initializers/checker-after-init.ts50
-rw-r--r--server/initializers/checker-before-init.ts13
-rw-r--r--server/initializers/config.ts41
-rw-r--r--server/initializers/constants.ts52
-rw-r--r--server/initializers/database.ts6
-rw-r--r--server/initializers/migrations/0785-video-password-protection.ts31
-rw-r--r--server/initializers/migrations/0790-thumbnail-disk.ts47
-rw-r--r--server/lib/activitypub/activity.ts14
-rw-r--r--server/lib/activitypub/actors/get.ts42
-rw-r--r--server/lib/activitypub/actors/refresh.ts4
-rw-r--r--server/lib/activitypub/context.ts13
-rw-r--r--server/lib/activitypub/playlists/create-update.ts6
-rw-r--r--server/lib/activitypub/playlists/get.ts4
-rw-r--r--server/lib/activitypub/process/process-create.ts58
-rw-r--r--server/lib/activitypub/process/process-dislike.ts11
-rw-r--r--server/lib/activitypub/process/process-flag.ts8
-rw-r--r--server/lib/activitypub/process/process-undo.ts43
-rw-r--r--server/lib/activitypub/process/process-update.ts36
-rw-r--r--server/lib/activitypub/send/send-create.ts23
-rw-r--r--server/lib/activitypub/send/send-undo.ts19
-rw-r--r--server/lib/activitypub/send/send-update.ts23
-rw-r--r--server/lib/activitypub/videos/federate.ts13
-rw-r--r--server/lib/activitypub/videos/get.ts8
-rw-r--r--server/lib/activitypub/videos/shared/abstract-builder.ts54
-rw-r--r--server/lib/activitypub/videos/shared/creator.ts80
-rw-r--r--server/lib/activitypub/videos/shared/object-to-model-attributes.ts26
-rw-r--r--server/lib/activitypub/videos/updater.ts18
-rw-r--r--server/lib/client-html.ts3
-rw-r--r--server/lib/files-cache/avatar-permanent-file-cache.ts27
-rw-r--r--server/lib/files-cache/index.ts9
-rw-r--r--server/lib/files-cache/shared/abstract-permanent-file-cache.ts132
-rw-r--r--server/lib/files-cache/shared/abstract-simple-file-cache.ts (renamed from server/lib/files-cache/abstract-video-static-file-cache.ts)4
-rw-r--r--server/lib/files-cache/shared/index.ts2
-rw-r--r--server/lib/files-cache/video-captions-simple-file-cache.ts (renamed from server/lib/files-cache/videos-caption-cache.ts)12
-rw-r--r--server/lib/files-cache/video-miniature-permanent-file-cache.ts28
-rw-r--r--server/lib/files-cache/video-previews-simple-file-cache.ts (renamed from server/lib/files-cache/videos-preview-cache.ts)8
-rw-r--r--server/lib/files-cache/video-storyboards-simple-file-cache.ts53
-rw-r--r--server/lib/files-cache/video-torrents-simple-file-cache.ts (renamed from server/lib/files-cache/videos-torrent-cache.ts)8
-rw-r--r--server/lib/hls.ts8
-rw-r--r--server/lib/job-queue/handlers/generate-storyboard.ts149
-rw-r--r--server/lib/job-queue/handlers/move-to-object-storage.ts10
-rw-r--r--server/lib/job-queue/handlers/video-file-import.ts6
-rw-r--r--server/lib/job-queue/handlers/video-import.ts17
-rw-r--r--server/lib/job-queue/handlers/video-live-ending.ts28
-rw-r--r--server/lib/job-queue/handlers/video-transcoding.ts32
-rw-r--r--server/lib/job-queue/job-queue.ts11
-rw-r--r--server/lib/local-actor.ts46
-rw-r--r--server/lib/object-storage/index.ts1
-rw-r--r--server/lib/object-storage/keys.ts4
-rw-r--r--server/lib/object-storage/pre-signed-urls.ts46
-rw-r--r--server/lib/object-storage/proxy.ts8
-rw-r--r--server/lib/object-storage/urls.ts12
-rw-r--r--server/lib/object-storage/videos.ts42
-rw-r--r--server/lib/paths.ts4
-rw-r--r--server/lib/plugins/plugin-helpers-builder.ts10
-rw-r--r--server/lib/redis.ts4
-rw-r--r--server/lib/runners/job-handlers/shared/vod-helpers.ts4
-rw-r--r--server/lib/runners/job-handlers/vod-audio-merge-transcoding-job-handler.ts2
-rw-r--r--server/lib/runners/job-handlers/vod-hls-transcoding-job-handler.ts4
-rw-r--r--server/lib/schedulers/videos-redundancy-scheduler.ts4
-rw-r--r--server/lib/server-config-manager.ts4
-rw-r--r--server/lib/thumbnail.ts118
-rw-r--r--server/lib/transcoding/create-transcoding-job.ts2
-rw-r--r--server/lib/transcoding/shared/job-builders/abstract-job-builder.ts2
-rw-r--r--server/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts42
-rw-r--r--server/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts8
-rw-r--r--server/lib/transcoding/web-transcoding.ts42
-rw-r--r--server/lib/video-file.ts18
-rw-r--r--server/lib/video-path-manager.ts4
-rw-r--r--server/lib/video-pre-import.ts22
-rw-r--r--server/lib/video-privacy.ts26
-rw-r--r--server/lib/video-studio.ts8
-rw-r--r--server/lib/video-tokens-manager.ts25
-rw-r--r--server/lib/video-urls.ts4
-rw-r--r--server/lib/video.ts4
-rw-r--r--server/lib/worker/workers/image-downloader.ts2
-rw-r--r--server/middlewares/auth.ts15
-rw-r--r--server/middlewares/validators/config.ts7
-rw-r--r--server/middlewares/validators/shared/index.ts1
-rw-r--r--server/middlewares/validators/shared/video-passwords.ts80
-rw-r--r--server/middlewares/validators/shared/videos.ts85
-rw-r--r--server/middlewares/validators/sort.ts1
-rw-r--r--server/middlewares/validators/static.ts18
-rw-r--r--server/middlewares/validators/videos/index.ts2
-rw-r--r--server/middlewares/validators/videos/video-captions.ts5
-rw-r--r--server/middlewares/validators/videos/video-comments.ts7
-rw-r--r--server/middlewares/validators/videos/video-files.ts26
-rw-r--r--server/middlewares/validators/videos/video-imports.ts12
-rw-r--r--server/middlewares/validators/videos/video-live.ts13
-rw-r--r--server/middlewares/validators/videos/video-passwords.ts77
-rw-r--r--server/middlewares/validators/videos/video-playlists.ts2
-rw-r--r--server/middlewares/validators/videos/video-rates.ts3
-rw-r--r--server/middlewares/validators/videos/video-token.ts24
-rw-r--r--server/middlewares/validators/videos/videos.ts52
-rw-r--r--server/models/actor/actor-image.ts4
-rw-r--r--server/models/redundancy/video-redundancy.ts2
-rw-r--r--server/models/user/user.ts6
-rw-r--r--server/models/video/formatter/index.ts2
-rw-r--r--server/models/video/formatter/shared/index.ts1
-rw-r--r--server/models/video/formatter/shared/video-format-utils.ts7
-rw-r--r--server/models/video/formatter/video-activity-pub-format.ts295
-rw-r--r--server/models/video/formatter/video-api-format.ts304
-rw-r--r--server/models/video/formatter/video-format-utils.ts543
-rw-r--r--server/models/video/sql/video/shared/abstract-video-query-builder.ts4
-rw-r--r--server/models/video/sql/video/shared/video-file-query-builder.ts12
-rw-r--r--server/models/video/sql/video/shared/video-model-builder.ts20
-rw-r--r--server/models/video/sql/video/shared/video-table-attributes.ts1
-rw-r--r--server/models/video/sql/video/video-model-get-query-builder.ts12
-rw-r--r--server/models/video/sql/video/videos-id-list-query-builder.ts16
-rw-r--r--server/models/video/sql/video/videos-model-list-query-builder.ts10
-rw-r--r--server/models/video/storyboard.ts169
-rw-r--r--server/models/video/thumbnail.ts12
-rw-r--r--server/models/video/video-caption.ts8
-rw-r--r--server/models/video/video-change-ownership.ts2
-rw-r--r--server/models/video/video-file.ts34
-rw-r--r--server/models/video/video-password.ts137
-rw-r--r--server/models/video/video-playlist-element.ts5
-rw-r--r--server/models/video/video-playlist.ts6
-rw-r--r--server/models/video/video.ts97
-rw-r--r--server/tests/api/check-params/config.ts9
-rw-r--r--server/tests/api/check-params/index.ts1
-rw-r--r--server/tests/api/check-params/live.ts8
-rw-r--r--server/tests/api/check-params/runners.ts2
-rw-r--r--server/tests/api/check-params/transcoding.ts14
-rw-r--r--server/tests/api/check-params/video-files.ts44
-rw-r--r--server/tests/api/check-params/video-imports.ts4
-rw-r--r--server/tests/api/check-params/video-passwords.ts609
-rw-r--r--server/tests/api/check-params/video-playlists.ts4
-rw-r--r--server/tests/api/check-params/video-storyboards.ts45
-rw-r--r--server/tests/api/check-params/video-studio.ts6
-rw-r--r--server/tests/api/check-params/video-token.ts44
-rw-r--r--server/tests/api/check-params/videos-overviews.ts2
-rw-r--r--server/tests/api/check-params/videos.ts8
-rw-r--r--server/tests/api/live/live.ts6
-rw-r--r--server/tests/api/object-storage/video-static-file-privacy.ts150
-rw-r--r--server/tests/api/object-storage/videos.ts42
-rw-r--r--server/tests/api/redundancy/redundancy.ts26
-rw-r--r--server/tests/api/runners/runner-studio-transcoding.ts2
-rw-r--r--server/tests/api/runners/runner-vod-transcoding.ts2
-rw-r--r--server/tests/api/server/config.ts11
-rw-r--r--server/tests/api/server/follows.ts949
-rw-r--r--server/tests/api/server/stats.ts2
-rw-r--r--server/tests/api/transcoding/audio-only.ts8
-rw-r--r--server/tests/api/transcoding/create-transcoding.ts18
-rw-r--r--server/tests/api/transcoding/hls.ts8
-rw-r--r--server/tests/api/transcoding/transcoder.ts42
-rw-r--r--server/tests/api/transcoding/update-while-transcoding.ts4
-rw-r--r--server/tests/api/transcoding/video-studio.ts10
-rw-r--r--server/tests/api/users/user-videos.ts4
-rw-r--r--server/tests/api/users/users.ts24
-rw-r--r--server/tests/api/videos/index.ts1
-rw-r--r--server/tests/api/videos/multiple-servers.ts25
-rw-r--r--server/tests/api/videos/resumable-upload.ts42
-rw-r--r--server/tests/api/videos/single-server.ts4
-rw-r--r--server/tests/api/videos/video-files.ts34
-rw-r--r--server/tests/api/videos/video-imports.ts14
-rw-r--r--server/tests/api/videos/video-passwords.ts97
-rw-r--r--server/tests/api/videos/video-playlist-thumbnails.ts18
-rw-r--r--server/tests/api/videos/video-playlists.ts33
-rw-r--r--server/tests/api/videos/video-static-file-privacy.ts129
-rw-r--r--server/tests/api/videos/video-storyboard.ts213
-rw-r--r--server/tests/api/videos/videos-common-filters.ts34
-rw-r--r--server/tests/cli/create-generate-storyboard-job.ts120
-rw-r--r--server/tests/cli/create-move-video-storage-job.ts4
-rw-r--r--server/tests/cli/index.ts1
-rw-r--r--server/tests/cli/prune-storage.ts12
-rw-r--r--server/tests/cli/regenerate-thumbnails.ts3
-rw-r--r--server/tests/client.ts13
-rw-r--r--server/tests/feeds/feeds.ts11
-rw-r--r--server/tests/fixtures/custom-preview-big.pngbin0 -> 536513 bytes
-rw-r--r--server/tests/fixtures/custom-preview.jpgbin0 -> 14146 bytes
-rw-r--r--server/tests/fixtures/custom-thumbnail-big.jpgbin0 -> 20379 bytes
-rw-r--r--server/tests/fixtures/custom-thumbnail.jpgbin0 -> 6898 bytes
-rw-r--r--server/tests/fixtures/custom-thumbnail.pngbin0 -> 18070 bytes
-rw-r--r--server/tests/fixtures/preview-big.pngbin432157 -> 0 bytes
-rw-r--r--server/tests/fixtures/preview.jpgbin26147 -> 0 bytes
-rw-r--r--server/tests/fixtures/thumbnail-big.jpgbin16286 -> 0 bytes
-rw-r--r--server/tests/fixtures/thumbnail.jpgbin5568 -> 0 bytes
-rw-r--r--server/tests/fixtures/thumbnail.pngbin4221 -> 0 bytes
-rw-r--r--server/tests/fixtures/video_short1-preview.webm.jpgbin31351 -> 31188 bytes
-rw-r--r--server/tests/fixtures/video_short1.webm.jpgbin6222 -> 6334 bytes
-rw-r--r--server/tests/fixtures/video_short2.webm.jpgbin5568 -> 6607 bytes
-rw-r--r--server/tests/fixtures/video_very_long_10p.mp4bin0 -> 185338 bytes
-rw-r--r--server/tests/helpers/image.ts8
-rw-r--r--server/tests/peertube-runner/studio-transcoding.ts4
-rw-r--r--server/tests/peertube-runner/vod-transcoding.ts46
-rw-r--r--server/tests/plugins/filter-hooks.ts2
-rw-r--r--server/tests/plugins/plugin-helpers.ts6
-rw-r--r--server/tests/plugins/plugin-transcoding.ts4
-rw-r--r--server/tests/shared/checks.ts11
-rw-r--r--server/tests/shared/videos.ts23
-rw-r--r--server/tools/peertube-redundancy.ts8
-rw-r--r--server/types/express.d.ts3
-rw-r--r--server/types/models/video/index.ts2
-rw-r--r--server/types/models/video/storyboard.ts15
-rw-r--r--server/types/models/video/video-caption.ts2
-rw-r--r--server/types/models/video/video-file.ts2
-rw-r--r--server/types/models/video/video-password.ts3
-rw-r--r--server/types/models/video/video.ts10
-rw-r--r--server/types/plugins/register-server-option.model.ts12
-rw-r--r--shared/core-utils/common/array.ts14
-rw-r--r--shared/core-utils/videos/common.ts2
-rw-r--r--shared/ffmpeg/ffmpeg-images.ts61
-rw-r--r--shared/models/activitypub/activity.ts56
-rw-r--r--shared/models/activitypub/objects/activitypub-object.ts17
-rw-r--r--shared/models/activitypub/objects/common-objects.ts5
-rw-r--r--shared/models/activitypub/objects/dislike-object.ts6
-rw-r--r--shared/models/activitypub/objects/index.ts5
-rw-r--r--shared/models/activitypub/objects/object.model.ts1
-rw-r--r--shared/models/activitypub/objects/playlist-object.ts4
-rw-r--r--shared/models/activitypub/objects/video-comment-object.ts4
-rw-r--r--shared/models/activitypub/objects/video-object.ts (renamed from shared/models/activitypub/objects/video-torrent-object.ts)16
-rw-r--r--shared/models/metrics/playback-metric-create.model.ts2
-rw-r--r--shared/models/plugins/client/client-hook.model.ts4
-rw-r--r--shared/models/search/videos-common-query.model.ts4
-rw-r--r--shared/models/server/custom-config.model.ts6
-rw-r--r--shared/models/server/job.model.ts20
-rw-r--r--shared/models/server/server-config.model.ts2
-rw-r--r--shared/models/server/server-error-code.enum.ts5
-rw-r--r--shared/models/users/user-update-me.model.ts2
-rw-r--r--shared/models/users/user.model.ts2
-rw-r--r--shared/models/videos/index.ts2
-rw-r--r--shared/models/videos/storyboard.model.ts11
-rw-r--r--shared/models/videos/transcoding/video-transcoding-create.model.ts2
-rw-r--r--shared/models/videos/video-create.model.ts1
-rw-r--r--shared/models/videos/video-password.model.ts7
-rw-r--r--shared/models/videos/video-privacy.enum.ts3
-rw-r--r--shared/models/videos/video-update.model.ts1
-rw-r--r--shared/models/videos/video.model.ts24
-rw-r--r--shared/server-commands/requests/requests.ts4
-rw-r--r--shared/server-commands/server/config-command.ts21
-rw-r--r--shared/server-commands/server/jobs.ts15
-rw-r--r--shared/server-commands/server/object-storage-command.ts6
-rw-r--r--shared/server-commands/server/server.ts13
-rw-r--r--shared/server-commands/server/servers-command.ts4
-rw-r--r--shared/server-commands/shared/abstract-command.ts18
-rw-r--r--shared/server-commands/videos/captions-command.ts4
-rw-r--r--shared/server-commands/videos/comments-command.ts12
-rw-r--r--shared/server-commands/videos/index.ts2
-rw-r--r--shared/server-commands/videos/live-command.ts12
-rw-r--r--shared/server-commands/videos/storyboard-command.ts19
-rw-r--r--shared/server-commands/videos/video-passwords-command.ts55
-rw-r--r--shared/server-commands/videos/video-studio-command.ts2
-rw-r--r--shared/server-commands/videos/video-token-command.ts5
-rw-r--r--shared/server-commands/videos/videos-command.ts33
-rw-r--r--support/doc/api/embeds.md8
-rw-r--r--support/doc/api/openapi.yaml207
-rw-r--r--support/doc/development/lib.md2
-rw-r--r--support/doc/development/release.md8
-rw-r--r--support/doc/development/tests.md1
-rw-r--r--support/doc/tools.md42
-rw-r--r--support/docker/production/config/custom-environment-variables.yaml16
-rw-r--r--support/docker/production/config/production.yaml2
-rw-r--r--support/nginx/peertube34
-rw-r--r--yarn.lock139
479 files changed, 10283 insertions, 6689 deletions
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 407907e53..baddba758 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -46,6 +46,7 @@ jobs:
46 PGHOST: localhost 46 PGHOST: localhost
47 NODE_PENDING_JOB_WAIT: 250 47 NODE_PENDING_JOB_WAIT: 250
48 ENABLE_OBJECT_STORAGE_TESTS: true 48 ENABLE_OBJECT_STORAGE_TESTS: true
49 ENABLE_FFMPEG_THUMBNAIL_PIXEL_COMPARISON_TESTS: true
49 OBJECT_STORAGE_SCALEWAY_KEY_ID: ${{ secrets.OBJECT_STORAGE_SCALEWAY_KEY_ID }} 50 OBJECT_STORAGE_SCALEWAY_KEY_ID: ${{ secrets.OBJECT_STORAGE_SCALEWAY_KEY_ID }}
50 OBJECT_STORAGE_SCALEWAY_ACCESS_KEY: ${{ secrets.OBJECT_STORAGE_SCALEWAY_ACCESS_KEY }} 51 OBJECT_STORAGE_SCALEWAY_ACCESS_KEY: ${{ secrets.OBJECT_STORAGE_SCALEWAY_ACCESS_KEY }}
51 YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 YOUTUBE_DL_DOWNLOAD_BEARER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index c6029ad65..e0004004d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,7 @@ yarn-error.log
23/ffmpeg-4/ 23/ffmpeg-4/
24/thumbnails/ 24/thumbnails/
25/torrents/ 25/torrents/
26/web-videos/
26/videos/ 27/videos/
27/previews/ 28/previews/
28/logs/ 29/logs/
diff --git a/README.md b/README.md
index 0c2989c62..09585aa15 100644
--- a/README.md
+++ b/README.md
@@ -116,7 +116,7 @@ Be it as a user or an instance administrator, you can decide what your experienc
116 116
117<h3 align="right">Communities that help each other</h3> 117<h3 align="right">Communities that help each other</h3>
118<p align="right"> 118<p align="right">
119In 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>). 119In 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>).
120</p> 120</p>
121<p align="right"> 121<p align="right">
122Content 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>). 122Content 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>).
diff --git a/client/.eslintrc.json b/client/.eslintrc.json
index a297cdc94..c5685b9dc 100644
--- a/client/.eslintrc.json
+++ b/client/.eslintrc.json
@@ -3,7 +3,7 @@
3 "ignorePatterns": [ 3 "ignorePatterns": [
4 "projects/**/*", 4 "projects/**/*",
5 "node_modules/", 5 "node_modules/",
6 "src/standalone/player/dist" 6 "src/standalone/embed-player-api/dist"
7 ], 7 ],
8 "overrides": [ 8 "overrides": [
9 { 9 {
diff --git a/client/.gitignore b/client/.gitignore
index ca68413c8..cb85788f5 100644
--- a/client/.gitignore
+++ b/client/.gitignore
@@ -12,5 +12,5 @@
12/e2e/local.log 12/e2e/local.log
13/e2e/browserstack.err 13/e2e/browserstack.err
14/e2e/screenshots 14/e2e/screenshots
15/src/standalone/player/build 15/src/standalone/embed-player-api/build
16/src/standalone/player/dist 16/src/standalone/embed-player-api/dist
diff --git a/client/angular.json b/client/angular.json
index d929248d4..9b069422f 100644
--- a/client/angular.json
+++ b/client/angular.json
@@ -199,7 +199,11 @@
199 "is-plain-object", 199 "is-plain-object",
200 "parse-srcset", 200 "parse-srcset",
201 "deepmerge", 201 "deepmerge",
202 "core-js/features/reflect" 202 "core-js/features/reflect",
203 "@formatjs/intl-locale/polyfill",
204 "@formatjs/intl-locale/should-polyfill",
205 "@formatjs/intl-pluralrules/polyfill-force",
206 "@formatjs/intl-pluralrules/should-polyfill"
203 ], 207 ],
204 "scripts": [], 208 "scripts": [],
205 "vendorChunk": true, 209 "vendorChunk": true,
diff --git a/client/e2e/src/suites-all/private-videos.e2e-spec.ts b/client/e2e/src/suites-all/private-videos.e2e-spec.ts
index a25208bb3..829d76a84 100644
--- a/client/e2e/src/suites-all/private-videos.e2e-spec.ts
+++ b/client/e2e/src/suites-all/private-videos.e2e-spec.ts
@@ -31,8 +31,8 @@ describe('Private videos all workflow', () => {
31 return loginPage.loginOnPeerTube2() 31 return loginPage.loginOnPeerTube2()
32 }) 32 })
33 33
34 it('Should play an internal webtorrent video', async () => { 34 it('Should play an internal web video video', async () => {
35 await go(FIXTURE_URLS.INTERNAL_WEBTORRENT_VIDEO) 35 await go(FIXTURE_URLS.INTERNAL_WEB_VIDEO)
36 36
37 await videoWatchPage.waitWatchVideoName(internalVideoName) 37 await videoWatchPage.waitWatchVideoName(internalVideoName)
38 await checkCorrectlyPlay(playerPage) 38 await checkCorrectlyPlay(playerPage)
@@ -52,8 +52,8 @@ describe('Private videos all workflow', () => {
52 await checkCorrectlyPlay(playerPage) 52 await checkCorrectlyPlay(playerPage)
53 }) 53 })
54 54
55 it('Should play an internal WebTorrent video in embed', async () => { 55 it('Should play an internal Web Video in embed', async () => {
56 await go(FIXTURE_URLS.INTERNAL_EMBED_WEBTORRENT_VIDEO) 56 await go(FIXTURE_URLS.INTERNAL_EMBED_WEB_VIDEO)
57 57
58 await videoWatchPage.waitEmbedForDisplayed() 58 await videoWatchPage.waitEmbedForDisplayed()
59 await checkCorrectlyPlay(playerPage) 59 await checkCorrectlyPlay(playerPage)
diff --git a/client/e2e/src/suites-all/videos.e2e-spec.ts b/client/e2e/src/suites-all/videos.e2e-spec.ts
index d1ab9aef3..5d0f8c152 100644
--- a/client/e2e/src/suites-all/videos.e2e-spec.ts
+++ b/client/e2e/src/suites-all/videos.e2e-spec.ts
@@ -89,7 +89,7 @@ describe('Videos all workflow', () => {
89 let videoNameToExcept = videoName 89 let videoNameToExcept = videoName
90 90
91 if (isMobileDevice() || isSafari()) { 91 if (isMobileDevice() || isSafari()) {
92 await go(FIXTURE_URLS.WEBTORRENT_VIDEO) 92 await go(FIXTURE_URLS.WEB_VIDEO)
93 videoNameToExcept = 'E2E tests' 93 videoNameToExcept = 'E2E tests'
94 } else { 94 } else {
95 await videoListPage.clickOnVideo(videoName) 95 await videoListPage.clickOnVideo(videoName)
@@ -176,7 +176,7 @@ describe('Videos all workflow', () => {
176 await videoWatchPage.waitUntilVideoName(video2Name, 40 * 1000) 176 await videoWatchPage.waitUntilVideoName(video2Name, 40 * 1000)
177 }) 177 })
178 178
179 it('Should watch the webtorrent playlist in the embed', async () => { 179 it('Should watch the WEB VIDEO playlist in the embed', async () => {
180 if (isUploadUnsupported()) return 180 if (isUploadUnsupported()) return
181 181
182 const accessToken = await browser.execute(`return window.localStorage.getItem('access_token');`) 182 const accessToken = await browser.execute(`return window.localStorage.getItem('access_token');`)
diff --git a/client/e2e/src/utils/urls.ts b/client/e2e/src/utils/urls.ts
index cc0bdfbff..eafe0aa5d 100644
--- a/client/e2e/src/utils/urls.ts
+++ b/client/e2e/src/utils/urls.ts
@@ -1,14 +1,14 @@
1const FIXTURE_URLS = { 1const FIXTURE_URLS = {
2 INTERNAL_WEBTORRENT_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?mode=webtorrent&start=0', 2 INTERNAL_WEB_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?mode=web-video&start=0',
3 INTERNAL_HLS_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?start=0', 3 INTERNAL_HLS_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?start=0',
4 4
5 INTERNAL_EMBED_WEBTORRENT_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?mode=webtorrent&start=0', 5 INTERNAL_EMBED_WEB_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?mode=web-video&start=0',
6 INTERNAL_EMBED_HLS_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?start=0', 6 INTERNAL_EMBED_HLS_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?start=0',
7 7
8 INTERNAL_HLS_ONLY_VIDEO: 'https://peertube2.cpy.re/w/tKQmHcqdYZRdCszLUiWM3V?start=0', 8 INTERNAL_HLS_ONLY_VIDEO: 'https://peertube2.cpy.re/w/tKQmHcqdYZRdCszLUiWM3V?start=0',
9 INTERNAL_EMBED_HLS_ONLY_VIDEO: 'https://peertube2.cpy.re/videos/embed/tKQmHcqdYZRdCszLUiWM3V?start=0', 9 INTERNAL_EMBED_HLS_ONLY_VIDEO: 'https://peertube2.cpy.re/videos/embed/tKQmHcqdYZRdCszLUiWM3V?start=0',
10 10
11 WEBTORRENT_VIDEO: 'https://peertube2.cpy.re/w/122d093a-1ede-43bd-bd34-59d2931ffc5e', 11 WEB_VIDEO: 'https://peertube2.cpy.re/w/122d093a-1ede-43bd-bd34-59d2931ffc5e',
12 12
13 HLS_EMBED: 'https://peertube2.cpy.re/videos/embed/969bf103-7818-43b5-94a0-de159e13de50', 13 HLS_EMBED: 'https://peertube2.cpy.re/videos/embed/969bf103-7818-43b5-94a0-de159e13de50',
14 HLS_PLAYLIST_EMBED: 'https://peertube2.cpy.re/video-playlists/embed/73804a40-da9a-40c2-b1eb-2c6d9eec8f0a', 14 HLS_PLAYLIST_EMBED: 'https://peertube2.cpy.re/video-playlists/embed/73804a40-da9a-40c2-b1eb-2c6d9eec8f0a',
diff --git a/client/e2e/wdio.local-test.conf.ts b/client/e2e/wdio.local-test.conf.ts
index 96ddc67ca..6c0171372 100644
--- a/client/e2e/wdio.local-test.conf.ts
+++ b/client/e2e/wdio.local-test.conf.ts
@@ -24,19 +24,19 @@ module.exports = {
24 specFileRetries: 0, 24 specFileRetries: 0,
25 25
26 capabilities: [ 26 capabilities: [
27 { 27 // {
28 'browserName': 'chrome', 28 // 'browserName': 'chrome',
29 'acceptInsecureCerts': true, 29 // 'acceptInsecureCerts': true,
30 'goog:chromeOptions': { 30 // 'goog:chromeOptions': {
31 args: [ '--disable-gpu', windowSizeArg ], 31 // args: [ '--disable-gpu', windowSizeArg ],
32 prefs 32 // prefs
33 } 33 // }
34 }, 34 // },
35 { 35 {
36 'browserName': 'firefox', 36 'browserName': 'firefox',
37 'moz:firefoxOptions': { 37 'moz:firefoxOptions': {
38 binary: '/usr/bin/firefox-developer-edition', 38 binary: '/usr/bin/firefox-developer-edition',
39 args: [ '--headless', windowSizeArg ], 39 // args: [ '--headless', windowSizeArg ],
40 40
41 prefs 41 prefs
42 } 42 }
diff --git a/client/package.json b/client/package.json
index 202a0f836..149322192 100644
--- a/client/package.json
+++ b/client/package.json
@@ -47,6 +47,8 @@
47 "@angular/service-worker": "^16.0.2", 47 "@angular/service-worker": "^16.0.2",
48 "@babel/core": "^7.18.5", 48 "@babel/core": "^7.18.5",
49 "@babel/preset-env": "^7.18.2", 49 "@babel/preset-env": "^7.18.2",
50 "@formatjs/intl-locale": "^3.3.1",
51 "@formatjs/intl-pluralrules": "^5.2.2",
50 "@ng-bootstrap/ng-bootstrap": "^14.0.1", 52 "@ng-bootstrap/ng-bootstrap": "^14.0.1",
51 "@ng-select/ng-select": "^10.0.3", 53 "@ng-select/ng-select": "^10.0.3",
52 "@ngx-loading-bar/core": "^6.0.0", 54 "@ngx-loading-bar/core": "^6.0.0",
@@ -69,7 +71,6 @@
69 "@types/sanitize-html": "2.6.2", 71 "@types/sanitize-html": "2.6.2",
70 "@types/sha.js": "^2.4.0", 72 "@types/sha.js": "^2.4.0",
71 "@types/video.js": "^7.3.40", 73 "@types/video.js": "^7.3.40",
72 "@types/webtorrent": "^0.109.0",
73 "@typescript-eslint/eslint-plugin": "^5.43.0", 74 "@typescript-eslint/eslint-plugin": "^5.43.0",
74 "@typescript-eslint/parser": "^5.43.0", 75 "@typescript-eslint/parser": "^5.43.0",
75 "@wdio/browserstack-service": "^8.10.5", 76 "@wdio/browserstack-service": "^8.10.5",
@@ -83,14 +84,12 @@
83 "babel-loader": "^9.1.0", 84 "babel-loader": "^9.1.0",
84 "bootstrap": "^5.1.3", 85 "bootstrap": "^5.1.3",
85 "buffer": "^6.0.3", 86 "buffer": "^6.0.3",
86 "cache-chunk-store": "^3.0.0",
87 "chart.js": "^4.3.0", 87 "chart.js": "^4.3.0",
88 "chartjs-plugin-zoom": "~2.0.1", 88 "chartjs-plugin-zoom": "~2.0.1",
89 "chromedriver": "^113.0.0", 89 "chromedriver": "^113.0.0",
90 "core-js": "^3.22.8", 90 "core-js": "^3.22.8",
91 "css-loader": "^6.2.0", 91 "css-loader": "^6.2.0",
92 "debug": "^4.3.1", 92 "debug": "^4.3.1",
93 "dexie": "^3.2.2",
94 "eslint": "^8.28.0", 93 "eslint": "^8.28.0",
95 "eslint-plugin-import": "2.27.5", 94 "eslint-plugin-import": "2.27.5",
96 "eslint-plugin-jsdoc": "^44.2.4", 95 "eslint-plugin-jsdoc": "^44.2.4",
@@ -101,7 +100,6 @@
101 "hls.js": "~1.3", 100 "hls.js": "~1.3",
102 "html-loader": "^4.1.0", 101 "html-loader": "^4.1.0",
103 "html-webpack-plugin": "^5.3.1", 102 "html-webpack-plugin": "^5.3.1",
104 "https-browserify": "^1.0.0",
105 "intl-messageformat": "^10.1.0", 103 "intl-messageformat": "^10.1.0",
106 "jschannel": "^1.0.2", 104 "jschannel": "^1.0.2",
107 "linkify-html": "^4.0.2", 105 "linkify-html": "^4.0.2",
@@ -113,9 +111,7 @@
113 "path-browserify": "^1.0.0", 111 "path-browserify": "^1.0.0",
114 "postcss": "^8.4.14", 112 "postcss": "^8.4.14",
115 "primeng": "^16.0.0-rc.2", 113 "primeng": "^16.0.0-rc.2",
116 "process": "^0.11.10",
117 "purify-css": "^1.2.5", 114 "purify-css": "^1.2.5",
118 "querystring": "^0.2.1",
119 "raw-loader": "^4.0.2", 115 "raw-loader": "^4.0.2",
120 "rxjs": "^7.3.0", 116 "rxjs": "^7.3.0",
121 "sanitize-html": "^2.1.2", 117 "sanitize-html": "^2.1.2",
@@ -123,23 +119,17 @@
123 "sass-loader": "^13.2.0", 119 "sass-loader": "^13.2.0",
124 "sha.js": "^2.4.11", 120 "sha.js": "^2.4.11",
125 "socket.io-client": "^4.5.4", 121 "socket.io-client": "^4.5.4",
126 "stream-browserify": "^3.0.0",
127 "stream-http": "^3.0.0",
128 "stylelint": "^15.1.0", 122 "stylelint": "^15.1.0",
129 "stylelint-config-sass-guidelines": "^10.0.0", 123 "stylelint-config-sass-guidelines": "^10.0.0",
130 "ts-loader": "^9.3.0", 124 "ts-loader": "^9.3.0",
131 "tslib": "^2.4.0", 125 "tslib": "^2.4.0",
132 "typescript": "~4.9.5", 126 "typescript": "~4.9.5",
133 "url": "^0.11.0",
134 "video.js": "^7.19.2", 127 "video.js": "^7.19.2",
135 "videostream": "~3.2.1",
136 "wdio-chromedriver-service": "^8.1.1", 128 "wdio-chromedriver-service": "^8.1.1",
137 "wdio-geckodriver-service": "^5.0.1", 129 "wdio-geckodriver-service": "^5.0.1",
138 "webpack": "^5.73.0", 130 "webpack": "^5.73.0",
139 "webpack-bundle-analyzer": "^4.4.2", 131 "webpack-bundle-analyzer": "^4.4.2",
140 "webpack-cli": "^5.0.1", 132 "webpack-cli": "^5.0.1",
141 "webtorrent": "1.8.26",
142 "whatwg-fetch": "^3.0.0",
143 "zone.js": "~0.13.0" 133 "zone.js": "~0.13.0"
144 }, 134 },
145 "dependencies": {} 135 "dependencies": {}
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html
index bbf946df0..9701e7f85 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html
@@ -52,6 +52,20 @@
52 52
53 <div *ngIf="formErrors.cache.torrents.size" class="form-error">{{ formErrors.cache.torrents.size }}</div> 53 <div *ngIf="formErrors.cache.torrents.size" class="form-error">{{ formErrors.cache.torrents.size }}</div>
54 </div> 54 </div>
55
56 <div class="form-group" formGroupName="torrents">
57 <label i18n for="cacheTorrentsSize">Number of video storyboard images to keep in cache</label>
58
59 <div class="number-with-unit">
60 <input
61 type="number" min="0" id="cacheStoryboardsSize" class="form-control"
62 formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.storyboards.size'] }"
63 >
64 <span i18n>{getCacheSize('storyboards'), plural, =1 {cached storyboard} other {cached storyboards}}</span>
65 </div>
66
67 <div *ngIf="formErrors.cache.storyboards.size" class="form-error">{{ formErrors.cache.storyboards.size }}</div>
68 </div>
55 </ng-container> 69 </ng-container>
56 70
57 </div> 71 </div>
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts
index 79a98f288..06c5e6221 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts
@@ -10,7 +10,7 @@ export class EditAdvancedConfigurationComponent {
10 @Input() form: FormGroup 10 @Input() form: FormGroup
11 @Input() formErrors: any 11 @Input() formErrors: any
12 12
13 getCacheSize (type: 'captions' | 'previews' | 'torrents') { 13 getCacheSize (type: 'captions' | 'previews' | 'torrents' | 'storyboards') {
14 return this.form.value['cache'][type]['size'] 14 return this.form.value['cache'][type]['size']
15 } 15 }
16} 16}
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-configuration.service.ts b/client/src/app/+admin/config/edit-custom-config/edit-configuration.service.ts
index 628c2d102..42c0e6dc2 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-configuration.service.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-configuration.service.ts
@@ -1,6 +1,6 @@
1import { Injectable } from '@angular/core' 1import { Injectable } from '@angular/core'
2import { FormGroup } from '@angular/forms' 2import { FormGroup } from '@angular/forms'
3import { prepareIcu } from '@app/helpers' 3import { formatICU } from '@app/helpers'
4 4
5export type ResolutionOption = { 5export type ResolutionOption = {
6 id: string 6 id: string
@@ -99,10 +99,7 @@ export class EditConfigurationService {
99 return { 99 return {
100 value, 100 value,
101 atMost: noneOnAuto, // auto switches everything to a least estimation since ffmpeg will take as many threads as possible 101 atMost: noneOnAuto, // auto switches everything to a least estimation since ffmpeg will take as many threads as possible
102 unit: prepareIcu($localize`{value, plural, =1 {thread} other {threads}}`)( 102 unit: formatICU($localize`{value, plural, =1 {thread} other {threads}}`, { value })
103 { value },
104 $localize`threads`
105 )
106 } 103 }
107 } 104 }
108} 105}
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index 2c3b7560d..b381473d6 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -9,8 +9,7 @@ import { Notifier } from '@app/core'
9import { ServerService } from '@app/core/server/server.service' 9import { ServerService } from '@app/core/server/server.service'
10import { 10import {
11 ADMIN_EMAIL_VALIDATOR, 11 ADMIN_EMAIL_VALIDATOR,
12 CACHE_CAPTIONS_SIZE_VALIDATOR, 12 CACHE_SIZE_VALIDATOR,
13 CACHE_PREVIEWS_SIZE_VALIDATOR,
14 CONCURRENCY_VALIDATOR, 13 CONCURRENCY_VALIDATOR,
15 INDEX_URL_VALIDATOR, 14 INDEX_URL_VALIDATOR,
16 INSTANCE_NAME_VALIDATOR, 15 INSTANCE_NAME_VALIDATOR,
@@ -120,13 +119,16 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
120 }, 119 },
121 cache: { 120 cache: {
122 previews: { 121 previews: {
123 size: CACHE_PREVIEWS_SIZE_VALIDATOR 122 size: CACHE_SIZE_VALIDATOR
124 }, 123 },
125 captions: { 124 captions: {
126 size: CACHE_CAPTIONS_SIZE_VALIDATOR 125 size: CACHE_SIZE_VALIDATOR
127 }, 126 },
128 torrents: { 127 torrents: {
129 size: CACHE_CAPTIONS_SIZE_VALIDATOR 128 size: CACHE_SIZE_VALIDATOR
129 },
130 storyboards: {
131 size: CACHE_SIZE_VALIDATOR
130 } 132 }
131 }, 133 },
132 signup: { 134 signup: {
@@ -188,7 +190,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
188 hls: { 190 hls: {
189 enabled: null 191 enabled: null
190 }, 192 },
191 webtorrent: { 193 webVideos: {
192 enabled: null 194 enabled: null
193 }, 195 },
194 remoteRunners: { 196 remoteRunners: {
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html
index fb750aca6..accf2c28c 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html
@@ -67,11 +67,11 @@
67 <div class="callout callout-light pt-2 mt-2 pb-0"> 67 <div class="callout callout-light pt-2 mt-2 pb-0">
68 <h3 class="callout-title" i18n>Output formats</h3> 68 <h3 class="callout-title" i18n>Output formats</h3>
69 69
70 <ng-container formGroupName="webtorrent"> 70 <ng-container formGroupName="webVideos">
71 <div class="form-group" [ngClass]="getTranscodingDisabledClass()"> 71 <div class="form-group" [ngClass]="getTranscodingDisabledClass()">
72 <my-peertube-checkbox 72 <my-peertube-checkbox
73 inputName="transcodingWebTorrentEnabled" formControlName="enabled" 73 inputName="transcodingWebVideosEnabled" formControlName="enabled"
74 i18n-labelText labelText="WebTorrent enabled" 74 i18n-labelText labelText="Web Videos enabled"
75 > 75 >
76 <ng-template ptTemplate="help"> 76 <ng-template ptTemplate="help">
77 <ng-container> 77 <ng-container>
@@ -93,14 +93,14 @@
93 <ng-container i18n> 93 <ng-container i18n>
94 <strong>Requires ffmpeg >= 4.1</strong> 94 <strong>Requires ffmpeg >= 4.1</strong>
95 95
96 <p>Generate HLS playlists and fragmented MP4 files resulting in a better playback than with plain WebTorrent:</p> 96 <p>Generate HLS playlists and fragmented MP4 files resulting in a better playback than with Web Videos:</p>
97 <ul> 97 <ul>
98 <li>Resolution change is smoother</li> 98 <li>Resolution change is smoother</li>
99 <li>Faster playback especially with long videos</li> 99 <li>Faster playback especially with long videos</li>
100 <li>More stable playback (less bugs/infinite loading)</li> 100 <li>More stable playback (less bugs/infinite loading)</li>
101 </ul> 101 </ul>
102 102
103 <p>If you also enabled WebTorrent support, it will multiply videos storage by 2</p> 103 <p>If you also enabled Web Videos support, it will multiply videos storage by 2</p>
104 </ng-container> 104 </ng-container>
105 </ng-template> 105 </ng-template>
106 </my-peertube-checkbox> 106 </my-peertube-checkbox>
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts
index c5f4ecddb..6496e8753 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts
@@ -90,9 +90,9 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
90 const transcodingControl = this.form.get('transcoding.enabled') 90 const transcodingControl = this.form.get('transcoding.enabled')
91 const videoStudioControl = this.form.get('videoStudio.enabled') 91 const videoStudioControl = this.form.get('videoStudio.enabled')
92 const hlsControl = this.form.get('transcoding.hls.enabled') 92 const hlsControl = this.form.get('transcoding.hls.enabled')
93 const webtorrentControl = this.form.get('transcoding.webtorrent.enabled') 93 const webVideosControl = this.form.get('transcoding.webVideos.enabled')
94 94
95 webtorrentControl.valueChanges 95 webVideosControl.valueChanges
96 .subscribe(newValue => { 96 .subscribe(newValue => {
97 if (newValue === false && !hlsControl.disabled) { 97 if (newValue === false && !hlsControl.disabled) {
98 hlsControl.disable() 98 hlsControl.disable()
@@ -105,12 +105,12 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
105 105
106 hlsControl.valueChanges 106 hlsControl.valueChanges
107 .subscribe(newValue => { 107 .subscribe(newValue => {
108 if (newValue === false && !webtorrentControl.disabled) { 108 if (newValue === false && !webVideosControl.disabled) {
109 webtorrentControl.disable() 109 webVideosControl.disable()
110 } 110 }
111 111
112 if (newValue === true && !webtorrentControl.enabled) { 112 if (newValue === true && !webVideosControl.enabled) {
113 webtorrentControl.enable() 113 webVideosControl.enable()
114 } 114 }
115 }) 115 })
116 116
@@ -122,7 +122,7 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
122 }) 122 })
123 123
124 transcodingControl.updateValueAndValidity() 124 transcodingControl.updateValueAndValidity()
125 webtorrentControl.updateValueAndValidity() 125 webVideosControl.updateValueAndValidity()
126 videoStudioControl.updateValueAndValidity() 126 videoStudioControl.updateValueAndValidity()
127 hlsControl.updateValueAndValidity() 127 hlsControl.updateValueAndValidity()
128 } 128 }
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.ts b/client/src/app/+admin/follows/followers-list/followers-list.component.ts
index cebb2e1a2..618892242 100644
--- a/client/src/app/+admin/follows/followers-list/followers-list.component.ts
+++ b/client/src/app/+admin/follows/followers-list/followers-list.component.ts
@@ -1,7 +1,7 @@
1import { SortMeta } from 'primeng/api' 1import { SortMeta } from 'primeng/api'
2import { Component, OnInit } from '@angular/core' 2import { Component, OnInit } from '@angular/core'
3import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' 3import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
4import { prepareIcu } from '@app/helpers' 4import { formatICU } from '@app/helpers'
5import { AdvancedInputFilter } from '@app/shared/shared-forms' 5import { AdvancedInputFilter } from '@app/shared/shared-forms'
6import { InstanceFollowService } from '@app/shared/shared-instance' 6import { InstanceFollowService } from '@app/shared/shared-instance'
7import { DropdownAction } from '@app/shared/shared-main' 7import { DropdownAction } from '@app/shared/shared-main'
@@ -63,9 +63,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O
63 .subscribe({ 63 .subscribe({
64 next: () => { 64 next: () => {
65 // eslint-disable-next-line max-len 65 // eslint-disable-next-line max-len
66 const message = prepareIcu($localize`Accepted {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)( 66 const message = formatICU(
67 { count: follows.length, followerName: this.buildFollowerName(follows[0]) }, 67 $localize`Accepted {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`,
68 $localize`Follow requests accepted` 68 { count: follows.length, followerName: this.buildFollowerName(follows[0]) }
69 ) 69 )
70 this.notifier.success(message) 70 this.notifier.success(message)
71 71
@@ -78,9 +78,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O
78 78
79 async rejectFollower (follows: ActorFollow[]) { 79 async rejectFollower (follows: ActorFollow[]) {
80 // eslint-disable-next-line max-len 80 // eslint-disable-next-line max-len
81 const message = prepareIcu($localize`Do you really want to reject {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`)( 81 const message = formatICU(
82 { count: follows.length, followerName: this.buildFollowerName(follows[0]) }, 82 $localize`Do you really want to reject {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`,
83 $localize`Do you really want to reject these follow requests?` 83 { count: follows.length, followerName: this.buildFollowerName(follows[0]) }
84 ) 84 )
85 85
86 const res = await this.confirmService.confirm(message, $localize`Reject`) 86 const res = await this.confirmService.confirm(message, $localize`Reject`)
@@ -90,9 +90,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O
90 .subscribe({ 90 .subscribe({
91 next: () => { 91 next: () => {
92 // eslint-disable-next-line max-len 92 // eslint-disable-next-line max-len
93 const message = prepareIcu($localize`Rejected {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)( 93 const message = formatICU(
94 { count: follows.length, followerName: this.buildFollowerName(follows[0]) }, 94 $localize`Rejected {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`,
95 $localize`Follow requests rejected` 95 { count: follows.length, followerName: this.buildFollowerName(follows[0]) }
96 ) 96 )
97 this.notifier.success(message) 97 this.notifier.success(message)
98 98
@@ -110,9 +110,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O
110 message += '<br /><br />' 110 message += '<br /><br />'
111 111
112 // eslint-disable-next-line max-len 112 // eslint-disable-next-line max-len
113 message += prepareIcu($localize`Do you really want to delete {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`)( 113 message += formatICU(
114 icuParams, 114 $localize`Do you really want to delete {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`,
115 $localize`Do you really want to delete these follow requests?` 115 icuParams
116 ) 116 )
117 117
118 const res = await this.confirmService.confirm(message, $localize`Delete`) 118 const res = await this.confirmService.confirm(message, $localize`Delete`)
@@ -122,9 +122,9 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O
122 .subscribe({ 122 .subscribe({
123 next: () => { 123 next: () => {
124 // eslint-disable-next-line max-len 124 // eslint-disable-next-line max-len
125 const message = prepareIcu($localize`Removed {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)( 125 const message = formatICU(
126 icuParams, 126 $localize`Removed {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`,
127 $localize`Follow requests removed` 127 icuParams
128 ) 128 )
129 129
130 this.notifier.success(message) 130 this.notifier.success(message)
diff --git a/client/src/app/+admin/follows/following-list/follow-modal.component.ts b/client/src/app/+admin/follows/following-list/follow-modal.component.ts
index 8f74e82a6..54b3cebc5 100644
--- a/client/src/app/+admin/follows/following-list/follow-modal.component.ts
+++ b/client/src/app/+admin/follows/following-list/follow-modal.component.ts
@@ -1,6 +1,6 @@
1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { prepareIcu } from '@app/helpers' 3import { formatICU } from '@app/helpers'
4import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators' 4import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators'
5import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' 5import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
6import { InstanceFollowService } from '@app/shared/shared-instance' 6import { InstanceFollowService } from '@app/shared/shared-instance'
@@ -62,9 +62,9 @@ export class FollowModalComponent extends FormReactive implements OnInit {
62 .subscribe({ 62 .subscribe({
63 next: () => { 63 next: () => {
64 this.notifier.success( 64 this.notifier.success(
65 prepareIcu($localize`{count, plural, =1 {Follow request sent!} other {Follow requests sent!}}`)( 65 formatICU(
66 { count: hostsOrHandles.length }, 66 $localize`{count, plural, =1 {Follow request sent!} other {Follow requests sent!}}`,
67 $localize`Follow request(s) sent!` 67 { count: hostsOrHandles.length }
68 ) 68 )
69 ) 69 )
70 70
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.ts b/client/src/app/+admin/follows/following-list/following-list.component.ts
index 71f2fbe66..6c8723c16 100644
--- a/client/src/app/+admin/follows/following-list/following-list.component.ts
+++ b/client/src/app/+admin/follows/following-list/following-list.component.ts
@@ -6,7 +6,7 @@ import { InstanceFollowService } from '@app/shared/shared-instance'
6import { ActorFollow } from '@shared/models' 6import { ActorFollow } from '@shared/models'
7import { FollowModalComponent } from './follow-modal.component' 7import { FollowModalComponent } from './follow-modal.component'
8import { DropdownAction } from '@app/shared/shared-main' 8import { DropdownAction } from '@app/shared/shared-main'
9import { prepareIcu } from '@app/helpers' 9import { formatICU } from '@app/helpers'
10 10
11@Component({ 11@Component({
12 templateUrl: './following-list.component.html', 12 templateUrl: './following-list.component.html',
@@ -64,9 +64,9 @@ export class FollowingListComponent extends RestTable <ActorFollow> implements O
64 async removeFollowing (follows: ActorFollow[]) { 64 async removeFollowing (follows: ActorFollow[]) {
65 const icuParams = { count: follows.length, entryName: this.buildFollowingName(follows[0]) } 65 const icuParams = { count: follows.length, entryName: this.buildFollowingName(follows[0]) }
66 66
67 const message = prepareIcu($localize`Do you really want to unfollow {count, plural, =1 {{entryName}?} other {{count} entries?}}`)( 67 const message = formatICU(
68 icuParams, 68 $localize`Do you really want to unfollow {count, plural, =1 {{entryName}?} other {{count} entries?}}`,
69 $localize`Do you really want to unfollow these entries?` 69 icuParams
70 ) 70 )
71 71
72 const res = await this.confirmService.confirm(message, $localize`Unfollow`) 72 const res = await this.confirmService.confirm(message, $localize`Unfollow`)
@@ -76,9 +76,9 @@ export class FollowingListComponent extends RestTable <ActorFollow> implements O
76 .subscribe({ 76 .subscribe({
77 next: () => { 77 next: () => {
78 // eslint-disable-next-line max-len 78 // eslint-disable-next-line max-len
79 const message = prepareIcu($localize`You are not following {count, plural, =1 {{entryName} anymore.} other {these {count} entries anymore.}}`)( 79 const message = formatICU(
80 icuParams, 80 $localize`You are not following {count, plural, =1 {{entryName} anymore.} other {these {count} entries anymore.}}`,
81 $localize`You are not following them anymore.` 81 icuParams
82 ) 82 )
83 83
84 this.notifier.success(message) 84 this.notifier.success(message)
diff --git a/client/src/app/+admin/moderation/registration-list/registration-list.component.ts b/client/src/app/+admin/moderation/registration-list/registration-list.component.ts
index 3ca1ceab8..35d9d13d7 100644
--- a/client/src/app/+admin/moderation/registration-list/registration-list.component.ts
+++ b/client/src/app/+admin/moderation/registration-list/registration-list.component.ts
@@ -2,7 +2,7 @@ import { SortMeta } from 'primeng/api'
2import { Component, OnInit, ViewChild } from '@angular/core' 2import { Component, OnInit, ViewChild } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
4import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable, ServerService } from '@app/core' 4import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
5import { prepareIcu } from '@app/helpers' 5import { formatICU } from '@app/helpers'
6import { AdvancedInputFilter } from '@app/shared/shared-forms' 6import { AdvancedInputFilter } from '@app/shared/shared-forms'
7import { DropdownAction } from '@app/shared/shared-main' 7import { DropdownAction } from '@app/shared/shared-main'
8import { UserRegistration, UserRegistrationState } from '@shared/models' 8import { UserRegistration, UserRegistrationState } from '@shared/models'
@@ -121,9 +121,9 @@ export class RegistrationListComponent extends RestTable <UserRegistration> impl
121 const icuParams = { count: registrations.length, username: registrations[0].username } 121 const icuParams = { count: registrations.length, username: registrations[0].username }
122 122
123 // eslint-disable-next-line max-len 123 // eslint-disable-next-line max-len
124 const message = prepareIcu($localize`Do you really want to delete {count, plural, =1 {{username} registration request?} other {{count} registration requests?}}`)( 124 const message = formatICU(
125 icuParams, 125 $localize`Do you really want to delete {count, plural, =1 {{username} registration request?} other {{count} registration requests?}}`,
126 $localize`Do you really want to delete these registration requests?` 126 icuParams
127 ) 127 )
128 128
129 const res = await this.confirmService.confirm(message, $localize`Delete`) 129 const res = await this.confirmService.confirm(message, $localize`Delete`)
@@ -133,9 +133,9 @@ export class RegistrationListComponent extends RestTable <UserRegistration> impl
133 .subscribe({ 133 .subscribe({
134 next: () => { 134 next: () => {
135 // eslint-disable-next-line max-len 135 // eslint-disable-next-line max-len
136 const message = prepareIcu($localize`Removed {count, plural, =1 {{username} registration request} other {{count} registration requests}}`)( 136 const message = formatICU(
137 icuParams, 137 $localize`Removed {count, plural, =1 {{username} registration request} other {{count} registration requests}}`,
138 $localize`Registration requests removed` 138 icuParams
139 ) 139 )
140 140
141 this.notifier.success(message) 141 this.notifier.success(message)
diff --git a/client/src/app/+admin/overview/comments/video-comment-list.component.ts b/client/src/app/+admin/overview/comments/video-comment-list.component.ts
index 28efdc076..b77072665 100644
--- a/client/src/app/+admin/overview/comments/video-comment-list.component.ts
+++ b/client/src/app/+admin/overview/comments/video-comment-list.component.ts
@@ -7,7 +7,7 @@ import { DropdownAction } from '@app/shared/shared-main'
7import { BulkService } from '@app/shared/shared-moderation' 7import { BulkService } from '@app/shared/shared-moderation'
8import { VideoCommentAdmin, VideoCommentService } from '@app/shared/shared-video-comment' 8import { VideoCommentAdmin, VideoCommentService } from '@app/shared/shared-video-comment'
9import { FeedFormat, UserRight } from '@shared/models' 9import { FeedFormat, UserRight } from '@shared/models'
10import { prepareIcu } from '@app/helpers' 10import { formatICU } from '@app/helpers'
11 11
12@Component({ 12@Component({
13 selector: 'my-video-comment-list', 13 selector: 'my-video-comment-list',
@@ -146,9 +146,9 @@ export class VideoCommentListComponent extends RestTable <VideoCommentAdmin> imp
146 .subscribe({ 146 .subscribe({
147 next: () => { 147 next: () => {
148 this.notifier.success( 148 this.notifier.success(
149 prepareIcu($localize`{count, plural, =1 {1 comment deleted.} other {{count} comments deleted.}}`)( 149 formatICU(
150 { count: commentArgs.length }, 150 $localize`{count, plural, =1 {1 comment deleted.} other {{count} comments deleted.}}`,
151 $localize`${commentArgs.length} comment(s) deleted.` 151 { count: commentArgs.length }
152 ) 152 )
153 ) 153 )
154 154
diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.ts b/client/src/app/+admin/overview/users/user-list/user-list.component.ts
index 19420b748..5d5abf6f4 100644
--- a/client/src/app/+admin/overview/users/user-list/user-list.component.ts
+++ b/client/src/app/+admin/overview/users/user-list/user-list.component.ts
@@ -2,7 +2,7 @@ import { SortMeta } from 'primeng/api'
2import { Component, OnInit, ViewChild } from '@angular/core' 2import { Component, OnInit, ViewChild } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
4import { AuthService, ConfirmService, LocalStorageService, Notifier, RestPagination, RestTable, ServerService } from '@app/core' 4import { AuthService, ConfirmService, LocalStorageService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
5import { getAPIHost, prepareIcu } from '@app/helpers' 5import { formatICU, getAPIHost } from '@app/helpers'
6import { AdvancedInputFilter } from '@app/shared/shared-forms' 6import { AdvancedInputFilter } from '@app/shared/shared-forms'
7import { Actor, DropdownAction } from '@app/shared/shared-main' 7import { Actor, DropdownAction } from '@app/shared/shared-main'
8import { AccountMutedStatus, BlocklistService, UserBanModalComponent, UserModerationDisplayType } from '@app/shared/shared-moderation' 8import { AccountMutedStatus, BlocklistService, UserBanModalComponent, UserModerationDisplayType } from '@app/shared/shared-moderation'
@@ -210,9 +210,9 @@ export class UserListComponent extends RestTable <User> implements OnInit {
210 210
211 async unbanUsers (users: User[]) { 211 async unbanUsers (users: User[]) {
212 const res = await this.confirmService.confirm( 212 const res = await this.confirmService.confirm(
213 prepareIcu($localize`Do you really want to unban {count, plural, =1 {1 user} other {{count} users}}?`)( 213 formatICU(
214 { count: users.length }, 214 $localize`Do you really want to unban {count, plural, =1 {1 user} other {{count} users}}?`,
215 $localize`Do you really want to unban ${users.length} users?` 215 { count: users.length }
216 ), 216 ),
217 $localize`Unban` 217 $localize`Unban`
218 ) 218 )
@@ -223,9 +223,9 @@ export class UserListComponent extends RestTable <User> implements OnInit {
223 .subscribe({ 223 .subscribe({
224 next: () => { 224 next: () => {
225 this.notifier.success( 225 this.notifier.success(
226 prepareIcu($localize`{count, plural, =1 {1 user unbanned.} other {{count} users unbanned.}}`)( 226 formatICU(
227 { count: users.length }, 227 $localize`{count, plural, =1 {1 user unbanned.} other {{count} users unbanned.}}`,
228 $localize`${users.length} users unbanned.` 228 { count: users.length }
229 ) 229 )
230 ) 230 )
231 this.reloadData() 231 this.reloadData()
@@ -252,9 +252,9 @@ export class UserListComponent extends RestTable <User> implements OnInit {
252 .subscribe({ 252 .subscribe({
253 next: () => { 253 next: () => {
254 this.notifier.success( 254 this.notifier.success(
255 prepareIcu($localize`{count, plural, =1 {1 user deleted.} other {{count} users deleted.}}`)( 255 formatICU(
256 { count: users.length }, 256 $localize`{count, plural, =1 {1 user deleted.} other {{count} users deleted.}}`,
257 $localize`${users.length} users deleted.` 257 { count: users.length }
258 ) 258 )
259 ) 259 )
260 260
@@ -270,9 +270,9 @@ export class UserListComponent extends RestTable <User> implements OnInit {
270 .subscribe({ 270 .subscribe({
271 next: () => { 271 next: () => {
272 this.notifier.success( 272 this.notifier.success(
273 prepareIcu($localize`{count, plural, =1 {1 user email set as verified.} other {{count} user emails set as verified.}}`)( 273 formatICU(
274 { count: users.length }, 274 $localize`{count, plural, =1 {1 user email set as verified.} other {{count} user emails set as verified.}}`,
275 $localize`${users.length} users email set as verified.` 275 { count: users.length }
276 ) 276 )
277 ) 277 )
278 278
diff --git a/client/src/app/+admin/overview/videos/video-admin.service.ts b/client/src/app/+admin/overview/videos/video-admin.service.ts
index 4b9357fb7..722495706 100644
--- a/client/src/app/+admin/overview/videos/video-admin.service.ts
+++ b/client/src/app/+admin/overview/videos/video-admin.service.ts
@@ -59,12 +59,12 @@ export class VideoAdminService {
59 title: $localize`Video files`, 59 title: $localize`Video files`,
60 children: [ 60 children: [
61 { 61 {
62 value: 'webtorrent:true isLocal:true', 62 value: 'webVideos:true isLocal:true',
63 label: $localize`With WebTorrent` 63 label: $localize`With Web Videos`
64 }, 64 },
65 { 65 {
66 value: 'webtorrent:false isLocal:true', 66 value: 'webVideos:false isLocal:true',
67 label: $localize`Without WebTorrent` 67 label: $localize`Without Web Videos`
68 }, 68 },
69 { 69 {
70 value: 'hls:true isLocal:true', 70 value: 'hls:true isLocal:true',
@@ -126,8 +126,8 @@ export class VideoAdminService {
126 prefix: 'hls:', 126 prefix: 'hls:',
127 isBoolean: true 127 isBoolean: true
128 }, 128 },
129 hasWebtorrentFiles: { 129 hasWebVideoFiles: {
130 prefix: 'webtorrent:', 130 prefix: 'webVideos:',
131 isBoolean: true 131 isBoolean: true
132 }, 132 },
133 isLive: { 133 isLive: {
@@ -151,7 +151,7 @@ export class VideoAdminService {
151 } 151 }
152 152
153 if (filters.excludePublic) { 153 if (filters.excludePublic) {
154 privacyOneOf = [ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL ] 154 privacyOneOf = [ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]
155 155
156 filters.excludePublic = undefined 156 filters.excludePublic = undefined
157 } 157 }
diff --git a/client/src/app/+admin/overview/videos/video-list.component.html b/client/src/app/+admin/overview/videos/video-list.component.html
index c4f78cadc..3a4666435 100644
--- a/client/src/app/+admin/overview/videos/video-list.component.html
+++ b/client/src/app/+admin/overview/videos/video-list.component.html
@@ -83,8 +83,8 @@
83 </td> 83 </td>
84 84
85 <td> 85 <td>
86 <span *ngIf="isHLS(video)" class="pt-badge badge-blue">HLS</span> 86 <span *ngIf="hasHLS(video)" class="pt-badge badge-blue">HLS</span>
87 <span *ngIf="isWebTorrent(video)" class="pt-badge badge-blue">WebTorrent ({{ video.files.length }})</span> 87 <span *ngIf="hasWebVideos(video)" class="pt-badge badge-blue">Web Videos ({{ video.files.length }})</span>
88 <span i18n *ngIf="video.isLive" class="pt-badge badge-blue">Live</span> 88 <span i18n *ngIf="video.isLive" class="pt-badge badge-blue">Live</span>
89 <span i18n *ngIf="hasObjectStorage(video)" class="pt-badge badge-purple">Object storage</span> 89 <span i18n *ngIf="hasObjectStorage(video)" class="pt-badge badge-purple">Object storage</span>
90 90
@@ -102,8 +102,8 @@
102 <tr> 102 <tr>
103 <td class="video-info expand-cell" myAutoColspan> 103 <td class="video-info expand-cell" myAutoColspan>
104 <div> 104 <div>
105 <div *ngIf="isWebTorrent(video)"> 105 <div *ngIf="hasWebVideos(video)">
106 WebTorrent: 106 Web Videos:
107 107
108 <ul> 108 <ul>
109 <li *ngFor="let file of video.files"> 109 <li *ngFor="let file of video.files">
@@ -112,13 +112,13 @@
112 <my-global-icon 112 <my-global-icon
113 *ngIf="canRemoveOneFile(video)" 113 *ngIf="canRemoveOneFile(video)"
114 i18n-ngbTooltip ngbTooltip="Delete this file" iconName="delete" role="button" 114 i18n-ngbTooltip ngbTooltip="Delete this file" iconName="delete" role="button"
115 (click)="removeVideoFile(video, file, 'webtorrent')" 115 (click)="removeVideoFile(video, file, 'web-videos')"
116 ></my-global-icon> 116 ></my-global-icon>
117 </li> 117 </li>
118 </ul> 118 </ul>
119 </div> 119 </div>
120 120
121 <div *ngIf="isHLS(video)"> 121 <div *ngIf="hasHLS(video)">
122 HLS: 122 HLS:
123 123
124 <ul> 124 <ul>
diff --git a/client/src/app/+admin/overview/videos/video-list.component.ts b/client/src/app/+admin/overview/videos/video-list.component.ts
index ebf82ce16..52f02d8d0 100644
--- a/client/src/app/+admin/overview/videos/video-list.component.ts
+++ b/client/src/app/+admin/overview/videos/video-list.component.ts
@@ -3,7 +3,7 @@ import { finalize } from 'rxjs/operators'
3import { Component, OnInit, ViewChild } from '@angular/core' 3import { Component, OnInit, ViewChild } from '@angular/core'
4import { ActivatedRoute, Router } from '@angular/router' 4import { ActivatedRoute, Router } from '@angular/router'
5import { AuthService, ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' 5import { AuthService, ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
6import { prepareIcu } from '@app/helpers' 6import { formatICU } from '@app/helpers'
7import { AdvancedInputFilter } from '@app/shared/shared-forms' 7import { AdvancedInputFilter } from '@app/shared/shared-forms'
8import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' 8import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
9import { VideoBlockComponent, VideoBlockService } from '@app/shared/shared-moderation' 9import { VideoBlockComponent, VideoBlockService } from '@app/shared/shared-moderation'
@@ -99,8 +99,8 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
99 iconName: 'cog' 99 iconName: 'cog'
100 }, 100 },
101 { 101 {
102 label: $localize`Run WebTorrent transcoding`, 102 label: $localize`Run Web Video transcoding`,
103 handler: videos => this.runTranscoding(videos, 'webtorrent'), 103 handler: videos => this.runTranscoding(videos, 'web-video'),
104 isDisplayed: videos => videos.every(v => v.canRunTranscoding(this.authUser)), 104 isDisplayed: videos => videos.every(v => v.canRunTranscoding(this.authUser)),
105 iconName: 'cog' 105 iconName: 'cog'
106 }, 106 },
@@ -111,8 +111,8 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
111 iconName: 'delete' 111 iconName: 'delete'
112 }, 112 },
113 { 113 {
114 label: $localize`Delete WebTorrent files`, 114 label: $localize`Delete Web Video files`,
115 handler: videos => this.removeVideoFiles(videos, 'webtorrent'), 115 handler: videos => this.removeVideoFiles(videos, 'web-videos'),
116 isDisplayed: videos => videos.every(v => v.canRemoveFiles(this.authUser)), 116 isDisplayed: videos => videos.every(v => v.canRemoveFiles(this.authUser)),
117 iconName: 'delete' 117 iconName: 'delete'
118 } 118 }
@@ -150,14 +150,14 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
150 return video.state.id === VideoState.TO_IMPORT 150 return video.state.id === VideoState.TO_IMPORT
151 } 151 }
152 152
153 isHLS (video: Video) { 153 hasHLS (video: Video) {
154 const p = video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) 154 const p = video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
155 if (!p) return false 155 if (!p) return false
156 156
157 return p.files.length !== 0 157 return p.files.length !== 0
158 } 158 }
159 159
160 isWebTorrent (video: Video) { 160 hasWebVideos (video: Video) {
161 return video.files.length !== 0 161 return video.files.length !== 0
162 } 162 }
163 163
@@ -176,14 +176,14 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
176 getFilesSize (video: Video) { 176 getFilesSize (video: Video) {
177 let files = video.files 177 let files = video.files
178 178
179 if (this.isHLS(video)) { 179 if (this.hasHLS(video)) {
180 files = files.concat(video.streamingPlaylists[0].files) 180 files = files.concat(video.streamingPlaylists[0].files)
181 } 181 }
182 182
183 return files.reduce((p, f) => p += f.size, 0) 183 return files.reduce((p, f) => p += f.size, 0)
184 } 184 }
185 185
186 async removeVideoFile (video: Video, file: VideoFile, type: 'hls' | 'webtorrent') { 186 async removeVideoFile (video: Video, file: VideoFile, type: 'hls' | 'web-videos') {
187 const message = $localize`Are you sure you want to delete this ${file.resolution.label} file?` 187 const message = $localize`Are you sure you want to delete this ${file.resolution.label} file?`
188 const res = await this.confirmService.confirm(message, $localize`Delete file`) 188 const res = await this.confirmService.confirm(message, $localize`Delete file`)
189 if (res === false) return 189 if (res === false) return
@@ -219,9 +219,9 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
219 } 219 }
220 220
221 private async removeVideos (videos: Video[]) { 221 private async removeVideos (videos: Video[]) {
222 const message = prepareIcu($localize`Are you sure you want to delete {count, plural, =1 {this video} other {these {count} videos}}?`)( 222 const message = formatICU(
223 { count: videos.length }, 223 $localize`Are you sure you want to delete {count, plural, =1 {this video} other {these {count} videos}}?`,
224 $localize`Are you sure you want to delete these ${videos.length} videos?` 224 { count: videos.length }
225 ) 225 )
226 226
227 const res = await this.confirmService.confirm(message, $localize`Delete`) 227 const res = await this.confirmService.confirm(message, $localize`Delete`)
@@ -231,9 +231,9 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
231 .subscribe({ 231 .subscribe({
232 next: () => { 232 next: () => {
233 this.notifier.success( 233 this.notifier.success(
234 prepareIcu($localize`Deleted {count, plural, =1 {1 video} other {{count} videos}}.`)( 234 formatICU(
235 { count: videos.length }, 235 $localize`Deleted {count, plural, =1 {1 video} other {{count} videos}}.`,
236 $localize`Deleted ${videos.length} videos.` 236 { count: videos.length }
237 ) 237 )
238 ) 238 )
239 239
@@ -249,9 +249,9 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
249 .subscribe({ 249 .subscribe({
250 next: () => { 250 next: () => {
251 this.notifier.success( 251 this.notifier.success(
252 prepareIcu($localize`Unblocked {count, plural, =1 {1 video} other {{count} videos}}.`)( 252 formatICU(
253 { count: videos.length }, 253 $localize`Unblocked {count, plural, =1 {1 video} other {{count} videos}}.`,
254 $localize`Unblocked ${videos.length} videos.` 254 { count: videos.length }
255 ) 255 )
256 ) 256 )
257 257
@@ -262,20 +262,20 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
262 }) 262 })
263 } 263 }
264 264
265 private async removeVideoFiles (videos: Video[], type: 'hls' | 'webtorrent') { 265 private async removeVideoFiles (videos: Video[], type: 'hls' | 'web-videos') {
266 let message: string 266 let message: string
267 267
268 if (type === 'hls') { 268 if (type === 'hls') {
269 // eslint-disable-next-line max-len 269 // eslint-disable-next-line max-len
270 message = prepareIcu($localize`Are you sure you want to delete {count, plural, =1 {1 HLS streaming playlist} other {{count} HLS streaming playlists}}?`)( 270 message = formatICU(
271 { count: videos.length }, 271 $localize`Are you sure you want to delete {count, plural, =1 {1 HLS streaming playlist} other {{count} HLS streaming playlists}}?`,
272 $localize`Are you sure you want to delete ${videos.length} HLS streaming playlists?` 272 { count: videos.length }
273 ) 273 )
274 } else { 274 } else {
275 // eslint-disable-next-line max-len 275 // eslint-disable-next-line max-len
276 message = prepareIcu($localize`Are you sure you want to delete WebTorrent files of {count, plural, =1 {1 video} other {{count} videos}}?`)( 276 message = formatICU(
277 { count: videos.length }, 277 $localize`Are you sure you want to delete Web Video files of {count, plural, =1 {1 video} other {{count} videos}}?`,
278 $localize`Are you sure you want to delete WebTorrent files of ${videos.length} videos?` 278 { count: videos.length }
279 ) 279 )
280 } 280 }
281 281
@@ -293,7 +293,7 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
293 }) 293 })
294 } 294 }
295 295
296 private runTranscoding (videos: Video[], type: 'hls' | 'webtorrent') { 296 private runTranscoding (videos: Video[], type: 'hls' | 'web-video') {
297 this.videoService.runTranscoding(videos.map(v => v.id), type) 297 this.videoService.runTranscoding(videos.map(v => v.id), type)
298 .subscribe({ 298 .subscribe({
299 next: () => { 299 next: () => {
diff --git a/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts b/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts
index 8ba956eb8..8994c1d00 100644
--- a/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts
+++ b/client/src/app/+admin/system/runners/runner-job-list/runner-job-list.component.ts
@@ -1,7 +1,7 @@
1import { SortMeta } from 'primeng/api' 1import { SortMeta } from 'primeng/api'
2import { Component, OnInit } from '@angular/core' 2import { Component, OnInit } from '@angular/core'
3import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' 3import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
4import { prepareIcu } from '@app/helpers' 4import { formatICU } from '@app/helpers'
5import { DropdownAction } from '@app/shared/shared-main' 5import { DropdownAction } from '@app/shared/shared-main'
6import { RunnerJob, RunnerJobState } from '@shared/models' 6import { RunnerJob, RunnerJobState } from '@shared/models'
7import { RunnerJobFormatted, RunnerService } from '../runner.service' 7import { RunnerJobFormatted, RunnerService } from '../runner.service'
@@ -57,9 +57,10 @@ export class RunnerJobListComponent extends RestTable <RunnerJob> implements OnI
57 } 57 }
58 58
59 async cancelJobs (jobs: RunnerJob[]) { 59 async cancelJobs (jobs: RunnerJob[]) {
60 const message = prepareIcu( 60 const message = formatICU(
61 $localize`Do you really want to cancel {count, plural, =1 {this job} other {{count} jobs}}? Children jobs will also be cancelled.` 61 $localize`Do you really want to cancel {count, plural, =1 {this job} other {{count} jobs}}? Children jobs will also be cancelled.`,
62 )({ count: jobs.length }, $localize`Do you really want to cancel these jobs? Children jobs will also be cancelled.`) 62 { count: jobs.length }
63 )
63 64
64 const res = await this.confirmService.confirm(message, $localize`Cancel`) 65 const res = await this.confirmService.confirm(message, $localize`Cancel`)
65 66
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts
index 97ffb6013..393c3ad6b 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts
@@ -30,7 +30,7 @@ export class MyAccountTwoFactorButtonComponent implements OnInit {
30 async disableTwoFactor () { 30 async disableTwoFactor () {
31 const message = $localize`Are you sure you want to disable two factor authentication of your account?` 31 const message = $localize`Are you sure you want to disable two factor authentication of your account?`
32 32
33 const { confirmed, password } = await this.confirmService.confirmWithPassword(message, $localize`Disable two factor`) 33 const { confirmed, password } = await this.confirmService.confirmWithPassword({ message, title: $localize`Disable two factor` })
34 if (confirmed === false) return 34 if (confirmed === false) return
35 35
36 this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password }) 36 this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password })
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts
index 633720a6c..4d5dbbc2b 100644
--- a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts
@@ -54,7 +54,7 @@ export class MyVideoChannelsComponent {
54 const res = await this.confirmService.confirmWithExpectedInput( 54 const res = await this.confirmService.confirmWithExpectedInput(
55 $localize`Do you really want to delete ${videoChannel.displayName}? 55 $localize`Do you really want to delete ${videoChannel.displayName}?
56It will delete ${videoChannel.videosCount} videos uploaded in this channel, and you will not be able to create another 56It will delete ${videoChannel.videosCount} videos uploaded in this channel, and you will not be able to create another
57channel with the same name (${videoChannel.name})!`, 57channel or account with the same name (${videoChannel.name})!`,
58 58
59 $localize`Please type the name of the video channel (${videoChannel.name}) to confirm`, 59 $localize`Please type the name of the video channel (${videoChannel.name}) to confirm`,
60 60
diff --git a/client/src/app/+my-library/my-videos/my-videos.component.ts b/client/src/app/+my-library/my-videos/my-videos.component.ts
index 57b8bdf7d..1827d6a0b 100644
--- a/client/src/app/+my-library/my-videos/my-videos.component.ts
+++ b/client/src/app/+my-library/my-videos/my-videos.component.ts
@@ -5,7 +5,7 @@ import { Component, OnInit, ViewChild } from '@angular/core'
5import { ActivatedRoute, Router } from '@angular/router' 5import { ActivatedRoute, Router } from '@angular/router'
6import { AuthService, ComponentPagination, ConfirmService, Notifier, ScreenService, ServerService, User } from '@app/core' 6import { AuthService, ComponentPagination, ConfirmService, Notifier, ScreenService, ServerService, User } from '@app/core'
7import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' 7import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
8import { immutableAssign, prepareIcu } from '@app/helpers' 8import { immutableAssign, formatICU } from '@app/helpers'
9import { AdvancedInputFilter } from '@app/shared/shared-forms' 9import { AdvancedInputFilter } from '@app/shared/shared-forms'
10import { DropdownAction, Video, VideoService } from '@app/shared/shared-main' 10import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
11import { LiveStreamInformationComponent } from '@app/shared/shared-video-live' 11import { LiveStreamInformationComponent } from '@app/shared/shared-video-live'
@@ -184,9 +184,9 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
184 .map(([ k, _v ]) => parseInt(k, 10)) 184 .map(([ k, _v ]) => parseInt(k, 10))
185 185
186 const res = await this.confirmService.confirm( 186 const res = await this.confirmService.confirm(
187 prepareIcu($localize`Do you really want to delete {length, plural, =1 {this video} other {{length} videos}}?`)( 187 formatICU(
188 { length: toDeleteVideosIds.length }, 188 $localize`Do you really want to delete {length, plural, =1 {this video} other {{length} videos}}?`,
189 $localize`Do you really want to delete ${toDeleteVideosIds.length} videos?` 189 { length: toDeleteVideosIds.length }
190 ), 190 ),
191 $localize`Delete` 191 $localize`Delete`
192 ) 192 )
@@ -205,9 +205,9 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
205 .subscribe({ 205 .subscribe({
206 next: () => { 206 next: () => {
207 this.notifier.success( 207 this.notifier.success(
208 prepareIcu($localize`{length, plural, =1 {Video has been deleted} other {{length} videos have been deleted}}`)( 208 formatICU(
209 { length: toDeleteVideosIds.length }, 209 $localize`{length, plural, =1 {Video has been deleted} other {{length} videos have been deleted}}`,
210 $localize`${toDeleteVideosIds.length} have been deleted.` 210 { length: toDeleteVideosIds.length }
211 ) 211 )
212 ) 212 )
213 213
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
index b607dabe9..97b713874 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
@@ -120,7 +120,12 @@
120 </div> 120 </div>
121 </div> 121 </div>
122 122
123 <div *ngIf="schedulePublicationEnabled" class="form-group"> 123 <div *ngIf="passwordProtectionSelected" class="form-group">
124 <label i18n for="videoPassword">Password</label>
125 <my-input-text formControlName="videoPassword" inputId="videoPassword" [withCopy]="true" [formError]="formErrors['videoPassword']"></my-input-text>
126 </div>
127
128 <div *ngIf="schedulePublicationSelected" class="form-group">
124 <label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label> 129 <label i18n for="schedulePublicationAt">Schedule publication ({{ calendarTimezone }})</label>
125 <p-calendar 130 <p-calendar
126 id="schedulePublicationAt" formControlName="schedulePublicationAt" [dateFormat]="calendarDateFormat" 131 id="schedulePublicationAt" formControlName="schedulePublicationAt" [dateFormat]="calendarDateFormat"
@@ -287,7 +292,7 @@
287 <div class="form-group mx-4" *ngIf="isSaveReplayEnabled()"> 292 <div class="form-group mx-4" *ngIf="isSaveReplayEnabled()">
288 <label i18n for="replayPrivacy">Privacy of the new replay</label> 293 <label i18n for="replayPrivacy">Privacy of the new replay</label>
289 <my-select-options 294 <my-select-options
290 labelForId="replayPrivacy" [items]="videoPrivacies" [clearable]="false" formControlName="replayPrivacy" 295 labelForId="replayPrivacy" [items]="replayPrivacies" [clearable]="false" formControlName="replayPrivacy"
291 ></my-select-options> 296 ></my-select-options>
292 </div> 297 </div>
293 298
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
index 8ed54ce6b..5e5df8db7 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
@@ -14,6 +14,7 @@ import {
14 VIDEO_LICENCE_VALIDATOR, 14 VIDEO_LICENCE_VALIDATOR,
15 VIDEO_NAME_VALIDATOR, 15 VIDEO_NAME_VALIDATOR,
16 VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR, 16 VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR,
17 VIDEO_PASSWORD_VALIDATOR,
17 VIDEO_PRIVACY_VALIDATOR, 18 VIDEO_PRIVACY_VALIDATOR,
18 VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR, 19 VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR,
19 VIDEO_SUPPORT_VALIDATOR, 20 VIDEO_SUPPORT_VALIDATOR,
@@ -79,7 +80,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
79 // So that it can be accessed in the template 80 // So that it can be accessed in the template
80 readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY 81 readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
81 82
82 videoPrivacies: VideoConstant<VideoPrivacy>[] = [] 83 videoPrivacies: VideoConstant<VideoPrivacy | typeof VideoEdit.SPECIAL_SCHEDULED_PRIVACY > [] = []
84 replayPrivacies: VideoConstant<VideoPrivacy> [] = []
83 videoCategories: VideoConstant<number>[] = [] 85 videoCategories: VideoConstant<number>[] = []
84 videoLicences: VideoConstant<number>[] = [] 86 videoLicences: VideoConstant<number>[] = []
85 videoLanguages: VideoLanguages[] = [] 87 videoLanguages: VideoLanguages[] = []
@@ -103,7 +105,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
103 105
104 pluginDataFormGroup: FormGroup 106 pluginDataFormGroup: FormGroup
105 107
106 schedulePublicationEnabled = false 108 schedulePublicationSelected = false
109 passwordProtectionSelected = false
107 110
108 calendarLocale: any = {} 111 calendarLocale: any = {}
109 minScheduledDate = new Date() 112 minScheduledDate = new Date()
@@ -148,6 +151,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
148 const obj: { [ id: string ]: BuildFormValidator } = { 151 const obj: { [ id: string ]: BuildFormValidator } = {
149 name: VIDEO_NAME_VALIDATOR, 152 name: VIDEO_NAME_VALIDATOR,
150 privacy: VIDEO_PRIVACY_VALIDATOR, 153 privacy: VIDEO_PRIVACY_VALIDATOR,
154 videoPassword: VIDEO_PASSWORD_VALIDATOR,
151 channelId: VIDEO_CHANNEL_VALIDATOR, 155 channelId: VIDEO_CHANNEL_VALIDATOR,
152 nsfw: null, 156 nsfw: null,
153 commentsEnabled: null, 157 commentsEnabled: null,
@@ -222,7 +226,9 @@ export class VideoEditComponent implements OnInit, OnDestroy {
222 226
223 this.serverService.getVideoPrivacies() 227 this.serverService.getVideoPrivacies()
224 .subscribe(privacies => { 228 .subscribe(privacies => {
225 this.videoPrivacies = this.videoService.explainedPrivacyLabels(privacies).videoPrivacies 229 const videoPrivacies = this.videoService.explainedPrivacyLabels(privacies).videoPrivacies
230 this.videoPrivacies = videoPrivacies
231 this.replayPrivacies = videoPrivacies.filter((privacy) => privacy.id !== VideoPrivacy.PASSWORD_PROTECTED)
226 232
227 // Can't schedule publication if private privacy is not available (could be deleted by a plugin) 233 // Can't schedule publication if private privacy is not available (could be deleted by a plugin)
228 const hasPrivatePrivacy = this.videoPrivacies.some(p => p.id === VideoPrivacy.PRIVATE) 234 const hasPrivatePrivacy = this.videoPrivacies.some(p => p.id === VideoPrivacy.PRIVATE)
@@ -410,13 +416,13 @@ export class VideoEditComponent implements OnInit, OnDestroy {
410 .subscribe( 416 .subscribe(
411 newPrivacyId => { 417 newPrivacyId => {
412 418
413 this.schedulePublicationEnabled = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY 419 this.schedulePublicationSelected = newPrivacyId === this.SPECIAL_SCHEDULED_PRIVACY
414 420
415 // Value changed 421 // Value changed
416 const scheduleControl = this.form.get('schedulePublicationAt') 422 const scheduleControl = this.form.get('schedulePublicationAt')
417 const waitTranscodingControl = this.form.get('waitTranscoding') 423 const waitTranscodingControl = this.form.get('waitTranscoding')
418 424
419 if (this.schedulePublicationEnabled) { 425 if (this.schedulePublicationSelected) {
420 scheduleControl.setValidators([ Validators.required ]) 426 scheduleControl.setValidators([ Validators.required ])
421 427
422 waitTranscodingControl.disable() 428 waitTranscodingControl.disable()
@@ -437,6 +443,16 @@ export class VideoEditComponent implements OnInit, OnDestroy {
437 443
438 this.firstPatchDone = true 444 this.firstPatchDone = true
439 445
446 this.passwordProtectionSelected = newPrivacyId === VideoPrivacy.PASSWORD_PROTECTED
447 const videoPasswordControl = this.form.get('videoPassword')
448
449 if (this.passwordProtectionSelected) {
450 videoPasswordControl.setValidators([ Validators.required ])
451 } else {
452 videoPasswordControl.clearValidators()
453 }
454 videoPasswordControl.updateValueAndValidity()
455
440 } 456 }
441 ) 457 )
442 } 458 }
diff --git a/client/src/app/+videos/+video-edit/video-update.component.ts b/client/src/app/+videos/+video-edit/video-update.component.ts
index ad71162b8..e51047e8c 100644
--- a/client/src/app/+videos/+video-edit/video-update.component.ts
+++ b/client/src/app/+videos/+video-edit/video-update.component.ts
@@ -10,7 +10,7 @@ import { LiveVideoService } from '@app/shared/shared-video-live'
10import { LoadingBarService } from '@ngx-loading-bar/core' 10import { LoadingBarService } from '@ngx-loading-bar/core'
11import { logger } from '@root-helpers/logger' 11import { logger } from '@root-helpers/logger'
12import { pick, simpleObjectsDeepEqual } from '@shared/core-utils' 12import { pick, simpleObjectsDeepEqual } from '@shared/core-utils'
13import { LiveVideo, LiveVideoUpdate, VideoPrivacy } from '@shared/models' 13import { LiveVideo, LiveVideoUpdate, VideoPrivacy, VideoState } from '@shared/models'
14import { VideoSource } from '@shared/models/videos/video-source' 14import { VideoSource } from '@shared/models/videos/video-source'
15import { hydrateFormFromVideo } from './shared/video-edit-utils' 15import { hydrateFormFromVideo } from './shared/video-edit-utils'
16 16
@@ -49,10 +49,10 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
49 this.buildForm({}) 49 this.buildForm({})
50 50
51 const { videoData } = this.route.snapshot.data 51 const { videoData } = this.route.snapshot.data
52 const { video, videoChannels, videoCaptions, videoSource, liveVideo } = videoData 52 const { video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword } = videoData
53 53
54 this.videoDetails = video 54 this.videoDetails = video
55 this.videoEdit = new VideoEdit(this.videoDetails) 55 this.videoEdit = new VideoEdit(this.videoDetails, videoPassword)
56 56
57 this.userVideoChannels = videoChannels 57 this.userVideoChannels = videoChannels
58 this.videoCaptions = videoCaptions 58 this.videoCaptions = videoCaptions
@@ -98,11 +98,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
98 } 98 }
99 99
100 isWaitTranscodingHidden () { 100 isWaitTranscodingHidden () {
101 if (this.videoDetails.getFiles().length > 1) { // Already transcoded 101 return this.videoDetails.state.id !== VideoState.TO_TRANSCODE
102 return true
103 }
104
105 return false
106 } 102 }
107 103
108 async update () { 104 async update () {
diff --git a/client/src/app/+videos/+video-edit/video-update.resolver.ts b/client/src/app/+videos/+video-edit/video-update.resolver.ts
index 6612d22de..2c99b36a8 100644
--- a/client/src/app/+videos/+video-edit/video-update.resolver.ts
+++ b/client/src/app/+videos/+video-edit/video-update.resolver.ts
@@ -4,8 +4,9 @@ import { Injectable } from '@angular/core'
4import { ActivatedRouteSnapshot } from '@angular/router' 4import { ActivatedRouteSnapshot } from '@angular/router'
5import { AuthService } from '@app/core' 5import { AuthService } from '@app/core'
6import { listUserChannelsForSelect } from '@app/helpers' 6import { listUserChannelsForSelect } from '@app/helpers'
7import { VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main' 7import { VideoCaptionService, VideoDetails, VideoService, VideoPasswordService } from '@app/shared/shared-main'
8import { LiveVideoService } from '@app/shared/shared-video-live' 8import { LiveVideoService } from '@app/shared/shared-video-live'
9import { VideoPrivacy } from '@shared/models/videos'
9 10
10@Injectable() 11@Injectable()
11export class VideoUpdateResolver { 12export class VideoUpdateResolver {
@@ -13,7 +14,8 @@ export class VideoUpdateResolver {
13 private videoService: VideoService, 14 private videoService: VideoService,
14 private liveVideoService: LiveVideoService, 15 private liveVideoService: LiveVideoService,
15 private authService: AuthService, 16 private authService: AuthService,
16 private videoCaptionService: VideoCaptionService 17 private videoCaptionService: VideoCaptionService,
18 private videoPasswordService: VideoPasswordService
17 ) { 19 ) {
18 } 20 }
19 21
@@ -21,11 +23,11 @@ export class VideoUpdateResolver {
21 const uuid: string = route.params['uuid'] 23 const uuid: string = route.params['uuid']
22 24
23 return this.videoService.getVideo({ videoId: uuid }) 25 return this.videoService.getVideo({ videoId: uuid })
24 .pipe( 26 .pipe(
25 switchMap(video => forkJoin(this.buildVideoObservables(video))), 27 switchMap(video => forkJoin(this.buildVideoObservables(video))),
26 map(([ video, videoSource, videoChannels, videoCaptions, liveVideo ]) => 28 map(([ video, videoSource, videoChannels, videoCaptions, liveVideo, videoPassword ]) =>
27 ({ video, videoChannels, videoCaptions, videoSource, liveVideo })) 29 ({ video, videoChannels, videoCaptions, videoSource, liveVideo, videoPassword }))
28 ) 30 )
29 } 31 }
30 32
31 private buildVideoObservables (video: VideoDetails) { 33 private buildVideoObservables (video: VideoDetails) {
@@ -46,6 +48,10 @@ export class VideoUpdateResolver {
46 48
47 video.isLive 49 video.isLive
48 ? this.liveVideoService.getVideoLive(video.id) 50 ? this.liveVideoService.getVideoLive(video.id)
51 : of(undefined),
52
53 video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
54 ? this.videoPasswordService.getVideoPasswords({ videoUUID: video.uuid })
49 : of(undefined) 55 : of(undefined)
50 ] 56 ]
51 } 57 }
diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html
index cf32e371a..140a391e9 100644
--- a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html
+++ b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html
@@ -1,7 +1,7 @@
1<div class="video-actions-rates"> 1<div class="video-actions-rates">
2 <div class="video-actions justify-content-end"> 2 <div class="video-actions justify-content-end">
3 <my-video-rate 3 <my-video-rate
4 [video]="video" [isUserLoggedIn]="isUserLoggedIn" 4 [video]="video" [videoPassword]="videoPassword" [isUserLoggedIn]="isUserLoggedIn"
5 (rateUpdated)="onRateUpdated($event)" (userRatingLoaded)="onRateUpdated($event)" 5 (rateUpdated)="onRateUpdated($event)" (userRatingLoaded)="onRateUpdated($event)"
6 ></my-video-rate> 6 ></my-video-rate>
7 7
@@ -20,7 +20,7 @@
20 20
21 <div 21 <div
22 class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside" 22 class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside"
23 *ngIf="isUserLoggedIn" (openChange)="addContent.openChange($event)" 23 *ngIf="isVideoAddableToPlaylist()" (openChange)="addContent.openChange($event)"
24 [ngbTooltip]="tooltipSaveToPlaylist" 24 [ngbTooltip]="tooltipSaveToPlaylist"
25 placement="bottom auto" 25 placement="bottom auto"
26 > 26 >
@@ -43,7 +43,7 @@
43 <span class="icon-text d-none d-sm-inline" i18n>DOWNLOAD</span> 43 <span class="icon-text d-none d-sm-inline" i18n>DOWNLOAD</span>
44 </button> 44 </button>
45 45
46 <my-video-download #videoDownloadModal></my-video-download> 46 <my-video-download #videoDownloadModal [videoPassword]="videoPassword"></my-video-download>
47 </ng-container> 47 </ng-container>
48 48
49 <ng-container *ngIf="isUserLoggedIn"> 49 <ng-container *ngIf="isUserLoggedIn">
diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
index 51718827d..e6c0d4de1 100644
--- a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.ts
@@ -5,7 +5,7 @@ import { VideoShareComponent } from '@app/shared/shared-share-modal'
5import { SupportModalComponent } from '@app/shared/shared-support-modal' 5import { SupportModalComponent } from '@app/shared/shared-support-modal'
6import { VideoActionsDisplayType, VideoDownloadComponent } from '@app/shared/shared-video-miniature' 6import { VideoActionsDisplayType, VideoDownloadComponent } from '@app/shared/shared-video-miniature'
7import { VideoPlaylist } from '@app/shared/shared-video-playlist' 7import { VideoPlaylist } from '@app/shared/shared-video-playlist'
8import { UserVideoRateType, VideoCaption } from '@shared/models/videos' 8import { UserVideoRateType, VideoCaption, VideoPrivacy } from '@shared/models/videos'
9 9
10@Component({ 10@Component({
11 selector: 'my-action-buttons', 11 selector: 'my-action-buttons',
@@ -18,10 +18,12 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
18 @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent 18 @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
19 19
20 @Input() video: VideoDetails 20 @Input() video: VideoDetails
21 @Input() videoPassword: string
21 @Input() videoCaptions: VideoCaption[] 22 @Input() videoCaptions: VideoCaption[]
22 @Input() playlist: VideoPlaylist 23 @Input() playlist: VideoPlaylist
23 24
24 @Input() isUserLoggedIn: boolean 25 @Input() isUserLoggedIn: boolean
26 @Input() isUserOwner: boolean
25 27
26 @Input() currentTime: number 28 @Input() currentTime: number
27 @Input() currentPlaylistPosition: number 29 @Input() currentPlaylistPosition: number
@@ -92,4 +94,14 @@ export class ActionButtonsComponent implements OnInit, OnChanges {
92 private setVideoLikesBarTooltipText () { 94 private setVideoLikesBarTooltipText () {
93 this.likesBarTooltipText = `${this.video.likes} likes / ${this.video.dislikes} dislikes` 95 this.likesBarTooltipText = `${this.video.likes} likes / ${this.video.dislikes} dislikes`
94 } 96 }
97
98 isVideoAddableToPlaylist () {
99 const isPasswordProtected = this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
100
101 if (!this.isUserLoggedIn) return false
102
103 if (isPasswordProtected) return this.isUserOwner
104
105 return true
106 }
95} 107}
diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/video-rate.component.ts b/client/src/app/+videos/+video-watch/shared/action-buttons/video-rate.component.ts
index d0c138834..11966ce34 100644
--- a/client/src/app/+videos/+video-watch/shared/action-buttons/video-rate.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/action-buttons/video-rate.component.ts
@@ -12,6 +12,7 @@ import { UserVideoRateType } from '@shared/models'
12}) 12})
13export class VideoRateComponent implements OnInit, OnChanges, OnDestroy { 13export class VideoRateComponent implements OnInit, OnChanges, OnDestroy {
14 @Input() video: VideoDetails 14 @Input() video: VideoDetails
15 @Input() videoPassword: string
15 @Input() isUserLoggedIn: boolean 16 @Input() isUserLoggedIn: boolean
16 17
17 @Output() userRatingLoaded = new EventEmitter<UserVideoRateType>() 18 @Output() userRatingLoaded = new EventEmitter<UserVideoRateType>()
@@ -103,13 +104,13 @@ export class VideoRateComponent implements OnInit, OnChanges, OnDestroy {
103 } 104 }
104 105
105 private setRating (nextRating: UserVideoRateType) { 106 private setRating (nextRating: UserVideoRateType) {
106 const ratingMethods: { [id in UserVideoRateType]: (id: string) => Observable<any> } = { 107 const ratingMethods: { [id in UserVideoRateType]: (id: string, videoPassword: string) => Observable<any> } = {
107 like: this.videoService.setVideoLike, 108 like: this.videoService.setVideoLike,
108 dislike: this.videoService.setVideoDislike, 109 dislike: this.videoService.setVideoDislike,
109 none: this.videoService.unsetVideoLike 110 none: this.videoService.unsetVideoLike
110 } 111 }
111 112
112 ratingMethods[nextRating].call(this.videoService, this.video.uuid) 113 ratingMethods[nextRating].call(this.videoService, this.video.uuid, this.videoPassword)
113 .subscribe({ 114 .subscribe({
114 next: () => { 115 next: () => {
115 // Update the video like attribute 116 // Update the video like attribute
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts
index 033097084..1d9e10d0a 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts
@@ -29,6 +29,7 @@ import { VideoCommentCreate } from '@shared/models'
29export class VideoCommentAddComponent extends FormReactive implements OnChanges, OnInit { 29export class VideoCommentAddComponent extends FormReactive implements OnChanges, OnInit {
30 @Input() user: User 30 @Input() user: User
31 @Input() video: Video 31 @Input() video: Video
32 @Input() videoPassword: string
32 @Input() parentComment?: VideoComment 33 @Input() parentComment?: VideoComment
33 @Input() parentComments?: VideoComment[] 34 @Input() parentComments?: VideoComment[]
34 @Input() focusOnInit = false 35 @Input() focusOnInit = false
@@ -176,12 +177,17 @@ export class VideoCommentAddComponent extends FormReactive implements OnChanges,
176 177
177 private addCommentReply (commentCreate: VideoCommentCreate) { 178 private addCommentReply (commentCreate: VideoCommentCreate) {
178 return this.videoCommentService 179 return this.videoCommentService
179 .addCommentReply(this.video.uuid, this.parentComment.id, commentCreate) 180 .addCommentReply({
181 videoId: this.video.uuid,
182 inReplyToCommentId: this.parentComment.id,
183 comment: commentCreate,
184 videoPassword: this.videoPassword
185 })
180 } 186 }
181 187
182 private addCommentThread (commentCreate: VideoCommentCreate) { 188 private addCommentThread (commentCreate: VideoCommentCreate) {
183 return this.videoCommentService 189 return this.videoCommentService
184 .addCommentThread(this.video.uuid, commentCreate) 190 .addCommentThread(this.video.uuid, commentCreate, this.videoPassword)
185 } 191 }
186 192
187 private initTextValue () { 193 private initTextValue () {
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html
index 91bd8309c..80ea22a20 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html
@@ -62,6 +62,7 @@
62 *ngIf="!comment.isDeleted && inReplyToCommentId === comment.id" 62 *ngIf="!comment.isDeleted && inReplyToCommentId === comment.id"
63 [user]="user" 63 [user]="user"
64 [video]="video" 64 [video]="video"
65 [videoPassword]="videoPassword"
65 [parentComment]="comment" 66 [parentComment]="comment"
66 [parentComments]="newParentComments" 67 [parentComments]="newParentComments"
67 [focusOnInit]="true" 68 [focusOnInit]="true"
@@ -75,6 +76,7 @@
75 <my-video-comment 76 <my-video-comment
76 [comment]="commentChild.comment" 77 [comment]="commentChild.comment"
77 [video]="video" 78 [video]="video"
79 [videoPassword]="videoPassword"
78 [inReplyToCommentId]="inReplyToCommentId" 80 [inReplyToCommentId]="inReplyToCommentId"
79 [commentTree]="commentChild" 81 [commentTree]="commentChild"
80 [parentComments]="newParentComments" 82 [parentComments]="newParentComments"
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts
index 191ec4a28..4c85df657 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts
@@ -16,6 +16,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
16 @ViewChild('commentReportModal') commentReportModal: CommentReportComponent 16 @ViewChild('commentReportModal') commentReportModal: CommentReportComponent
17 17
18 @Input() video: Video 18 @Input() video: Video
19 @Input() videoPassword: string
19 @Input() comment: VideoComment 20 @Input() comment: VideoComment
20 @Input() parentComments: VideoComment[] = [] 21 @Input() parentComments: VideoComment[] = []
21 @Input() commentTree: VideoCommentThreadTree 22 @Input() commentTree: VideoCommentThreadTree
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html
index a003a10eb..0932d2b7f 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html
@@ -20,6 +20,7 @@
20 <ng-template [ngIf]="video.commentsEnabled === true"> 20 <ng-template [ngIf]="video.commentsEnabled === true">
21 <my-video-comment-add 21 <my-video-comment-add
22 [video]="video" 22 [video]="video"
23 [videoPassword]="videoPassword"
23 [user]="user" 24 [user]="user"
24 (commentCreated)="onCommentThreadCreated($event)" 25 (commentCreated)="onCommentThreadCreated($event)"
25 [textValue]="commentThreadRedraftValue" 26 [textValue]="commentThreadRedraftValue"
@@ -34,6 +35,7 @@
34 *ngIf="highlightedThread" 35 *ngIf="highlightedThread"
35 [comment]="highlightedThread" 36 [comment]="highlightedThread"
36 [video]="video" 37 [video]="video"
38 [videoPassword]="videoPassword"
37 [inReplyToCommentId]="inReplyToCommentId" 39 [inReplyToCommentId]="inReplyToCommentId"
38 [commentTree]="threadComments[highlightedThread.id]" 40 [commentTree]="threadComments[highlightedThread.id]"
39 [highlightedComment]="true" 41 [highlightedComment]="true"
@@ -53,6 +55,7 @@
53 *ngIf="!highlightedThread || comment.id !== highlightedThread.id" 55 *ngIf="!highlightedThread || comment.id !== highlightedThread.id"
54 [comment]="comment" 56 [comment]="comment"
55 [video]="video" 57 [video]="video"
58 [videoPassword]="videoPassword"
56 [inReplyToCommentId]="inReplyToCommentId" 59 [inReplyToCommentId]="inReplyToCommentId"
57 [commentTree]="threadComments[comment.id]" 60 [commentTree]="threadComments[comment.id]"
58 [firstInThread]="i + 1 !== comments.length" 61 [firstInThread]="i + 1 !== comments.length"
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts
index 96bdb28c9..848936f91 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts
@@ -15,6 +15,7 @@ import { PeerTubeProblemDocument, ServerErrorCode } from '@shared/models'
15export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { 15export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
16 @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef 16 @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef
17 @Input() video: VideoDetails 17 @Input() video: VideoDetails
18 @Input() videoPassword: string
18 @Input() user: User 19 @Input() user: User
19 20
20 @Output() timestampClicked = new EventEmitter<number>() 21 @Output() timestampClicked = new EventEmitter<number>()
@@ -80,7 +81,8 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
80 81
81 const params = { 82 const params = {
82 videoId: this.video.uuid, 83 videoId: this.video.uuid,
83 threadId: commentId 84 threadId: commentId,
85 videoPassword: this.videoPassword
84 } 86 }
85 87
86 const obs = this.hooks.wrapObsFun( 88 const obs = this.hooks.wrapObsFun(
@@ -119,6 +121,7 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
119 loadMoreThreads () { 121 loadMoreThreads () {
120 const params = { 122 const params = {
121 videoId: this.video.uuid, 123 videoId: this.video.uuid,
124 videoPassword: this.videoPassword,
122 componentPagination: this.componentPagination, 125 componentPagination: this.componentPagination,
123 sort: this.sort 126 sort: this.sort
124 } 127 }
diff --git a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html
index 79b83811d..45e222743 100644
--- a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html
+++ b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.html
@@ -42,3 +42,7 @@
42 <div class="blocked-label" i18n>This video is blocked.</div> 42 <div class="blocked-label" i18n>This video is blocked.</div>
43 {{ video.blacklistedReason }} 43 {{ video.blacklistedReason }}
44</div> 44</div>
45
46<div i18n class="alert alert-warning" *ngIf="video?.canAccessPasswordProtectedVideoWithoutPassword(user)">
47 This video is password protected.
48</div>
diff --git a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts
index ba79fabc8..8781ead7e 100644
--- a/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/information/video-alert.component.ts
@@ -1,6 +1,7 @@
1import { Component, Input } from '@angular/core' 1import { Component, Input } from '@angular/core'
2import { AuthUser } from '@app/core'
2import { VideoDetails } from '@app/shared/shared-main' 3import { VideoDetails } from '@app/shared/shared-main'
3import { VideoState } from '@shared/models' 4import { VideoPrivacy, VideoState } from '@shared/models'
4 5
5@Component({ 6@Component({
6 selector: 'my-video-alert', 7 selector: 'my-video-alert',
@@ -8,6 +9,7 @@ import { VideoState } from '@shared/models'
8 styleUrls: [ './video-alert.component.scss' ] 9 styleUrls: [ './video-alert.component.scss' ]
9}) 10})
10export class VideoAlertComponent { 11export class VideoAlertComponent {
12 @Input() user: AuthUser
11 @Input() video: VideoDetails 13 @Input() video: VideoDetails
12 @Input() noPlaylistVideoFound: boolean 14 @Input() noPlaylistVideoFound: boolean
13 15
@@ -46,4 +48,8 @@ export class VideoAlertComponent {
46 isLiveEnded () { 48 isLiveEnded () {
47 return this.video?.state.id === VideoState.LIVE_ENDED 49 return this.video?.state.id === VideoState.LIVE_ENDED
48 } 50 }
51
52 isVideoPasswordProtected () {
53 return this.video?.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
54 }
49} 55}
diff --git a/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts
index ec85db0ff..97d71a510 100644
--- a/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts
@@ -152,12 +152,24 @@ export class VideoWatchPlaylistComponent {
152 this.onPlaylistVideosNearOfBottom(position) 152 this.onPlaylistVideosNearOfBottom(position)
153 } 153 }
154 154
155 // ---------------------------------------------------------------------------
156
155 hasPreviousVideo () { 157 hasPreviousVideo () {
156 return !!this.findPlaylistVideo(this.currentPlaylistPosition - 1, 'previous') 158 return !!this.getPreviousVideo()
159 }
160
161 getPreviousVideo () {
162 return this.findPlaylistVideo(this.currentPlaylistPosition - 1, 'previous')
157 } 163 }
158 164
165 // ---------------------------------------------------------------------------
166
159 hasNextVideo () { 167 hasNextVideo () {
160 return !!this.findPlaylistVideo(this.currentPlaylistPosition + 1, 'next') 168 return !!this.getNextVideo()
169 }
170
171 getNextVideo () {
172 return this.findPlaylistVideo(this.currentPlaylistPosition + 1, 'next')
161 } 173 }
162 174
163 navigateToPreviousPlaylistVideo () { 175 navigateToPreviousPlaylistVideo () {
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.html b/client/src/app/+videos/+video-watch/video-watch.component.html
index 461891779..294ff4b3a 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.html
+++ b/client/src/app/+videos/+video-watch/video-watch.component.html
@@ -8,7 +8,7 @@
8 </div> 8 </div>
9 9
10 <div id="videojs-wrapper"> 10 <div id="videojs-wrapper">
11 <img class="placeholder-image" *ngIf="playerPlaceholderImgSrc" [src]="playerPlaceholderImgSrc" alt="Placeholder image" i18n-alt> 11 <video #playerElement class="video-js vjs-peertube-skin" playsinline="true"></video>
12 </div> 12 </div>
13 13
14 <my-video-watch-playlist 14 <my-video-watch-playlist
@@ -19,7 +19,7 @@
19 <my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder> 19 <my-plugin-placeholder pluginId="player-next"></my-plugin-placeholder>
20 </div> 20 </div>
21 21
22 <my-video-alert [video]="video" [noPlaylistVideoFound]="noPlaylistVideoFound"></my-video-alert> 22 <my-video-alert [video]="video" [user]="user" [noPlaylistVideoFound]="noPlaylistVideoFound"></my-video-alert>
23 23
24 <!-- Video information --> 24 <!-- Video information -->
25 <div *ngIf="video" class="margin-content video-bottom"> 25 <div *ngIf="video" class="margin-content video-bottom">
@@ -51,8 +51,8 @@
51 </div> 51 </div>
52 52
53 <my-action-buttons 53 <my-action-buttons
54 [video]="video" [isUserLoggedIn]="isUserLoggedIn()" [videoCaptions]="videoCaptions" [playlist]="playlist" 54 [video]="video" [videoPassword]="videoPassword" [isUserLoggedIn]="isUserLoggedIn()" [isUserOwner]="isUserOwner()" [videoCaptions]="videoCaptions"
55 [currentTime]="getCurrentTime()" [currentPlaylistPosition]="getCurrentPlaylistPosition()" 55 [playlist]="playlist" [currentTime]="getCurrentTime()" [currentPlaylistPosition]="getCurrentPlaylistPosition()"
56 ></my-action-buttons> 56 ></my-action-buttons>
57 </div> 57 </div>
58 </div> 58 </div>
@@ -92,6 +92,7 @@
92 <my-video-comments 92 <my-video-comments
93 class="border-top" 93 class="border-top"
94 [video]="video" 94 [video]="video"
95 [videoPassword]="videoPassword"
95 [user]="user" 96 [user]="user"
96 (timestampClicked)="handleTimestampClicked($event)" 97 (timestampClicked)="handleTimestampClicked($event)"
97 ></my-video-comments> 98 ></my-video-comments>
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts
index 19ad97d42..aebec52fb 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/+videos/+video-watch/video-watch.component.ts
@@ -1,6 +1,5 @@
1import { Hotkey, HotkeysService } from 'angular2-hotkeys' 1import { Hotkey, HotkeysService } from 'angular2-hotkeys'
2import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs' 2import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs'
3import { VideoJsPlayer } from 'video.js'
4import { PlatformLocation } from '@angular/common' 3import { PlatformLocation } from '@angular/common'
5import { Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' 4import { Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
6import { ActivatedRoute, Router } from '@angular/router' 5import { ActivatedRoute, Router } from '@angular/router'
@@ -19,13 +18,13 @@ import {
19 UserService 18 UserService
20} from '@app/core' 19} from '@app/core'
21import { HooksService } from '@app/core/plugins/hooks.service' 20import { HooksService } from '@app/core/plugins/hooks.service'
22import { isXPercentInViewport, scrollToTop } from '@app/helpers' 21import { isXPercentInViewport, scrollToTop, toBoolean } from '@app/helpers'
23import { Video, VideoCaptionService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main' 22import { Video, VideoCaptionService, VideoDetails, VideoFileTokenService, VideoService } from '@app/shared/shared-main'
24import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' 23import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
25import { LiveVideoService } from '@app/shared/shared-video-live' 24import { LiveVideoService } from '@app/shared/shared-video-live'
26import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' 25import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
27import { logger } from '@root-helpers/logger' 26import { logger } from '@root-helpers/logger'
28import { isP2PEnabled, videoRequiresAuth } from '@root-helpers/video' 27import { isP2PEnabled, videoRequiresFileToken, videoRequiresUserAuth } from '@root-helpers/video'
29import { timeToInt } from '@shared/core-utils' 28import { timeToInt } from '@shared/core-utils'
30import { 29import {
31 HTMLServerConfig, 30 HTMLServerConfig,
@@ -33,15 +32,16 @@ import {
33 LiveVideo, 32 LiveVideo,
34 PeerTubeProblemDocument, 33 PeerTubeProblemDocument,
35 ServerErrorCode, 34 ServerErrorCode,
35 Storyboard,
36 VideoCaption, 36 VideoCaption,
37 VideoPrivacy, 37 VideoPrivacy,
38 VideoState 38 VideoState
39} from '@shared/models' 39} from '@shared/models'
40import { 40import {
41 CustomizationOptions, 41 HLSOptions,
42 P2PMediaLoaderOptions, 42 PeerTubePlayer,
43 PeertubePlayerManager, 43 PeerTubePlayerContructorOptions,
44 PeertubePlayerManagerOptions, 44 PeerTubePlayerLoadOptions,
45 PlayerMode, 45 PlayerMode,
46 videojs 46 videojs
47} from '../../../assets/player' 47} from '../../../assets/player'
@@ -49,7 +49,24 @@ import { cleanupVideoWatch, getStoredTheater, getStoredVideoWatchHistory } from
49import { environment } from '../../../environments/environment' 49import { environment } from '../../../environments/environment'
50import { VideoWatchPlaylistComponent } from './shared' 50import { VideoWatchPlaylistComponent } from './shared'
51 51
52type URLOptions = CustomizationOptions & { playerMode: PlayerMode } 52type URLOptions = {
53 playerMode: PlayerMode
54
55 startTime: number | string
56 stopTime: number | string
57
58 controls?: boolean
59 controlBar?: boolean
60
61 muted?: boolean
62 loop?: boolean
63 subtitle?: string
64 resume?: string
65
66 peertubeLink: boolean
67
68 playbackRate?: number | string
69}
53 70
54@Component({ 71@Component({
55 selector: 'my-video-watch', 72 selector: 'my-video-watch',
@@ -59,15 +76,16 @@ type URLOptions = CustomizationOptions & { playerMode: PlayerMode }
59export class VideoWatchComponent implements OnInit, OnDestroy { 76export class VideoWatchComponent implements OnInit, OnDestroy {
60 @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent 77 @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent
61 @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent 78 @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent
79 @ViewChild('playerElement') playerElement: ElementRef<HTMLVideoElement>
62 80
63 player: VideoJsPlayer 81 peertubePlayer: PeerTubePlayer
64 playerElement: HTMLVideoElement
65 playerPlaceholderImgSrc: string
66 theaterEnabled = false 82 theaterEnabled = false
67 83
68 video: VideoDetails = null 84 video: VideoDetails = null
69 videoCaptions: VideoCaption[] = [] 85 videoCaptions: VideoCaption[] = []
70 liveVideo: LiveVideo 86 liveVideo: LiveVideo
87 videoPassword: string
88 storyboards: Storyboard[] = []
71 89
72 playlistPosition: number 90 playlistPosition: number
73 playlist: VideoPlaylist = null 91 playlist: VideoPlaylist = null
@@ -75,8 +93,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
75 remoteServerDown = false 93 remoteServerDown = false
76 noPlaylistVideoFound = false 94 noPlaylistVideoFound = false
77 95
78 private nextVideoUUID = '' 96 private nextRecommendedVideoUUID = ''
79 private nextVideoTitle = '' 97 private nextRecommendedVideoTitle = ''
80 98
81 private videoFileToken: string 99 private videoFileToken: string
82 100
@@ -127,11 +145,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
127 return this.userService.getAnonymousUser() 145 return this.userService.getAnonymousUser()
128 } 146 }
129 147
130 ngOnInit () { 148 async ngOnInit () {
131 this.serverConfig = this.serverService.getHTMLConfig() 149 this.serverConfig = this.serverService.getHTMLConfig()
132 150
133 PeertubePlayerManager.initState()
134
135 this.loadRouteParams() 151 this.loadRouteParams()
136 this.loadRouteQuery() 152 this.loadRouteQuery()
137 153
@@ -140,10 +156,20 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
140 this.hooks.runAction('action:video-watch.init', 'video-watch') 156 this.hooks.runAction('action:video-watch.init', 'video-watch')
141 157
142 setTimeout(cleanupVideoWatch, 1500) // Run in timeout to ensure we're not blocking the UI 158 setTimeout(cleanupVideoWatch, 1500) // Run in timeout to ensure we're not blocking the UI
159
160 const constructorOptions = await this.hooks.wrapFun(
161 this.buildPeerTubePlayerConstructorOptions.bind(this),
162 { urlOptions: this.getUrlOptions() },
163 'video-watch',
164 'filter:internal.video-watch.player.build-options.params',
165 'filter:internal.video-watch.player.build-options.result'
166 )
167
168 this.peertubePlayer = new PeerTubePlayer(constructorOptions)
143 } 169 }
144 170
145 ngOnDestroy () { 171 ngOnDestroy () {
146 this.flushPlayer() 172 if (this.peertubePlayer) this.peertubePlayer.destroy()
147 173
148 // Unsubscribe subscriptions 174 // Unsubscribe subscriptions
149 if (this.paramsSub) this.paramsSub.unsubscribe() 175 if (this.paramsSub) this.paramsSub.unsubscribe()
@@ -168,14 +194,14 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
168 194
169 // The recommended videos's first element should be the next video 195 // The recommended videos's first element should be the next video
170 const video = videos[0] 196 const video = videos[0]
171 this.nextVideoUUID = video.uuid 197 this.nextRecommendedVideoUUID = video.uuid
172 this.nextVideoTitle = video.name 198 this.nextRecommendedVideoTitle = video.name
173 } 199 }
174 200
175 handleTimestampClicked (timestamp: number) { 201 handleTimestampClicked (timestamp: number) {
176 if (!this.player || this.video.isLive) return 202 if (!this.peertubePlayer || this.video.isLive) return
177 203
178 this.player.currentTime(timestamp) 204 this.peertubePlayer.getPlayer().currentTime(timestamp)
179 scrollToTop() 205 scrollToTop()
180 } 206 }
181 207
@@ -191,6 +217,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
191 return this.authService.isLoggedIn() 217 return this.authService.isLoggedIn()
192 } 218 }
193 219
220 isUserOwner () {
221 return this.video.isLocal === true && this.video.account.name === this.user?.username
222 }
223
194 isVideoBlur (video: Video) { 224 isVideoBlur (video: Video) {
195 return video.isVideoNSFWForUser(this.user, this.serverConfig) 225 return video.isVideoNSFWForUser(this.user, this.serverConfig)
196 } 226 }
@@ -236,25 +266,24 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
236 this.videoWatchPlaylist.updatePlaylistIndex(this.playlistPosition) 266 this.videoWatchPlaylist.updatePlaylistIndex(this.playlistPosition)
237 267
238 const start = queryParams['start'] 268 const start = queryParams['start']
239 if (this.player && start) this.player.currentTime(parseInt(start, 10)) 269 if (this.peertubePlayer && start) this.peertubePlayer.getPlayer().currentTime(parseInt(start, 10))
240 }) 270 })
241 } 271 }
242 272
243 private loadVideo (options: { 273 private loadVideo (options: {
244 videoId: string 274 videoId: string
245 forceAutoplay: boolean 275 forceAutoplay: boolean
276 videoPassword?: string
246 }) { 277 }) {
247 const { videoId, forceAutoplay } = options 278 const { videoId, forceAutoplay, videoPassword } = options
248 279
249 if (this.isSameElement(this.video, videoId)) return 280 if (this.isSameElement(this.video, videoId)) return
250 281
251 if (this.player) this.player.pause()
252
253 this.video = undefined 282 this.video = undefined
254 283
255 const videoObs = this.hooks.wrapObsFun( 284 const videoObs = this.hooks.wrapObsFun(
256 this.videoService.getVideo.bind(this.videoService), 285 this.videoService.getVideo.bind(this.videoService),
257 { videoId }, 286 { videoId, videoPassword },
258 'video-watch', 287 'video-watch',
259 'filter:api.video-watch.video.get.params', 288 'filter:api.video-watch.video.get.params',
260 'filter:api.video-watch.video.get.result' 289 'filter:api.video-watch.video.get.result'
@@ -269,48 +298,44 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
269 }), 298 }),
270 299
271 switchMap(({ video, live }) => { 300 switchMap(({ video, live }) => {
272 if (!videoRequiresAuth(video)) return of({ video, live, videoFileToken: undefined }) 301 if (!videoRequiresFileToken(video)) return of({ video, live, videoFileToken: undefined })
273 302
274 return this.videoFileTokenService.getVideoFileToken(video.uuid) 303 return this.videoFileTokenService.getVideoFileToken({ videoUUID: video.uuid, videoPassword })
275 .pipe(map(({ token }) => ({ video, live, videoFileToken: token }))) 304 .pipe(map(({ token }) => ({ video, live, videoFileToken: token })))
276 }) 305 })
277 ) 306 )
278 307
279 forkJoin([ 308 forkJoin([
280 videoAndLiveObs, 309 videoAndLiveObs,
281 this.videoCaptionService.listCaptions(videoId), 310 this.videoCaptionService.listCaptions(videoId, videoPassword),
311 this.videoService.getStoryboards(videoId, videoPassword),
282 this.userService.getAnonymousOrLoggedUser() 312 this.userService.getAnonymousOrLoggedUser()
283 ]).subscribe({ 313 ]).subscribe({
284 next: ([ { video, live, videoFileToken }, captionsResult, loggedInOrAnonymousUser ]) => { 314 next: ([ { video, live, videoFileToken }, captionsResult, storyboards, loggedInOrAnonymousUser ]) => {
285 const queryParams = this.route.snapshot.queryParams
286
287 const urlOptions = {
288 resume: queryParams.resume,
289
290 startTime: queryParams.start,
291 stopTime: queryParams.stop,
292
293 muted: queryParams.muted,
294 loop: queryParams.loop,
295 subtitle: queryParams.subtitle,
296
297 playerMode: queryParams.mode,
298 playbackRate: queryParams.playbackRate,
299 peertubeLink: false
300 }
301
302 this.onVideoFetched({ 315 this.onVideoFetched({
303 video, 316 video,
304 live, 317 live,
305 videoCaptions: captionsResult.data, 318 videoCaptions: captionsResult.data,
319 storyboards,
306 videoFileToken, 320 videoFileToken,
321 videoPassword,
307 loggedInOrAnonymousUser, 322 loggedInOrAnonymousUser,
308 urlOptions,
309 forceAutoplay 323 forceAutoplay
310 }).catch(err => this.handleGlobalError(err)) 324 }).catch(err => {
325 this.handleGlobalError(err)
326 })
311 }, 327 },
328 error: async err => {
329 if (err.body.code === ServerErrorCode.VIDEO_REQUIRES_PASSWORD || err.body.code === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) {
330 const { confirmed, password } = await this.handleVideoPasswordError(err)
312 331
313 error: err => this.handleRequestError(err) 332 if (confirmed === false) return this.location.back()
333
334 this.loadVideo({ ...options, videoPassword: password })
335 } else {
336 this.handleRequestError(err)
337 }
338 }
314 }) 339 })
315 } 340 }
316 341
@@ -364,28 +389,47 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
364 const errorMessage: string = typeof err === 'string' ? err : err.message 389 const errorMessage: string = typeof err === 'string' ? err : err.message
365 if (!errorMessage) return 390 if (!errorMessage) return
366 391
367 // Display a message in the video player instead of a notification 392 this.notifier.error(errorMessage)
368 if (errorMessage.includes('from xs param')) { 393 }
369 this.flushPlayer()
370 this.remoteServerDown = true
371 394
372 return 395 private handleVideoPasswordError (err: any) {
396 let isIncorrectPassword: boolean
397
398 if (err.body.code === ServerErrorCode.VIDEO_REQUIRES_PASSWORD) {
399 isIncorrectPassword = false
400 } else if (err.body.code === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) {
401 this.videoPassword = undefined
402 isIncorrectPassword = true
373 } 403 }
374 404
375 this.notifier.error(errorMessage) 405 return this.confirmService.confirmWithPassword({
406 message: $localize`You need a password to watch this video`,
407 title: $localize`This video is password protected`,
408 errorMessage: isIncorrectPassword ? $localize`Incorrect password, please enter a correct password` : ''
409 })
376 } 410 }
377 411
378 private async onVideoFetched (options: { 412 private async onVideoFetched (options: {
379 video: VideoDetails 413 video: VideoDetails
380 live: LiveVideo 414 live: LiveVideo
381 videoCaptions: VideoCaption[] 415 videoCaptions: VideoCaption[]
416 storyboards: Storyboard[]
382 videoFileToken: string 417 videoFileToken: string
418 videoPassword: string
383 419
384 urlOptions: URLOptions
385 loggedInOrAnonymousUser: User 420 loggedInOrAnonymousUser: User
386 forceAutoplay: boolean 421 forceAutoplay: boolean
387 }) { 422 }) {
388 const { video, live, videoCaptions, urlOptions, videoFileToken, loggedInOrAnonymousUser, forceAutoplay } = options 423 const {
424 video,
425 live,
426 videoCaptions,
427 storyboards,
428 videoFileToken,
429 videoPassword,
430 loggedInOrAnonymousUser,
431 forceAutoplay
432 } = options
389 433
390 this.subscribeToLiveEventsIfNeeded(this.video, video) 434 this.subscribeToLiveEventsIfNeeded(this.video, video)
391 435
@@ -393,9 +437,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
393 this.videoCaptions = videoCaptions 437 this.videoCaptions = videoCaptions
394 this.liveVideo = live 438 this.liveVideo = live
395 this.videoFileToken = videoFileToken 439 this.videoFileToken = videoFileToken
440 this.videoPassword = videoPassword
441 this.storyboards = storyboards
396 442
397 // Re init attributes 443 // Re init attributes
398 this.playerPlaceholderImgSrc = undefined
399 this.remoteServerDown = false 444 this.remoteServerDown = false
400 this.currentTime = undefined 445 this.currentTime = undefined
401 446
@@ -409,7 +454,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
409 454
410 this.buildHotkeysHelp(video) 455 this.buildHotkeysHelp(video)
411 456
412 this.buildPlayer({ urlOptions, loggedInOrAnonymousUser, forceAutoplay }) 457 this.loadPlayer({ loggedInOrAnonymousUser, forceAutoplay })
413 .catch(err => logger.error('Cannot build the player', err)) 458 .catch(err => logger.error('Cannot build the player', err))
414 459
415 this.setOpenGraphTags() 460 this.setOpenGraphTags()
@@ -422,114 +467,70 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
422 this.hooks.runAction('action:video-watch.video.loaded', 'video-watch', hookOptions) 467 this.hooks.runAction('action:video-watch.video.loaded', 'video-watch', hookOptions)
423 } 468 }
424 469
425 private async buildPlayer (options: { 470 private async loadPlayer (options: {
426 urlOptions: URLOptions
427 loggedInOrAnonymousUser: User 471 loggedInOrAnonymousUser: User
428 forceAutoplay: boolean 472 forceAutoplay: boolean
429 }) { 473 }) {
430 const { urlOptions, loggedInOrAnonymousUser, forceAutoplay } = options 474 const { loggedInOrAnonymousUser, forceAutoplay } = options
431
432 // Flush old player if needed
433 this.flushPlayer()
434 475
435 const videoState = this.video.state.id 476 const videoState = this.video.state.id
436 if (videoState === VideoState.LIVE_ENDED || videoState === VideoState.WAITING_FOR_LIVE) { 477 if (videoState === VideoState.LIVE_ENDED || videoState === VideoState.WAITING_FOR_LIVE) {
437 this.playerPlaceholderImgSrc = this.video.previewPath 478 this.updatePlayerOnNoLive()
438 return 479 return
439 } 480 }
440 481
441 // Build video element, because videojs removes it on dispose 482 this.peertubePlayer?.enable()
442 const playerElementWrapper = this.elementRef.nativeElement.querySelector('#videojs-wrapper')
443 this.playerElement = document.createElement('video')
444 this.playerElement.className = 'video-js vjs-peertube-skin'
445 this.playerElement.setAttribute('playsinline', 'true')
446 playerElementWrapper.appendChild(this.playerElement)
447 483
448 const params = { 484 const params = {
449 video: this.video, 485 video: this.video,
450 videoCaptions: this.videoCaptions, 486 videoCaptions: this.videoCaptions,
487 storyboards: this.storyboards,
451 liveVideo: this.liveVideo, 488 liveVideo: this.liveVideo,
452 videoFileToken: this.videoFileToken, 489 videoFileToken: this.videoFileToken,
453 urlOptions, 490 videoPassword: this.videoPassword,
491 urlOptions: this.getUrlOptions(),
454 loggedInOrAnonymousUser, 492 loggedInOrAnonymousUser,
455 forceAutoplay, 493 forceAutoplay,
456 user: this.user 494 user: this.user
457 } 495 }
458 const { playerMode, playerOptions } = await this.hooks.wrapFun( 496
459 this.buildPlayerManagerOptions.bind(this), 497 const loadOptions = await this.hooks.wrapFun(
498 this.buildPeerTubePlayerLoadOptions.bind(this),
460 params, 499 params,
461 'video-watch', 500 'video-watch',
462 'filter:internal.video-watch.player.build-options.params', 501 'filter:internal.video-watch.player.load-options.params',
463 'filter:internal.video-watch.player.build-options.result' 502 'filter:internal.video-watch.player.load-options.result'
464 ) 503 )
465 504
466 this.zone.runOutsideAngular(async () => { 505 this.zone.runOutsideAngular(async () => {
467 this.player = await PeertubePlayerManager.initialize(playerMode, playerOptions, player => this.player = player) 506 await this.peertubePlayer.load(loadOptions)
468 507
469 this.player.on('customError', (_e, data: any) => { 508 const player = this.peertubePlayer.getPlayer()
470 this.zone.run(() => this.handleGlobalError(data.err))
471 })
472 509
473 this.player.on('timeupdate', () => { 510 player.on('timeupdate', () => {
474 // Don't need to trigger angular change for this variable, that is sent to children components on click 511 // Don't need to trigger angular change for this variable, that is sent to children components on click
475 this.currentTime = Math.floor(this.player.currentTime()) 512 this.currentTime = Math.floor(player.currentTime())
476 }) 513 })
477 514
478 /** 515 if (this.video.isLive) {
479 * condition: true to make the upnext functionality trigger, false to disable the upnext functionality 516 player.one('ended', () => {
480 * go to the next video in 'condition()' if you don't want of the timer. 517 this.zone.run(() => {
481 * next: function triggered at the end of the timer. 518 // We changed the video, it's not a live anymore
482 * suspended: function used at each click of the timer checking if we need to reset progress 519 if (!this.video.isLive) return
483 * and wait until suspended becomes truthy again.
484 */
485 this.player.upnext({
486 timeout: 5000, // 5s
487
488 headText: $localize`Up Next`,
489 cancelText: $localize`Cancel`,
490 suspendedText: $localize`Autoplay is suspended`,
491
492 getTitle: () => this.nextVideoTitle,
493 520
494 next: () => this.zone.run(() => this.playNextVideoInAngularZone()), 521 this.video.state.id = VideoState.LIVE_ENDED
495 condition: () => {
496 if (!this.playlist) return this.isAutoPlayNext()
497 522
498 // Don't wait timeout to play the next playlist video 523 this.updatePlayerOnNoLive()
499 if (this.isPlaylistAutoPlayNext()) { 524 })
500 this.playNextVideoInAngularZone() 525 })
501 return undefined 526 }
502 }
503
504 return false
505 },
506
507 suspended: () => {
508 return (
509 !isXPercentInViewport(this.player.el() as HTMLElement, 80) ||
510 !document.getElementById('content').contains(document.activeElement)
511 )
512 }
513 })
514
515 this.player.one('stopped', () => {
516 if (this.playlist && this.isPlaylistAutoPlayNext()) {
517 this.playNextVideoInAngularZone()
518 }
519 })
520
521 this.player.one('ended', () => {
522 if (this.video.isLive) {
523 this.zone.run(() => this.video.state.id = VideoState.LIVE_ENDED)
524 }
525 })
526 527
527 this.player.on('theaterChange', (_: any, enabled: boolean) => { 528 player.on('theater-change', (_: any, enabled: boolean) => {
528 this.zone.run(() => this.theaterEnabled = enabled) 529 this.zone.run(() => this.theaterEnabled = enabled)
529 }) 530 })
530 531
531 this.hooks.runAction('action:video-watch.player.loaded', 'video-watch', { 532 this.hooks.runAction('action:video-watch.player.loaded', 'video-watch', {
532 player: this.player, 533 player,
533 playlist: this.playlist, 534 playlist: this.playlist,
534 playlistPosition: this.playlistPosition, 535 playlistPosition: this.playlistPosition,
535 videojs, 536 videojs,
@@ -546,15 +547,25 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
546 return true 547 return true
547 } 548 }
548 549
549 private playNextVideoInAngularZone () { 550 private getNextVideoTitle () {
550 if (this.playlist) { 551 if (this.playlist) {
551 this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) 552 return this.videoWatchPlaylist.getNextVideo()?.video?.name || ''
552 return
553 } 553 }
554 554
555 if (this.nextVideoUUID) { 555 return this.nextRecommendedVideoTitle
556 this.router.navigate([ '/w', this.nextVideoUUID ]) 556 }
557 } 557
558 private playNextVideoInAngularZone () {
559 this.zone.run(() => {
560 if (this.playlist) {
561 this.videoWatchPlaylist.navigateToNextPlaylistVideo()
562 return
563 }
564
565 if (this.nextRecommendedVideoUUID) {
566 this.router.navigate([ '/w', this.nextRecommendedVideoUUID ])
567 }
568 })
558 } 569 }
559 570
560 private isAutoplay () { 571 private isAutoplay () {
@@ -582,32 +593,93 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
582 ) 593 )
583 } 594 }
584 595
585 private flushPlayer () { 596 private buildPeerTubePlayerConstructorOptions (options: {
586 // Remove player if it exists 597 urlOptions: URLOptions
587 if (!this.player) return 598 }): PeerTubePlayerContructorOptions {
599 const { urlOptions } = options
600
601 return {
602 playerElement: () => this.playerElement.nativeElement,
603
604 enableHotkeys: true,
605 inactivityTimeout: 2500,
606
607 theaterButton: true,
608
609 controls: urlOptions.controls,
610 controlBar: urlOptions.controlBar,
611
612 muted: urlOptions.muted,
613 loop: urlOptions.loop,
614
615 playbackRate: urlOptions.playbackRate,
616
617 instanceName: this.serverConfig.instance.name,
618 language: this.localeId,
619 metricsUrl: environment.apiUrl + '/api/v1/metrics/playback',
620
621 videoViewIntervalMs: VideoWatchComponent.VIEW_VIDEO_INTERVAL_MS,
622 authorizationHeader: () => this.authService.getRequestHeaderValue(),
588 623
589 try { 624 serverUrl: environment.originServerUrl || window.location.origin,
590 this.player.dispose() 625
591 this.player = undefined 626 errorNotifier: (message: string) => this.notifier.error(message),
592 } catch (err) { 627
593 logger.error('Cannot dispose player.', err) 628 peertubeLink: () => false,
629
630 pluginsManager: this.pluginService.getPluginsManager()
594 } 631 }
595 } 632 }
596 633
597 private buildPlayerManagerOptions (params: { 634 private buildPeerTubePlayerLoadOptions (options: {
598 video: VideoDetails 635 video: VideoDetails
599 liveVideo: LiveVideo 636 liveVideo: LiveVideo
600 videoCaptions: VideoCaption[] 637 videoCaptions: VideoCaption[]
638 storyboards: Storyboard[]
601 639
602 videoFileToken: string 640 videoFileToken: string
641 videoPassword: string
603 642
604 urlOptions: CustomizationOptions & { playerMode: PlayerMode } 643 urlOptions: URLOptions
605 644
606 loggedInOrAnonymousUser: User 645 loggedInOrAnonymousUser: User
607 forceAutoplay: boolean 646 forceAutoplay: boolean
608 user?: AuthUser // Keep for plugins 647 user?: AuthUser // Keep for plugins
609 }) { 648 }): PeerTubePlayerLoadOptions {
610 const { video, liveVideo, videoCaptions, videoFileToken, urlOptions, loggedInOrAnonymousUser, forceAutoplay } = params 649 const {
650 video,
651 liveVideo,
652 videoCaptions,
653 storyboards,
654 videoFileToken,
655 videoPassword,
656 urlOptions,
657 loggedInOrAnonymousUser,
658 forceAutoplay
659 } = options
660
661 let mode: PlayerMode
662
663 if (urlOptions.playerMode) {
664 if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader'
665 else mode = 'web-video'
666 } else {
667 if (video.hasHlsPlaylist()) mode = 'p2p-media-loader'
668 else mode = 'web-video'
669 }
670
671 let hlsOptions: HLSOptions
672 if (video.hasHlsPlaylist()) {
673 const hlsPlaylist = video.getHlsPlaylist()
674
675 hlsOptions = {
676 playlistUrl: hlsPlaylist.playlistUrl,
677 segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
678 redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
679 trackerAnnounce: video.trackerUrls,
680 videoFiles: hlsPlaylist.files
681 }
682 }
611 683
612 const getStartTime = () => { 684 const getStartTime = () => {
613 const byUrl = urlOptions.startTime !== undefined 685 const byUrl = urlOptions.startTime !== undefined
@@ -634,117 +706,93 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
634 src: environment.apiUrl + c.captionPath 706 src: environment.apiUrl + c.captionPath
635 })) 707 }))
636 708
709 const storyboard = storyboards.length !== 0
710 ? {
711 url: environment.apiUrl + storyboards[0].storyboardPath,
712 height: storyboards[0].spriteHeight,
713 width: storyboards[0].spriteWidth,
714 interval: storyboards[0].spriteDuration
715 }
716 : undefined
717
637 const liveOptions = video.isLive 718 const liveOptions = video.isLive
638 ? { latencyMode: liveVideo.latencyMode } 719 ? { latencyMode: liveVideo.latencyMode }
639 : undefined 720 : undefined
640 721
641 const options: PeertubePlayerManagerOptions = { 722 return {
642 common: { 723 mode,
643 autoplay: this.isAutoplay(),
644 forceAutoplay,
645 p2pEnabled: isP2PEnabled(video, this.serverConfig, loggedInOrAnonymousUser.p2pEnabled),
646
647 hasNextVideo: () => this.hasNextVideo(),
648 nextVideo: () => this.playNextVideoInAngularZone(),
649
650 playerElement: this.playerElement,
651 onPlayerElementChange: (element: HTMLVideoElement) => this.playerElement = element,
652
653 videoDuration: video.duration,
654 enableHotkeys: true,
655 inactivityTimeout: 2500,
656 poster: video.previewUrl,
657
658 startTime,
659 stopTime: urlOptions.stopTime,
660 controlBar: urlOptions.controlBar,
661 controls: urlOptions.controls,
662 muted: urlOptions.muted,
663 loop: urlOptions.loop,
664 subtitle: urlOptions.subtitle,
665 playbackRate: urlOptions.playbackRate,
666 724
667 peertubeLink: urlOptions.peertubeLink, 725 autoplay: this.isAutoplay(),
726 forceAutoplay,
668 727
669 theaterButton: true, 728 duration: this.video.duration,
670 captions: videoCaptions.length !== 0, 729 poster: video.previewUrl,
730 p2pEnabled: isP2PEnabled(video, this.serverConfig, loggedInOrAnonymousUser.p2pEnabled),
671 731
672 embedUrl: video.embedUrl, 732 startTime,
673 embedTitle: video.name, 733 stopTime: urlOptions.stopTime,
674 instanceName: this.serverConfig.instance.name,
675 734
676 isLive: video.isLive, 735 embedUrl: video.embedUrl,
677 liveOptions, 736 embedTitle: video.name,
678 737
679 language: this.localeId, 738 isLive: video.isLive,
739 liveOptions,
680 740
681 metricsUrl: environment.apiUrl + '/api/v1/metrics/playback', 741 videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE
742 ? this.videoService.getVideoViewUrl(video.uuid)
743 : null,
682 744
683 videoViewUrl: video.privacy.id !== VideoPrivacy.PRIVATE 745 videoFileToken: () => videoFileToken,
684 ? this.videoService.getVideoViewUrl(video.uuid) 746 requiresUserAuth: videoRequiresUserAuth(video, videoPassword),
685 : null, 747 requiresPassword: video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED &&
686 videoViewIntervalMs: VideoWatchComponent.VIEW_VIDEO_INTERVAL_MS, 748 !video.canAccessPasswordProtectedVideoWithoutPassword(this.user),
687 authorizationHeader: () => this.authService.getRequestHeaderValue(), 749 videoPassword: () => videoPassword,
688 750
689 serverUrl: environment.originServerUrl || window.location.origin, 751 videoCaptions: playerCaptions,
752 storyboard,
690 753
691 videoFileToken: () => videoFileToken, 754 videoShortUUID: video.shortUUID,
692 requiresAuth: videoRequiresAuth(video), 755 videoUUID: video.uuid,
693 756
694 videoCaptions: playerCaptions, 757 previousVideo: {
758 enabled: this.playlist && this.videoWatchPlaylist.hasPreviousVideo(),
695 759
696 videoShortUUID: video.shortUUID, 760 handler: this.playlist
697 videoUUID: video.uuid, 761 ? () => this.zone.run(() => this.videoWatchPlaylist.navigateToPreviousPlaylistVideo())
762 : undefined,
698 763
699 errorNotifier: (message: string) => this.notifier.error(message) 764 displayControlBarButton: !!this.playlist
700 }, 765 },
701 766
702 webtorrent: { 767 nextVideo: {
703 videoFiles: video.files 768 enabled: this.hasNextVideo(),
769 handler: () => this.playNextVideoInAngularZone(),
770 getVideoTitle: () => this.getNextVideoTitle(),
771 displayControlBarButton: this.hasNextVideo()
704 }, 772 },
705 773
706 pluginsManager: this.pluginService.getPluginsManager() 774 upnext: {
707 } 775 isEnabled: () => {
776 if (this.playlist) return this.isPlaylistAutoPlayNext()
708 777
709 // Only set this if we're in a playlist 778 return this.isAutoPlayNext()
710 if (this.playlist) { 779 },
711 options.common.hasPreviousVideo = () => this.videoWatchPlaylist.hasPreviousVideo()
712
713 options.common.previousVideo = () => {
714 this.zone.run(() => this.videoWatchPlaylist.navigateToPreviousPlaylistVideo())
715 }
716 }
717
718 let mode: PlayerMode
719
720 if (urlOptions.playerMode) {
721 if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader'
722 else mode = 'webtorrent'
723 } else {
724 if (video.hasHlsPlaylist()) mode = 'p2p-media-loader'
725 else mode = 'webtorrent'
726 }
727 780
728 // p2p-media-loader needs TextEncoder, fallback on WebTorrent if not available 781 isSuspended: (player: videojs.Player) => {
729 if (typeof TextEncoder === 'undefined') { 782 return !isXPercentInViewport(player.el() as HTMLElement, 80)
730 mode = 'webtorrent' 783 },
731 }
732 784
733 if (mode === 'p2p-media-loader') { 785 timeout: this.playlist
734 const hlsPlaylist = video.getHlsPlaylist() 786 ? 0 // Don't wait to play next video in playlist
787 : 5000 // 5 seconds for a recommended video
788 },
735 789
736 const p2pMediaLoader = { 790 hls: hlsOptions,
737 playlistUrl: hlsPlaylist.playlistUrl,
738 segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
739 redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
740 trackerAnnounce: video.trackerUrls,
741 videoFiles: hlsPlaylist.files
742 } as P2PMediaLoaderOptions
743 791
744 Object.assign(options, { p2pMediaLoader }) 792 webVideo: {
793 videoFiles: video.files
794 }
745 } 795 }
746
747 return { playerMode: mode, playerOptions: options }
748 } 796 }
749 797
750 private async subscribeToLiveEventsIfNeeded (oldVideo: VideoDetails, newVideo: VideoDetails) { 798 private async subscribeToLiveEventsIfNeeded (oldVideo: VideoDetails, newVideo: VideoDetails) {
@@ -792,6 +840,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
792 this.video.viewers = newViewers 840 this.video.viewers = newViewers
793 } 841 }
794 842
843 private updatePlayerOnNoLive () {
844 this.peertubePlayer.unload()
845 this.peertubePlayer.disable()
846 this.peertubePlayer.setPoster(this.video.previewPath)
847 }
848
795 private buildHotkeysHelp (video: Video) { 849 private buildHotkeysHelp (video: Video) {
796 if (this.hotkeys.length !== 0) { 850 if (this.hotkeys.length !== 0) {
797 this.hotkeysService.remove(this.hotkeys) 851 this.hotkeysService.remove(this.hotkeys)
@@ -863,4 +917,26 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
863 this.metaService.setTag('og:url', window.location.href) 917 this.metaService.setTag('og:url', window.location.href)
864 this.metaService.setTag('url', window.location.href) 918 this.metaService.setTag('url', window.location.href)
865 } 919 }
920
921 private getUrlOptions (): URLOptions {
922 const queryParams = this.route.snapshot.queryParams
923
924 return {
925 resume: queryParams.resume,
926
927 startTime: queryParams.start,
928 stopTime: queryParams.stop,
929
930 muted: toBoolean(queryParams.muted),
931 loop: toBoolean(queryParams.loop),
932 subtitle: queryParams.subtitle,
933
934 playerMode: queryParams.mode,
935 playbackRate: queryParams.playbackRate,
936
937 controlBar: toBoolean(queryParams.controlBar),
938
939 peertubeLink: false
940 }
941 }
866} 942}
diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts
index 7e4fac730..9339865f1 100644
--- a/client/src/app/app.module.ts
+++ b/client/src/app/app.module.ts
@@ -12,13 +12,14 @@ import { CoreModule, PluginService, RedirectService, ServerService } from './cor
12import { EmptyComponent } from './empty.component' 12import { EmptyComponent } from './empty.component'
13import { HeaderComponent, SearchTypeaheadComponent, SuggestionComponent } from './header' 13import { HeaderComponent, SearchTypeaheadComponent, SuggestionComponent } from './header'
14import { HighlightPipe } from './header/highlight.pipe' 14import { HighlightPipe } from './header/highlight.pipe'
15import { polyfillICU } from './helpers'
15import { LanguageChooserComponent, MenuComponent, NotificationComponent } from './menu' 16import { LanguageChooserComponent, MenuComponent, NotificationComponent } from './menu'
17import { AccountSetupWarningModalComponent } from './modal/account-setup-warning-modal.component'
18import { AdminWelcomeModalComponent } from './modal/admin-welcome-modal.component'
16import { ConfirmComponent } from './modal/confirm.component' 19import { ConfirmComponent } from './modal/confirm.component'
17import { CustomModalComponent } from './modal/custom-modal.component' 20import { CustomModalComponent } from './modal/custom-modal.component'
18import { InstanceConfigWarningModalComponent } from './modal/instance-config-warning-modal.component' 21import { InstanceConfigWarningModalComponent } from './modal/instance-config-warning-modal.component'
19import { QuickSettingsModalComponent } from './modal/quick-settings-modal.component' 22import { QuickSettingsModalComponent } from './modal/quick-settings-modal.component'
20import { AdminWelcomeModalComponent } from './modal/admin-welcome-modal.component'
21import { AccountSetupWarningModalComponent } from './modal/account-setup-warning-modal.component'
22import { SharedActorImageModule } from './shared/shared-actor-image/shared-actor-image.module' 23import { SharedActorImageModule } from './shared/shared-actor-image/shared-actor-image.module'
23import { SharedFormModule } from './shared/shared-forms' 24import { SharedFormModule } from './shared/shared-forms'
24import { SharedGlobalIconModule } from './shared/shared-icons' 25import { SharedGlobalIconModule } from './shared/shared-icons'
@@ -90,6 +91,11 @@ export function loadConfigFactory (server: ServerService, pluginService: PluginS
90 useFactory: loadConfigFactory, 91 useFactory: loadConfigFactory,
91 deps: [ ServerService, PluginService, RedirectService ], 92 deps: [ ServerService, PluginService, RedirectService ],
92 multi: true 93 multi: true
94 },
95 {
96 provide: APP_INITIALIZER,
97 useFactory: () => polyfillICU,
98 multi: true
93 } 99 }
94 ] 100 ]
95}) 101})
diff --git a/client/src/app/core/confirm/confirm.service.ts b/client/src/app/core/confirm/confirm.service.ts
index 89a25f0a5..abe163aae 100644
--- a/client/src/app/core/confirm/confirm.service.ts
+++ b/client/src/app/core/confirm/confirm.service.ts
@@ -4,6 +4,7 @@ import { Injectable } from '@angular/core'
4type ConfirmOptions = { 4type ConfirmOptions = {
5 title: string 5 title: string
6 message: string 6 message: string
7 errorMessage?: string
7} & ( 8} & (
8 { 9 {
9 type: 'confirm' 10 type: 'confirm'
@@ -12,6 +13,7 @@ type ConfirmOptions = {
12 { 13 {
13 type: 'confirm-password' 14 type: 'confirm-password'
14 confirmButtonText?: string 15 confirmButtonText?: string
16 isIncorrectPassword?: boolean
15 } | 17 } |
16 { 18 {
17 type: 'confirm-expected-input' 19 type: 'confirm-expected-input'
@@ -32,8 +34,14 @@ export class ConfirmService {
32 return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable())) 34 return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable()))
33 } 35 }
34 36
35 confirmWithPassword (message: string, title = '', confirmButtonText?: string) { 37 confirmWithPassword (options: {
36 this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText }) 38 message: string
39 title?: string
40 confirmButtonText?: string
41 errorMessage?: string
42 }) {
43 const { message, title = '', confirmButtonText, errorMessage } = options
44 this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText, errorMessage })
37 45
38 const obs = this.confirmResponse.asObservable() 46 const obs = this.confirmResponse.asObservable()
39 .pipe(map(({ confirmed, value }) => ({ confirmed, password: value }))) 47 .pipe(map(({ confirmed, value }) => ({ confirmed, password: value })))
diff --git a/client/src/app/core/users/user.model.ts b/client/src/app/core/users/user.model.ts
index d57608f1c..5aa02e472 100644
--- a/client/src/app/core/users/user.model.ts
+++ b/client/src/app/core/users/user.model.ts
@@ -30,8 +30,6 @@ export class User implements UserServerModel {
30 autoPlayNextVideoPlaylist: boolean 30 autoPlayNextVideoPlaylist: boolean
31 31
32 p2pEnabled: boolean 32 p2pEnabled: boolean
33 // FIXME: deprecated in 4.1
34 webTorrentEnabled: never
35 33
36 videosHistoryEnabled: boolean 34 videosHistoryEnabled: boolean
37 videoLanguages: string[] 35 videoLanguages: string[]
diff --git a/client/src/app/helpers/i18n-utils.ts b/client/src/app/helpers/i18n-utils.ts
index b7d73d16b..9e22bb4c1 100644
--- a/client/src/app/helpers/i18n-utils.ts
+++ b/client/src/app/helpers/i18n-utils.ts
@@ -1,4 +1,6 @@
1import IntlMessageFormat from 'intl-messageformat' 1import IntlMessageFormat from 'intl-messageformat'
2import { shouldPolyfill as shouldPolyfillLocale } from '@formatjs/intl-locale/should-polyfill'
3import { shouldPolyfill as shouldPolyfillPlural } from '@formatjs/intl-pluralrules/should-polyfill'
2import { logger } from '@root-helpers/logger' 4import { logger } from '@root-helpers/logger'
3import { environment } from '../../environments/environment' 5import { environment } from '../../environments/environment'
4 6
@@ -10,31 +12,68 @@ function getDevLocale () {
10 return 'fr-FR' 12 return 'fr-FR'
11} 13}
12 14
13function prepareIcu (icu: string) { 15async function polyfillICU () {
14 let alreadyWarned = false 16 // Important to be in this order, Plural needs Locale (https://formatjs.io/docs/polyfills/intl-pluralrules)
17 await polyfillICULocale()
18 await polyfillICUPlural()
19}
15 20
16 try { 21async function polyfillICULocale () {
17 const msg = new IntlMessageFormat(icu, $localize.locale) 22 // This locale is supported
23 if (shouldPolyfillLocale()) {
24 // TODO: remove, it's only needed to support Plural polyfill and so iOS 12
25 console.log('Loading Intl Locale polyfill for ' + $localize.locale)
26
27 await import('@formatjs/intl-locale/polyfill')
28 }
29}
30
31async function polyfillICUPlural () {
32 const unsupportedLocale = shouldPolyfillPlural($localize.locale)
33
34 // This locale is supported
35 if (!unsupportedLocale) {
36 return
37 }
18 38
19 return (context: { [id: string]: number | string }, fallback: string) => { 39 // TODO: remove, it's only needed to support iOS 12
20 try { 40 console.log('Loading Intl Plural rules polyfill for ' + $localize.locale)
21 return msg.format(context) as string
22 } catch (err) {
23 if (!alreadyWarned) logger.warn(`Cannot format ICU ${icu}.`, err)
24 41
25 alreadyWarned = true 42 // Load the polyfill 1st BEFORE loading data
26 return fallback 43 await import('@formatjs/intl-pluralrules/polyfill-force')
27 } 44 // Degraded mode, so only load the en local data
45 await import(`@formatjs/intl-pluralrules/locale-data/en.js`)
46}
47
48// ---------------------------------------------------------------------------
49
50const icuCache = new Map<string, IntlMessageFormat>()
51const icuWarnings = new Set<string>()
52const fallback = 'String translation error'
53
54function formatICU (icu: string, context: { [id: string]: number | string }) {
55 try {
56 let msg = icuCache.get(icu)
57
58 if (!msg) {
59 msg = new IntlMessageFormat(icu, $localize.locale)
60 icuCache.set(icu, msg)
28 } 61 }
62
63 return msg.format(context) as string
29 } catch (err) { 64 } catch (err) {
30 logger.warn(`Cannot build intl message ${icu}.`, err) 65 if (!icuWarnings.has(icu)) {
66 logger.warn(`Cannot format ICU ${icu}.`, err)
67 }
31 68
32 return (_context: unknown, fallback: string) => fallback 69 icuWarnings.add(icu)
70 return fallback
33 } 71 }
34} 72}
35 73
36export { 74export {
37 getDevLocale, 75 getDevLocale,
38 prepareIcu, 76 polyfillICU,
77 formatICU,
39 isOnDevLocale 78 isOnDevLocale
40} 79}
diff --git a/client/src/app/helpers/utils/object.ts b/client/src/app/helpers/utils/object.ts
index 69b2b18c0..b69e31edf 100644
--- a/client/src/app/helpers/utils/object.ts
+++ b/client/src/app/helpers/utils/object.ts
@@ -34,6 +34,8 @@ function toBoolean (value: any) {
34 34
35 if (value === 'true') return true 35 if (value === 'true') return true
36 if (value === 'false') return false 36 if (value === 'false') return false
37 if (value === '1') return true
38 if (value === '0') return false
37 39
38 return undefined 40 return undefined
39} 41}
diff --git a/client/src/app/modal/confirm.component.html b/client/src/app/modal/confirm.component.html
index 6584db3e6..33696d0a5 100644
--- a/client/src/app/modal/confirm.component.html
+++ b/client/src/app/modal/confirm.component.html
@@ -12,10 +12,12 @@
12 <div *ngIf="inputLabel" class="form-group mt-3"> 12 <div *ngIf="inputLabel" class="form-group mt-3">
13 <label for="confirmInput">{{ inputLabel }}</label> 13 <label for="confirmInput">{{ inputLabel }}</label>
14 14
15 <input *ngIf="!isPasswordInput" type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" /> 15 <input *ngIf="!isPasswordInput" type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" (keyup.enter)="confirm()" />
16 16
17 <my-input-text *ngIf="isPasswordInput" inputId="confirmInput" [(ngModel)]="inputValue"></my-input-text> 17 <my-input-text *ngIf="isPasswordInput" inputId="confirmInput" [(ngModel)]="inputValue" (keyup.enter)="confirm()"></my-input-text>
18 </div> 18 </div>
19
20 <div *ngIf="hasError()" class="text-danger">{{ errorMessage }}</div>
19 </div> 21 </div>
20 22
21 <div class="modal-footer inputs"> 23 <div class="modal-footer inputs">
diff --git a/client/src/app/modal/confirm.component.ts b/client/src/app/modal/confirm.component.ts
index 3bb8b9b21..43369befa 100644
--- a/client/src/app/modal/confirm.component.ts
+++ b/client/src/app/modal/confirm.component.ts
@@ -21,6 +21,8 @@ export class ConfirmComponent implements OnInit {
21 inputValue = '' 21 inputValue = ''
22 confirmButtonText = '' 22 confirmButtonText = ''
23 23
24 errorMessage = ''
25
24 isPasswordInput = false 26 isPasswordInput = false
25 27
26 private openedModal: NgbModalRef 28 private openedModal: NgbModalRef
@@ -42,8 +44,9 @@ export class ConfirmComponent implements OnInit {
42 this.inputValue = '' 44 this.inputValue = ''
43 this.confirmButtonText = '' 45 this.confirmButtonText = ''
44 this.isPasswordInput = false 46 this.isPasswordInput = false
47 this.errorMessage = ''
45 48
46 const { type, title, message, confirmButtonText } = payload 49 const { type, title, message, confirmButtonText, errorMessage } = payload
47 50
48 this.title = title 51 this.title = title
49 52
@@ -53,6 +56,7 @@ export class ConfirmComponent implements OnInit {
53 } else if (type === 'confirm-password') { 56 } else if (type === 'confirm-password') {
54 this.inputLabel = $localize`Confirm your password` 57 this.inputLabel = $localize`Confirm your password`
55 this.isPasswordInput = true 58 this.isPasswordInput = true
59 this.errorMessage = errorMessage
56 } 60 }
57 61
58 this.confirmButtonText = confirmButtonText || $localize`Confirm` 62 this.confirmButtonText = confirmButtonText || $localize`Confirm`
@@ -78,6 +82,9 @@ export class ConfirmComponent implements OnInit {
78 return this.expectedInputValue !== this.inputValue 82 return this.expectedInputValue !== this.inputValue
79 } 83 }
80 84
85 hasError () {
86 return this.errorMessage
87 }
81 showModal () { 88 showModal () {
82 this.inputValue = '' 89 this.inputValue = ''
83 90
diff --git a/client/src/app/shared/form-validators/custom-config-validators.ts b/client/src/app/shared/form-validators/custom-config-validators.ts
index ff0813f7d..3672e5610 100644
--- a/client/src/app/shared/form-validators/custom-config-validators.ts
+++ b/client/src/app/shared/form-validators/custom-config-validators.ts
@@ -22,21 +22,12 @@ export const SERVICES_TWITTER_USERNAME_VALIDATOR: BuildFormValidator = {
22 } 22 }
23} 23}
24 24
25export const CACHE_PREVIEWS_SIZE_VALIDATOR: BuildFormValidator = { 25export const CACHE_SIZE_VALIDATOR: BuildFormValidator = {
26 VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ], 26 VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
27 MESSAGES: { 27 MESSAGES: {
28 required: $localize`Previews cache size is required.`, 28 required: $localize`Cache size is required.`,
29 min: $localize`Previews cache size must be greater than 1.`, 29 min: $localize`Cache size must be greater than 1.`,
30 pattern: $localize`Previews cache size must be a number.` 30 pattern: $localize`Cache size must be a number.`
31 }
32}
33
34export const CACHE_CAPTIONS_SIZE_VALIDATOR: BuildFormValidator = {
35 VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
36 MESSAGES: {
37 required: $localize`Captions cache size is required.`,
38 min: $localize`Captions cache size must be greater than 1.`,
39 pattern: $localize`Captions cache size must be a number.`
40 } 31 }
41} 32}
42 33
diff --git a/client/src/app/shared/form-validators/video-validators.ts b/client/src/app/shared/form-validators/video-validators.ts
index a4bda8f16..090a76e43 100644
--- a/client/src/app/shared/form-validators/video-validators.ts
+++ b/client/src/app/shared/form-validators/video-validators.ts
@@ -26,6 +26,15 @@ export const VIDEO_PRIVACY_VALIDATOR: BuildFormValidator = {
26 } 26 }
27} 27}
28 28
29export const VIDEO_PASSWORD_VALIDATOR: BuildFormValidator = {
30 VALIDATORS: [ Validators.minLength(2), Validators.maxLength(100) ], // Required is set dynamically
31 MESSAGES: {
32 minLength: $localize`A password should be at least 2 characters long.`,
33 maxLength: $localize`A password should be shorter than 100 characters long.`,
34 required: $localize`A password is required for password protected video.`
35 }
36}
37
29export const VIDEO_CATEGORY_VALIDATOR: BuildFormValidator = { 38export const VIDEO_CATEGORY_VALIDATOR: BuildFormValidator = {
30 VALIDATORS: [ ], 39 VALIDATORS: [ ],
31 MESSAGES: {} 40 MESSAGES: {}
diff --git a/client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts b/client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts
index 2c3226f68..8b6cd091a 100644
--- a/client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts
+++ b/client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts
@@ -1,7 +1,7 @@
1import { Component, forwardRef, Input } from '@angular/core' 1import { Component, forwardRef, Input } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
4import { prepareIcu } from '@app/helpers' 4import { formatICU } from '@app/helpers'
5import { SelectOptionsItem } from '../../../../types/select-options-item.model' 5import { SelectOptionsItem } from '../../../../types/select-options-item.model'
6import { ItemSelectCheckboxValue } from './select-checkbox.component' 6import { ItemSelectCheckboxValue } from './select-checkbox.component'
7 7
@@ -80,9 +80,9 @@ export class SelectCheckboxAllComponent implements ControlValueAccessor {
80 80
81 if (outputItems.length >= this.maxItems) { 81 if (outputItems.length >= this.maxItems) {
82 this.notifier.error( 82 this.notifier.error(
83 prepareIcu($localize`You can't select more than {maxItems, plural, =1 {1 item} other {{maxItems} items}}`)( 83 formatICU(
84 { maxItems: this.maxItems }, 84 $localize`You can't select more than {maxItems, plural, =1 {1 item} other {{maxItems} items}}`,
85 $localize`You can't select more than ${this.maxItems} items` 85 { maxItems: this.maxItems }
86 ) 86 )
87 ) 87 )
88 88
diff --git a/client/src/app/shared/shared-instance/instance-features-table.component.ts b/client/src/app/shared/shared-instance/instance-features-table.component.ts
index 2e63f6c17..ab1b1458a 100644
--- a/client/src/app/shared/shared-instance/instance-features-table.component.ts
+++ b/client/src/app/shared/shared-instance/instance-features-table.component.ts
@@ -1,6 +1,6 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { ServerService } from '@app/core' 2import { ServerService } from '@app/core'
3import { prepareIcu } from '@app/helpers' 3import { formatICU } from '@app/helpers'
4import { ServerConfig } from '@shared/models' 4import { ServerConfig } from '@shared/models'
5 5
6@Component({ 6@Component({
@@ -71,17 +71,17 @@ export class InstanceFeaturesTableComponent implements OnInit {
71 const hours = Math.floor(seconds / 3600) 71 const hours = Math.floor(seconds / 3600)
72 72
73 if (hours !== 0) { 73 if (hours !== 0) {
74 return prepareIcu($localize`~ {hours, plural, =1 {1 hour} other {{hours} hours}}`)( 74 return formatICU(
75 { hours }, 75 $localize`~ {hours, plural, =1 {1 hour} other {{hours} hours}}`,
76 $localize`~ ${hours} hours` 76 { hours }
77 ) 77 )
78 } 78 }
79 79
80 const minutes = Math.floor(seconds % 3600 / 60) 80 const minutes = Math.floor(seconds % 3600 / 60)
81 81
82 return prepareIcu($localize`~ {minutes, plural, =1 {1 minute} other {{minutes} minutes}}`)( 82 return formatICU(
83 { minutes }, 83 $localize`~ {minutes, plural, =1 {1 minute} other {{minutes} minutes}}`,
84 $localize`~ ${minutes} minutes` 84 { minutes }
85 ) 85 )
86 } 86 }
87 87
diff --git a/client/src/app/shared/shared-main/angular/from-now.pipe.ts b/client/src/app/shared/shared-main/angular/from-now.pipe.ts
index dc6a25e83..4ff244bbb 100644
--- a/client/src/app/shared/shared-main/angular/from-now.pipe.ts
+++ b/client/src/app/shared/shared-main/angular/from-now.pipe.ts
@@ -1,14 +1,9 @@
1import { Pipe, PipeTransform } from '@angular/core' 1import { Pipe, PipeTransform } from '@angular/core'
2import { prepareIcu } from '@app/helpers' 2import { formatICU } from '@app/helpers'
3 3
4// Thanks: https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site 4// Thanks: https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site
5@Pipe({ name: 'myFromNow' }) 5@Pipe({ name: 'myFromNow' })
6export class FromNowPipe implements PipeTransform { 6export class FromNowPipe implements PipeTransform {
7 private yearICU = prepareIcu($localize`{interval, plural, =1 {1 year ago} other {{interval} years ago}}`)
8 private monthICU = prepareIcu($localize`{interval, plural, =1 {1 month ago} other {{interval} months ago}}`)
9 private weekICU = prepareIcu($localize`{interval, plural, =1 {1 week ago} other {{interval} weeks ago}}`)
10 private dayICU = prepareIcu($localize`{interval, plural, =1 {1 day ago} other {{interval} days ago}}`)
11 private hourICU = prepareIcu($localize`{interval, plural, =1 {1 hour ago} other {{interval} hours ago}}`)
12 7
13 transform (arg: number | Date | string) { 8 transform (arg: number | Date | string) {
14 const argDate = new Date(arg) 9 const argDate = new Date(arg)
@@ -16,7 +11,7 @@ export class FromNowPipe implements PipeTransform {
16 11
17 let interval = Math.floor(seconds / 31536000) 12 let interval = Math.floor(seconds / 31536000)
18 if (interval >= 1) { 13 if (interval >= 1) {
19 return this.yearICU({ interval }, $localize`${interval} year(s) ago`) 14 return formatICU($localize`{interval, plural, =1 {1 year ago} other {{interval} years ago}}`, { interval })
20 } 15 }
21 16
22 interval = Math.floor(seconds / 2419200) 17 interval = Math.floor(seconds / 2419200)
@@ -25,7 +20,7 @@ export class FromNowPipe implements PipeTransform {
25 if (interval >= 12) return $localize`1 year ago` 20 if (interval >= 12) return $localize`1 year ago`
26 21
27 if (interval >= 1) { 22 if (interval >= 1) {
28 return this.monthICU({ interval }, $localize`${interval} month(s) ago`) 23 return formatICU($localize`{interval, plural, =1 {1 month ago} other {{interval} months ago}}`, { interval })
29 } 24 }
30 25
31 interval = Math.floor(seconds / 604800) 26 interval = Math.floor(seconds / 604800)
@@ -34,17 +29,17 @@ export class FromNowPipe implements PipeTransform {
34 if (interval >= 4) return $localize`1 month ago` 29 if (interval >= 4) return $localize`1 month ago`
35 30
36 if (interval >= 1) { 31 if (interval >= 1) {
37 return this.weekICU({ interval }, $localize`${interval} week(s) ago`) 32 return formatICU($localize`{interval, plural, =1 {1 week ago} other {{interval} weeks ago}}`, { interval })
38 } 33 }
39 34
40 interval = Math.floor(seconds / 86400) 35 interval = Math.floor(seconds / 86400)
41 if (interval >= 1) { 36 if (interval >= 1) {
42 return this.dayICU({ interval }, $localize`${interval} day(s) ago`) 37 return formatICU($localize`{interval, plural, =1 {1 day ago} other {{interval} days ago}}`, { interval })
43 } 38 }
44 39
45 interval = Math.floor(seconds / 3600) 40 interval = Math.floor(seconds / 3600)
46 if (interval >= 1) { 41 if (interval >= 1) {
47 return this.hourICU({ interval }, $localize`${interval} hour(s) ago`) 42 return formatICU($localize`{interval, plural, =1 {1 hour ago} other {{interval} hours ago}}`, { interval })
48 } 43 }
49 44
50 interval = Math.floor(seconds / 60) 45 interval = Math.floor(seconds / 60)
diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts
index d3ec31d6e..480277450 100644
--- a/client/src/app/shared/shared-main/shared-main.module.ts
+++ b/client/src/app/shared/shared-main/shared-main.module.ts
@@ -52,6 +52,7 @@ import {
52 VideoFileTokenService, 52 VideoFileTokenService,
53 VideoImportService, 53 VideoImportService,
54 VideoOwnershipService, 54 VideoOwnershipService,
55 VideoPasswordService,
55 VideoResolver, 56 VideoResolver,
56 VideoService 57 VideoService
57} from './video' 58} from './video'
@@ -210,6 +211,8 @@ import { VideoChannelService } from './video-channel'
210 211
211 VideoChannelService, 212 VideoChannelService,
212 213
214 VideoPasswordService,
215
213 CustomPageService, 216 CustomPageService,
214 217
215 ActorRedirectGuard 218 ActorRedirectGuard
diff --git a/client/src/app/shared/shared-main/video-caption/video-caption.service.ts b/client/src/app/shared/shared-main/video-caption/video-caption.service.ts
index 0f3afd116..21f31a717 100644
--- a/client/src/app/shared/shared-main/video-caption/video-caption.service.ts
+++ b/client/src/app/shared/shared-main/video-caption/video-caption.service.ts
@@ -4,7 +4,7 @@ import { HttpClient } from '@angular/common/http'
4import { Injectable } from '@angular/core' 4import { Injectable } from '@angular/core'
5import { RestExtractor, ServerService } from '@app/core' 5import { RestExtractor, ServerService } from '@app/core'
6import { objectToFormData, sortBy } from '@app/helpers' 6import { objectToFormData, sortBy } from '@app/helpers'
7import { VideoService } from '@app/shared/shared-main/video' 7import { VideoPasswordService, VideoService } from '@app/shared/shared-main/video'
8import { peertubeTranslate } from '@shared/core-utils/i18n' 8import { peertubeTranslate } from '@shared/core-utils/i18n'
9import { ResultList, VideoCaption } from '@shared/models' 9import { ResultList, VideoCaption } from '@shared/models'
10import { environment } from '../../../../environments/environment' 10import { environment } from '../../../../environments/environment'
@@ -18,8 +18,10 @@ export class VideoCaptionService {
18 private restExtractor: RestExtractor 18 private restExtractor: RestExtractor
19 ) {} 19 ) {}
20 20
21 listCaptions (videoId: string): Observable<ResultList<VideoCaption>> { 21 listCaptions (videoId: string, videoPassword?: string): Observable<ResultList<VideoCaption>> {
22 return this.authHttp.get<ResultList<VideoCaption>>(`${VideoService.BASE_VIDEO_URL}/${videoId}/captions`) 22 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
23
24 return this.authHttp.get<ResultList<VideoCaption>>(`${VideoService.BASE_VIDEO_URL}/${videoId}/captions`, { headers })
23 .pipe( 25 .pipe(
24 switchMap(captionsResult => { 26 switchMap(captionsResult => {
25 return this.serverService.getServerLocale() 27 return this.serverService.getServerLocale()
diff --git a/client/src/app/shared/shared-main/video/index.ts b/client/src/app/shared/shared-main/video/index.ts
index a2e47883e..07d40b117 100644
--- a/client/src/app/shared/shared-main/video/index.ts
+++ b/client/src/app/shared/shared-main/video/index.ts
@@ -5,6 +5,7 @@ export * from './video-edit.model'
5export * from './video-file-token.service' 5export * from './video-file-token.service'
6export * from './video-import.service' 6export * from './video-import.service'
7export * from './video-ownership.service' 7export * from './video-ownership.service'
8export * from './video-password.service'
8export * from './video.model' 9export * from './video.model'
9export * from './video.resolver' 10export * from './video.resolver'
10export * from './video.service' 11export * from './video.service'
diff --git a/client/src/app/shared/shared-main/video/video-edit.model.ts b/client/src/app/shared/shared-main/video/video-edit.model.ts
index 47eee80d8..1b8b67ee2 100644
--- a/client/src/app/shared/shared-main/video/video-edit.model.ts
+++ b/client/src/app/shared/shared-main/video/video-edit.model.ts
@@ -1,5 +1,5 @@
1import { getAbsoluteAPIUrl } from '@app/helpers' 1import { getAbsoluteAPIUrl } from '@app/helpers'
2import { VideoPrivacy, VideoScheduleUpdate, VideoUpdate } from '@shared/models' 2import { VideoPassword, VideoPrivacy, VideoScheduleUpdate, VideoUpdate } from '@shared/models'
3import { VideoDetails } from './video-details.model' 3import { VideoDetails } from './video-details.model'
4import { objectKeysTyped } from '@shared/core-utils' 4import { objectKeysTyped } from '@shared/core-utils'
5 5
@@ -18,6 +18,7 @@ export class VideoEdit implements VideoUpdate {
18 waitTranscoding: boolean 18 waitTranscoding: boolean
19 channelId: number 19 channelId: number
20 privacy: VideoPrivacy 20 privacy: VideoPrivacy
21 videoPassword?: string
21 support: string 22 support: string
22 thumbnailfile?: any 23 thumbnailfile?: any
23 previewfile?: any 24 previewfile?: any
@@ -32,7 +33,7 @@ export class VideoEdit implements VideoUpdate {
32 33
33 pluginData?: any 34 pluginData?: any
34 35
35 constructor (video?: VideoDetails) { 36 constructor (video?: VideoDetails, videoPassword?: VideoPassword) {
36 if (!video) return 37 if (!video) return
37 38
38 this.id = video.id 39 this.id = video.id
@@ -63,6 +64,8 @@ export class VideoEdit implements VideoUpdate {
63 : null 64 : null
64 65
65 this.pluginData = video.pluginData 66 this.pluginData = video.pluginData
67
68 if (videoPassword) this.videoPassword = videoPassword.password
66 } 69 }
67 70
68 patch (values: { [ id: string ]: any }) { 71 patch (values: { [ id: string ]: any }) {
@@ -112,6 +115,7 @@ export class VideoEdit implements VideoUpdate {
112 waitTranscoding: this.waitTranscoding, 115 waitTranscoding: this.waitTranscoding,
113 channelId: this.channelId, 116 channelId: this.channelId,
114 privacy: this.privacy, 117 privacy: this.privacy,
118 videoPassword: this.videoPassword,
115 originallyPublishedAt: this.originallyPublishedAt 119 originallyPublishedAt: this.originallyPublishedAt
116 } 120 }
117 121
diff --git a/client/src/app/shared/shared-main/video/video-file-token.service.ts b/client/src/app/shared/shared-main/video/video-file-token.service.ts
index 791607249..9bca5b9ec 100644
--- a/client/src/app/shared/shared-main/video/video-file-token.service.ts
+++ b/client/src/app/shared/shared-main/video/video-file-token.service.ts
@@ -4,6 +4,7 @@ import { Injectable } from '@angular/core'
4import { RestExtractor } from '@app/core' 4import { RestExtractor } from '@app/core'
5import { VideoToken } from '@shared/models' 5import { VideoToken } from '@shared/models'
6import { VideoService } from './video.service' 6import { VideoService } from './video.service'
7import { VideoPasswordService } from './video-password.service'
7 8
8@Injectable() 9@Injectable()
9export class VideoFileTokenService { 10export class VideoFileTokenService {
@@ -15,16 +16,18 @@ export class VideoFileTokenService {
15 private restExtractor: RestExtractor 16 private restExtractor: RestExtractor
16 ) {} 17 ) {}
17 18
18 getVideoFileToken (videoUUID: string) { 19 getVideoFileToken ({ videoUUID, videoPassword }: { videoUUID: string, videoPassword?: string }) {
19 const existing = this.store.get(videoUUID) 20 const existing = this.store.get(videoUUID)
20 if (existing) return of(existing) 21 if (existing) return of(existing)
21 22
22 return this.createVideoFileToken(videoUUID) 23 return this.createVideoFileToken(videoUUID, videoPassword)
23 .pipe(tap(result => this.store.set(videoUUID, { token: result.token, expires: new Date(result.expires) }))) 24 .pipe(tap(result => this.store.set(videoUUID, { token: result.token, expires: new Date(result.expires) })))
24 } 25 }
25 26
26 private createVideoFileToken (videoUUID: string) { 27 private createVideoFileToken (videoUUID: string, videoPassword?: string) {
27 return this.authHttp.post<VideoToken>(`${VideoService.BASE_VIDEO_URL}/${videoUUID}/token`, {}) 28 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
29
30 return this.authHttp.post<VideoToken>(`${VideoService.BASE_VIDEO_URL}/${videoUUID}/token`, {}, { headers })
28 .pipe( 31 .pipe(
29 map(({ files }) => files), 32 map(({ files }) => files),
30 catchError(err => this.restExtractor.handleError(err)) 33 catchError(err => this.restExtractor.handleError(err))
diff --git a/client/src/app/shared/shared-main/video/video-password.service.ts b/client/src/app/shared/shared-main/video/video-password.service.ts
new file mode 100644
index 000000000..d5b0406f8
--- /dev/null
+++ b/client/src/app/shared/shared-main/video/video-password.service.ts
@@ -0,0 +1,29 @@
1import { ResultList, VideoPassword } from '@shared/models'
2import { Injectable } from '@angular/core'
3import { catchError, switchMap } from 'rxjs'
4import { HttpClient, HttpHeaders } from '@angular/common/http'
5import { RestExtractor } from '@app/core'
6import { VideoService } from './video.service'
7
8@Injectable()
9export class VideoPasswordService {
10
11 constructor (
12 private authHttp: HttpClient,
13 private restExtractor: RestExtractor
14 ) {}
15
16 static buildVideoPasswordHeader (videoPassword: string) {
17 return videoPassword
18 ? new HttpHeaders().set('x-peertube-video-password', videoPassword)
19 : undefined
20 }
21
22 getVideoPasswords (options: { videoUUID: string }) {
23 return this.authHttp.get<ResultList<VideoPassword>>(`${VideoService.BASE_VIDEO_URL}/${options.videoUUID}/passwords`)
24 .pipe(
25 switchMap(res => res.data),
26 catchError(err => this.restExtractor.handleError(err))
27 )
28 }
29}
diff --git a/client/src/app/shared/shared-main/video/video.model.ts b/client/src/app/shared/shared-main/video/video.model.ts
index 6fdffb394..1ffc40411 100644
--- a/client/src/app/shared/shared-main/video/video.model.ts
+++ b/client/src/app/shared/shared-main/video/video.model.ts
@@ -1,6 +1,6 @@
1import { AuthUser } from '@app/core' 1import { AuthUser } from '@app/core'
2import { User } from '@app/core/users/user.model' 2import { User } from '@app/core/users/user.model'
3import { durationToString, getAbsoluteAPIUrl, getAbsoluteEmbedUrl, prepareIcu } from '@app/helpers' 3import { durationToString, formatICU, getAbsoluteAPIUrl, getAbsoluteEmbedUrl } from '@app/helpers'
4import { Actor } from '@app/shared/shared-main/account/actor.model' 4import { Actor } from '@app/shared/shared-main/account/actor.model'
5import { buildVideoWatchPath, getAllFiles } from '@shared/core-utils' 5import { buildVideoWatchPath, getAllFiles } from '@shared/core-utils'
6import { peertubeTranslate } from '@shared/core-utils/i18n' 6import { peertubeTranslate } from '@shared/core-utils/i18n'
@@ -19,9 +19,6 @@ import {
19} from '@shared/models' 19} from '@shared/models'
20 20
21export class Video implements VideoServerModel { 21export class Video implements VideoServerModel {
22 private static readonly viewsICU = prepareIcu($localize`{views, plural, =0 {No view} =1 {1 view} other {{views} views}}`)
23 private static readonly viewersICU = prepareIcu($localize`{viewers, plural, =0 {No viewers} =1 {1 viewer} other {{viewers} viewers}}`)
24
25 byVideoChannel: string 22 byVideoChannel: string
26 byAccount: string 23 byAccount: string
27 24
@@ -255,7 +252,7 @@ export class Video implements VideoServerModel {
255 user && user.hasRight(UserRight.MANAGE_VIDEO_FILES) && 252 user && user.hasRight(UserRight.MANAGE_VIDEO_FILES) &&
256 this.state.id !== VideoState.TO_TRANSCODE && 253 this.state.id !== VideoState.TO_TRANSCODE &&
257 this.hasHLS() && 254 this.hasHLS() &&
258 this.hasWebTorrent() 255 this.hasWebVideos()
259 } 256 }
260 257
261 canRunTranscoding (user: AuthUser) { 258 canRunTranscoding (user: AuthUser) {
@@ -268,7 +265,7 @@ export class Video implements VideoServerModel {
268 return this.streamingPlaylists?.some(p => p.type === VideoStreamingPlaylistType.HLS) 265 return this.streamingPlaylists?.some(p => p.type === VideoStreamingPlaylistType.HLS)
269 } 266 }
270 267
271 hasWebTorrent () { 268 hasWebVideos () {
272 return this.files && this.files.length !== 0 269 return this.files && this.files.length !== 0
273 } 270 }
274 271
@@ -281,11 +278,18 @@ export class Video implements VideoServerModel {
281 return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES) 278 return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES)
282 } 279 }
283 280
281 canAccessPasswordProtectedVideoWithoutPassword (user: AuthUser) {
282 return this.privacy.id === VideoPrivacy.PASSWORD_PROTECTED &&
283 user &&
284 this.isLocal === true &&
285 (this.account.name === user.username || user.hasRight(UserRight.SEE_ALL_VIDEOS))
286 }
287
284 getExactNumberOfViews () { 288 getExactNumberOfViews () {
285 if (this.isLive) { 289 if (this.isLive) {
286 return Video.viewersICU({ viewers: this.viewers }, $localize`${this.viewers} viewer(s)`) 290 return formatICU($localize`{viewers, plural, =0 {No viewers} =1 {1 viewer} other {{viewers} viewers}}`, { viewers: this.viewers })
287 } 291 }
288 292
289 return Video.viewsICU({ views: this.views }, $localize`{${this.views} view(s)}`) 293 return formatICU($localize`{views, plural, =0 {No view} =1 {1 view} other {{views} views}}`, { views: this.views })
290 } 294 }
291} 295}
diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts
index 78a49567f..20145b9c5 100644
--- a/client/src/app/shared/shared-main/video/video.service.ts
+++ b/client/src/app/shared/shared-main/video/video.service.ts
@@ -11,6 +11,7 @@ import {
11 FeedFormat, 11 FeedFormat,
12 NSFWPolicyType, 12 NSFWPolicyType,
13 ResultList, 13 ResultList,
14 Storyboard,
14 UserVideoRate, 15 UserVideoRate,
15 UserVideoRateType, 16 UserVideoRateType,
16 UserVideoRateUpdate, 17 UserVideoRateUpdate,
@@ -33,6 +34,7 @@ import { VideoChannel, VideoChannelService } from '../video-channel'
33import { VideoDetails } from './video-details.model' 34import { VideoDetails } from './video-details.model'
34import { VideoEdit } from './video-edit.model' 35import { VideoEdit } from './video-edit.model'
35import { Video } from './video.model' 36import { Video } from './video.model'
37import { VideoPasswordService } from './video-password.service'
36 38
37export type CommonVideoParams = { 39export type CommonVideoParams = {
38 videoPagination?: ComponentPaginationLight 40 videoPagination?: ComponentPaginationLight
@@ -69,16 +71,17 @@ export class VideoService {
69 return `${VideoService.BASE_VIDEO_URL}/${uuid}/views` 71 return `${VideoService.BASE_VIDEO_URL}/${uuid}/views`
70 } 72 }
71 73
72 getVideo (options: { videoId: string }): Observable<VideoDetails> { 74 getVideo (options: { videoId: string, videoPassword?: string }): Observable<VideoDetails> {
73 return this.serverService.getServerLocale() 75 const headers = VideoPasswordService.buildVideoPasswordHeader(options.videoPassword)
74 .pipe( 76
75 switchMap(translations => { 77 return this.serverService.getServerLocale().pipe(
76 return this.authHttp.get<VideoDetailsServerModel>(`${VideoService.BASE_VIDEO_URL}/${options.videoId}`) 78 switchMap(translations => {
77 .pipe(map(videoHash => ({ videoHash, translations }))) 79 return this.authHttp.get<VideoDetailsServerModel>(`${VideoService.BASE_VIDEO_URL}/${options.videoId}`, { headers })
78 }), 80 .pipe(map(videoHash => ({ videoHash, translations })))
79 map(({ videoHash, translations }) => new VideoDetails(videoHash, translations)), 81 }),
80 catchError(err => this.restExtractor.handleError(err)) 82 map(({ videoHash, translations }) => new VideoDetails(videoHash, translations)),
81 ) 83 catchError(err => this.restExtractor.handleError(err))
84 )
82 } 85 }
83 86
84 updateVideo (video: VideoEdit) { 87 updateVideo (video: VideoEdit) {
@@ -99,6 +102,9 @@ export class VideoService {
99 description, 102 description,
100 channelId: video.channelId, 103 channelId: video.channelId,
101 privacy: video.privacy, 104 privacy: video.privacy,
105 videoPasswords: video.privacy === VideoPrivacy.PASSWORD_PROTECTED
106 ? [ video.videoPassword ]
107 : undefined,
102 tags: video.tags, 108 tags: video.tags,
103 nsfw: video.nsfw, 109 nsfw: video.nsfw,
104 waitTranscoding: video.waitTranscoding, 110 waitTranscoding: video.waitTranscoding,
@@ -305,7 +311,7 @@ export class VideoService {
305 ) 311 )
306 } 312 }
307 313
308 removeVideoFiles (videoIds: (number | string)[], type: 'hls' | 'webtorrent') { 314 removeVideoFiles (videoIds: (number | string)[], type: 'hls' | 'web-videos') {
309 return from(videoIds) 315 return from(videoIds)
310 .pipe( 316 .pipe(
311 concatMap(id => this.authHttp.delete(VideoService.BASE_VIDEO_URL + '/' + id + '/' + type)), 317 concatMap(id => this.authHttp.delete(VideoService.BASE_VIDEO_URL + '/' + id + '/' + type)),
@@ -314,12 +320,12 @@ export class VideoService {
314 ) 320 )
315 } 321 }
316 322
317 removeFile (videoId: number | string, fileId: number, type: 'hls' | 'webtorrent') { 323 removeFile (videoId: number | string, fileId: number, type: 'hls' | 'web-videos') {
318 return this.authHttp.delete(VideoService.BASE_VIDEO_URL + '/' + videoId + '/' + type + '/' + fileId) 324 return this.authHttp.delete(VideoService.BASE_VIDEO_URL + '/' + videoId + '/' + type + '/' + fileId)
319 .pipe(catchError(err => this.restExtractor.handleError(err))) 325 .pipe(catchError(err => this.restExtractor.handleError(err)))
320 } 326 }
321 327
322 runTranscoding (videoIds: (number | string)[], type: 'hls' | 'webtorrent') { 328 runTranscoding (videoIds: (number | string)[], type: 'hls' | 'web-video') {
323 const body: VideoTranscodingCreate = { transcodingType: type } 329 const body: VideoTranscodingCreate = { transcodingType: type }
324 330
325 return from(videoIds) 331 return from(videoIds)
@@ -339,6 +345,27 @@ export class VideoService {
339 ) 345 )
340 } 346 }
341 347
348 // ---------------------------------------------------------------------------
349
350 getStoryboards (videoId: string | number, videoPassword: string) {
351 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
352
353 return this.authHttp
354 .get<{ storyboards: Storyboard[] }>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/storyboards', { headers })
355 .pipe(
356 map(({ storyboards }) => storyboards),
357 catchError(err => {
358 if (err.status === 404) {
359 return of([])
360 }
361
362 this.restExtractor.handleError(err)
363 })
364 )
365 }
366
367 // ---------------------------------------------------------------------------
368
342 getSource (videoId: number) { 369 getSource (videoId: number) {
343 return this.authHttp 370 return this.authHttp
344 .get<{ source: VideoSource }>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/source') 371 .get<{ source: VideoSource }>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/source')
@@ -353,18 +380,22 @@ export class VideoService {
353 ) 380 )
354 } 381 }
355 382
356 setVideoLike (id: string) { 383 // ---------------------------------------------------------------------------
357 return this.setVideoRate(id, 'like') 384
385 setVideoLike (id: string, videoPassword: string) {
386 return this.setVideoRate(id, 'like', videoPassword)
358 } 387 }
359 388
360 setVideoDislike (id: string) { 389 setVideoDislike (id: string, videoPassword: string) {
361 return this.setVideoRate(id, 'dislike') 390 return this.setVideoRate(id, 'dislike', videoPassword)
362 } 391 }
363 392
364 unsetVideoLike (id: string) { 393 unsetVideoLike (id: string, videoPassword: string) {
365 return this.setVideoRate(id, 'none') 394 return this.setVideoRate(id, 'none', videoPassword)
366 } 395 }
367 396
397 // ---------------------------------------------------------------------------
398
368 getUserVideoRating (id: string) { 399 getUserVideoRating (id: string) {
369 const url = UserService.BASE_USERS_URL + 'me/videos/' + id + '/rating' 400 const url = UserService.BASE_USERS_URL + 'me/videos/' + id + '/rating'
370 401
@@ -394,7 +425,8 @@ export class VideoService {
394 [VideoPrivacy.PRIVATE]: $localize`Only I can see this video`, 425 [VideoPrivacy.PRIVATE]: $localize`Only I can see this video`,
395 [VideoPrivacy.UNLISTED]: $localize`Only shareable via a private link`, 426 [VideoPrivacy.UNLISTED]: $localize`Only shareable via a private link`,
396 [VideoPrivacy.PUBLIC]: $localize`Anyone can see this video`, 427 [VideoPrivacy.PUBLIC]: $localize`Anyone can see this video`,
397 [VideoPrivacy.INTERNAL]: $localize`Only users of this instance can see this video` 428 [VideoPrivacy.INTERNAL]: $localize`Only users of this instance can see this video`,
429 [VideoPrivacy.PASSWORD_PROTECTED]: $localize`Only users with the appropriate password can see this video`
398 } 430 }
399 431
400 const videoPrivacies = serverPrivacies.map(p => { 432 const videoPrivacies = serverPrivacies.map(p => {
@@ -412,7 +444,13 @@ export class VideoService {
412 } 444 }
413 445
414 getHighestAvailablePrivacy (serverPrivacies: VideoConstant<VideoPrivacy>[]) { 446 getHighestAvailablePrivacy (serverPrivacies: VideoConstant<VideoPrivacy>[]) {
415 const order = [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.UNLISTED, VideoPrivacy.PUBLIC ] 447 // We do not add a password as this requires additional configuration.
448 const order = [
449 VideoPrivacy.PRIVATE,
450 VideoPrivacy.INTERNAL,
451 VideoPrivacy.UNLISTED,
452 VideoPrivacy.PUBLIC
453 ]
416 454
417 for (const privacy of order) { 455 for (const privacy of order) {
418 if (serverPrivacies.find(p => p.id === privacy)) { 456 if (serverPrivacies.find(p => p.id === privacy)) {
@@ -499,14 +537,15 @@ export class VideoService {
499 } 537 }
500 } 538 }
501 539
502 private setVideoRate (id: string, rateType: UserVideoRateType) { 540 private setVideoRate (id: string, rateType: UserVideoRateType, videoPassword?: string) {
503 const url = `${VideoService.BASE_VIDEO_URL}/${id}/rate` 541 const url = `${VideoService.BASE_VIDEO_URL}/${id}/rate`
504 const body: UserVideoRateUpdate = { 542 const body: UserVideoRateUpdate = {
505 rating: rateType 543 rating: rateType
506 } 544 }
545 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
507 546
508 return this.authHttp 547 return this.authHttp
509 .put(url, body) 548 .put(url, body, { headers })
510 .pipe(catchError(err => this.restExtractor.handleError(err))) 549 .pipe(catchError(err => this.restExtractor.handleError(err)))
511 } 550 }
512} 551}
diff --git a/client/src/app/shared/shared-moderation/user-ban-modal.component.ts b/client/src/app/shared/shared-moderation/user-ban-modal.component.ts
index 27dcf043a..34295c34a 100644
--- a/client/src/app/shared/shared-moderation/user-ban-modal.component.ts
+++ b/client/src/app/shared/shared-moderation/user-ban-modal.component.ts
@@ -1,7 +1,7 @@
1import { forkJoin } from 'rxjs' 1import { forkJoin } from 'rxjs'
2import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' 2import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
3import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
4import { prepareIcu } from '@app/helpers' 4import { formatICU } from '@app/helpers'
5import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' 5import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
6import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -67,9 +67,9 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
67 let message: string 67 let message: string
68 68
69 if (Array.isArray(this.usersToBan)) { 69 if (Array.isArray(this.usersToBan)) {
70 message = prepareIcu($localize`{count, plural, =1 {1 user banned.} other {{count} users banned.}}`)( 70 message = formatICU(
71 { count: this.usersToBan.length }, 71 $localize`{count, plural, =1 {1 user banned.} other {{count} users banned.}}`,
72 $localize`${this.usersToBan.length} users banned.` 72 { count: this.usersToBan.length }
73 ) 73 )
74 } else { 74 } else {
75 message = $localize`User ${this.usersToBan.username} banned.` 75 message = $localize`User ${this.usersToBan.username} banned.`
@@ -88,9 +88,9 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
88 88
89 getModalTitle () { 89 getModalTitle () {
90 if (Array.isArray(this.usersToBan)) { 90 if (Array.isArray(this.usersToBan)) {
91 return prepareIcu($localize`Ban {count, plural, =1 {1 user} other {{count} users}}`)( 91 return formatICU(
92 { count: this.usersToBan.length }, 92 $localize`Ban {count, plural, =1 {1 user} other {{count} users}}`,
93 $localize`Ban ${this.usersToBan.length} users` 93 { count: this.usersToBan.length }
94 ) 94 )
95 } 95 }
96 96
diff --git a/client/src/app/shared/shared-moderation/video-block.component.ts b/client/src/app/shared/shared-moderation/video-block.component.ts
index 3ff53443a..0137def89 100644
--- a/client/src/app/shared/shared-moderation/video-block.component.ts
+++ b/client/src/app/shared/shared-moderation/video-block.component.ts
@@ -1,6 +1,6 @@
1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { prepareIcu } from '@app/helpers' 3import { formatICU } from '@app/helpers'
4import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' 4import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
5import { Video } from '@app/shared/shared-main' 5import { Video } from '@app/shared/shared-main'
6import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
@@ -81,9 +81,9 @@ export class VideoBlockComponent extends FormReactive implements OnInit {
81 this.videoBlocklistService.blockVideo(options) 81 this.videoBlocklistService.blockVideo(options)
82 .subscribe({ 82 .subscribe({
83 next: () => { 83 next: () => {
84 const message = prepareIcu($localize`{count, plural, =1 {Blocked {videoName}.} other {Blocked {count} videos.}}`)( 84 const message = formatICU(
85 { count: this.videos.length, videoName: this.getSingleVideo().name }, 85 $localize`{count, plural, =1 {Blocked {videoName}.} other {Blocked {count} videos.}}`,
86 $localize`Blocked ${this.videos.length} videos.` 86 { count: this.videos.length, videoName: this.getSingleVideo().name }
87 ) 87 )
88 88
89 this.notifier.success(message) 89 this.notifier.success(message)
diff --git a/client/src/app/shared/shared-share-modal/video-share.component.html b/client/src/app/shared/shared-share-modal/video-share.component.html
index 5650fa948..9f1455561 100644
--- a/client/src/app/shared/shared-share-modal/video-share.component.html
+++ b/client/src/app/shared/shared-share-modal/video-share.component.html
@@ -107,6 +107,10 @@
107 </a> 107 </a>
108 </div> 108 </div>
109 109
110 <div i18n *ngIf="isPasswordProtectedVideo()" class="alert-private alert alert-warning">
111 This video is password protected, please note that recipients will require the corresponding password to access the content.
112 </div>
113
110 <div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeVideoId"> 114 <div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeVideoId">
111 115
112 <ng-container ngbNavItem="url"> 116 <ng-container ngbNavItem="url">
diff --git a/client/src/app/shared/shared-share-modal/video-share.component.ts b/client/src/app/shared/shared-share-modal/video-share.component.ts
index 32f900f15..da4f2a4b4 100644
--- a/client/src/app/shared/shared-share-modal/video-share.component.ts
+++ b/client/src/app/shared/shared-share-modal/video-share.component.ts
@@ -243,6 +243,10 @@ export class VideoShareComponent {
243 return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE 243 return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE
244 } 244 }
245 245
246 isPasswordProtectedVideo () {
247 return this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
248 }
249
246 private getPlaylistOptions (baseUrl?: string) { 250 private getPlaylistOptions (baseUrl?: string) {
247 return { 251 return {
248 baseUrl, 252 baseUrl,
diff --git a/client/src/app/shared/shared-video-comment/video-comment.service.ts b/client/src/app/shared/shared-video-comment/video-comment.service.ts
index 8d2deedf7..3906652be 100644
--- a/client/src/app/shared/shared-video-comment/video-comment.service.ts
+++ b/client/src/app/shared/shared-video-comment/video-comment.service.ts
@@ -18,6 +18,7 @@ import {
18import { environment } from '../../../environments/environment' 18import { environment } from '../../../environments/environment'
19import { VideoCommentThreadTree } from './video-comment-thread-tree.model' 19import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
20import { VideoComment } from './video-comment.model' 20import { VideoComment } from './video-comment.model'
21import { VideoPasswordService } from '../shared-main'
21 22
22@Injectable() 23@Injectable()
23export class VideoCommentService { 24export class VideoCommentService {
@@ -31,22 +32,25 @@ export class VideoCommentService {
31 private restService: RestService 32 private restService: RestService
32 ) {} 33 ) {}
33 34
34 addCommentThread (videoId: string, comment: VideoCommentCreate) { 35 addCommentThread (videoId: string, comment: VideoCommentCreate, videoPassword?: string) {
36 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
35 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' 37 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
36 const normalizedComment = objectLineFeedToHtml(comment, 'text') 38 const normalizedComment = objectLineFeedToHtml(comment, 'text')
37 39
38 return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) 40 return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment, { headers })
39 .pipe( 41 .pipe(
40 map(data => this.extractVideoComment(data.comment)), 42 map(data => this.extractVideoComment(data.comment)),
41 catchError(err => this.restExtractor.handleError(err)) 43 catchError(err => this.restExtractor.handleError(err))
42 ) 44 )
43 } 45 }
44 46
45 addCommentReply (videoId: string, inReplyToCommentId: number, comment: VideoCommentCreate) { 47 addCommentReply (options: { videoId: string, inReplyToCommentId: number, comment: VideoCommentCreate, videoPassword?: string }) {
48 const { videoId, inReplyToCommentId, comment, videoPassword } = options
49 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
46 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId 50 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId
47 const normalizedComment = objectLineFeedToHtml(comment, 'text') 51 const normalizedComment = objectLineFeedToHtml(comment, 'text')
48 52
49 return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment) 53 return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment, { headers })
50 .pipe( 54 .pipe(
51 map(data => this.extractVideoComment(data.comment)), 55 map(data => this.extractVideoComment(data.comment)),
52 catchError(err => this.restExtractor.handleError(err)) 56 catchError(err => this.restExtractor.handleError(err))
@@ -76,10 +80,13 @@ export class VideoCommentService {
76 80
77 getVideoCommentThreads (parameters: { 81 getVideoCommentThreads (parameters: {
78 videoId: string 82 videoId: string
83 videoPassword: string
79 componentPagination: ComponentPaginationLight 84 componentPagination: ComponentPaginationLight
80 sort: string 85 sort: string
81 }): Observable<ThreadsResultList<VideoComment>> { 86 }): Observable<ThreadsResultList<VideoComment>> {
82 const { videoId, componentPagination, sort } = parameters 87 const { videoId, videoPassword, componentPagination, sort } = parameters
88
89 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
83 90
84 const pagination = this.restService.componentToRestPagination(componentPagination) 91 const pagination = this.restService.componentToRestPagination(componentPagination)
85 92
@@ -87,7 +94,7 @@ export class VideoCommentService {
87 params = this.restService.addRestGetParams(params, pagination, sort) 94 params = this.restService.addRestGetParams(params, pagination, sort)
88 95
89 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads' 96 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
90 return this.authHttp.get<ThreadsResultList<VideoComment>>(url, { params }) 97 return this.authHttp.get<ThreadsResultList<VideoComment>>(url, { params, headers })
91 .pipe( 98 .pipe(
92 map(result => this.extractVideoComments(result)), 99 map(result => this.extractVideoComments(result)),
93 catchError(err => this.restExtractor.handleError(err)) 100 catchError(err => this.restExtractor.handleError(err))
@@ -97,12 +104,14 @@ export class VideoCommentService {
97 getVideoThreadComments (parameters: { 104 getVideoThreadComments (parameters: {
98 videoId: string 105 videoId: string
99 threadId: number 106 threadId: number
107 videoPassword?: string
100 }): Observable<VideoCommentThreadTree> { 108 }): Observable<VideoCommentThreadTree> {
101 const { videoId, threadId } = parameters 109 const { videoId, threadId, videoPassword } = parameters
102 const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}` 110 const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}`
111 const headers = VideoPasswordService.buildVideoPasswordHeader(videoPassword)
103 112
104 return this.authHttp 113 return this.authHttp
105 .get<VideoCommentThreadTreeServerModel>(url) 114 .get<VideoCommentThreadTreeServerModel>(url, { headers })
106 .pipe( 115 .pipe(
107 map(tree => this.extractVideoCommentTree(tree)), 116 map(tree => this.extractVideoCommentTree(tree)),
108 catchError(err => this.restExtractor.handleError(err)) 117 catchError(err => this.restExtractor.handleError(err))
diff --git a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
index 56527ddfa..0a3ada711 100644
--- a/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-actions-dropdown.component.ts
@@ -273,7 +273,7 @@ export class VideoActionsDropdownComponent implements OnChanges {
273 }) 273 })
274 } 274 }
275 275
276 async removeVideoFiles (video: Video, type: 'hls' | 'webtorrent') { 276 async removeVideoFiles (video: Video, type: 'hls' | 'web-videos') {
277 const confirmMessage = $localize`Do you really want to remove "${this.video.name}" files?` 277 const confirmMessage = $localize`Do you really want to remove "${this.video.name}" files?`
278 278
279 const res = await this.confirmService.confirm(confirmMessage, $localize`Remove "${this.video.name}" files`) 279 const res = await this.confirmService.confirm(confirmMessage, $localize`Remove "${this.video.name}" files`)
@@ -290,7 +290,7 @@ export class VideoActionsDropdownComponent implements OnChanges {
290 }) 290 })
291 } 291 }
292 292
293 runTranscoding (video: Video, type: 'hls' | 'webtorrent') { 293 runTranscoding (video: Video, type: 'hls' | 'web-video') {
294 this.videoService.runTranscoding([ video.id ], type) 294 this.videoService.runTranscoding([ video.id ], type)
295 .subscribe({ 295 .subscribe({
296 next: () => { 296 next: () => {
@@ -394,8 +394,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
394 iconName: 'cog' 394 iconName: 'cog'
395 }, 395 },
396 { 396 {
397 label: $localize`Run WebTorrent transcoding`, 397 label: $localize`Run Web Video transcoding`,
398 handler: ({ video }) => this.runTranscoding(video, 'webtorrent'), 398 handler: ({ video }) => this.runTranscoding(video, 'web-video'),
399 isDisplayed: () => this.displayOptions.transcoding && this.canRunTranscoding(), 399 isDisplayed: () => this.displayOptions.transcoding && this.canRunTranscoding(),
400 iconName: 'cog' 400 iconName: 'cog'
401 }, 401 },
@@ -406,8 +406,8 @@ export class VideoActionsDropdownComponent implements OnChanges {
406 iconName: 'delete' 406 iconName: 'delete'
407 }, 407 },
408 { 408 {
409 label: $localize`Delete WebTorrent files`, 409 label: $localize`Delete Web Video files`,
410 handler: ({ video }) => this.removeVideoFiles(video, 'webtorrent'), 410 handler: ({ video }) => this.removeVideoFiles(video, 'web-videos'),
411 isDisplayed: () => this.displayOptions.removeFiles && this.canRemoveVideoFiles(), 411 isDisplayed: () => this.displayOptions.removeFiles && this.canRemoveVideoFiles(),
412 iconName: 'delete' 412 iconName: 'delete'
413 } 413 }
diff --git a/client/src/app/shared/shared-video-miniature/video-download.component.ts b/client/src/app/shared/shared-video-miniature/video-download.component.ts
index cac82d8d0..146ea7dfe 100644
--- a/client/src/app/shared/shared-video-miniature/video-download.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-download.component.ts
@@ -1,13 +1,13 @@
1import { mapValues } from 'lodash-es' 1import { mapValues } from 'lodash-es'
2import { firstValueFrom } from 'rxjs' 2import { firstValueFrom } from 'rxjs'
3import { tap } from 'rxjs/operators' 3import { tap } from 'rxjs/operators'
4import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core' 4import { Component, ElementRef, Inject, Input, LOCALE_ID, ViewChild } from '@angular/core'
5import { HooksService } from '@app/core' 5import { HooksService } from '@app/core'
6import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
7import { logger } from '@root-helpers/logger' 7import { logger } from '@root-helpers/logger'
8import { videoRequiresAuth } from '@root-helpers/video' 8import { videoRequiresFileToken } from '@root-helpers/video'
9import { objectKeysTyped, pick } from '@shared/core-utils' 9import { objectKeysTyped, pick } from '@shared/core-utils'
10import { VideoCaption, VideoFile, VideoPrivacy } from '@shared/models' 10import { VideoCaption, VideoFile } from '@shared/models'
11import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoFileTokenService, VideoService } from '../shared-main' 11import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoFileTokenService, VideoService } from '../shared-main'
12 12
13type DownloadType = 'video' | 'subtitles' 13type DownloadType = 'video' | 'subtitles'
@@ -21,6 +21,8 @@ type FileMetadata = { [key: string]: { label: string, value: string | number } }
21export class VideoDownloadComponent { 21export class VideoDownloadComponent {
22 @ViewChild('modal', { static: true }) modal: ElementRef 22 @ViewChild('modal', { static: true }) modal: ElementRef
23 23
24 @Input() videoPassword: string
25
24 downloadType: 'direct' | 'torrent' = 'direct' 26 downloadType: 'direct' | 'torrent' = 'direct'
25 27
26 resolutionId: number | string = -1 28 resolutionId: number | string = -1
@@ -89,8 +91,8 @@ export class VideoDownloadComponent {
89 this.subtitleLanguageId = this.videoCaptions[0].language.id 91 this.subtitleLanguageId = this.videoCaptions[0].language.id
90 } 92 }
91 93
92 if (videoRequiresAuth(this.video)) { 94 if (this.isConfidentialVideo()) {
93 this.videoFileTokenService.getVideoFileToken(this.video.uuid) 95 this.videoFileTokenService.getVideoFileToken({ videoUUID: this.video.uuid, videoPassword: this.videoPassword })
94 .subscribe(({ token }) => this.videoFileToken = token) 96 .subscribe(({ token }) => this.videoFileToken = token)
95 } 97 }
96 98
@@ -201,7 +203,8 @@ export class VideoDownloadComponent {
201 } 203 }
202 204
203 isConfidentialVideo () { 205 isConfidentialVideo () {
204 return this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL 206 return videoRequiresFileToken(this.video)
207
205 } 208 }
206 209
207 switchToType (type: DownloadType) { 210 switchToType (type: DownloadType) {
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
index 3d39c6fdc..3fbfaed28 100644
--- a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
+++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
@@ -125,7 +125,7 @@
125 <my-peertube-checkbox 125 <my-peertube-checkbox
126 formControlName="allVideos" 126 formControlName="allVideos"
127 inputName="allVideos" 127 inputName="allVideos"
128 i18n-labelText labelText="Display all videos (private, unlisted or not yet published)" 128 i18n-labelText labelText="Display all videos (private, unlisted, password protected or not yet published)"
129 ></my-peertube-checkbox> 129 ></my-peertube-checkbox>
130 </div> 130 </div>
131 </div> 131 </div>
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.html b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
index 3f0180695..9e0a4f79b 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.html
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
@@ -5,6 +5,7 @@
5 > 5 >
6 <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container> 6 <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
7 <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPrivateVideo()" i18n>Private</ng-container> 7 <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPrivateVideo()" i18n>Private</ng-container>
8 <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPasswordProtectedVideo()" i18n>Password protected</ng-container>
8 </my-video-thumbnail> 9 </my-video-thumbnail>
9 10
10 <div class="video-bottom"> 11 <div class="video-bottom">
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
index 2384b34d7..d453f37a1 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
@@ -171,6 +171,10 @@ export class VideoMiniatureComponent implements OnInit {
171 return this.video.privacy.id === VideoPrivacy.PRIVATE 171 return this.video.privacy.id === VideoPrivacy.PRIVATE
172 } 172 }
173 173
174 isPasswordProtectedVideo () {
175 return this.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
176 }
177
174 getStateLabel (video: Video) { 178 getStateLabel (video: Video) {
175 if (!video.state) return '' 179 if (!video.state) return ''
176 180
diff --git a/client/src/app/shared/shared-video-miniature/videos-list.component.ts b/client/src/app/shared/shared-video-miniature/videos-list.component.ts
index 7b832263e..14a5abd7a 100644
--- a/client/src/app/shared/shared-video-miniature/videos-list.component.ts
+++ b/client/src/app/shared/shared-video-miniature/videos-list.component.ts
@@ -419,6 +419,7 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
419 this.lastQueryLength = data.length 419 this.lastQueryLength = data.length
420 420
421 if (reset) this.videos = [] 421 if (reset) this.videos = []
422
422 this.videos = this.videos.concat(data) 423 this.videos = this.videos.concat(data)
423 424
424 if (this.groupByDate) this.buildGroupedDateLabels() 425 if (this.groupByDate) this.buildGroupedDateLabels()
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html
index 75afa0709..882b14c5e 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.html
@@ -21,7 +21,8 @@
21 [attr.title]="playlistElement.video.name" 21 [attr.title]="playlistElement.video.name"
22 >{{ playlistElement.video.name }}</a> 22 >{{ playlistElement.video.name }}</a>
23 23
24 <span *ngIf="isVideoPrivate()" class="pt-badge badge-yellow">Private</span> 24 <span i18n *ngIf="isVideoPrivate()" class="pt-badge badge-yellow">Private</span>
25 <span i18n *ngIf="isVideoPasswordProtected()" class="pt-badge badge-yellow">Password protected</span>
25 </div> 26 </div>
26 27
27 <span class="video-miniature-created-at-views"> 28 <span class="video-miniature-created-at-views">
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts
index 552ea742b..b9a1d9623 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-element-miniature.component.ts
@@ -60,6 +60,10 @@ export class VideoPlaylistElementMiniatureComponent implements OnInit {
60 return this.playlistElement.video.privacy.id === VideoPrivacy.PRIVATE 60 return this.playlistElement.video.privacy.id === VideoPrivacy.PRIVATE
61 } 61 }
62 62
63 isVideoPasswordProtected () {
64 return this.playlistElement.video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED
65 }
66
63 isUnavailable (e: VideoPlaylistElement) { 67 isUnavailable (e: VideoPlaylistElement) {
64 return e.type === VideoPlaylistElementType.UNAVAILABLE 68 return e.type === VideoPlaylistElementType.UNAVAILABLE
65 } 69 }
diff --git a/client/src/assets/player/index.ts b/client/src/assets/player/index.ts
index 9b87afc4a..d34188ea7 100644
--- a/client/src/assets/player/index.ts
+++ b/client/src/assets/player/index.ts
@@ -1,2 +1,2 @@
1export * from './peertube-player-manager' 1export * from './peertube-player'
2export * from './types' 2export * from './types'
diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts
deleted file mode 100644
index 2781850b9..000000000
--- a/client/src/assets/player/peertube-player-manager.ts
+++ /dev/null
@@ -1,266 +0,0 @@
1import '@peertube/videojs-contextmenu'
2import './shared/upnext/end-card'
3import './shared/upnext/upnext-plugin'
4import './shared/stats/stats-card'
5import './shared/stats/stats-plugin'
6import './shared/bezels/bezels-plugin'
7import './shared/peertube/peertube-plugin'
8import './shared/resolutions/peertube-resolutions-plugin'
9import './shared/control-bar/next-previous-video-button'
10import './shared/control-bar/p2p-info-button'
11import './shared/control-bar/peertube-link-button'
12import './shared/control-bar/peertube-load-progress-bar'
13import './shared/control-bar/theater-button'
14import './shared/control-bar/peertube-live-display'
15import './shared/settings/resolution-menu-button'
16import './shared/settings/resolution-menu-item'
17import './shared/settings/settings-dialog'
18import './shared/settings/settings-menu-button'
19import './shared/settings/settings-menu-item'
20import './shared/settings/settings-panel'
21import './shared/settings/settings-panel-child'
22import './shared/playlist/playlist-plugin'
23import './shared/mobile/peertube-mobile-plugin'
24import './shared/mobile/peertube-mobile-buttons'
25import './shared/hotkeys/peertube-hotkeys-plugin'
26import './shared/metrics/metrics-plugin'
27import videojs from 'video.js'
28import { logger } from '@root-helpers/logger'
29import { PluginsManager } from '@root-helpers/plugins-manager'
30import { isMobile } from '@root-helpers/web-browser'
31import { saveAverageBandwidth } from './peertube-player-local-storage'
32import { ManagerOptionsBuilder } from './shared/manager-options'
33import { TranslationsManager } from './translations-manager'
34import { CommonOptions, PeertubePlayerManagerOptions, PlayerMode, PlayerNetworkInfo } from './types'
35
36// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
37(videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed'
38
39const CaptionsButton = videojs.getComponent('CaptionsButton') as any
40// Change Captions to Subtitles/CC
41CaptionsButton.prototype.controlText_ = 'Subtitles/CC'
42// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know)
43CaptionsButton.prototype.label_ = ' '
44
45export class PeertubePlayerManager {
46 private static playerElementClassName: string
47 private static playerElementAttributes: { name: string, value: string }[] = []
48
49 private static onPlayerChange: (player: videojs.Player) => void
50 private static alreadyPlayed = false
51 private static pluginsManager: PluginsManager
52
53 private static videojsDecodeErrors = 0
54
55 private static p2pMediaLoaderModule: any
56
57 static initState () {
58 this.alreadyPlayed = false
59 }
60
61 static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions, onPlayerChange: (player: videojs.Player) => void) {
62 this.pluginsManager = options.pluginsManager
63
64 this.onPlayerChange = onPlayerChange
65
66 this.playerElementClassName = options.common.playerElement.className
67
68 for (const name of options.common.playerElement.getAttributeNames()) {
69 this.playerElementAttributes.push({ name, value: options.common.playerElement.getAttribute(name) })
70 }
71
72 if (mode === 'webtorrent') await import('./shared/webtorrent/webtorrent-plugin')
73 if (mode === 'p2p-media-loader') {
74 const [ p2pMediaLoaderModule ] = await Promise.all([
75 import('@peertube/p2p-media-loader-hlsjs'),
76 import('./shared/p2p-media-loader/p2p-media-loader-plugin')
77 ])
78
79 this.p2pMediaLoaderModule = p2pMediaLoaderModule
80 }
81
82 await TranslationsManager.loadLocaleInVideoJS(options.common.serverUrl, options.common.language, videojs)
83
84 return this.buildPlayer(mode, options)
85 }
86
87 private static async buildPlayer (mode: PlayerMode, options: PeertubePlayerManagerOptions): Promise<videojs.Player> {
88 const videojsOptionsBuilder = new ManagerOptionsBuilder(mode, options, this.p2pMediaLoaderModule)
89
90 const videojsOptions = await this.pluginsManager.runHook(
91 'filter:internal.player.videojs.options.result',
92 videojsOptionsBuilder.getVideojsOptions(this.alreadyPlayed)
93 )
94
95 const self = this
96 return new Promise(res => {
97 videojs(options.common.playerElement, videojsOptions, function (this: videojs.Player) {
98 const player = this
99
100 if (!isNaN(+options.common.playbackRate)) {
101 player.playbackRate(+options.common.playbackRate)
102 }
103
104 let alreadyFallback = false
105
106 const handleError = () => {
107 if (alreadyFallback) return
108 alreadyFallback = true
109
110 if (mode === 'p2p-media-loader') {
111 self.tryToRecoverHLSError(player.error(), player, options)
112 } else {
113 self.maybeFallbackToWebTorrent(mode, player, options)
114 }
115 }
116
117 player.one('error', () => handleError())
118
119 player.one('play', () => {
120 self.alreadyPlayed = true
121 })
122
123 self.addContextMenu(videojsOptionsBuilder, player, options.common)
124
125 if (isMobile()) player.peertubeMobile()
126 if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin({ isLive: options.common.isLive })
127 if (options.common.controlBar === false) player.controlBar.addClass('control-bar-hidden')
128
129 player.bezels()
130
131 player.stats({
132 videoUUID: options.common.videoUUID,
133 videoIsLive: options.common.isLive,
134 mode,
135 p2pEnabled: options.common.p2pEnabled
136 })
137
138 player.on('p2pInfo', (_, data: PlayerNetworkInfo) => {
139 if (data.source !== 'p2p-media-loader' || isNaN(data.bandwidthEstimate)) return
140
141 saveAverageBandwidth(data.bandwidthEstimate)
142 })
143
144 const offlineNotificationElem = document.createElement('div')
145 offlineNotificationElem.classList.add('vjs-peertube-offline-notification')
146 offlineNotificationElem.innerText = player.localize('You seem to be offline and the video may not work')
147
148 let offlineNotificationElemAdded = false
149
150 const handleOnline = () => {
151 if (!offlineNotificationElemAdded) return
152
153 player.el().removeChild(offlineNotificationElem)
154 offlineNotificationElemAdded = false
155
156 logger.info('The browser is online')
157 }
158
159 const handleOffline = () => {
160 if (offlineNotificationElemAdded) return
161
162 player.el().appendChild(offlineNotificationElem)
163 offlineNotificationElemAdded = true
164
165 logger.info('The browser is offline')
166 }
167
168 window.addEventListener('online', handleOnline)
169 window.addEventListener('offline', handleOffline)
170
171 player.on('dispose', () => {
172 window.removeEventListener('online', handleOnline)
173 window.removeEventListener('offline', handleOffline)
174 })
175
176 return res(player)
177 })
178 })
179 }
180
181 private static async tryToRecoverHLSError (err: any, currentPlayer: videojs.Player, options: PeertubePlayerManagerOptions) {
182 if (err.code === MediaError.MEDIA_ERR_DECODE) {
183
184 // Display a notification to user
185 if (this.videojsDecodeErrors === 0) {
186 options.common.errorNotifier(currentPlayer.localize('The video failed to play, will try to fast forward.'))
187 }
188
189 if (this.videojsDecodeErrors === 20) {
190 this.maybeFallbackToWebTorrent('p2p-media-loader', currentPlayer, options)
191 return
192 }
193
194 logger.info('Fast forwarding HLS to recover from an error.')
195
196 this.videojsDecodeErrors++
197
198 options.common.startTime = currentPlayer.currentTime() + 2
199 options.common.autoplay = true
200 this.rebuildAndUpdateVideoElement(currentPlayer, options.common)
201
202 const newPlayer = await this.buildPlayer('p2p-media-loader', options)
203 this.onPlayerChange(newPlayer)
204 } else {
205 this.maybeFallbackToWebTorrent('p2p-media-loader', currentPlayer, options)
206 }
207 }
208
209 private static async maybeFallbackToWebTorrent (
210 currentMode: PlayerMode,
211 currentPlayer: videojs.Player,
212 options: PeertubePlayerManagerOptions
213 ) {
214 if (options.webtorrent.videoFiles.length === 0 || currentMode === 'webtorrent') {
215 currentPlayer.peertube().displayFatalError()
216 return
217 }
218
219 logger.info('Fallback to webtorrent.')
220
221 this.rebuildAndUpdateVideoElement(currentPlayer, options.common)
222
223 await import('./shared/webtorrent/webtorrent-plugin')
224
225 const newPlayer = await this.buildPlayer('webtorrent', options)
226 this.onPlayerChange(newPlayer)
227 }
228
229 private static rebuildAndUpdateVideoElement (player: videojs.Player, commonOptions: CommonOptions) {
230 const newVideoElement = document.createElement('video')
231
232 // Reset class
233 newVideoElement.className = this.playerElementClassName
234
235 // Reapply attributes
236 for (const { name, value } of this.playerElementAttributes) {
237 newVideoElement.setAttribute(name, value)
238 }
239
240 // VideoJS wraps our video element inside a div
241 let currentParentPlayerElement = commonOptions.playerElement.parentNode
242 // Fix on IOS, don't ask me why
243 if (!currentParentPlayerElement) currentParentPlayerElement = document.getElementById(commonOptions.playerElement.id).parentNode
244
245 currentParentPlayerElement.parentNode.insertBefore(newVideoElement, currentParentPlayerElement)
246
247 commonOptions.playerElement = newVideoElement
248 commonOptions.onPlayerElementChange(newVideoElement)
249
250 player.dispose()
251
252 return newVideoElement
253 }
254
255 private static addContextMenu (optionsBuilder: ManagerOptionsBuilder, player: videojs.Player, commonOptions: CommonOptions) {
256 const options = optionsBuilder.getContextMenuOptions(player, commonOptions)
257
258 player.contextmenuUI(options)
259 }
260}
261
262// ############################################################################
263
264export {
265 videojs
266}
diff --git a/client/src/assets/player/peertube-player.ts b/client/src/assets/player/peertube-player.ts
new file mode 100644
index 000000000..a7a2b4065
--- /dev/null
+++ b/client/src/assets/player/peertube-player.ts
@@ -0,0 +1,522 @@
1import '@peertube/videojs-contextmenu'
2import './shared/upnext/end-card'
3import './shared/upnext/upnext-plugin'
4import './shared/stats/stats-card'
5import './shared/stats/stats-plugin'
6import './shared/bezels/bezels-plugin'
7import './shared/peertube/peertube-plugin'
8import './shared/resolutions/peertube-resolutions-plugin'
9import './shared/control-bar/storyboard-plugin'
10import './shared/control-bar/next-previous-video-button'
11import './shared/control-bar/p2p-info-button'
12import './shared/control-bar/peertube-link-button'
13import './shared/control-bar/theater-button'
14import './shared/control-bar/peertube-live-display'
15import './shared/settings/resolution-menu-button'
16import './shared/settings/resolution-menu-item'
17import './shared/settings/settings-dialog'
18import './shared/settings/settings-menu-button'
19import './shared/settings/settings-menu-item'
20import './shared/settings/settings-panel'
21import './shared/settings/settings-panel-child'
22import './shared/playlist/playlist-plugin'
23import './shared/mobile/peertube-mobile-plugin'
24import './shared/mobile/peertube-mobile-buttons'
25import './shared/hotkeys/peertube-hotkeys-plugin'
26import './shared/metrics/metrics-plugin'
27import videojs, { VideoJsPlayer } from 'video.js'
28import { logger } from '@root-helpers/logger'
29import { PluginsManager } from '@root-helpers/plugins-manager'
30import { copyToClipboard } from '@root-helpers/utils'
31import { buildVideoOrPlaylistEmbed } from '@root-helpers/video'
32import { isMobile } from '@root-helpers/web-browser'
33import { buildVideoLink, decorateVideoLink, isDefaultLocale, pick } from '@shared/core-utils'
34import { saveAverageBandwidth } from './peertube-player-local-storage'
35import { ControlBarOptionsBuilder, HLSOptionsBuilder, WebVideoOptionsBuilder } from './shared/player-options-builder'
36import { TranslationsManager } from './translations-manager'
37import { PeerTubePlayerContructorOptions, PeerTubePlayerLoadOptions, PlayerNetworkInfo, VideoJSPluginOptions } from './types'
38
39// Change 'Playback Rate' to 'Speed' (smaller for our settings menu)
40(videojs.getComponent('PlaybackRateMenuButton') as any).prototype.controlText_ = 'Speed'
41
42const CaptionsButton = videojs.getComponent('CaptionsButton') as any
43// Change Captions to Subtitles/CC
44CaptionsButton.prototype.controlText_ = 'Subtitles/CC'
45// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know)
46CaptionsButton.prototype.label_ = ' '
47
48// TODO: remove when https://github.com/videojs/video.js/pull/7598 is merged
49const PlayProgressBar = videojs.getComponent('PlayProgressBar') as any
50if (PlayProgressBar.prototype.options_.children.includes('timeTooltip') !== true) {
51 PlayProgressBar.prototype.options_.children.push('timeTooltip')
52}
53
54export class PeerTubePlayer {
55 private pluginsManager: PluginsManager
56
57 private videojsDecodeErrors = 0
58
59 private p2pMediaLoaderModule: any
60
61 private player: VideoJsPlayer
62
63 private currentLoadOptions: PeerTubePlayerLoadOptions
64
65 private moduleLoaded = {
66 webVideo: false,
67 p2pMediaLoader: false
68 }
69
70 constructor (private options: PeerTubePlayerContructorOptions) {
71 this.pluginsManager = options.pluginsManager
72 }
73
74 unload () {
75 if (!this.player) return
76
77 this.disposeDynamicPluginsIfNeeded()
78
79 this.player.reset()
80 }
81
82 async load (loadOptions: PeerTubePlayerLoadOptions) {
83 this.currentLoadOptions = loadOptions
84
85 this.setPoster('')
86
87 this.disposeDynamicPluginsIfNeeded()
88
89 await this.lazyLoadModulesIfNeeded()
90 await this.buildPlayerIfNeeded()
91
92 if (this.currentLoadOptions.mode === 'p2p-media-loader') {
93 await this.loadP2PMediaLoader()
94 } else {
95 this.loadWebVideo()
96 }
97
98 this.loadDynamicPlugins()
99
100 if (this.options.controlBar === false) this.player.controlBar.hide()
101 else this.player.controlBar.show()
102
103 this.player.autoplay(this.getAutoPlayValue(this.currentLoadOptions.autoplay))
104
105 this.player.trigger('video-change')
106 }
107
108 getPlayer () {
109 return this.player
110 }
111
112 destroy () {
113 if (this.player) this.player.dispose()
114 }
115
116 setPoster (url: string) {
117 this.player?.poster(url)
118 this.options.playerElement().poster = url
119 }
120
121 enable () {
122 if (!this.player) return
123
124 (this.player.el() as HTMLElement).style.pointerEvents = 'auto'
125 }
126
127 disable () {
128 if (!this.player) return
129
130 if (this.player.isFullscreen()) {
131 this.player.exitFullscreen()
132 }
133
134 // Disable player
135 this.player.hasStarted(false)
136 this.player.removeClass('vjs-has-autoplay')
137 this.player.bigPlayButton.hide();
138
139 (this.player.el() as HTMLElement).style.pointerEvents = 'none'
140 }
141
142 private async loadP2PMediaLoader () {
143 const hlsOptionsBuilder = new HLSOptionsBuilder({
144 ...pick(this.options, [ 'pluginsManager', 'serverUrl', 'authorizationHeader' ]),
145 ...pick(this.currentLoadOptions, [
146 'videoPassword',
147 'requiresUserAuth',
148 'videoFileToken',
149 'requiresPassword',
150 'isLive',
151 'p2pEnabled',
152 'liveOptions',
153 'hls'
154 ])
155 }, this.p2pMediaLoaderModule)
156
157 const { hlsjs, p2pMediaLoader } = await hlsOptionsBuilder.getPluginOptions()
158
159 this.player.hlsjs(hlsjs)
160 this.player.p2pMediaLoader(p2pMediaLoader)
161 }
162
163 private loadWebVideo () {
164 const webVideoOptionsBuilder = new WebVideoOptionsBuilder(pick(this.currentLoadOptions, [
165 'videoFileToken',
166 'webVideo',
167 'hls',
168 'startTime'
169 ]))
170
171 this.player.webVideo(webVideoOptionsBuilder.getPluginOptions())
172 }
173
174 private async buildPlayerIfNeeded () {
175 if (this.player) return
176
177 await TranslationsManager.loadLocaleInVideoJS(this.options.serverUrl, this.options.language, videojs)
178
179 const videojsOptions = await this.pluginsManager.runHook(
180 'filter:internal.player.videojs.options.result',
181 this.getVideojsOptions()
182 )
183
184 this.player = videojs(this.options.playerElement(), videojsOptions)
185
186 this.player.ready(() => {
187 if (!isNaN(+this.options.playbackRate)) {
188 this.player.playbackRate(+this.options.playbackRate)
189 }
190
191 let alreadyFallback = false
192
193 const handleError = () => {
194 if (alreadyFallback) return
195 alreadyFallback = true
196
197 if (this.currentLoadOptions.mode === 'p2p-media-loader') {
198 this.tryToRecoverHLSError(this.player.error())
199 } else {
200 this.maybeFallbackToWebVideo()
201 }
202 }
203
204 this.player.one('error', () => handleError())
205
206 this.player.on('p2p-info', (_, data: PlayerNetworkInfo) => {
207 if (data.source !== 'p2p-media-loader' || isNaN(data.bandwidthEstimate)) return
208
209 saveAverageBandwidth(data.bandwidthEstimate)
210 })
211
212 this.player.contextmenuUI(this.getContextMenuOptions())
213
214 this.displayNotificationWhenOffline()
215 })
216 }
217
218 private disposeDynamicPluginsIfNeeded () {
219 if (!this.player) return
220
221 if (this.player.usingPlugin('peertubeMobile')) this.player.peertubeMobile().dispose()
222 if (this.player.usingPlugin('peerTubeHotkeysPlugin')) this.player.peerTubeHotkeysPlugin().dispose()
223 if (this.player.usingPlugin('playlist')) this.player.playlist().dispose()
224 if (this.player.usingPlugin('bezels')) this.player.bezels().dispose()
225 if (this.player.usingPlugin('upnext')) this.player.upnext().dispose()
226 if (this.player.usingPlugin('stats')) this.player.stats().dispose()
227 if (this.player.usingPlugin('storyboard')) this.player.storyboard().dispose()
228
229 if (this.player.usingPlugin('peertubeDock')) this.player.peertubeDock().dispose()
230
231 if (this.player.usingPlugin('p2pMediaLoader')) this.player.p2pMediaLoader().dispose()
232 if (this.player.usingPlugin('hlsjs')) this.player.hlsjs().dispose()
233
234 if (this.player.usingPlugin('webVideo')) this.player.webVideo().dispose()
235 }
236
237 private loadDynamicPlugins () {
238 if (isMobile()) this.player.peertubeMobile()
239
240 this.player.bezels()
241
242 this.player.stats({
243 videoUUID: this.currentLoadOptions.videoUUID,
244 videoIsLive: this.currentLoadOptions.isLive,
245 mode: this.currentLoadOptions.mode,
246 p2pEnabled: this.currentLoadOptions.p2pEnabled
247 })
248
249 if (this.options.enableHotkeys === true) {
250 this.player.peerTubeHotkeysPlugin({ isLive: this.currentLoadOptions.isLive })
251 }
252
253 if (this.currentLoadOptions.playlist) {
254 this.player.playlist(this.currentLoadOptions.playlist)
255 }
256
257 if (this.currentLoadOptions.upnext) {
258 this.player.upnext({
259 timeout: this.currentLoadOptions.upnext.timeout,
260
261 getTitle: () => this.currentLoadOptions.nextVideo.getVideoTitle(),
262
263 next: () => this.currentLoadOptions.nextVideo.handler(),
264 isDisplayed: () => this.currentLoadOptions.nextVideo.enabled && this.currentLoadOptions.upnext.isEnabled(),
265
266 isSuspended: () => this.currentLoadOptions.upnext.isSuspended(this.player)
267 })
268 }
269
270 if (this.currentLoadOptions.storyboard) {
271 this.player.storyboard(this.currentLoadOptions.storyboard)
272 }
273
274 if (this.currentLoadOptions.dock) {
275 this.player.peertubeDock(this.currentLoadOptions.dock)
276 }
277 }
278
279 private async lazyLoadModulesIfNeeded () {
280 if (this.currentLoadOptions.mode === 'web-video' && this.moduleLoaded.webVideo !== true) {
281 await import('./shared/web-video/web-video-plugin')
282 }
283
284 if (this.currentLoadOptions.mode === 'p2p-media-loader' && this.moduleLoaded.p2pMediaLoader !== true) {
285 const [ p2pMediaLoaderModule ] = await Promise.all([
286 import('@peertube/p2p-media-loader-hlsjs'),
287 import('./shared/p2p-media-loader/hls-plugin'),
288 import('./shared/p2p-media-loader/p2p-media-loader-plugin')
289 ])
290
291 this.p2pMediaLoaderModule = p2pMediaLoaderModule
292 }
293 }
294
295 private async tryToRecoverHLSError (err: any) {
296 if (err.code === MediaError.MEDIA_ERR_DECODE) {
297
298 // Display a notification to user
299 if (this.videojsDecodeErrors === 0) {
300 this.options.errorNotifier(this.player.localize('The video failed to play, will try to fast forward.'))
301 }
302
303 if (this.videojsDecodeErrors === 20) {
304 this.maybeFallbackToWebVideo()
305 return
306 }
307
308 logger.info('Fast forwarding HLS to recover from an error.')
309
310 this.videojsDecodeErrors++
311
312 await this.load({
313 ...this.currentLoadOptions,
314
315 mode: 'p2p-media-loader',
316 startTime: this.player.currentTime() + 2,
317 autoplay: true
318 })
319 } else {
320 this.maybeFallbackToWebVideo()
321 }
322 }
323
324 private async maybeFallbackToWebVideo () {
325 if (this.currentLoadOptions.webVideo.videoFiles.length === 0 || this.currentLoadOptions.mode === 'web-video') {
326 this.player.peertube().displayFatalError()
327 return
328 }
329
330 logger.info('Fallback to web-video.')
331
332 await this.load({
333 ...this.currentLoadOptions,
334
335 mode: 'web-video',
336 startTime: this.player.currentTime(),
337 autoplay: true
338 })
339 }
340
341 getVideojsOptions (): videojs.PlayerOptions {
342 const html5 = {
343 preloadTextTracks: false
344 }
345
346 const plugins: VideoJSPluginOptions = {
347 peertube: {
348 hasAutoplay: () => this.getAutoPlayValue(this.currentLoadOptions.autoplay),
349
350 videoViewUrl: () => this.currentLoadOptions.videoViewUrl,
351 videoViewIntervalMs: this.options.videoViewIntervalMs,
352
353 authorizationHeader: this.options.authorizationHeader,
354
355 videoDuration: () => this.currentLoadOptions.duration,
356
357 startTime: () => this.currentLoadOptions.startTime,
358 stopTime: () => this.currentLoadOptions.stopTime,
359
360 videoCaptions: () => this.currentLoadOptions.videoCaptions,
361 isLive: () => this.currentLoadOptions.isLive,
362 videoUUID: () => this.currentLoadOptions.videoUUID,
363 subtitle: () => this.currentLoadOptions.subtitle
364 },
365 metrics: {
366 mode: () => this.currentLoadOptions.mode,
367
368 metricsUrl: () => this.options.metricsUrl,
369 videoUUID: () => this.currentLoadOptions.videoUUID
370 }
371 }
372
373 const controlBarOptionsBuilder = new ControlBarOptionsBuilder({
374 ...this.options,
375
376 videoShortUUID: () => this.currentLoadOptions.videoShortUUID,
377 p2pEnabled: () => this.currentLoadOptions.p2pEnabled,
378
379 nextVideo: () => this.currentLoadOptions.nextVideo,
380 previousVideo: () => this.currentLoadOptions.previousVideo
381 })
382
383 const videojsOptions = {
384 html5,
385
386 // We don't use text track settings for now
387 textTrackSettings: false as any, // FIXME: typings
388 controls: this.options.controls !== undefined ? this.options.controls : true,
389 loop: this.options.loop !== undefined ? this.options.loop : false,
390
391 muted: this.options.muted !== undefined
392 ? this.options.muted
393 : undefined, // Undefined so the player knows it has to check the local storage
394
395 autoplay: this.getAutoPlayValue(this.currentLoadOptions.autoplay),
396
397 poster: this.currentLoadOptions.poster,
398 inactivityTimeout: this.options.inactivityTimeout,
399 playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ],
400
401 plugins,
402
403 controlBar: {
404 children: controlBarOptionsBuilder.getChildrenOptions() as any // FIXME: typings
405 },
406
407 language: this.options.language && !isDefaultLocale(this.options.language)
408 ? this.options.language
409 : undefined
410 }
411
412 return videojsOptions
413 }
414
415 private getAutoPlayValue (autoplay: boolean): videojs.Autoplay {
416 if (autoplay !== true) return false
417
418 return this.currentLoadOptions.forceAutoplay
419 ? 'any'
420 : 'play'
421 }
422
423 private displayNotificationWhenOffline () {
424 const offlineNotificationElem = document.createElement('div')
425 offlineNotificationElem.classList.add('vjs-peertube-offline-notification')
426 offlineNotificationElem.innerText = this.player.localize('You seem to be offline and the video may not work')
427
428 let offlineNotificationElemAdded = false
429
430 const handleOnline = () => {
431 if (!offlineNotificationElemAdded) return
432
433 this.player.el().removeChild(offlineNotificationElem)
434 offlineNotificationElemAdded = false
435
436 logger.info('The browser is online')
437 }
438
439 const handleOffline = () => {
440 if (offlineNotificationElemAdded) return
441
442 this.player.el().appendChild(offlineNotificationElem)
443 offlineNotificationElemAdded = true
444
445 logger.info('The browser is offline')
446 }
447
448 window.addEventListener('online', handleOnline)
449 window.addEventListener('offline', handleOffline)
450
451 this.player.on('dispose', () => {
452 window.removeEventListener('online', handleOnline)
453 window.removeEventListener('offline', handleOffline)
454 })
455 }
456
457 private getContextMenuOptions () {
458
459 const content = () => {
460 const self = this
461 const player = this.player
462
463 const shortUUID = self.currentLoadOptions.videoShortUUID
464 const isLoopEnabled = player.options_['loop']
465
466 const items = [
467 {
468 icon: 'repeat',
469 label: player.localize('Play in loop') + (isLoopEnabled ? '<span class="vjs-icon-tick-white"></span>' : ''),
470 listener: function () {
471 player.options_['loop'] = !isLoopEnabled
472 }
473 },
474 {
475 label: player.localize('Copy the video URL'),
476 listener: function () {
477 copyToClipboard(buildVideoLink({ shortUUID }))
478 }
479 },
480 {
481 label: player.localize('Copy the video URL at the current time'),
482 listener: function () {
483 const url = buildVideoLink({ shortUUID })
484
485 copyToClipboard(decorateVideoLink({ url, startTime: player.currentTime() }))
486 }
487 },
488 {
489 icon: 'code',
490 label: player.localize('Copy embed code'),
491 listener: () => {
492 copyToClipboard(buildVideoOrPlaylistEmbed({
493 embedUrl: self.currentLoadOptions.embedUrl,
494 embedTitle: self.currentLoadOptions.embedTitle
495 }))
496 }
497 }
498 ]
499
500 items.push({
501 icon: 'info',
502 label: player.localize('Stats for nerds'),
503 listener: () => {
504 player.stats().show()
505 }
506 })
507
508 return items.map(i => ({
509 ...i,
510 label: `<span class="vjs-icon-${i.icon || 'link-2'}"></span>` + i.label
511 }))
512 }
513
514 return { content }
515 }
516}
517
518// ############################################################################
519
520export {
521 videojs
522}
diff --git a/client/src/assets/player/shared/bezels/bezels-plugin.ts b/client/src/assets/player/shared/bezels/bezels-plugin.ts
index ca88bc1f9..6afb2c6a3 100644
--- a/client/src/assets/player/shared/bezels/bezels-plugin.ts
+++ b/client/src/assets/player/shared/bezels/bezels-plugin.ts
@@ -1,5 +1,5 @@
1import videojs from 'video.js' 1import videojs from 'video.js'
2import './pause-bezel' 2import { PauseBezel } from './pause-bezel'
3 3
4const Plugin = videojs.getPlugin('plugin') 4const Plugin = videojs.getPlugin('plugin')
5 5
@@ -12,7 +12,7 @@ class BezelsPlugin extends Plugin {
12 player.addClass('vjs-bezels') 12 player.addClass('vjs-bezels')
13 }) 13 })
14 14
15 player.addChild('PauseBezel', options) 15 player.addChild(new PauseBezel(player, options))
16 } 16 }
17} 17}
18 18
diff --git a/client/src/assets/player/shared/bezels/pause-bezel.ts b/client/src/assets/player/shared/bezels/pause-bezel.ts
index e35c39a5f..d364ad0dd 100644
--- a/client/src/assets/player/shared/bezels/pause-bezel.ts
+++ b/client/src/assets/player/shared/bezels/pause-bezel.ts
@@ -32,26 +32,61 @@ function getPlayBezel () {
32} 32}
33 33
34const Component = videojs.getComponent('Component') 34const Component = videojs.getComponent('Component')
35class PauseBezel extends Component { 35export class PauseBezel extends Component {
36 container: HTMLDivElement 36 container: HTMLDivElement
37 37
38 private firstPlayDone = false
39 private paused = false
40
41 private playerPauseHandler: () => void
42 private playerPlayHandler: () => void
43 private videoChangeHandler: () => void
44
38 constructor (player: videojs.Player, options?: videojs.ComponentOptions) { 45 constructor (player: videojs.Player, options?: videojs.ComponentOptions) {
39 super(player, options) 46 super(player, options)
40 47
41 // Hide bezels on mobile since we already have our mobile overlay 48 // Hide bezels on mobile since we already have our mobile overlay
42 if (isMobile()) return 49 if (isMobile()) return
43 50
44 player.on('pause', (_: any) => { 51 this.playerPauseHandler = () => {
45 if (player.seeking() || player.ended()) return 52 if (player.seeking()) return
53
54 this.paused = true
55
56 if (player.ended()) return
57
46 this.container.innerHTML = getPauseBezel() 58 this.container.innerHTML = getPauseBezel()
47 this.showBezel() 59 this.showBezel()
48 }) 60 }
61
62 this.playerPlayHandler = () => {
63 if (player.seeking() || !this.firstPlayDone || !this.paused) {
64 this.firstPlayDone = true
65 return
66 }
67
68 this.paused = false
69 this.firstPlayDone = true
49 70
50 player.on('play', (_: any) => {
51 if (player.seeking()) return
52 this.container.innerHTML = getPlayBezel() 71 this.container.innerHTML = getPlayBezel()
53 this.showBezel() 72 this.showBezel()
54 }) 73 }
74
75 this.videoChangeHandler = () => {
76 this.firstPlayDone = false
77 }
78
79 player.on('video-change', () => this.videoChangeHandler)
80 player.on('pause', this.playerPauseHandler)
81 player.on('play', this.playerPlayHandler)
82 }
83
84 dispose () {
85 if (this.playerPauseHandler) this.player().off('pause', this.playerPauseHandler)
86 if (this.playerPlayHandler) this.player().off('play', this.playerPlayHandler)
87 if (this.videoChangeHandler) this.player().off('video-change', this.videoChangeHandler)
88
89 super.dispose()
55 } 90 }
56 91
57 createEl () { 92 createEl () {
diff --git a/client/src/assets/player/shared/control-bar/index.ts b/client/src/assets/player/shared/control-bar/index.ts
index e71e90713..9307027f6 100644
--- a/client/src/assets/player/shared/control-bar/index.ts
+++ b/client/src/assets/player/shared/control-bar/index.ts
@@ -2,5 +2,5 @@ export * from './next-previous-video-button'
2export * from './p2p-info-button' 2export * from './p2p-info-button'
3export * from './peertube-link-button' 3export * from './peertube-link-button'
4export * from './peertube-live-display' 4export * from './peertube-live-display'
5export * from './peertube-load-progress-bar' 5export * from './storyboard-plugin'
6export * from './theater-button' 6export * from './theater-button'
diff --git a/client/src/assets/player/shared/control-bar/next-previous-video-button.ts b/client/src/assets/player/shared/control-bar/next-previous-video-button.ts
index b7b986806..18a107f52 100644
--- a/client/src/assets/player/shared/control-bar/next-previous-video-button.ts
+++ b/client/src/assets/player/shared/control-bar/next-previous-video-button.ts
@@ -4,14 +4,18 @@ import { NextPreviousVideoButtonOptions } from '../../types'
4const Button = videojs.getComponent('Button') 4const Button = videojs.getComponent('Button')
5 5
6class NextPreviousVideoButton extends Button { 6class NextPreviousVideoButton extends Button {
7 private readonly nextPreviousVideoButtonOptions: NextPreviousVideoButtonOptions 7 options_: NextPreviousVideoButtonOptions & videojs.ComponentOptions
8 8
9 constructor (player: videojs.Player, options?: NextPreviousVideoButtonOptions) { 9 constructor (player: videojs.Player, options?: NextPreviousVideoButtonOptions & videojs.ComponentOptions) {
10 super(player, options as any) 10 super(player, options)
11 11
12 this.nextPreviousVideoButtonOptions = options 12 this.player().on('video-change', () => {
13 this.updateDisabled()
14 this.updateShowing()
15 })
13 16
14 this.update() 17 this.updateDisabled()
18 this.updateShowing()
15 } 19 }
16 20
17 createEl () { 21 createEl () {
@@ -35,15 +39,20 @@ class NextPreviousVideoButton extends Button {
35 } 39 }
36 40
37 handleClick () { 41 handleClick () {
38 this.nextPreviousVideoButtonOptions.handler() 42 this.options_.handler()
39 } 43 }
40 44
41 update () { 45 updateDisabled () {
42 const disabled = this.nextPreviousVideoButtonOptions.isDisabled() 46 const disabled = this.options_.isDisabled()
43 47
44 if (disabled) this.addClass('vjs-disabled') 48 if (disabled) this.addClass('vjs-disabled')
45 else this.removeClass('vjs-disabled') 49 else this.removeClass('vjs-disabled')
46 } 50 }
51
52 updateShowing () {
53 if (this.options_.isDisplayed()) this.show()
54 else this.hide()
55 }
47} 56}
48 57
49videojs.registerComponent('NextVideoButton', NextPreviousVideoButton) 58videojs.registerComponent('NextVideoButton', NextPreviousVideoButton)
diff --git a/client/src/assets/player/shared/control-bar/p2p-info-button.ts b/client/src/assets/player/shared/control-bar/p2p-info-button.ts
index 1979654ad..4177b3280 100644
--- a/client/src/assets/player/shared/control-bar/p2p-info-button.ts
+++ b/client/src/assets/player/shared/control-bar/p2p-info-button.ts
@@ -1,71 +1,44 @@
1import videojs from 'video.js' 1import videojs from 'video.js'
2import { PeerTubeP2PInfoButtonOptions, PlayerNetworkInfo } from '../../types' 2import { PlayerNetworkInfo } from '../../types'
3import { bytes } from '../common' 3import { bytes } from '../common'
4 4
5const Button = videojs.getComponent('Button') 5const Button = videojs.getComponent('Button')
6class P2pInfoButton extends Button { 6class P2PInfoButton extends Button {
7 7 el_: HTMLElement
8 constructor (player: videojs.Player, options?: PeerTubeP2PInfoButtonOptions) {
9 super(player, options as any)
10 }
11 8
12 createEl () { 9 createEl () {
13 const div = videojs.dom.createEl('div', { 10 const div = videojs.dom.createEl('div', { className: 'vjs-peertube' })
14 className: 'vjs-peertube' 11 const subDivP2P = videojs.dom.createEl('div', {
15 })
16 const subDivWebtorrent = videojs.dom.createEl('div', {
17 className: 'vjs-peertube-hidden' // Hide the stats before we get the info 12 className: 'vjs-peertube-hidden' // Hide the stats before we get the info
18 }) as HTMLDivElement 13 }) as HTMLDivElement
19 div.appendChild(subDivWebtorrent) 14 div.appendChild(subDivP2P)
20 15
21 // Stop here if P2P is not enabled 16 const downloadIcon = videojs.dom.createEl('span', { className: 'icon icon-download' })
22 const p2pEnabled = (this.options_ as PeerTubeP2PInfoButtonOptions).p2pEnabled 17 subDivP2P.appendChild(downloadIcon)
23 if (!p2pEnabled) return div as HTMLButtonElement
24 18
25 const downloadIcon = videojs.dom.createEl('span', { 19 const downloadSpeedText = videojs.dom.createEl('span', { className: 'download-speed-text' })
26 className: 'icon icon-download' 20 const downloadSpeedNumber = videojs.dom.createEl('span', { className: 'download-speed-number' })
27 })
28 subDivWebtorrent.appendChild(downloadIcon)
29
30 const downloadSpeedText = videojs.dom.createEl('span', {
31 className: 'download-speed-text'
32 })
33 const downloadSpeedNumber = videojs.dom.createEl('span', {
34 className: 'download-speed-number'
35 })
36 const downloadSpeedUnit = videojs.dom.createEl('span') 21 const downloadSpeedUnit = videojs.dom.createEl('span')
37 downloadSpeedText.appendChild(downloadSpeedNumber) 22 downloadSpeedText.appendChild(downloadSpeedNumber)
38 downloadSpeedText.appendChild(downloadSpeedUnit) 23 downloadSpeedText.appendChild(downloadSpeedUnit)
39 subDivWebtorrent.appendChild(downloadSpeedText) 24 subDivP2P.appendChild(downloadSpeedText)
40 25
41 const uploadIcon = videojs.dom.createEl('span', { 26 const uploadIcon = videojs.dom.createEl('span', { className: 'icon icon-upload' })
42 className: 'icon icon-upload' 27 subDivP2P.appendChild(uploadIcon)
43 })
44 subDivWebtorrent.appendChild(uploadIcon)
45 28
46 const uploadSpeedText = videojs.dom.createEl('span', { 29 const uploadSpeedText = videojs.dom.createEl('span', { className: 'upload-speed-text' })
47 className: 'upload-speed-text' 30 const uploadSpeedNumber = videojs.dom.createEl('span', { className: 'upload-speed-number' })
48 })
49 const uploadSpeedNumber = videojs.dom.createEl('span', {
50 className: 'upload-speed-number'
51 })
52 const uploadSpeedUnit = videojs.dom.createEl('span') 31 const uploadSpeedUnit = videojs.dom.createEl('span')
53 uploadSpeedText.appendChild(uploadSpeedNumber) 32 uploadSpeedText.appendChild(uploadSpeedNumber)
54 uploadSpeedText.appendChild(uploadSpeedUnit) 33 uploadSpeedText.appendChild(uploadSpeedUnit)
55 subDivWebtorrent.appendChild(uploadSpeedText) 34 subDivP2P.appendChild(uploadSpeedText)
56 35
57 const peersText = videojs.dom.createEl('span', { 36 const peersText = videojs.dom.createEl('span', { className: 'peers-text' })
58 className: 'peers-text' 37 const peersNumber = videojs.dom.createEl('span', { className: 'peers-number' })
59 }) 38 subDivP2P.appendChild(peersNumber)
60 const peersNumber = videojs.dom.createEl('span', { 39 subDivP2P.appendChild(peersText)
61 className: 'peers-number'
62 })
63 subDivWebtorrent.appendChild(peersNumber)
64 subDivWebtorrent.appendChild(peersText)
65 40
66 const subDivHttp = videojs.dom.createEl('div', { 41 const subDivHttp = videojs.dom.createEl('div', { className: 'vjs-peertube-hidden' }) as HTMLElement
67 className: 'vjs-peertube-hidden'
68 })
69 const subDivHttpText = videojs.dom.createEl('span', { 42 const subDivHttpText = videojs.dom.createEl('span', {
70 className: 'http-fallback', 43 className: 'http-fallback',
71 textContent: 'HTTP' 44 textContent: 'HTTP'
@@ -74,14 +47,9 @@ class P2pInfoButton extends Button {
74 subDivHttp.appendChild(subDivHttpText) 47 subDivHttp.appendChild(subDivHttpText)
75 div.appendChild(subDivHttp) 48 div.appendChild(subDivHttp)
76 49
77 this.player_.on('p2pInfo', (event: any, data: PlayerNetworkInfo) => { 50 this.player_.on('p2p-info', (_event: any, data: PlayerNetworkInfo) => {
78 // We are in HTTP fallback 51 subDivP2P.className = 'vjs-peertube-displayed'
79 if (!data) { 52 subDivHttp.className = 'vjs-peertube-hidden'
80 subDivHttp.className = 'vjs-peertube-displayed'
81 subDivWebtorrent.className = 'vjs-peertube-hidden'
82
83 return
84 }
85 53
86 const p2pStats = data.p2p 54 const p2pStats = data.p2p
87 const httpStats = data.http 55 const httpStats = data.http
@@ -92,17 +60,17 @@ class P2pInfoButton extends Button {
92 const totalUploaded = bytes(p2pStats.uploaded) 60 const totalUploaded = bytes(p2pStats.uploaded)
93 const numPeers = p2pStats.numPeers 61 const numPeers = p2pStats.numPeers
94 62
95 subDivWebtorrent.title = this.player().localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' 63 subDivP2P.title = this.player().localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n'
96 64
97 if (data.source === 'p2p-media-loader') { 65 if (data.source === 'p2p-media-loader') {
98 const downloadedFromServer = bytes(httpStats.downloaded).join(' ') 66 const downloadedFromServer = bytes(httpStats.downloaded).join(' ')
99 const downloadedFromPeers = bytes(p2pStats.downloaded).join(' ') 67 const downloadedFromPeers = bytes(p2pStats.downloaded).join(' ')
100 68
101 subDivWebtorrent.title += 69 subDivP2P.title +=
102 ' * ' + this.player().localize('From servers: ') + downloadedFromServer + '\n' + 70 ' * ' + this.player().localize('From servers: ') + downloadedFromServer + '\n' +
103 ' * ' + this.player().localize('From peers: ') + downloadedFromPeers + '\n' 71 ' * ' + this.player().localize('From peers: ') + downloadedFromPeers + '\n'
104 } 72 }
105 subDivWebtorrent.title += this.player().localize('Total uploaded: ') + totalUploaded.join(' ') 73 subDivP2P.title += this.player().localize('Total uploaded: ') + totalUploaded.join(' ')
106 74
107 downloadSpeedNumber.textContent = downloadSpeed[0] 75 downloadSpeedNumber.textContent = downloadSpeed[0]
108 downloadSpeedUnit.textContent = ' ' + downloadSpeed[1] 76 downloadSpeedUnit.textContent = ' ' + downloadSpeed[1]
@@ -114,11 +82,24 @@ class P2pInfoButton extends Button {
114 peersText.textContent = ' ' + (numPeers > 1 ? this.player().localize('peers') : this.player_.localize('peer')) 82 peersText.textContent = ' ' + (numPeers > 1 ? this.player().localize('peers') : this.player_.localize('peer'))
115 83
116 subDivHttp.className = 'vjs-peertube-hidden' 84 subDivHttp.className = 'vjs-peertube-hidden'
117 subDivWebtorrent.className = 'vjs-peertube-displayed' 85 subDivP2P.className = 'vjs-peertube-displayed'
86 })
87
88 this.player_.on('http-info', (_event, data: PlayerNetworkInfo) => {
89 // We are in HTTP fallback
90 subDivHttp.className = 'vjs-peertube-displayed'
91 subDivP2P.className = 'vjs-peertube-hidden'
92
93 subDivHttp.title = this.player().localize('Total downloaded: ') + bytes(data.http.downloaded).join(' ')
94 })
95
96 this.player_.on('video-change', () => {
97 subDivP2P.className = 'vjs-peertube-hidden'
98 subDivHttp.className = 'vjs-peertube-hidden'
118 }) 99 })
119 100
120 return div as HTMLButtonElement 101 return div as HTMLButtonElement
121 } 102 }
122} 103}
123 104
124videojs.registerComponent('P2PInfoButton', P2pInfoButton) 105videojs.registerComponent('P2PInfoButton', P2PInfoButton)
diff --git a/client/src/assets/player/shared/control-bar/peertube-link-button.ts b/client/src/assets/player/shared/control-bar/peertube-link-button.ts
index 45d7ac42f..8242b9cea 100644
--- a/client/src/assets/player/shared/control-bar/peertube-link-button.ts
+++ b/client/src/assets/player/shared/control-bar/peertube-link-button.ts
@@ -3,37 +3,58 @@ import { buildVideoLink, decorateVideoLink } from '@shared/core-utils'
3import { PeerTubeLinkButtonOptions } from '../../types' 3import { PeerTubeLinkButtonOptions } from '../../types'
4 4
5const Component = videojs.getComponent('Component') 5const Component = videojs.getComponent('Component')
6
6class PeerTubeLinkButton extends Component { 7class PeerTubeLinkButton extends Component {
8 private mouseEnterHandler: () => void
9 private clickHandler: () => void
7 10
8 constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions) { 11 options_: PeerTubeLinkButtonOptions & videojs.ComponentOptions
9 super(player, options as any)
10 }
11 12
12 createEl () { 13 constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions & videojs.ComponentOptions) {
13 return this.buildElement() 14 super(player, options)
15
16 this.updateShowing()
17 this.player().on('video-change', () => this.updateShowing())
14 } 18 }
15 19
16 updateHref () { 20 dispose () {
17 this.el().setAttribute('href', this.buildLink()) 21 if (this.el()) return
22
23 this.el().removeEventListener('mouseenter', this.mouseEnterHandler)
24 this.el().removeEventListener('click', this.clickHandler)
25
26 super.dispose()
18 } 27 }
19 28
20 private buildElement () { 29 createEl () {
21 const el = videojs.dom.createEl('a', { 30 const el = videojs.dom.createEl('a', {
22 href: this.buildLink(), 31 href: this.buildLink(),
23 innerHTML: (this.options_ as PeerTubeLinkButtonOptions).instanceName, 32 innerHTML: this.options_.instanceName,
24 title: this.player().localize('Video page (new window)'), 33 title: this.player().localize('Video page (new window)'),
25 className: 'vjs-peertube-link', 34 className: 'vjs-peertube-link',
26 target: '_blank' 35 target: '_blank'
27 }) 36 })
28 37
29 el.addEventListener('mouseenter', () => this.updateHref()) 38 this.mouseEnterHandler = () => this.updateHref()
30 el.addEventListener('click', () => this.player().pause()) 39 this.clickHandler = () => this.player().pause()
40
41 el.addEventListener('mouseenter', this.mouseEnterHandler)
42 el.addEventListener('click', this.clickHandler)
43
44 return el
45 }
46
47 updateShowing () {
48 if (this.options_.isDisplayed()) this.show()
49 else this.hide()
50 }
31 51
32 return el as HTMLButtonElement 52 updateHref () {
53 this.el().setAttribute('href', this.buildLink())
33 } 54 }
34 55
35 private buildLink () { 56 private buildLink () {
36 const url = buildVideoLink({ shortUUID: (this.options_ as PeerTubeLinkButtonOptions).shortUUID }) 57 const url = buildVideoLink({ shortUUID: this.options_.shortUUID() })
37 58
38 return decorateVideoLink({ url, startTime: this.player().currentTime() }) 59 return decorateVideoLink({ url, startTime: this.player().currentTime() })
39 } 60 }
diff --git a/client/src/assets/player/shared/control-bar/peertube-live-display.ts b/client/src/assets/player/shared/control-bar/peertube-live-display.ts
index 649eb0b00..f9f6bf12f 100644
--- a/client/src/assets/player/shared/control-bar/peertube-live-display.ts
+++ b/client/src/assets/player/shared/control-bar/peertube-live-display.ts
@@ -13,7 +13,6 @@ class PeerTubeLiveDisplay extends ClickableComponent {
13 13
14 this.interval = this.setInterval(() => this.updateClass(), 1000) 14 this.interval = this.setInterval(() => this.updateClass(), 1000)
15 15
16 this.show()
17 this.updateSync(true) 16 this.updateSync(true)
18 } 17 }
19 18
@@ -30,7 +29,7 @@ class PeerTubeLiveDisplay extends ClickableComponent {
30 29
31 createEl () { 30 createEl () {
32 const el = super.createEl('div', { 31 const el = super.createEl('div', {
33 className: 'vjs-live-control vjs-control' 32 className: 'vjs-pt-live-control vjs-control'
34 }) 33 })
35 34
36 this.contentEl_ = videojs.dom.createEl('div', { 35 this.contentEl_ = videojs.dom.createEl('div', {
@@ -83,10 +82,9 @@ class PeerTubeLiveDisplay extends ClickableComponent {
83 } 82 }
84 83
85 private getHLSJS () { 84 private getHLSJS () {
86 const p2pMediaLoader = this.player()?.p2pMediaLoader 85 if (!this.player()?.usingPlugin('p2pMediaLoader')) return
87 if (!p2pMediaLoader) return undefined
88 86
89 return p2pMediaLoader().getHLSJS() 87 return this.player().p2pMediaLoader().getHLSJS()
90 } 88 }
91} 89}
92 90
diff --git a/client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts b/client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts
deleted file mode 100644
index 623e70eb2..000000000
--- a/client/src/assets/player/shared/control-bar/peertube-load-progress-bar.ts
+++ /dev/null
@@ -1,33 +0,0 @@
1import videojs from 'video.js'
2
3const Component = videojs.getComponent('Component')
4
5class PeerTubeLoadProgressBar extends Component {
6
7 constructor (player: videojs.Player, options?: videojs.ComponentOptions) {
8 super(player, options)
9
10 this.on(player, 'progress', this.update)
11 }
12
13 createEl () {
14 return super.createEl('div', {
15 className: 'vjs-load-progress',
16 innerHTML: `<span class="vjs-control-text"><span>${this.localize('Loaded')}</span>: 0%</span>`
17 })
18 }
19
20 dispose () {
21 super.dispose()
22 }
23
24 update () {
25 const torrent = this.player().webtorrent().getTorrent()
26 if (!torrent) return
27
28 (this.el() as HTMLElement).style.width = (torrent.progress * 100) + '%'
29 }
30
31}
32
33Component.registerComponent('PeerTubeLoadProgressBar', PeerTubeLoadProgressBar)
diff --git a/client/src/assets/player/shared/control-bar/storyboard-plugin.ts b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts
new file mode 100644
index 000000000..80c69b5f2
--- /dev/null
+++ b/client/src/assets/player/shared/control-bar/storyboard-plugin.ts
@@ -0,0 +1,197 @@
1import videojs from 'video.js'
2import { StoryboardOptions } from '../../types'
3
4// Big thanks to this beautiful plugin: https://github.com/phloxic/videojs-sprite-thumbnails
5// Adapted to respect peertube player style
6
7const Plugin = videojs.getPlugin('plugin')
8
9class StoryboardPlugin extends Plugin {
10 private url: string
11 private height: number
12 private width: number
13 private interval: number
14
15 private cached: boolean
16
17 private mouseTimeTooltip: videojs.MouseTimeDisplay
18 private seekBar: { el(): HTMLElement, mouseTimeDisplay: any, playProgressBar: any }
19 private progress: any
20
21 private spritePlaceholder: HTMLElement
22
23 private readonly sprites: { [id: string]: HTMLImageElement } = {}
24
25 private readonly boundedHijackMouseTooltip: typeof StoryboardPlugin.prototype.hijackMouseTooltip
26
27 private onReadyOrLoadstartHandler: (event: { type: 'ready' }) => void
28
29 constructor (player: videojs.Player, options: videojs.ComponentOptions & StoryboardOptions) {
30 super(player, options)
31
32 this.url = options.url
33 this.height = options.height
34 this.width = options.width
35 this.interval = options.interval
36
37 this.boundedHijackMouseTooltip = this.hijackMouseTooltip.bind(this)
38
39 this.init()
40
41 this.player.ready(() => {
42 player.addClass('vjs-storyboard')
43 })
44 }
45
46 init () {
47 const controls = this.player.controlBar as any
48
49 // default control bar component tree is expected
50 // https://docs.videojs.com/tutorial-components.html#default-component-tree
51 this.progress = controls?.progressControl
52 this.seekBar = this.progress?.seekBar
53
54 this.mouseTimeTooltip = this.seekBar?.mouseTimeDisplay?.timeTooltip
55
56 this.spritePlaceholder = videojs.dom.createEl('div', { className: 'vjs-storyboard-sprite-placeholder' }) as HTMLElement
57 this.seekBar?.el()?.appendChild(this.spritePlaceholder)
58
59 this.onReadyOrLoadstartHandler = event => {
60 if (event.type !== 'ready') {
61 const spriteSource = this.player.currentSources().find(source => {
62 return Object.prototype.hasOwnProperty.call(source, 'storyboard')
63 }) as any
64 const spriteOpts = spriteSource?.['storyboard'] as StoryboardOptions
65
66 if (spriteOpts) {
67 this.url = spriteOpts.url
68 this.height = spriteOpts.height
69 this.width = spriteOpts.width
70 this.interval = spriteOpts.interval
71 }
72 }
73
74 this.cached = !!this.sprites[this.url]
75
76 this.load()
77 }
78
79 this.player.on([ 'ready', 'loadstart' ], this.onReadyOrLoadstartHandler)
80 }
81
82 dispose () {
83 if (this.onReadyOrLoadstartHandler) this.player.off([ 'ready', 'loadstart' ], this.onReadyOrLoadstartHandler)
84 if (this.progress) this.progress.off([ 'mousemove', 'touchmove' ], this.boundedHijackMouseTooltip)
85
86 this.seekBar?.el()?.removeChild(this.spritePlaceholder)
87
88 super.dispose()
89 }
90
91 private load () {
92 const spriteEvents = [ 'mousemove', 'touchmove' ]
93
94 if (this.isReady()) {
95 if (!this.cached) {
96 this.sprites[this.url] = videojs.dom.createEl('img', {
97 src: this.url
98 })
99 }
100 this.progress.on(spriteEvents, this.boundedHijackMouseTooltip)
101 } else {
102 this.progress.off(spriteEvents, this.boundedHijackMouseTooltip)
103
104 this.resetMouseTooltip()
105 }
106 }
107
108 private hijackMouseTooltip (evt: Event) {
109 const sprite = this.sprites[this.url]
110 const imgWidth = sprite.naturalWidth
111 const imgHeight = sprite.naturalHeight
112 const seekBarEl = this.seekBar.el()
113
114 if (!sprite.complete || !imgWidth || !imgHeight) {
115 this.resetMouseTooltip()
116 return
117 }
118
119 this.player.requestNamedAnimationFrame('StoryBoardPlugin#hijackMouseTooltip', () => {
120 const seekBarRect = videojs.dom.getBoundingClientRect(seekBarEl)
121 const playerRect = videojs.dom.getBoundingClientRect(this.player.el())
122
123 if (!seekBarRect || !playerRect) return
124
125 const seekBarX = videojs.dom.getPointerPosition(seekBarEl, evt).x
126 let position = seekBarX * this.player.duration()
127
128 const maxPosition = Math.round((imgHeight / this.height) * (imgWidth / this.width)) - 1
129 position = Math.min(position / this.interval, maxPosition)
130
131 const responsive = 600
132 const playerWidth = this.player.currentWidth()
133 const scaleFactor = responsive && playerWidth < responsive
134 ? playerWidth / responsive
135 : 1
136 const columns = imgWidth / this.width
137
138 const scaledWidth = this.width * scaleFactor
139 const scaledHeight = this.height * scaleFactor
140 const cleft = Math.floor(position % columns) * -scaledWidth
141 const ctop = Math.floor(position / columns) * -scaledHeight
142
143 const bgSize = `${imgWidth * scaleFactor}px ${imgHeight * scaleFactor}px`
144 const topOffset = -scaledHeight - 60
145
146 const previewHalfSize = Math.round(scaledWidth / 2)
147 let left = seekBarRect.width * seekBarX - previewHalfSize
148
149 // Seek bar doesn't take all the player width, so we can add/minus a few more pixels
150 const minLeft = playerRect.left - seekBarRect.left
151 const maxLeft = seekBarRect.width - scaledWidth + (playerRect.right - seekBarRect.right)
152
153 if (left < minLeft) left = minLeft
154 if (left > maxLeft) left = maxLeft
155
156 const tooltipStyle: { [id: string]: string } = {
157 'background-image': `url("${this.url}")`,
158 'background-repeat': 'no-repeat',
159 'background-position': `${cleft}px ${ctop}px`,
160 'background-size': bgSize,
161
162 'color': '#fff',
163 'text-shadow': '1px 1px #000',
164
165 'position': 'relative',
166
167 'top': `${topOffset}px`,
168
169 'border': '1px solid #000',
170
171 // border should not overlay thumbnail area
172 'width': `${scaledWidth + 2}px`,
173 'height': `${scaledHeight + 2}px`
174 }
175
176 tooltipStyle.left = `${left}px`
177
178 for (const [ key, value ] of Object.entries(tooltipStyle)) {
179 this.spritePlaceholder.style.setProperty(key, value)
180 }
181 })
182 }
183
184 private resetMouseTooltip () {
185 if (this.spritePlaceholder) {
186 this.spritePlaceholder.style.cssText = ''
187 }
188 }
189
190 private isReady () {
191 return this.mouseTimeTooltip && this.width && this.height && this.url
192 }
193}
194
195videojs.registerPlugin('storyboard', StoryboardPlugin)
196
197export { StoryboardPlugin }
diff --git a/client/src/assets/player/shared/control-bar/theater-button.ts b/client/src/assets/player/shared/control-bar/theater-button.ts
index 56c349d6b..a5feb56ee 100644
--- a/client/src/assets/player/shared/control-bar/theater-button.ts
+++ b/client/src/assets/player/shared/control-bar/theater-button.ts
@@ -1,14 +1,19 @@
1import videojs from 'video.js' 1import videojs from 'video.js'
2import { getStoredTheater, saveTheaterInStore } from '../../peertube-player-local-storage' 2import { getStoredTheater, saveTheaterInStore } from '../../peertube-player-local-storage'
3import { TheaterButtonOptions } from '../../types'
3 4
4const Button = videojs.getComponent('Button') 5const Button = videojs.getComponent('Button')
5class TheaterButton extends Button { 6class TheaterButton extends Button {
6 7
7 private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled' 8 private static readonly THEATER_MODE_CLASS = 'vjs-theater-enabled'
8 9
9 constructor (player: videojs.Player, options: videojs.ComponentOptions) { 10 private theaterButtonOptions: TheaterButtonOptions
11
12 constructor (player: videojs.Player, options: TheaterButtonOptions & videojs.ComponentOptions) {
10 super(player, options) 13 super(player, options)
11 14
15 this.theaterButtonOptions = options
16
12 const enabled = getStoredTheater() 17 const enabled = getStoredTheater()
13 if (enabled === true) { 18 if (enabled === true) {
14 this.player().addClass(TheaterButton.THEATER_MODE_CLASS) 19 this.player().addClass(TheaterButton.THEATER_MODE_CLASS)
@@ -19,6 +24,9 @@ class TheaterButton extends Button {
19 this.controlText('Theater mode') 24 this.controlText('Theater mode')
20 25
21 this.player().theaterEnabled = enabled 26 this.player().theaterEnabled = enabled
27
28 this.updateShowing()
29 this.player().on('video-change', () => this.updateShowing())
22 } 30 }
23 31
24 buildCSSClass () { 32 buildCSSClass () {
@@ -36,7 +44,7 @@ class TheaterButton extends Button {
36 44
37 saveTheaterInStore(theaterEnabled) 45 saveTheaterInStore(theaterEnabled)
38 46
39 this.player_.trigger('theaterChange', theaterEnabled) 47 this.player_.trigger('theater-change', theaterEnabled)
40 } 48 }
41 49
42 handleClick () { 50 handleClick () {
@@ -48,6 +56,11 @@ class TheaterButton extends Button {
48 private isTheaterEnabled () { 56 private isTheaterEnabled () {
49 return this.player_.hasClass(TheaterButton.THEATER_MODE_CLASS) 57 return this.player_.hasClass(TheaterButton.THEATER_MODE_CLASS)
50 } 58 }
59
60 private updateShowing () {
61 if (this.theaterButtonOptions.isDisplayed()) this.show()
62 else this.hide()
63 }
51} 64}
52 65
53videojs.registerComponent('TheaterButton', TheaterButton) 66videojs.registerComponent('TheaterButton', TheaterButton)
diff --git a/client/src/assets/player/shared/dock/peertube-dock-component.ts b/client/src/assets/player/shared/dock/peertube-dock-component.ts
index 183c7a00f..c13ca647b 100644
--- a/client/src/assets/player/shared/dock/peertube-dock-component.ts
+++ b/client/src/assets/player/shared/dock/peertube-dock-component.ts
@@ -10,17 +10,20 @@ export type PeerTubeDockComponentOptions = {
10 10
11class PeerTubeDockComponent extends Component { 11class PeerTubeDockComponent extends Component {
12 12
13 createEl () { 13 options_: videojs.ComponentOptions & PeerTubeDockComponentOptions
14 const options = this.options_ as PeerTubeDockComponentOptions
15 14
16 const el = super.createEl('div', { 15 // eslint-disable-next-line @typescript-eslint/no-useless-constructor
17 className: 'peertube-dock' 16 constructor (player: videojs.Player, options: videojs.ComponentOptions & PeerTubeDockComponentOptions) {
18 }) 17 super(player, options)
18 }
19
20 createEl () {
21 const el = super.createEl('div', { className: 'peertube-dock' })
19 22
20 if (options.avatarUrl) { 23 if (this.options_.avatarUrl) {
21 const avatar = videojs.dom.createEl('img', { 24 const avatar = videojs.dom.createEl('img', {
22 className: 'peertube-dock-avatar', 25 className: 'peertube-dock-avatar',
23 src: options.avatarUrl 26 src: this.options_.avatarUrl
24 }) 27 })
25 28
26 el.appendChild(avatar) 29 el.appendChild(avatar)
@@ -30,27 +33,27 @@ class PeerTubeDockComponent extends Component {
30 className: 'peertube-dock-title-description' 33 className: 'peertube-dock-title-description'
31 }) 34 })
32 35
33 if (options.title) { 36 if (this.options_.title) {
34 const title = videojs.dom.createEl('div', { 37 const title = videojs.dom.createEl('div', {
35 className: 'peertube-dock-title', 38 className: 'peertube-dock-title',
36 title: options.title, 39 title: this.options_.title,
37 innerHTML: options.title 40 innerHTML: this.options_.title
38 }) 41 })
39 42
40 elWrapperTitleDescription.appendChild(title) 43 elWrapperTitleDescription.appendChild(title)
41 } 44 }
42 45
43 if (options.description) { 46 if (this.options_.description) {
44 const description = videojs.dom.createEl('div', { 47 const description = videojs.dom.createEl('div', {
45 className: 'peertube-dock-description', 48 className: 'peertube-dock-description',
46 title: options.description, 49 title: this.options_.description,
47 innerHTML: options.description 50 innerHTML: this.options_.description
48 }) 51 })
49 52
50 elWrapperTitleDescription.appendChild(description) 53 elWrapperTitleDescription.appendChild(description)
51 } 54 }
52 55
53 if (options.title || options.description) { 56 if (this.options_.title || this.options_.description) {
54 el.appendChild(elWrapperTitleDescription) 57 el.appendChild(elWrapperTitleDescription)
55 } 58 }
56 59
diff --git a/client/src/assets/player/shared/dock/peertube-dock-plugin.ts b/client/src/assets/player/shared/dock/peertube-dock-plugin.ts
index 245981692..fc71a8c4b 100644
--- a/client/src/assets/player/shared/dock/peertube-dock-plugin.ts
+++ b/client/src/assets/player/shared/dock/peertube-dock-plugin.ts
@@ -10,14 +10,25 @@ export type PeerTubeDockPluginOptions = {
10} 10}
11 11
12class PeerTubeDockPlugin extends Plugin { 12class PeerTubeDockPlugin extends Plugin {
13 private dockComponent: PeerTubeDockComponent
14
13 constructor (player: videojs.Player, options: videojs.PlayerOptions & PeerTubeDockPluginOptions) { 15 constructor (player: videojs.Player, options: videojs.PlayerOptions & PeerTubeDockPluginOptions) {
14 super(player, options) 16 super(player, options)
15 17
16 this.player.addClass('peertube-dock') 18 player.ready(() => {
17 19 player.addClass('peertube-dock')
18 this.player.ready(() => {
19 this.player.addChild('PeerTubeDockComponent', options) as PeerTubeDockComponent
20 }) 20 })
21
22 this.dockComponent = new PeerTubeDockComponent(player, options)
23 player.addChild(this.dockComponent)
24 }
25
26 dispose () {
27 this.dockComponent?.dispose()
28 this.player.removeChild(this.dockComponent)
29 this.player.removeClass('peertube-dock')
30
31 super.dispose()
21 } 32 }
22} 33}
23 34
diff --git a/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
index 2742b21a1..e77b7dc6d 100644
--- a/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
+++ b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
@@ -31,6 +31,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
31 31
32 dispose () { 32 dispose () {
33 document.removeEventListener('keydown', this.handleKeyFunction) 33 document.removeEventListener('keydown', this.handleKeyFunction)
34
35 super.dispose()
34 } 36 }
35 37
36 private onKeyDown (event: KeyboardEvent) { 38 private onKeyDown (event: KeyboardEvent) {
diff --git a/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts b/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts
deleted file mode 100644
index 26f923e92..000000000
--- a/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts
+++ /dev/null
@@ -1,155 +0,0 @@
1import {
2 CommonOptions,
3 NextPreviousVideoButtonOptions,
4 PeerTubeLinkButtonOptions,
5 PeertubePlayerManagerOptions,
6 PlayerMode
7} from '../../types'
8
9export class ControlBarOptionsBuilder {
10 private options: CommonOptions
11
12 constructor (
13 globalOptions: PeertubePlayerManagerOptions,
14 private mode: PlayerMode
15 ) {
16 this.options = globalOptions.common
17 }
18
19 getChildrenOptions () {
20 const children = {}
21
22 if (this.options.previousVideo) {
23 Object.assign(children, this.getPreviousVideo())
24 }
25
26 Object.assign(children, { playToggle: {} })
27
28 if (this.options.nextVideo) {
29 Object.assign(children, this.getNextVideo())
30 }
31
32 Object.assign(children, {
33 ...this.getTimeControls(),
34
35 flexibleWidthSpacer: {},
36
37 ...this.getProgressControl(),
38
39 p2PInfoButton: {
40 p2pEnabled: this.options.p2pEnabled
41 },
42
43 muteToggle: {},
44 volumeControl: {},
45
46 ...this.getSettingsButton()
47 })
48
49 if (this.options.peertubeLink === true) {
50 Object.assign(children, {
51 peerTubeLinkButton: {
52 shortUUID: this.options.videoShortUUID,
53 instanceName: this.options.instanceName
54 } as PeerTubeLinkButtonOptions
55 })
56 }
57
58 if (this.options.theaterButton === true) {
59 Object.assign(children, {
60 theaterButton: {}
61 })
62 }
63
64 Object.assign(children, {
65 fullscreenToggle: {}
66 })
67
68 return children
69 }
70
71 private getSettingsButton () {
72 const settingEntries: string[] = []
73
74 if (!this.options.isLive) {
75 settingEntries.push('playbackRateMenuButton')
76 }
77
78 if (this.options.captions === true) settingEntries.push('captionsButton')
79
80 settingEntries.push('resolutionMenuButton')
81
82 return {
83 settingsButton: {
84 setup: {
85 maxHeightOffset: 40
86 },
87 entries: settingEntries
88 }
89 }
90 }
91
92 private getTimeControls () {
93 if (this.options.isLive) {
94 return {
95 peerTubeLiveDisplay: {}
96 }
97 }
98
99 return {
100 currentTimeDisplay: {},
101 timeDivider: {},
102 durationDisplay: {}
103 }
104 }
105
106 private getProgressControl () {
107 if (this.options.isLive) return {}
108
109 const loadProgressBar = this.mode === 'webtorrent'
110 ? 'peerTubeLoadProgressBar'
111 : 'loadProgressBar'
112
113 return {
114 progressControl: {
115 children: {
116 seekBar: {
117 children: {
118 [loadProgressBar]: {},
119 mouseTimeDisplay: {},
120 playProgressBar: {}
121 }
122 }
123 }
124 }
125 }
126 }
127
128 private getPreviousVideo () {
129 const buttonOptions: NextPreviousVideoButtonOptions = {
130 type: 'previous',
131 handler: this.options.previousVideo,
132 isDisabled: () => {
133 if (!this.options.hasPreviousVideo) return false
134
135 return !this.options.hasPreviousVideo()
136 }
137 }
138
139 return { previousVideoButton: buttonOptions }
140 }
141
142 private getNextVideo () {
143 const buttonOptions: NextPreviousVideoButtonOptions = {
144 type: 'next',
145 handler: this.options.nextVideo,
146 isDisabled: () => {
147 if (!this.options.hasNextVideo) return false
148
149 return !this.options.hasNextVideo()
150 }
151 }
152
153 return { nextVideoButton: buttonOptions }
154 }
155}
diff --git a/client/src/assets/player/shared/manager-options/index.ts b/client/src/assets/player/shared/manager-options/index.ts
deleted file mode 100644
index 4934d8302..000000000
--- a/client/src/assets/player/shared/manager-options/index.ts
+++ /dev/null
@@ -1 +0,0 @@
1export * from './manager-options-builder'
diff --git a/client/src/assets/player/shared/manager-options/manager-options-builder.ts b/client/src/assets/player/shared/manager-options/manager-options-builder.ts
deleted file mode 100644
index 5d3ee4c4a..000000000
--- a/client/src/assets/player/shared/manager-options/manager-options-builder.ts
+++ /dev/null
@@ -1,186 +0,0 @@
1import videojs from 'video.js'
2import { copyToClipboard } from '@root-helpers/utils'
3import { buildVideoOrPlaylistEmbed } from '@root-helpers/video'
4import { isIOS, isSafari } from '@root-helpers/web-browser'
5import { buildVideoLink, decorateVideoLink, pick } from '@shared/core-utils'
6import { isDefaultLocale } from '@shared/core-utils/i18n'
7import { VideoJSPluginOptions } from '../../types'
8import { CommonOptions, PeertubePlayerManagerOptions, PlayerMode } from '../../types/manager-options'
9import { ControlBarOptionsBuilder } from './control-bar-options-builder'
10import { HLSOptionsBuilder } from './hls-options-builder'
11import { WebTorrentOptionsBuilder } from './webtorrent-options-builder'
12
13export class ManagerOptionsBuilder {
14
15 constructor (
16 private mode: PlayerMode,
17 private options: PeertubePlayerManagerOptions,
18 private p2pMediaLoaderModule?: any
19 ) {
20
21 }
22
23 async getVideojsOptions (alreadyPlayed: boolean): Promise<videojs.PlayerOptions> {
24 const commonOptions = this.options.common
25
26 let autoplay = this.getAutoPlayValue(commonOptions.autoplay, alreadyPlayed)
27 const html5 = {
28 preloadTextTracks: false
29 }
30
31 const plugins: VideoJSPluginOptions = {
32 peertube: {
33 mode: this.mode,
34 autoplay, // Use peertube plugin autoplay because we could get the file by webtorrent
35
36 ...pick(commonOptions, [
37 'videoViewUrl',
38 'videoViewIntervalMs',
39 'authorizationHeader',
40 'startTime',
41 'videoDuration',
42 'subtitle',
43 'videoCaptions',
44 'stopTime',
45 'isLive',
46 'videoUUID'
47 ])
48 },
49 metrics: {
50 mode: this.mode,
51
52 ...pick(commonOptions, [
53 'metricsUrl',
54 'videoUUID'
55 ])
56 }
57 }
58
59 if (commonOptions.playlist) {
60 plugins.playlist = commonOptions.playlist
61 }
62
63 if (this.mode === 'p2p-media-loader') {
64 const hlsOptionsBuilder = new HLSOptionsBuilder(this.options, this.p2pMediaLoaderModule)
65 const options = await hlsOptionsBuilder.getPluginOptions()
66
67 Object.assign(plugins, pick(options, [ 'hlsjs', 'p2pMediaLoader' ]))
68 Object.assign(html5, options.html5)
69 } else if (this.mode === 'webtorrent') {
70 const webtorrentOptionsBuilder = new WebTorrentOptionsBuilder(this.options, this.getAutoPlayValue(autoplay, alreadyPlayed))
71
72 Object.assign(plugins, webtorrentOptionsBuilder.getPluginOptions())
73
74 // WebTorrent plugin handles autoplay, because we do some hackish stuff in there
75 autoplay = false
76 }
77
78 const controlBarOptionsBuilder = new ControlBarOptionsBuilder(this.options, this.mode)
79
80 const videojsOptions = {
81 html5,
82
83 // We don't use text track settings for now
84 textTrackSettings: false as any, // FIXME: typings
85 controls: commonOptions.controls !== undefined ? commonOptions.controls : true,
86 loop: commonOptions.loop !== undefined ? commonOptions.loop : false,
87
88 muted: commonOptions.muted !== undefined
89 ? commonOptions.muted
90 : undefined, // Undefined so the player knows it has to check the local storage
91
92 autoplay: this.getAutoPlayValue(autoplay, alreadyPlayed),
93
94 poster: commonOptions.poster,
95 inactivityTimeout: commonOptions.inactivityTimeout,
96 playbackRates: [ 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2 ],
97
98 plugins,
99
100 controlBar: {
101 children: controlBarOptionsBuilder.getChildrenOptions() as any // FIXME: typings
102 }
103 }
104
105 if (commonOptions.language && !isDefaultLocale(commonOptions.language)) {
106 Object.assign(videojsOptions, { language: commonOptions.language })
107 }
108
109 return videojsOptions
110 }
111
112 private getAutoPlayValue (autoplay: videojs.Autoplay, alreadyPlayed: boolean) {
113 if (autoplay !== true) return autoplay
114
115 // On first play, disable autoplay to avoid issues
116 // But if the player already played videos, we can safely autoplay next ones
117 if (isIOS() || isSafari()) {
118 return alreadyPlayed ? 'play' : false
119 }
120
121 return this.options.common.forceAutoplay
122 ? 'any'
123 : 'play'
124 }
125
126 getContextMenuOptions (player: videojs.Player, commonOptions: CommonOptions) {
127 const content = () => {
128 const isLoopEnabled = player.options_['loop']
129
130 const items = [
131 {
132 icon: 'repeat',
133 label: player.localize('Play in loop') + (isLoopEnabled ? '<span class="vjs-icon-tick-white"></span>' : ''),
134 listener: function () {
135 player.options_['loop'] = !isLoopEnabled
136 }
137 },
138 {
139 label: player.localize('Copy the video URL'),
140 listener: function () {
141 copyToClipboard(buildVideoLink({ shortUUID: commonOptions.videoShortUUID }))
142 }
143 },
144 {
145 label: player.localize('Copy the video URL at the current time'),
146 listener: function (this: videojs.Player) {
147 const url = buildVideoLink({ shortUUID: commonOptions.videoShortUUID })
148
149 copyToClipboard(decorateVideoLink({ url, startTime: this.currentTime() }))
150 }
151 },
152 {
153 icon: 'code',
154 label: player.localize('Copy embed code'),
155 listener: () => {
156 copyToClipboard(buildVideoOrPlaylistEmbed({ embedUrl: commonOptions.embedUrl, embedTitle: commonOptions.embedTitle }))
157 }
158 }
159 ]
160
161 if (this.mode === 'webtorrent') {
162 items.push({
163 label: player.localize('Copy magnet URI'),
164 listener: function (this: videojs.Player) {
165 copyToClipboard(this.webtorrent().getCurrentVideoFile().magnetUri)
166 }
167 })
168 }
169
170 items.push({
171 icon: 'info',
172 label: player.localize('Stats for nerds'),
173 listener: () => {
174 player.stats().show()
175 }
176 })
177
178 return items.map(i => ({
179 ...i,
180 label: `<span class="vjs-icon-${i.icon || 'link-2'}"></span>` + i.label
181 }))
182 }
183
184 return { content }
185 }
186}
diff --git a/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts b/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts
deleted file mode 100644
index b5bdcd4e6..000000000
--- a/client/src/assets/player/shared/manager-options/webtorrent-options-builder.ts
+++ /dev/null
@@ -1,47 +0,0 @@
1import { addQueryParams } from '../../../../../../shared/core-utils'
2import { PeertubePlayerManagerOptions, WebtorrentPluginOptions } from '../../types'
3
4export class WebTorrentOptionsBuilder {
5
6 constructor (
7 private options: PeertubePlayerManagerOptions,
8 private autoPlayValue: any
9 ) {
10
11 }
12
13 getPluginOptions () {
14 const commonOptions = this.options.common
15 const webtorrentOptions = this.options.webtorrent
16 const p2pMediaLoaderOptions = this.options.p2pMediaLoader
17
18 const autoplay = this.autoPlayValue === 'play'
19
20 const webtorrent: WebtorrentPluginOptions = {
21 autoplay,
22
23 playerRefusedP2P: commonOptions.p2pEnabled === false,
24 videoDuration: commonOptions.videoDuration,
25 playerElement: commonOptions.playerElement,
26
27 videoFileToken: commonOptions.videoFileToken,
28
29 requiresAuth: commonOptions.requiresAuth,
30
31 buildWebSeedUrls: file => {
32 if (!commonOptions.requiresAuth) return []
33
34 return [ addQueryParams(file.fileUrl, { videoFileToken: commonOptions.videoFileToken() }) ]
35 },
36
37 videoFiles: webtorrentOptions.videoFiles.length !== 0
38 ? webtorrentOptions.videoFiles
39 // The WebTorrent plugin won't be able to play these files, but it will fallback to HTTP mode
40 : p2pMediaLoaderOptions?.videoFiles || [],
41
42 startTime: commonOptions.startTime
43 }
44
45 return { webtorrent }
46 }
47}
diff --git a/client/src/assets/player/shared/metrics/metrics-plugin.ts b/client/src/assets/player/shared/metrics/metrics-plugin.ts
index 2aae3e90a..48363a724 100644
--- a/client/src/assets/player/shared/metrics/metrics-plugin.ts
+++ b/client/src/assets/player/shared/metrics/metrics-plugin.ts
@@ -1,14 +1,15 @@
1import debug from 'debug'
1import videojs from 'video.js' 2import videojs from 'video.js'
2import { PlaybackMetricCreate } from '../../../../../../shared/models'
3import { MetricsPluginOptions, PlayerMode, PlayerNetworkInfo } from '../../types'
4import { logger } from '@root-helpers/logger' 3import { logger } from '@root-helpers/logger'
4import { PlaybackMetricCreate } from '../../../../../../shared/models'
5import { MetricsPluginOptions, PlayerNetworkInfo } from '../../types'
6
7const debugLogger = debug('peertube:player:metrics')
5 8
6const Plugin = videojs.getPlugin('plugin') 9const Plugin = videojs.getPlugin('plugin')
7 10
8class MetricsPlugin extends Plugin { 11class MetricsPlugin extends Plugin {
9 private readonly metricsUrl: string 12 options_: MetricsPluginOptions
10 private readonly videoUUID: string
11 private readonly mode: PlayerMode
12 13
13 private downloadedBytesP2P = 0 14 private downloadedBytesP2P = 0
14 private downloadedBytesHTTP = 0 15 private downloadedBytesHTTP = 0
@@ -28,29 +29,54 @@ class MetricsPlugin extends Plugin {
28 constructor (player: videojs.Player, options: MetricsPluginOptions) { 29 constructor (player: videojs.Player, options: MetricsPluginOptions) {
29 super(player) 30 super(player)
30 31
31 this.metricsUrl = options.metricsUrl 32 this.options_ = options
32 this.videoUUID = options.videoUUID
33 this.mode = options.mode
34 33
35 this.player.one('play', () => { 34 this.trackBytes()
36 this.runMetricsInterval() 35 this.trackResolutionChange()
36 this.trackErrors()
37 37
38 this.trackBytes() 38 this.one('play', () => {
39 this.trackResolutionChange() 39 this.player.on('video-change', () => {
40 this.trackErrors() 40 this.runMetricsIntervalOnPlay()
41 })
41 }) 42 })
43
44 this.runMetricsIntervalOnPlay()
42 } 45 }
43 46
44 dispose () { 47 dispose () {
45 if (this.metricsInterval) clearInterval(this.metricsInterval) 48 if (this.metricsInterval) clearInterval(this.metricsInterval)
49
50 super.dispose()
51 }
52
53 private runMetricsIntervalOnPlay () {
54 this.downloadedBytesP2P = 0
55 this.downloadedBytesHTTP = 0
56 this.uploadedBytesP2P = 0
57
58 this.resolutionChanges = 0
59 this.errors = 0
60
61 this.lastPlayerNetworkInfo = undefined
62
63 debugLogger('Will track metrics on next play')
64
65 this.player.one('play', () => {
66 debugLogger('Tracking metrics')
67
68 this.runMetricsInterval()
69 })
46 } 70 }
47 71
48 private runMetricsInterval () { 72 private runMetricsInterval () {
73 if (this.metricsInterval) clearInterval(this.metricsInterval)
74
49 this.metricsInterval = setInterval(() => { 75 this.metricsInterval = setInterval(() => {
50 let resolution: number 76 let resolution: number
51 let fps: number 77 let fps: number
52 78
53 if (this.mode === 'p2p-media-loader') { 79 if (this.player.usingPlugin('p2pMediaLoader')) {
54 const level = this.player.p2pMediaLoader().getCurrentLevel() 80 const level = this.player.p2pMediaLoader().getCurrentLevel()
55 if (!level) return 81 if (!level) return
56 82
@@ -60,21 +86,23 @@ class MetricsPlugin extends Plugin {
60 fps = framerate 86 fps = framerate
61 ? parseInt(framerate, 10) 87 ? parseInt(framerate, 10)
62 : undefined 88 : undefined
63 } else { // webtorrent 89 } else if (this.player.usingPlugin('webVideo')) {
64 const videoFile = this.player.webtorrent().getCurrentVideoFile() 90 const videoFile = this.player.webVideo().getCurrentVideoFile()
65 if (!videoFile) return 91 if (!videoFile) return
66 92
67 resolution = videoFile.resolution.id 93 resolution = videoFile.resolution.id
68 fps = videoFile.fps && videoFile.fps !== -1 94 fps = videoFile.fps && videoFile.fps !== -1
69 ? videoFile.fps 95 ? videoFile.fps
70 : undefined 96 : undefined
97 } else {
98 return
71 } 99 }
72 100
73 const body: PlaybackMetricCreate = { 101 const body: PlaybackMetricCreate = {
74 resolution, 102 resolution,
75 fps, 103 fps,
76 104
77 playerMode: this.mode, 105 playerMode: this.options_.mode(),
78 106
79 resolutionChanges: this.resolutionChanges, 107 resolutionChanges: this.resolutionChanges,
80 108
@@ -85,7 +113,7 @@ class MetricsPlugin extends Plugin {
85 113
86 uploadedBytesP2P: this.uploadedBytesP2P, 114 uploadedBytesP2P: this.uploadedBytesP2P,
87 115
88 videoId: this.videoUUID 116 videoId: this.options_.videoUUID()
89 } 117 }
90 118
91 this.resolutionChanges = 0 119 this.resolutionChanges = 0
@@ -99,15 +127,13 @@ class MetricsPlugin extends Plugin {
99 127
100 const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' }) 128 const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' })
101 129
102 return fetch(this.metricsUrl, { method: 'POST', body: JSON.stringify(body), headers }) 130 return fetch(this.options_.metricsUrl(), { method: 'POST', body: JSON.stringify(body), headers })
103 .catch(err => logger.error('Cannot send metrics to the server.', err)) 131 .catch(err => logger.error('Cannot send metrics to the server.', err))
104 }, this.CONSTANTS.METRICS_INTERVAL) 132 }, this.CONSTANTS.METRICS_INTERVAL)
105 } 133 }
106 134
107 private trackBytes () { 135 private trackBytes () {
108 this.player.on('p2pInfo', (_event, data: PlayerNetworkInfo) => { 136 this.player.on('p2p-info', (_event, data: PlayerNetworkInfo) => {
109 if (!data) return
110
111 this.downloadedBytesHTTP += data.http.downloaded - (this.lastPlayerNetworkInfo?.http.downloaded || 0) 137 this.downloadedBytesHTTP += data.http.downloaded - (this.lastPlayerNetworkInfo?.http.downloaded || 0)
112 this.downloadedBytesP2P += data.p2p.downloaded - (this.lastPlayerNetworkInfo?.p2p.downloaded || 0) 138 this.downloadedBytesP2P += data.p2p.downloaded - (this.lastPlayerNetworkInfo?.p2p.downloaded || 0)
113 139
@@ -115,10 +141,18 @@ class MetricsPlugin extends Plugin {
115 141
116 this.lastPlayerNetworkInfo = data 142 this.lastPlayerNetworkInfo = data
117 }) 143 })
144
145 this.player.on('http-info', (_event, data: PlayerNetworkInfo) => {
146 this.downloadedBytesHTTP += data.http.downloaded - (this.lastPlayerNetworkInfo?.http.downloaded || 0)
147 })
118 } 148 }
119 149
120 private trackResolutionChange () { 150 private trackResolutionChange () {
121 this.player.on('engineResolutionChange', () => { 151 this.player.on('engine-resolution-change', () => {
152 this.resolutionChanges++
153 })
154
155 this.player.on('user-resolution-change', () => {
122 this.resolutionChanges++ 156 this.resolutionChanges++
123 }) 157 })
124 } 158 }
diff --git a/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts b/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts
index 09cb98f2e..1bc3ca38d 100644
--- a/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts
+++ b/client/src/assets/player/shared/mobile/peertube-mobile-buttons.ts
@@ -2,22 +2,20 @@ import videojs from 'video.js'
2 2
3const Component = videojs.getComponent('Component') 3const Component = videojs.getComponent('Component')
4class PeerTubeMobileButtons extends Component { 4class PeerTubeMobileButtons extends Component {
5 private mainButton: HTMLDivElement
5 6
6 private rewind: Element 7 private rewind: Element
7 private forward: Element 8 private forward: Element
8 private rewindText: Element 9 private rewindText: Element
9 private forwardText: Element 10 private forwardText: Element
10 11
11 createEl () { 12 private touchStartHandler: (e: TouchEvent) => void
12 const container = super.createEl('div', {
13 className: 'vjs-mobile-buttons-overlay'
14 }) as HTMLDivElement
15 13
16 const mainButton = super.createEl('div', { 14 createEl () {
17 className: 'main-button' 15 const container = super.createEl('div', { className: 'vjs-mobile-buttons-overlay' }) as HTMLDivElement
18 }) as HTMLDivElement 16 this.mainButton = super.createEl('div', { className: 'main-button' }) as HTMLDivElement
19 17
20 mainButton.addEventListener('touchstart', e => { 18 this.touchStartHandler = e => {
21 e.stopPropagation() 19 e.stopPropagation()
22 20
23 if (this.player_.paused() || this.player_.ended()) { 21 if (this.player_.paused() || this.player_.ended()) {
@@ -26,7 +24,9 @@ class PeerTubeMobileButtons extends Component {
26 } 24 }
27 25
28 this.player_.pause() 26 this.player_.pause()
29 }) 27 }
28
29 this.mainButton.addEventListener('touchstart', this.touchStartHandler, { passive: true })
30 30
31 this.rewind = super.createEl('div', { className: 'rewind-button vjs-hidden' }) 31 this.rewind = super.createEl('div', { className: 'rewind-button vjs-hidden' })
32 this.forward = super.createEl('div', { className: 'forward-button vjs-hidden' }) 32 this.forward = super.createEl('div', { className: 'forward-button vjs-hidden' })
@@ -40,12 +40,18 @@ class PeerTubeMobileButtons extends Component {
40 this.forwardText = this.forward.appendChild(super.createEl('div', { className: 'text' })) 40 this.forwardText = this.forward.appendChild(super.createEl('div', { className: 'text' }))
41 41
42 container.appendChild(this.rewind) 42 container.appendChild(this.rewind)
43 container.appendChild(mainButton) 43 container.appendChild(this.mainButton)
44 container.appendChild(this.forward) 44 container.appendChild(this.forward)
45 45
46 return container 46 return container
47 } 47 }
48 48
49 dispose () {
50 if (this.touchStartHandler) this.mainButton.removeEventListener('touchstart', this.touchStartHandler)
51
52 super.dispose()
53 }
54
49 displayFastSeek (amount: number) { 55 displayFastSeek (amount: number) {
50 if (amount === 0) { 56 if (amount === 0) {
51 this.hideRewind() 57 this.hideRewind()
diff --git a/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts b/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts
index 646e9f8c6..f31fa7ddb 100644
--- a/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts
+++ b/client/src/assets/player/shared/mobile/peertube-mobile-plugin.ts
@@ -21,6 +21,15 @@ class PeerTubeMobilePlugin extends Plugin {
21 21
22 private setCurrentTimeTimeout: ReturnType<typeof setTimeout> 22 private setCurrentTimeTimeout: ReturnType<typeof setTimeout>
23 23
24 private onPlayHandler: () => void
25 private onFullScreenChangeHandler: () => void
26 private onTouchStartHandler: (event: TouchEvent) => void
27 private onMobileButtonTouchStartHandler: (event: TouchEvent) => void
28 private sliderActiveHandler: () => void
29 private sliderInactiveHandler: () => void
30
31 private seekBar: videojs.Component
32
24 constructor (player: videojs.Player, options: videojs.PlayerOptions) { 33 constructor (player: videojs.Player, options: videojs.PlayerOptions) {
25 super(player, options) 34 super(player, options)
26 35
@@ -36,18 +45,38 @@ class PeerTubeMobilePlugin extends Plugin {
36 (this.player.options_.userActions as any).click = false 45 (this.player.options_.userActions as any).click = false
37 this.player.options_.userActions.doubleClick = false 46 this.player.options_.userActions.doubleClick = false
38 47
39 this.player.one('play', () => { 48 this.onPlayHandler = () => this.initTouchStartEvents()
40 this.initTouchStartEvents() 49 this.player.one('play', this.onPlayHandler)
41 }) 50
51 this.seekBar = this.player.getDescendant([ 'controlBar', 'progressControl', 'seekBar' ])
52
53 this.sliderActiveHandler = () => this.player.addClass('vjs-mobile-sliding')
54 this.sliderInactiveHandler = () => this.player.removeClass('vjs-mobile-sliding')
55
56 this.seekBar.on('slideractive', this.sliderActiveHandler)
57 this.seekBar.on('sliderinactive', this.sliderInactiveHandler)
58 }
59
60 dispose () {
61 if (this.onPlayHandler) this.player.off('play', this.onPlayHandler)
62 if (this.onFullScreenChangeHandler) this.player.off('fullscreenchange', this.onFullScreenChangeHandler)
63 if (this.onTouchStartHandler) this.player.off('touchstart', this.onFullScreenChangeHandler)
64 if (this.onMobileButtonTouchStartHandler) {
65 this.peerTubeMobileButtons?.el().removeEventListener('touchstart', this.onMobileButtonTouchStartHandler)
66 }
67
68 super.dispose()
42 } 69 }
43 70
44 private handleFullscreenRotation () { 71 private handleFullscreenRotation () {
45 this.player.on('fullscreenchange', () => { 72 this.onFullScreenChangeHandler = () => {
46 if (!this.player.isFullscreen() || this.isPortraitVideo()) return 73 if (!this.player.isFullscreen() || this.isPortraitVideo()) return
47 74
48 screen.orientation.lock('landscape') 75 screen.orientation.lock('landscape')
49 .catch(err => logger.error('Cannot lock screen to landscape.', err)) 76 .catch(err => logger.error('Cannot lock screen to landscape.', err))
50 }) 77 }
78
79 this.player.on('fullscreenchange', this.onFullScreenChangeHandler)
51 } 80 }
52 81
53 private isPortraitVideo () { 82 private isPortraitVideo () {
@@ -80,19 +109,22 @@ class PeerTubeMobilePlugin extends Plugin {
80 this.lastTapEvent = event 109 this.lastTapEvent = event
81 } 110 }
82 111
83 this.player.on('touchstart', (event: TouchEvent) => { 112 this.onTouchStartHandler = event => {
84 // Only enable user active on player touch, we listen event on peertube mobile buttons to disable it 113 // Only enable user active on player touch, we listen event on peertube mobile buttons to disable it
85 if (this.player.userActive()) return 114 if (this.player.userActive()) return
86 115
87 handleTouchStart(event) 116 handleTouchStart(event)
88 }) 117 }
118 this.player.on('touchstart', this.onTouchStartHandler)
89 119
90 this.peerTubeMobileButtons.el().addEventListener('touchstart', (event: TouchEvent) => { 120 this.onMobileButtonTouchStartHandler = event => {
91 // Prevent mousemove/click events firing on the player, that conflict with our user active logic 121 // Prevent mousemove/click events firing on the player, that conflict with our user active logic
92 event.preventDefault() 122 event.preventDefault()
93 123
94 handleTouchStart(event) 124 handleTouchStart(event)
95 }, { passive: false }) 125 }
126
127 this.peerTubeMobileButtons.el().addEventListener('touchstart', this.onMobileButtonTouchStartHandler, { passive: false })
96 } 128 }
97 129
98 private onDoubleTap (event: TouchEvent) { 130 private onDoubleTap (event: TouchEvent) {
diff --git a/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts
index d05d6193c..d83ec625a 100644
--- a/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts
+++ b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts
@@ -14,6 +14,10 @@ type Metadata = {
14 levels: Level[] 14 levels: Level[]
15} 15}
16 16
17// ---------------------------------------------------------------------------
18// Source handler registration
19// ---------------------------------------------------------------------------
20
17type HookFn = (player: videojs.Player, hljs: Hlsjs) => void 21type HookFn = (player: videojs.Player, hljs: Hlsjs) => void
18 22
19const registerSourceHandler = function (vjs: typeof videojs) { 23const registerSourceHandler = function (vjs: typeof videojs) {
@@ -25,10 +29,13 @@ const registerSourceHandler = function (vjs: typeof videojs) {
25 const html5 = vjs.getTech('Html5') 29 const html5 = vjs.getTech('Html5')
26 30
27 if (!html5) { 31 if (!html5) {
28 logger.error('No Hml5 tech found in videojs') 32 logger.error('No "Html5" tech found in videojs')
29 return 33 return
30 } 34 }
31 35
36 // Already registered
37 if ((html5 as any).canPlaySource({ type: 'application/x-mpegURL' })) return
38
32 // FIXME: typings 39 // FIXME: typings
33 (html5 as any).registerSourceHandler({ 40 (html5 as any).registerSourceHandler({
34 canHandleSource: function (source: videojs.Tech.SourceObject) { 41 canHandleSource: function (source: videojs.Tech.SourceObject) {
@@ -56,32 +63,55 @@ const registerSourceHandler = function (vjs: typeof videojs) {
56 (vjs as any).Html5Hlsjs = Html5Hlsjs 63 (vjs as any).Html5Hlsjs = Html5Hlsjs
57} 64}
58 65
59function hlsjsConfigHandler (this: videojs.Player, options: HlsjsConfigHandlerOptions) { 66// ---------------------------------------------------------------------------
60 const player = this 67// HLS options plugin
68// ---------------------------------------------------------------------------
61 69
62 if (!options) return 70const Plugin = videojs.getPlugin('plugin')
63 71
64 if (!player.srOptions_) { 72class HLSJSConfigHandler extends Plugin {
65 player.srOptions_ = {} 73
66 } 74 constructor (player: videojs.Player, options: HlsjsConfigHandlerOptions) {
75 super(player, options)
76
77 if (!options) return
78
79 if (!player.srOptions_) {
80 player.srOptions_ = {}
81 }
82
83 if (!player.srOptions_.hlsjsConfig) {
84 player.srOptions_.hlsjsConfig = options.hlsjsConfig
85 }
67 86
68 if (!player.srOptions_.hlsjsConfig) { 87 if (options.levelLabelHandler && !player.srOptions_.levelLabelHandler) {
69 player.srOptions_.hlsjsConfig = options.hlsjsConfig 88 player.srOptions_.levelLabelHandler = options.levelLabelHandler
89 }
90
91 registerSourceHandler(videojs)
70 } 92 }
71 93
72 if (options.levelLabelHandler && !player.srOptions_.levelLabelHandler) { 94 dispose () {
73 player.srOptions_.levelLabelHandler = options.levelLabelHandler 95 this.player.srOptions_ = undefined
96
97 const tech = this.player.tech(true) as any
98 if (tech.hlsProvider) {
99 tech.hlsProvider.dispose()
100 tech.hlsProvider = undefined
101 }
102
103 super.dispose()
74 } 104 }
75} 105}
76 106
77const registerConfigPlugin = function (vjs: typeof videojs) { 107videojs.registerPlugin('hlsjs', HLSJSConfigHandler)
78 // Used in Brightcove since we don't pass options directly there 108
79 const registerVjsPlugin = vjs.registerPlugin || vjs.plugin 109// ---------------------------------------------------------------------------
80 registerVjsPlugin('hlsjs', hlsjsConfigHandler) 110// HLS JS source handler
81} 111// ---------------------------------------------------------------------------
82 112
83class Html5Hlsjs { 113export class Html5Hlsjs {
84 private static readonly hooks: { [id: string]: HookFn[] } = {} 114 private static hooks: { [id: string]: HookFn[] } = {}
85 115
86 private readonly videoElement: HTMLVideoElement 116 private readonly videoElement: HTMLVideoElement
87 private readonly errorCounts: ErrorCounts = {} 117 private readonly errorCounts: ErrorCounts = {}
@@ -101,8 +131,9 @@ class Html5Hlsjs {
101 private dvrDuration: number = null 131 private dvrDuration: number = null
102 private edgeMargin: number = null 132 private edgeMargin: number = null
103 133
104 private handlers: { [ id in 'play' ]: EventListener } = { 134 private handlers: { [ id in 'play' | 'error' ]: EventListener } = {
105 play: null 135 play: null,
136 error: null
106 } 137 }
107 138
108 constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) { 139 constructor (vjs: typeof videojs, source: videojs.Tech.SourceObject, tech: videojs.Tech) {
@@ -115,7 +146,7 @@ class Html5Hlsjs {
115 this.videoElement = tech.el() as HTMLVideoElement 146 this.videoElement = tech.el() as HTMLVideoElement
116 this.player = vjs((tech.options_ as any).playerId) 147 this.player = vjs((tech.options_ as any).playerId)
117 148
118 this.videoElement.addEventListener('error', event => { 149 this.handlers.error = event => {
119 let errorTxt: string 150 let errorTxt: string
120 const mediaError = ((event.currentTarget || event.target) as HTMLVideoElement).error 151 const mediaError = ((event.currentTarget || event.target) as HTMLVideoElement).error
121 152
@@ -143,7 +174,8 @@ class Html5Hlsjs {
143 } 174 }
144 175
145 logger.error(`MEDIA_ERROR: ${errorTxt}`) 176 logger.error(`MEDIA_ERROR: ${errorTxt}`)
146 }) 177 }
178 this.videoElement.addEventListener('error', this.handlers.error)
147 179
148 this.initialize() 180 this.initialize()
149 } 181 }
@@ -174,6 +206,7 @@ class Html5Hlsjs {
174 // See comment for `initialize` method. 206 // See comment for `initialize` method.
175 dispose () { 207 dispose () {
176 this.videoElement.removeEventListener('play', this.handlers.play) 208 this.videoElement.removeEventListener('play', this.handlers.play)
209 this.videoElement.removeEventListener('error', this.handlers.error)
177 210
178 // FIXME: https://github.com/video-dev/hls.js/issues/4092 211 // FIXME: https://github.com/video-dev/hls.js/issues/4092
179 const untypedHLS = this.hls as any 212 const untypedHLS = this.hls as any
@@ -200,6 +233,10 @@ class Html5Hlsjs {
200 return true 233 return true
201 } 234 }
202 235
236 static removeAllHooks () {
237 Html5Hlsjs.hooks = {}
238 }
239
203 private _executeHooksFor (type: string) { 240 private _executeHooksFor (type: string) {
204 if (Html5Hlsjs.hooks[type] === undefined) { 241 if (Html5Hlsjs.hooks[type] === undefined) {
205 return 242 return
@@ -421,7 +458,7 @@ class Html5Hlsjs {
421 ? data.level 458 ? data.level
422 : -1 459 : -1
423 460
424 this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, byEngine: true }) 461 this.player.peertubeResolutions().select({ id: resolutionId, autoResolutionChosenId, fireCallback: false })
425 }) 462 })
426 463
427 this.hls.attachMedia(this.videoElement) 464 this.hls.attachMedia(this.videoElement)
@@ -433,9 +470,3 @@ class Html5Hlsjs {
433 this._initHlsjs() 470 this._initHlsjs()
434 } 471 }
435} 472}
436
437export {
438 Html5Hlsjs,
439 registerSourceHandler,
440 registerConfigPlugin
441}
diff --git a/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts b/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts
index e6f525fea..fe967a730 100644
--- a/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts
+++ b/client/src/assets/player/shared/p2p-media-loader/p2p-media-loader-plugin.ts
@@ -3,19 +3,12 @@ import videojs from 'video.js'
3import { Events, Segment } from '@peertube/p2p-media-loader-core' 3import { Events, Segment } from '@peertube/p2p-media-loader-core'
4import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs' 4import { Engine, initHlsJsPlayer, initVideoJsContribHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs'
5import { logger } from '@root-helpers/logger' 5import { logger } from '@root-helpers/logger'
6import { addQueryParams, timeToInt } from '@shared/core-utils' 6import { addQueryParams } from '@shared/core-utils'
7import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types' 7import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo } from '../../types'
8import { registerConfigPlugin, registerSourceHandler } from './hls-plugin' 8import { SettingsButton } from '../settings/settings-menu-button'
9
10registerConfigPlugin(videojs)
11registerSourceHandler(videojs)
12 9
13const Plugin = videojs.getPlugin('plugin') 10const Plugin = videojs.getPlugin('plugin')
14class P2pMediaLoaderPlugin extends Plugin { 11class P2pMediaLoaderPlugin extends Plugin {
15
16 private readonly CONSTANTS = {
17 INFO_SCHEDULER: 1000 // Don't change this
18 }
19 private readonly options: P2PMediaLoaderPluginOptions 12 private readonly options: P2PMediaLoaderPluginOptions
20 13
21 private hlsjs: Hlsjs 14 private hlsjs: Hlsjs
@@ -31,7 +24,6 @@ class P2pMediaLoaderPlugin extends Plugin {
31 pendingDownload: [] as number[], 24 pendingDownload: [] as number[],
32 totalDownload: 0 25 totalDownload: 0
33 } 26 }
34 private startTime: number
35 27
36 private networkInfoInterval: any 28 private networkInfoInterval: any
37 29
@@ -39,7 +31,6 @@ class P2pMediaLoaderPlugin extends Plugin {
39 super(player) 31 super(player)
40 32
41 this.options = options 33 this.options = options
42 this.startTime = timeToInt(options.startTime)
43 34
44 // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080 35 // FIXME: typings https://github.com/Microsoft/TypeScript/issues/14080
45 if (!(videojs as any).Html5Hlsjs) { 36 if (!(videojs as any).Html5Hlsjs) {
@@ -77,17 +68,22 @@ class P2pMediaLoaderPlugin extends Plugin {
77 }) 68 })
78 69
79 player.ready(() => { 70 player.ready(() => {
80 this.initializeCore()
81
82 this.initializePlugin() 71 this.initializePlugin()
83 }) 72 })
84 } 73 }
85 74
86 dispose () { 75 dispose () {
87 if (this.hlsjs) this.hlsjs.destroy() 76 this.p2pEngine?.removeAllListeners()
88 if (this.p2pEngine) this.p2pEngine.destroy() 77 this.p2pEngine?.destroy()
78
79 this.hlsjs?.destroy()
80 this.options.segmentValidator?.destroy();
81
82 (videojs as any).Html5Hlsjs?.removeAllHooks()
89 83
90 clearInterval(this.networkInfoInterval) 84 clearInterval(this.networkInfoInterval)
85
86 super.dispose()
91 } 87 }
92 88
93 getCurrentLevel () { 89 getCurrentLevel () {
@@ -104,18 +100,6 @@ class P2pMediaLoaderPlugin extends Plugin {
104 return this.hlsjs 100 return this.hlsjs
105 } 101 }
106 102
107 private initializeCore () {
108 this.player.one('play', () => {
109 this.player.addClass('vjs-has-big-play-button-clicked')
110 })
111
112 this.player.one('canplay', () => {
113 if (this.startTime) {
114 this.player.currentTime(this.startTime)
115 }
116 })
117 }
118
119 private initializePlugin () { 103 private initializePlugin () {
120 initHlsJsPlayer(this.hlsjs) 104 initHlsJsPlayer(this.hlsjs)
121 105
@@ -133,7 +117,7 @@ class P2pMediaLoaderPlugin extends Plugin {
133 117
134 this.runStats() 118 this.runStats()
135 119
136 this.hlsjs.on(Hlsjs.Events.LEVEL_SWITCHED, () => this.player.trigger('engineResolutionChange')) 120 this.hlsjs.on(Hlsjs.Events.LEVEL_SWITCHED, () => this.player.trigger('engine-resolution-change'))
137 } 121 }
138 122
139 private runStats () { 123 private runStats () {
@@ -167,7 +151,7 @@ class P2pMediaLoaderPlugin extends Plugin {
167 this.statsP2PBytes.pendingUpload = [] 151 this.statsP2PBytes.pendingUpload = []
168 this.statsHTTPBytes.pendingDownload = [] 152 this.statsHTTPBytes.pendingDownload = []
169 153
170 return this.player.trigger('p2pInfo', { 154 return this.player.trigger('p2p-info', {
171 source: 'p2p-media-loader', 155 source: 'p2p-media-loader',
172 http: { 156 http: {
173 downloadSpeed: httpDownloadSpeed, 157 downloadSpeed: httpDownloadSpeed,
@@ -182,7 +166,7 @@ class P2pMediaLoaderPlugin extends Plugin {
182 }, 166 },
183 bandwidthEstimate: (this.hlsjs as any).bandwidthEstimate / 8 167 bandwidthEstimate: (this.hlsjs as any).bandwidthEstimate / 8
184 } as PlayerNetworkInfo) 168 } as PlayerNetworkInfo)
185 }, this.CONSTANTS.INFO_SCHEDULER) 169 }, 1000)
186 } 170 }
187 171
188 private arraySum (data: number[]) { 172 private arraySum (data: number[]) {
@@ -190,10 +174,7 @@ class P2pMediaLoaderPlugin extends Plugin {
190 } 174 }
191 175
192 private fallbackToBuiltInIOS () { 176 private fallbackToBuiltInIOS () {
193 logger.info('HLS.js does not seem to be supported. Fallback to built-in HLS.'); 177 logger.info('HLS.js does not seem to be supported. Fallback to built-in HLS.')
194
195 // Workaround to force video.js to not re create a video element
196 (this.player as any).playerElIngest_ = this.player.el().parentNode
197 178
198 this.player.src({ 179 this.player.src({
199 type: this.options.type, 180 type: this.options.type,
@@ -203,9 +184,14 @@ class P2pMediaLoaderPlugin extends Plugin {
203 }) 184 })
204 }) 185 })
205 186
206 this.player.ready(() => { 187 // Resolution button is not supported in built-in HLS player
207 this.initializeCore() 188 this.getResolutionButton().hide()
208 }) 189 }
190
191 private getResolutionButton () {
192 const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton
193
194 return settingsButton.menu.getChild('resolutionMenuButton')
209 } 195 }
210} 196}
211 197
diff --git a/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts
index 44a31bfb4..a2f7e676d 100644
--- a/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts
+++ b/client/src/assets/player/shared/p2p-media-loader/segment-validator.ts
@@ -9,21 +9,29 @@ type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string
9 9
10const maxRetries = 10 10const maxRetries = 10
11 11
12function segmentValidatorFactory (options: { 12export class SegmentValidator {
13 serverUrl: string 13
14 segmentsSha256Url: string 14 private readonly bytesRangeRegex = /bytes=(\d+)-(\d+)/
15 authorizationHeader: () => string 15
16 requiresAuth: boolean 16 private destroyed = false
17}) { 17
18 const { serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth } = options 18 constructor (private readonly options: {
19 19 serverUrl: string
20 let segmentsJSON = fetchSha256Segments({ serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth }) 20 segmentsSha256Url: string
21 const regex = /bytes=(\d+)-(\d+)/ 21 authorizationHeader: () => string
22 22 requiresUserAuth: boolean
23 return async function segmentValidator (segment: Segment, _method: string, _peerId: string, retry = 1) { 23 requiresPassword: boolean
24 videoPassword: () => string
25 }) {
26
27 }
28
29 async validate (segment: Segment, _method: string, _peerId: string, retry = 1) {
30 if (this.destroyed) return
31
24 const filename = basename(removeQueryParams(segment.url)) 32 const filename = basename(removeQueryParams(segment.url))
25 33
26 const segmentValue = (await segmentsJSON)[filename] 34 const segmentValue = (await this.fetchSha256Segments())[filename]
27 35
28 if (!segmentValue && retry > maxRetries) { 36 if (!segmentValue && retry > maxRetries) {
29 throw new Error(`Unknown segment name ${filename} in segment validator`) 37 throw new Error(`Unknown segment name ${filename} in segment validator`)
@@ -34,8 +42,7 @@ function segmentValidatorFactory (options: {
34 42
35 await wait(500) 43 await wait(500)
36 44
37 segmentsJSON = fetchSha256Segments({ serverUrl, segmentsSha256Url, authorizationHeader, requiresAuth }) 45 await this.validate(segment, _method, _peerId, retry + 1)
38 await segmentValidator(segment, _method, _peerId, retry + 1)
39 46
40 return 47 return
41 } 48 }
@@ -46,7 +53,7 @@ function segmentValidatorFactory (options: {
46 if (typeof segmentValue === 'string') { 53 if (typeof segmentValue === 'string') {
47 hashShouldBe = segmentValue 54 hashShouldBe = segmentValue
48 } else { 55 } else {
49 const captured = regex.exec(segment.range) 56 const captured = this.bytesRangeRegex.exec(segment.range)
50 range = captured[1] + '-' + captured[2] 57 range = captured[1] + '-' + captured[2]
51 58
52 hashShouldBe = segmentValue[range] 59 hashShouldBe = segmentValue[range]
@@ -56,7 +63,7 @@ function segmentValidatorFactory (options: {
56 throw new Error(`Unknown segment name ${filename}/${range} in segment validator`) 63 throw new Error(`Unknown segment name ${filename}/${range} in segment validator`)
57 } 64 }
58 65
59 const calculatedSha = await sha256Hex(segment.data) 66 const calculatedSha = await this.sha256Hex(segment.data)
60 if (calculatedSha !== hashShouldBe) { 67 if (calculatedSha !== hashShouldBe) {
61 throw new Error( 68 throw new Error(
62 `Hashes does not correspond for segment ${filename}/${range}` + 69 `Hashes does not correspond for segment ${filename}/${range}` +
@@ -64,61 +71,53 @@ function segmentValidatorFactory (options: {
64 ) 71 )
65 } 72 }
66 } 73 }
67}
68 74
69// --------------------------------------------------------------------------- 75 destroy () {
76 this.destroyed = true
77 }
70 78
71export { 79 private fetchSha256Segments (): Promise<SegmentsJSON> {
72 segmentValidatorFactory 80 let headers: { [ id: string ]: string } = {}
73}
74 81
75// --------------------------------------------------------------------------- 82 if (isSameOrigin(this.options.serverUrl, this.options.segmentsSha256Url)) {
76 83 if (this.options.requiresPassword) headers = { 'x-peertube-video-password': this.options.videoPassword() }
77function fetchSha256Segments (options: { 84 else if (this.options.requiresUserAuth) headers = { Authorization: this.options.authorizationHeader() }
78 serverUrl: string 85 }
79 segmentsSha256Url: string
80 authorizationHeader: () => string
81 requiresAuth: boolean
82}): Promise<SegmentsJSON> {
83 const { serverUrl, segmentsSha256Url, requiresAuth, authorizationHeader } = options
84
85 const headers = requiresAuth && isSameOrigin(serverUrl, segmentsSha256Url)
86 ? { Authorization: authorizationHeader() }
87 : {}
88
89 return fetch(segmentsSha256Url, { headers })
90 .then(res => res.json() as Promise<SegmentsJSON>)
91 .catch(err => {
92 logger.error('Cannot get sha256 segments', err)
93 return {}
94 })
95}
96
97async function sha256Hex (data?: ArrayBuffer) {
98 if (!data) return undefined
99 86
100 if (window.crypto.subtle) { 87 return fetch(this.options.segmentsSha256Url, { headers })
101 return window.crypto.subtle.digest('SHA-256', data) 88 .then(res => res.json() as Promise<SegmentsJSON>)
102 .then(data => bufferToHex(data)) 89 .catch(err => {
90 logger.error('Cannot get sha256 segments', err)
91 return {}
92 })
103 } 93 }
104 94
105 // Fallback for non HTTPS context 95 private async sha256Hex (data?: ArrayBuffer) {
106 const shaModule = (await import('sha.js') as any).default 96 if (!data) return undefined
107 // eslint-disable-next-line new-cap 97
108 return new shaModule.sha256().update(Buffer.from(data)).digest('hex') 98 if (window.crypto.subtle) {
109} 99 return window.crypto.subtle.digest('SHA-256', data)
100 .then(data => this.bufferToHex(data))
101 }
110 102
111// Thanks: https://stackoverflow.com/a/53307879 103 // Fallback for non HTTPS context
112function bufferToHex (buffer?: ArrayBuffer) { 104 const shaModule = (await import('sha.js') as any).default
113 if (!buffer) return '' 105 // eslint-disable-next-line new-cap
106 return new shaModule.sha256().update(Buffer.from(data)).digest('hex')
107 }
114 108
115 let s = '' 109 // Thanks: https://stackoverflow.com/a/53307879
116 const h = '0123456789abcdef' 110 private bufferToHex (buffer?: ArrayBuffer) {
117 const o = new Uint8Array(buffer) 111 if (!buffer) return ''
118 112
119 o.forEach((v: any) => { 113 let s = ''
120 s += h[v >> 4] + h[v & 15] 114 const h = '0123456789abcdef'
121 }) 115 const o = new Uint8Array(buffer)
122 116
123 return s 117 o.forEach((v: any) => {
118 s += h[v >> 4] + h[v & 15]
119 })
120
121 return s
122 }
124} 123}
diff --git a/client/src/assets/player/shared/peertube/peertube-plugin.ts b/client/src/assets/player/shared/peertube/peertube-plugin.ts
index af2147749..f52ec75f4 100644
--- a/client/src/assets/player/shared/peertube/peertube-plugin.ts
+++ b/client/src/assets/player/shared/peertube/peertube-plugin.ts
@@ -1,7 +1,7 @@
1import debug from 'debug' 1import debug from 'debug'
2import videojs from 'video.js' 2import videojs from 'video.js'
3import { logger } from '@root-helpers/logger' 3import { logger } from '@root-helpers/logger'
4import { isMobile } from '@root-helpers/web-browser' 4import { isIOS, isMobile } from '@root-helpers/web-browser'
5import { timeToInt } from '@shared/core-utils' 5import { timeToInt } from '@shared/core-utils'
6import { VideoView, VideoViewEvent } from '@shared/models/videos' 6import { VideoView, VideoViewEvent } from '@shared/models/videos'
7import { 7import {
@@ -13,7 +13,7 @@ import {
13 saveVideoWatchHistory, 13 saveVideoWatchHistory,
14 saveVolumeInStore 14 saveVolumeInStore
15} from '../../peertube-player-local-storage' 15} from '../../peertube-player-local-storage'
16import { PeerTubePluginOptions, VideoJSCaption } from '../../types' 16import { PeerTubePluginOptions } from '../../types'
17import { SettingsButton } from '../settings/settings-menu-button' 17import { SettingsButton } from '../settings/settings-menu-button'
18 18
19const debugLogger = debug('peertube:player:peertube') 19const debugLogger = debug('peertube:player:peertube')
@@ -21,43 +21,59 @@ const debugLogger = debug('peertube:player:peertube')
21const Plugin = videojs.getPlugin('plugin') 21const Plugin = videojs.getPlugin('plugin')
22 22
23class PeerTubePlugin extends Plugin { 23class PeerTubePlugin extends Plugin {
24 private readonly videoViewUrl: string 24 private readonly videoViewUrl: () => string
25 private readonly authorizationHeader: () => string 25 private readonly authorizationHeader: () => string
26 private readonly initialInactivityTimeout: number
26 27
27 private readonly videoUUID: string 28 private readonly hasAutoplay: () => videojs.Autoplay
28 private readonly startTime: number
29
30 private readonly videoViewIntervalMs: number
31 29
32 private videoCaptions: VideoJSCaption[] 30 private currentSubtitle: string
33 private defaultSubtitle: string 31 private currentPlaybackRate: number
34 32
35 private videoViewInterval: any 33 private videoViewInterval: any
36 34
37 private menuOpened = false 35 private menuOpened = false
38 private mouseInControlBar = false 36 private mouseInControlBar = false
39 private mouseInSettings = false 37 private mouseInSettings = false
40 private readonly initialInactivityTimeout: number
41 38
42 constructor (player: videojs.Player, options?: PeerTubePluginOptions) { 39 private videoViewOnPlayHandler: (...args: any[]) => void
40 private videoViewOnSeekedHandler: (...args: any[]) => void
41 private videoViewOnEndedHandler: (...args: any[]) => void
42
43 private stopTimeHandler: (...args: any[]) => void
44
45 constructor (player: videojs.Player, private readonly options: PeerTubePluginOptions) {
43 super(player) 46 super(player)
44 47
45 this.videoViewUrl = options.videoViewUrl 48 this.videoViewUrl = options.videoViewUrl
46 this.authorizationHeader = options.authorizationHeader 49 this.authorizationHeader = options.authorizationHeader
47 this.videoUUID = options.videoUUID 50 this.hasAutoplay = options.hasAutoplay
48 this.startTime = timeToInt(options.startTime)
49 this.videoViewIntervalMs = options.videoViewIntervalMs
50 51
51 this.videoCaptions = options.videoCaptions
52 this.initialInactivityTimeout = this.player.options_.inactivityTimeout 52 this.initialInactivityTimeout = this.player.options_.inactivityTimeout
53 53
54 if (options.autoplay !== false) this.player.addClass('vjs-has-autoplay') 54 this.currentSubtitle = this.options.subtitle() || getStoredLastSubtitle()
55
56 this.initializePlayer()
57 this.initOnVideoChange()
58
59 this.deleteLegacyIndexedDB()
55 60
56 this.player.on('autoplay-failure', () => { 61 this.player.on('autoplay-failure', () => {
62 debugLogger('Autoplay failed')
63
57 this.player.removeClass('vjs-has-autoplay') 64 this.player.removeClass('vjs-has-autoplay')
65
66 // Fix a bug on iOS where the big play button is not displayed when autoplay fails
67 if (isIOS()) this.player.hasStarted(false)
58 }) 68 })
59 69
60 this.player.ready(() => { 70 this.player.on('ratechange', () => {
71 this.currentPlaybackRate = this.player.playbackRate()
72
73 this.player.defaultPlaybackRate(this.currentPlaybackRate)
74 })
75
76 this.player.one('canplay', () => {
61 const playerOptions = this.player.options_ 77 const playerOptions = this.player.options_
62 78
63 const volume = getStoredVolume() 79 const volume = getStoredVolume()
@@ -65,28 +81,15 @@ class PeerTubePlugin extends Plugin {
65 81
66 const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute() 82 const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute()
67 if (muted !== undefined) this.player.muted(muted) 83 if (muted !== undefined) this.player.muted(muted)
84 })
68 85
69 this.defaultSubtitle = options.subtitle || getStoredLastSubtitle() 86 this.player.ready(() => {
70 87
71 this.player.on('volumechange', () => { 88 this.player.on('volumechange', () => {
72 saveVolumeInStore(this.player.volume()) 89 saveVolumeInStore(this.player.volume())
73 saveMuteInStore(this.player.muted()) 90 saveMuteInStore(this.player.muted())
74 }) 91 })
75 92
76 if (options.stopTime) {
77 const stopTime = timeToInt(options.stopTime)
78 const self = this
79
80 this.player.on('timeupdate', function onTimeUpdate () {
81 if (self.player.currentTime() > stopTime) {
82 self.player.pause()
83 self.player.trigger('stopped')
84
85 self.player.off('timeupdate', onTimeUpdate)
86 }
87 })
88 }
89
90 this.player.textTracks().addEventListener('change', () => { 93 this.player.textTracks().addEventListener('change', () => {
91 const showing = this.player.textTracks().tracks_.find(t => { 94 const showing = this.player.textTracks().tracks_.find(t => {
92 return t.kind === 'captions' && t.mode === 'showing' 95 return t.kind === 'captions' && t.mode === 'showing'
@@ -94,23 +97,24 @@ class PeerTubePlugin extends Plugin {
94 97
95 if (!showing) { 98 if (!showing) {
96 saveLastSubtitle('off') 99 saveLastSubtitle('off')
100 this.currentSubtitle = undefined
97 return 101 return
98 } 102 }
99 103
104 this.currentSubtitle = showing.language
100 saveLastSubtitle(showing.language) 105 saveLastSubtitle(showing.language)
101 }) 106 })
102 107
103 this.player.on('sourcechange', () => this.initCaptions()) 108 this.player.on('video-change', () => {
104 109 this.initOnVideoChange()
105 this.player.duration(options.videoDuration) 110 })
106
107 this.initializePlayer()
108 this.runUserViewing()
109 }) 111 })
110 } 112 }
111 113
112 dispose () { 114 dispose () {
113 if (this.videoViewInterval) clearInterval(this.videoViewInterval) 115 if (this.videoViewInterval) clearInterval(this.videoViewInterval)
116
117 super.dispose()
114 } 118 }
115 119
116 onMenuOpened () { 120 onMenuOpened () {
@@ -162,40 +166,70 @@ class PeerTubePlugin extends Plugin {
162 166
163 this.initSmoothProgressBar() 167 this.initSmoothProgressBar()
164 168
165 this.initCaptions() 169 this.player.ready(() => {
166 170 this.listenControlBarMouse()
167 this.listenControlBarMouse() 171 })
168 172
169 this.listenFullScreenChange() 173 this.listenFullScreenChange()
170 } 174 }
171 175
176 private initOnVideoChange () {
177 if (this.hasAutoplay() !== false) this.player.addClass('vjs-has-autoplay')
178 else this.player.removeClass('vjs-has-autoplay')
179
180 if (this.currentPlaybackRate && this.currentPlaybackRate !== 1) {
181 debugLogger('Setting playback rate to ' + this.currentPlaybackRate)
182
183 this.player.playbackRate(this.currentPlaybackRate)
184 }
185
186 this.player.ready(() => {
187 this.initCaptions()
188 this.updateControlBar()
189 })
190
191 this.handleStartStopTime()
192 this.runUserViewing()
193 }
194
172 // --------------------------------------------------------------------------- 195 // ---------------------------------------------------------------------------
173 196
174 private runUserViewing () { 197 private runUserViewing () {
175 let lastCurrentTime = this.startTime 198 const startTime = timeToInt(this.options.startTime())
199
200 let lastCurrentTime = startTime
176 let lastViewEvent: VideoViewEvent 201 let lastViewEvent: VideoViewEvent
177 202
178 this.player.one('play', () => { 203 if (this.videoViewInterval) clearInterval(this.videoViewInterval)
179 this.notifyUserIsWatching(this.startTime, lastViewEvent) 204 if (this.videoViewOnPlayHandler) this.player.off('play', this.videoViewOnPlayHandler)
180 }) 205 if (this.videoViewOnSeekedHandler) this.player.off('seeked', this.videoViewOnSeekedHandler)
206 if (this.videoViewOnEndedHandler) this.player.off('ended', this.videoViewOnEndedHandler)
181 207
182 this.player.on('seeked', () => { 208 this.videoViewOnPlayHandler = () => {
209 this.notifyUserIsWatching(startTime, lastViewEvent)
210 }
211
212 this.videoViewOnSeekedHandler = () => {
183 const diff = Math.floor(this.player.currentTime()) - lastCurrentTime 213 const diff = Math.floor(this.player.currentTime()) - lastCurrentTime
184 214
185 // Don't take into account small forwards 215 // Don't take into account small forwards
186 if (diff > 0 && diff < 3) return 216 if (diff > 0 && diff < 3) return
187 217
188 lastViewEvent = 'seek' 218 lastViewEvent = 'seek'
189 }) 219 }
190 220
191 this.player.one('ended', () => { 221 this.videoViewOnEndedHandler = () => {
192 const currentTime = Math.floor(this.player.duration()) 222 const currentTime = Math.floor(this.player.duration())
193 lastCurrentTime = currentTime 223 lastCurrentTime = currentTime
194 224
195 this.notifyUserIsWatching(currentTime, lastViewEvent) 225 this.notifyUserIsWatching(currentTime, lastViewEvent)
196 226
197 lastViewEvent = undefined 227 lastViewEvent = undefined
198 }) 228 }
229
230 this.player.one('play', this.videoViewOnPlayHandler)
231 this.player.on('seeked', this.videoViewOnSeekedHandler)
232 this.player.one('ended', this.videoViewOnEndedHandler)
199 233
200 this.videoViewInterval = setInterval(() => { 234 this.videoViewInterval = setInterval(() => {
201 const currentTime = Math.floor(this.player.currentTime()) 235 const currentTime = Math.floor(this.player.currentTime())
@@ -209,13 +243,13 @@ class PeerTubePlugin extends Plugin {
209 .catch(err => logger.error('Cannot notify user is watching.', err)) 243 .catch(err => logger.error('Cannot notify user is watching.', err))
210 244
211 lastViewEvent = undefined 245 lastViewEvent = undefined
212 }, this.videoViewIntervalMs) 246 }, this.options.videoViewIntervalMs)
213 } 247 }
214 248
215 private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) { 249 private notifyUserIsWatching (currentTime: number, viewEvent: VideoViewEvent) {
216 // Server won't save history, so save the video position in local storage 250 // Server won't save history, so save the video position in local storage
217 if (!this.authorizationHeader()) { 251 if (!this.authorizationHeader()) {
218 saveVideoWatchHistory(this.videoUUID, currentTime) 252 saveVideoWatchHistory(this.options.videoUUID(), currentTime)
219 } 253 }
220 254
221 if (!this.videoViewUrl) return Promise.resolve(true) 255 if (!this.videoViewUrl) return Promise.resolve(true)
@@ -225,7 +259,7 @@ class PeerTubePlugin extends Plugin {
225 const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' }) 259 const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' })
226 if (this.authorizationHeader()) headers.set('Authorization', this.authorizationHeader()) 260 if (this.authorizationHeader()) headers.set('Authorization', this.authorizationHeader())
227 261
228 return fetch(this.videoViewUrl, { method: 'POST', body: JSON.stringify(body), headers }) 262 return fetch(this.videoViewUrl(), { method: 'POST', body: JSON.stringify(body), headers })
229 } 263 }
230 264
231 // --------------------------------------------------------------------------- 265 // ---------------------------------------------------------------------------
@@ -279,18 +313,89 @@ class PeerTubePlugin extends Plugin {
279 } 313 }
280 314
281 private initCaptions () { 315 private initCaptions () {
282 for (const caption of this.videoCaptions) { 316 debugLogger('Init captions with current subtitle ' + this.currentSubtitle)
317
318 this.player.tech(true).clearTracks('text')
319
320 for (const caption of this.options.videoCaptions()) {
283 this.player.addRemoteTextTrack({ 321 this.player.addRemoteTextTrack({
284 kind: 'captions', 322 kind: 'captions',
285 label: caption.label, 323 label: caption.label,
286 language: caption.language, 324 language: caption.language,
287 id: caption.language, 325 id: caption.language,
288 src: caption.src, 326 src: caption.src,
289 default: this.defaultSubtitle === caption.language 327 default: this.currentSubtitle === caption.language
290 }, false) 328 }, true)
329 }
330
331 this.player.trigger('captions-changed')
332 }
333
334 private updateControlBar () {
335 debugLogger('Updating control bar')
336
337 if (this.options.isLive()) {
338 this.getPlaybackRateButton().hide()
339
340 this.player.controlBar.getChild('progressControl').hide()
341 this.player.controlBar.getChild('currentTimeDisplay').hide()
342 this.player.controlBar.getChild('timeDivider').hide()
343 this.player.controlBar.getChild('durationDisplay').hide()
344
345 this.player.controlBar.getChild('peerTubeLiveDisplay').show()
346 } else {
347 this.getPlaybackRateButton().show()
348
349 this.player.controlBar.getChild('progressControl').show()
350 this.player.controlBar.getChild('currentTimeDisplay').show()
351 this.player.controlBar.getChild('timeDivider').show()
352 this.player.controlBar.getChild('durationDisplay').show()
353
354 this.player.controlBar.getChild('peerTubeLiveDisplay').hide()
291 } 355 }
292 356
293 this.player.trigger('captionsChanged') 357 if (this.options.videoCaptions().length === 0) {
358 this.getCaptionsButton().hide()
359 } else {
360 this.getCaptionsButton().show()
361 }
362 }
363
364 private handleStartStopTime () {
365 this.player.duration(this.options.videoDuration())
366
367 if (this.stopTimeHandler) {
368 this.player.off('timeupdate', this.stopTimeHandler)
369 this.stopTimeHandler = undefined
370 }
371
372 // Prefer canplaythrough instead of canplay because Chrome has issues with the second one
373 this.player.one('canplaythrough', () => {
374 if (this.options.startTime()) {
375 debugLogger('Start the video at ' + this.options.startTime())
376
377 this.player.currentTime(timeToInt(this.options.startTime()))
378 }
379
380 if (this.options.stopTime()) {
381 const stopTime = timeToInt(this.options.stopTime())
382
383 this.stopTimeHandler = () => {
384 if (this.player.currentTime() <= stopTime) return
385
386 debugLogger('Stopping the video at ' + this.options.stopTime())
387
388 // Time top stop
389 this.player.pause()
390 this.player.trigger('auto-stopped')
391
392 this.player.off('timeupdate', this.stopTimeHandler)
393 this.stopTimeHandler = undefined
394 }
395
396 this.player.on('timeupdate', this.stopTimeHandler)
397 }
398 })
294 } 399 }
295 400
296 // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657 401 // Thanks: https://github.com/videojs/video.js/issues/4460#issuecomment-312861657
@@ -314,6 +419,37 @@ class PeerTubePlugin extends Plugin {
314 this.update() 419 this.update()
315 } 420 }
316 } 421 }
422
423 private getCaptionsButton () {
424 const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton
425
426 return settingsButton.menu.getChild('captionsButton') as videojs.CaptionsButton
427 }
428
429 private getPlaybackRateButton () {
430 const settingsButton = this.player.controlBar.getDescendant([ 'settingsButton' ]) as SettingsButton
431
432 return settingsButton.menu.getChild('playbackRateMenuButton')
433 }
434
435 // We don't use webtorrent anymore, so we can safely remove old chunks from IndexedDB
436 private deleteLegacyIndexedDB () {
437 try {
438 if (typeof window.indexedDB === 'undefined') return
439 if (!window.indexedDB) return
440 if (typeof window.indexedDB.databases !== 'function') return
441
442 window.indexedDB.databases()
443 .then(databases => {
444 for (const db of databases) {
445 window.indexedDB.deleteDatabase(db.name)
446 }
447 })
448 } catch (err) {
449 debugLogger('Cannot delete legacy indexed DB', err)
450 // Nothing to do
451 }
452 }
317} 453}
318 454
319videojs.registerPlugin('peertube', PeerTubePlugin) 455videojs.registerPlugin('peertube', PeerTubePlugin)
diff --git a/client/src/assets/player/shared/player-options-builder/control-bar-options-builder.ts b/client/src/assets/player/shared/player-options-builder/control-bar-options-builder.ts
new file mode 100644
index 000000000..b467e3637
--- /dev/null
+++ b/client/src/assets/player/shared/player-options-builder/control-bar-options-builder.ts
@@ -0,0 +1,136 @@
1import {
2 NextPreviousVideoButtonOptions,
3 PeerTubeLinkButtonOptions,
4 PeerTubePlayerContructorOptions,
5 PeerTubePlayerLoadOptions,
6 TheaterButtonOptions
7} from '../../types'
8
9type ControlBarOptionsBuilderConstructorOptions =
10 Pick<PeerTubePlayerContructorOptions, 'peertubeLink' | 'instanceName' | 'theaterButton'> &
11 {
12 videoShortUUID: () => string
13 p2pEnabled: () => boolean
14
15 previousVideo: () => PeerTubePlayerLoadOptions['previousVideo']
16 nextVideo: () => PeerTubePlayerLoadOptions['nextVideo']
17 }
18
19export class ControlBarOptionsBuilder {
20
21 constructor (private options: ControlBarOptionsBuilderConstructorOptions) {
22 }
23
24 getChildrenOptions () {
25 const children = {
26 ...this.getPreviousVideo(),
27
28 playToggle: {},
29
30 ...this.getNextVideo(),
31
32 ...this.getTimeControls(),
33
34 ...this.getProgressControl(),
35
36 p2PInfoButton: {},
37 muteToggle: {},
38 volumeControl: {},
39
40 ...this.getSettingsButton(),
41
42 ...this.getPeerTubeLinkButton(),
43
44 ...this.getTheaterButton(),
45
46 fullscreenToggle: {}
47 }
48
49 return children
50 }
51
52 private getSettingsButton () {
53 const settingEntries: string[] = []
54
55 settingEntries.push('playbackRateMenuButton')
56 settingEntries.push('captionsButton')
57 settingEntries.push('resolutionMenuButton')
58
59 return {
60 settingsButton: {
61 setup: {
62 maxHeightOffset: 40
63 },
64 entries: settingEntries
65 }
66 }
67 }
68
69 private getTimeControls () {
70 return {
71 peerTubeLiveDisplay: {},
72
73 currentTimeDisplay: {},
74 timeDivider: {},
75 durationDisplay: {}
76 }
77 }
78
79 private getProgressControl () {
80 return {
81 progressControl: {
82 children: {
83 seekBar: {
84 children: {
85 loadProgressBar: {},
86 mouseTimeDisplay: {},
87 playProgressBar: {}
88 }
89 }
90 }
91 }
92 }
93 }
94
95 private getPreviousVideo () {
96 const buttonOptions: NextPreviousVideoButtonOptions = {
97 type: 'previous',
98 handler: () => this.options.previousVideo().handler(),
99 isDisabled: () => !this.options.previousVideo().enabled,
100 isDisplayed: () => this.options.previousVideo().displayControlBarButton
101 }
102
103 return { previousVideoButton: buttonOptions }
104 }
105
106 private getNextVideo () {
107 const buttonOptions: NextPreviousVideoButtonOptions = {
108 type: 'next',
109 handler: () => this.options.nextVideo().handler(),
110 isDisabled: () => !this.options.nextVideo().enabled,
111 isDisplayed: () => this.options.nextVideo().displayControlBarButton
112 }
113
114 return { nextVideoButton: buttonOptions }
115 }
116
117 private getPeerTubeLinkButton () {
118 const options: PeerTubeLinkButtonOptions = {
119 isDisplayed: this.options.peertubeLink,
120 shortUUID: this.options.videoShortUUID,
121 instanceName: this.options.instanceName
122 }
123
124 return { peerTubeLinkButton: options }
125 }
126
127 private getTheaterButton () {
128 const options: TheaterButtonOptions = {
129 isDisplayed: () => this.options.theaterButton
130 }
131
132 return {
133 theaterButton: options
134 }
135 }
136}
diff --git a/client/src/assets/player/shared/manager-options/hls-options-builder.ts b/client/src/assets/player/shared/player-options-builder/hls-options-builder.ts
index 194991fa4..10df2db5d 100644
--- a/client/src/assets/player/shared/manager-options/hls-options-builder.ts
+++ b/client/src/assets/player/shared/player-options-builder/hls-options-builder.ts
@@ -3,49 +3,61 @@ import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs'
3import { logger } from '@root-helpers/logger' 3import { logger } from '@root-helpers/logger'
4import { LiveVideoLatencyMode } from '@shared/models' 4import { LiveVideoLatencyMode } from '@shared/models'
5import { getAverageBandwidthInStore } from '../../peertube-player-local-storage' 5import { getAverageBandwidthInStore } from '../../peertube-player-local-storage'
6import { P2PMediaLoader, P2PMediaLoaderPluginOptions } from '../../types' 6import { P2PMediaLoader, P2PMediaLoaderPluginOptions, PeerTubePlayerContructorOptions, PeerTubePlayerLoadOptions } from '../../types'
7import { PeertubePlayerManagerOptions } from '../../types/manager-options'
8import { getRtcConfig, isSameOrigin } from '../common' 7import { getRtcConfig, isSameOrigin } from '../common'
9import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager' 8import { RedundancyUrlManager } from '../p2p-media-loader/redundancy-url-manager'
10import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder' 9import { segmentUrlBuilderFactory } from '../p2p-media-loader/segment-url-builder'
11import { segmentValidatorFactory } from '../p2p-media-loader/segment-validator' 10import { SegmentValidator } from '../p2p-media-loader/segment-validator'
11
12type ConstructorOptions =
13 Pick<PeerTubePlayerContructorOptions, 'pluginsManager' | 'serverUrl' | 'authorizationHeader'> &
14 Pick<PeerTubePlayerLoadOptions, 'videoPassword' | 'requiresUserAuth' | 'videoFileToken' | 'requiresPassword' |
15 'isLive' | 'liveOptions' | 'p2pEnabled' | 'hls'>
12 16
13export class HLSOptionsBuilder { 17export class HLSOptionsBuilder {
14 18
15 constructor ( 19 constructor (
16 private options: PeertubePlayerManagerOptions, 20 private options: ConstructorOptions,
17 private p2pMediaLoaderModule?: any 21 private p2pMediaLoaderModule?: any
18 ) { 22 ) {
19 23
20 } 24 }
21 25
22 async getPluginOptions () { 26 async getPluginOptions () {
23 const commonOptions = this.options.common 27 const redundancyUrlManager = new RedundancyUrlManager(this.options.hls.redundancyBaseUrls)
24 28 const segmentValidator = new SegmentValidator({
25 const redundancyUrlManager = new RedundancyUrlManager(this.options.p2pMediaLoader.redundancyBaseUrls) 29 segmentsSha256Url: this.options.hls.segmentsSha256Url,
30 authorizationHeader: this.options.authorizationHeader,
31 requiresUserAuth: this.options.requiresUserAuth,
32 serverUrl: this.options.serverUrl,
33 requiresPassword: this.options.requiresPassword,
34 videoPassword: this.options.videoPassword
35 })
26 36
27 const p2pMediaLoaderConfig = await this.options.pluginsManager.runHook( 37 const p2pMediaLoaderConfig = await this.options.pluginsManager.runHook(
28 'filter:internal.player.p2p-media-loader.options.result', 38 'filter:internal.player.p2p-media-loader.options.result',
29 this.getP2PMediaLoaderOptions(redundancyUrlManager) 39 this.getP2PMediaLoaderOptions({ redundancyUrlManager, segmentValidator })
30 ) 40 )
31 const loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader 41 const loader = new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() as P2PMediaLoader
32 42
33 const p2pMediaLoader: P2PMediaLoaderPluginOptions = { 43 const p2pMediaLoader: P2PMediaLoaderPluginOptions = {
34 requiresAuth: commonOptions.requiresAuth, 44 requiresUserAuth: this.options.requiresUserAuth,
35 videoFileToken: commonOptions.videoFileToken, 45 videoFileToken: this.options.videoFileToken,
36 46
37 redundancyUrlManager, 47 redundancyUrlManager,
38 type: 'application/x-mpegURL', 48 type: 'application/x-mpegURL',
39 startTime: commonOptions.startTime, 49 src: this.options.hls.playlistUrl,
40 src: this.options.p2pMediaLoader.playlistUrl, 50 segmentValidator,
41 loader 51 loader
42 } 52 }
43 53
44 const hlsjs = { 54 const hlsjs = {
55 hlsjsConfig: this.getHLSJSOptions(loader),
56
45 levelLabelHandler: (level: { height: number, width: number }) => { 57 levelLabelHandler: (level: { height: number, width: number }) => {
46 const resolution = Math.min(level.height || 0, level.width || 0) 58 const resolution = Math.min(level.height || 0, level.width || 0)
47 59
48 const file = this.options.p2pMediaLoader.videoFiles.find(f => f.resolution.id === resolution) 60 const file = this.options.hls.videoFiles.find(f => f.resolution.id === resolution)
49 // We don't have files for live videos 61 // We don't have files for live videos
50 if (!file) return level.height 62 if (!file) return level.height
51 63
@@ -56,26 +68,27 @@ export class HLSOptionsBuilder {
56 } 68 }
57 } 69 }
58 70
59 const html5 = { 71 return { p2pMediaLoader, hlsjs }
60 hlsjsConfig: this.getHLSJSOptions(loader)
61 }
62
63 return { p2pMediaLoader, hlsjs, html5 }
64 } 72 }
65 73
66 // --------------------------------------------------------------------------- 74 // ---------------------------------------------------------------------------
67 75
68 private getP2PMediaLoaderOptions (redundancyUrlManager: RedundancyUrlManager): HlsJsEngineSettings { 76 private getP2PMediaLoaderOptions (options: {
77 redundancyUrlManager: RedundancyUrlManager
78 segmentValidator: SegmentValidator
79 }): HlsJsEngineSettings {
80 const { redundancyUrlManager, segmentValidator } = options
81
69 let consumeOnly = false 82 let consumeOnly = false
70 if ((navigator as any)?.connection?.type === 'cellular') { 83 if ((navigator as any)?.connection?.type === 'cellular') {
71 logger.info('We are on a cellular connection: disabling seeding.') 84 logger.info('We are on a cellular connection: disabling seeding.')
72 consumeOnly = true 85 consumeOnly = true
73 } 86 }
74 87
75 const trackerAnnounce = this.options.p2pMediaLoader.trackerAnnounce 88 const trackerAnnounce = this.options.hls.trackerAnnounce
76 .filter(t => t.startsWith('ws')) 89 .filter(t => t.startsWith('ws'))
77 90
78 const specificLiveOrVODOptions = this.options.common.isLive 91 const specificLiveOrVODOptions = this.options.isLive
79 ? this.getP2PMediaLoaderLiveOptions() 92 ? this.getP2PMediaLoaderLiveOptions()
80 : this.getP2PMediaLoaderVODOptions() 93 : this.getP2PMediaLoaderVODOptions()
81 94
@@ -88,28 +101,28 @@ export class HLSOptionsBuilder {
88 httpFailedSegmentTimeout: 1000, 101 httpFailedSegmentTimeout: 1000,
89 102
90 xhrSetup: (xhr, url) => { 103 xhrSetup: (xhr, url) => {
91 if (!this.options.common.requiresAuth) return 104 const { requiresUserAuth, requiresPassword } = this.options
92 if (!isSameOrigin(this.options.common.serverUrl, url)) return 105
106 if (!(requiresUserAuth || requiresPassword)) return
107
108 if (!isSameOrigin(this.options.serverUrl, url)) return
109
110 if (requiresPassword) xhr.setRequestHeader('x-peertube-video-password', this.options.videoPassword())
93 111
94 xhr.setRequestHeader('Authorization', this.options.common.authorizationHeader()) 112 else xhr.setRequestHeader('Authorization', this.options.authorizationHeader())
95 }, 113 },
96 114
97 segmentValidator: segmentValidatorFactory({ 115 segmentValidator: segmentValidator.validate.bind(segmentValidator),
98 segmentsSha256Url: this.options.p2pMediaLoader.segmentsSha256Url,
99 authorizationHeader: this.options.common.authorizationHeader,
100 requiresAuth: this.options.common.requiresAuth,
101 serverUrl: this.options.common.serverUrl
102 }),
103 116
104 segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager), 117 segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager),
105 118
106 useP2P: this.options.common.p2pEnabled, 119 useP2P: this.options.p2pEnabled,
107 consumeOnly, 120 consumeOnly,
108 121
109 ...specificLiveOrVODOptions 122 ...specificLiveOrVODOptions
110 }, 123 },
111 segments: { 124 segments: {
112 swarmId: this.options.p2pMediaLoader.playlistUrl, 125 swarmId: this.options.hls.playlistUrl,
113 forwardSegmentCount: specificLiveOrVODOptions.p2pDownloadMaxPriority ?? 20 126 forwardSegmentCount: specificLiveOrVODOptions.p2pDownloadMaxPriority ?? 20
114 } 127 }
115 } 128 }
@@ -120,7 +133,7 @@ export class HLSOptionsBuilder {
120 requiredSegmentsPriority: 1 133 requiredSegmentsPriority: 1
121 } 134 }
122 135
123 const latencyMode = this.options.common.liveOptions.latencyMode 136 const latencyMode = this.options.liveOptions.latencyMode
124 137
125 switch (latencyMode) { 138 switch (latencyMode) {
126 case LiveVideoLatencyMode.SMALL_LATENCY: 139 case LiveVideoLatencyMode.SMALL_LATENCY:
@@ -158,7 +171,7 @@ export class HLSOptionsBuilder {
158 // --------------------------------------------------------------------------- 171 // ---------------------------------------------------------------------------
159 172
160 private getHLSJSOptions (loader: P2PMediaLoader) { 173 private getHLSJSOptions (loader: P2PMediaLoader) {
161 const specificLiveOrVODOptions = this.options.common.isLive 174 const specificLiveOrVODOptions = this.options.isLive
162 ? this.getHLSLiveOptions() 175 ? this.getHLSLiveOptions()
163 : this.getHLSVODOptions() 176 : this.getHLSVODOptions()
164 177
@@ -186,7 +199,7 @@ export class HLSOptionsBuilder {
186 } 199 }
187 200
188 private getHLSLiveOptions () { 201 private getHLSLiveOptions () {
189 const latencyMode = this.options.common.liveOptions.latencyMode 202 const latencyMode = this.options.liveOptions.latencyMode
190 203
191 switch (latencyMode) { 204 switch (latencyMode) {
192 case LiveVideoLatencyMode.SMALL_LATENCY: 205 case LiveVideoLatencyMode.SMALL_LATENCY:
diff --git a/client/src/assets/player/shared/player-options-builder/index.ts b/client/src/assets/player/shared/player-options-builder/index.ts
new file mode 100644
index 000000000..674754a94
--- /dev/null
+++ b/client/src/assets/player/shared/player-options-builder/index.ts
@@ -0,0 +1,3 @@
1export * from './control-bar-options-builder'
2export * from './hls-options-builder'
3export * from './web-video-options-builder'
diff --git a/client/src/assets/player/shared/player-options-builder/web-video-options-builder.ts b/client/src/assets/player/shared/player-options-builder/web-video-options-builder.ts
new file mode 100644
index 000000000..a3c3c3f27
--- /dev/null
+++ b/client/src/assets/player/shared/player-options-builder/web-video-options-builder.ts
@@ -0,0 +1,22 @@
1import { PeerTubePlayerLoadOptions, WebVideoPluginOptions } from '../../types'
2
3type ConstructorOptions = Pick<PeerTubePlayerLoadOptions, 'videoFileToken' | 'webVideo' | 'hls' | 'startTime'>
4
5export class WebVideoOptionsBuilder {
6
7 constructor (private options: ConstructorOptions) {
8
9 }
10
11 getPluginOptions (): WebVideoPluginOptions {
12 return {
13 videoFileToken: this.options.videoFileToken,
14
15 videoFiles: this.options.webVideo.videoFiles.length !== 0
16 ? this.options.webVideo.videoFiles
17 : this.options?.hls.videoFiles || [],
18
19 startTime: this.options.startTime
20 }
21 }
22}
diff --git a/client/src/assets/player/shared/playlist/playlist-button.ts b/client/src/assets/player/shared/playlist/playlist-button.ts
index 6cfaf4158..45cbb4899 100644
--- a/client/src/assets/player/shared/playlist/playlist-button.ts
+++ b/client/src/assets/player/shared/playlist/playlist-button.ts
@@ -8,8 +8,15 @@ class PlaylistButton extends ClickableComponent {
8 private playlistInfoElement: HTMLElement 8 private playlistInfoElement: HTMLElement
9 private wrapper: HTMLElement 9 private wrapper: HTMLElement
10 10
11 constructor (player: videojs.Player, options?: PlaylistPluginOptions & { playlistMenu: PlaylistMenu }) { 11 options_: PlaylistPluginOptions & { playlistMenu: PlaylistMenu } & videojs.ClickableComponentOptions
12 super(player, options as any) 12
13 // FIXME: eslint -> it's not a useless constructor, we need to extend constructor options typings
14 // eslint-disable-next-line @typescript-eslint/no-useless-constructor
15 constructor (
16 player: videojs.Player,
17 options?: PlaylistPluginOptions & { playlistMenu: PlaylistMenu } & videojs.ClickableComponentOptions
18 ) {
19 super(player, options)
13 } 20 }
14 21
15 createEl () { 22 createEl () {
@@ -40,20 +47,15 @@ class PlaylistButton extends ClickableComponent {
40 } 47 }
41 48
42 update () { 49 update () {
43 const options = this.options_ as PlaylistPluginOptions 50 this.playlistInfoElement.innerHTML = this.options_.getCurrentPosition() + '/' + this.options_.playlist.videosLength
44 51
45 this.playlistInfoElement.innerHTML = options.getCurrentPosition() + '/' + options.playlist.videosLength 52 this.wrapper.title = this.player().localize('Playlist: {1}', [ this.options_.playlist.displayName ])
46 this.wrapper.title = this.player().localize('Playlist: {1}', [ options.playlist.displayName ])
47 } 53 }
48 54
49 handleClick () { 55 handleClick () {
50 const playlistMenu = this.getPlaylistMenu() 56 const playlistMenu = this.options_.playlistMenu
51 playlistMenu.open() 57 playlistMenu.open()
52 } 58 }
53
54 private getPlaylistMenu () {
55 return (this.options_ as any).playlistMenu as PlaylistMenu
56 }
57} 59}
58 60
59videojs.registerComponent('PlaylistButton', PlaylistButton) 61videojs.registerComponent('PlaylistButton', PlaylistButton)
diff --git a/client/src/assets/player/shared/playlist/playlist-menu-item.ts b/client/src/assets/player/shared/playlist/playlist-menu-item.ts
index 81b5acf30..f9366332d 100644
--- a/client/src/assets/player/shared/playlist/playlist-menu-item.ts
+++ b/client/src/assets/player/shared/playlist/playlist-menu-item.ts
@@ -8,6 +8,11 @@ const Component = videojs.getComponent('Component')
8class PlaylistMenuItem extends Component { 8class PlaylistMenuItem extends Component {
9 private element: VideoPlaylistElement 9 private element: VideoPlaylistElement
10 10
11 private clickHandler: () => void
12 private keyDownHandler: (event: KeyboardEvent) => void
13
14 options_: videojs.ComponentOptions & PlaylistItemOptions
15
11 constructor (player: videojs.Player, options?: PlaylistItemOptions) { 16 constructor (player: videojs.Player, options?: PlaylistItemOptions) {
12 super(player, options as any) 17 super(player, options as any)
13 18
@@ -15,19 +20,27 @@ class PlaylistMenuItem extends Component {
15 20
16 this.element = options.element 21 this.element = options.element
17 22
18 this.on([ 'click', 'tap' ], () => this.switchPlaylistItem()) 23 this.clickHandler = () => this.switchPlaylistItem()
19 this.on('keydown', event => this.handleKeyDown(event)) 24 this.keyDownHandler = event => this.handleKeyDown(event)
25
26 this.on([ 'click', 'tap' ], this.clickHandler)
27 this.on('keydown', this.keyDownHandler)
20 } 28 }
21 29
22 createEl () { 30 dispose () {
23 const options = this.options_ as PlaylistItemOptions 31 this.off([ 'click', 'tap' ], this.clickHandler)
32 this.off('keydown', this.keyDownHandler)
24 33
34 super.dispose()
35 }
36
37 createEl () {
25 const li = super.createEl('li', { 38 const li = super.createEl('li', {
26 className: 'vjs-playlist-menu-item', 39 className: 'vjs-playlist-menu-item',
27 innerHTML: '' 40 innerHTML: ''
28 }) as HTMLElement 41 }) as HTMLElement
29 42
30 if (!options.element.video) { 43 if (!this.options_.element.video) {
31 li.classList.add('vjs-disabled') 44 li.classList.add('vjs-disabled')
32 } 45 }
33 46
@@ -37,14 +50,14 @@ class PlaylistMenuItem extends Component {
37 50
38 const position = super.createEl('div', { 51 const position = super.createEl('div', {
39 className: 'item-position', 52 className: 'item-position',
40 innerHTML: options.element.position 53 innerHTML: this.options_.element.position
41 }) 54 })
42 55
43 positionBlock.appendChild(position) 56 positionBlock.appendChild(position)
44 li.appendChild(positionBlock) 57 li.appendChild(positionBlock)
45 58
46 if (options.element.video) { 59 if (this.options_.element.video) {
47 this.buildAvailableVideo(li, positionBlock, options) 60 this.buildAvailableVideo(li, positionBlock, this.options_)
48 } else { 61 } else {
49 this.buildUnavailableVideo(li) 62 this.buildUnavailableVideo(li)
50 } 63 }
@@ -125,9 +138,7 @@ class PlaylistMenuItem extends Component {
125 } 138 }
126 139
127 private switchPlaylistItem () { 140 private switchPlaylistItem () {
128 const options = this.options_ as PlaylistItemOptions 141 this.options_.onClicked()
129
130 options.onClicked()
131 } 142 }
132} 143}
133 144
diff --git a/client/src/assets/player/shared/playlist/playlist-menu.ts b/client/src/assets/player/shared/playlist/playlist-menu.ts
index 1ec9ac804..53a5a7274 100644
--- a/client/src/assets/player/shared/playlist/playlist-menu.ts
+++ b/client/src/assets/player/shared/playlist/playlist-menu.ts
@@ -6,26 +6,32 @@ import { PlaylistMenuItem } from './playlist-menu-item'
6const Component = videojs.getComponent('Component') 6const Component = videojs.getComponent('Component')
7 7
8class PlaylistMenu extends Component { 8class PlaylistMenu extends Component {
9 private menuItems: PlaylistMenuItem[] 9 private menuItems: PlaylistMenuItem[] = []
10 10
11 constructor (player: videojs.Player, options?: PlaylistPluginOptions) { 11 private readonly userInactiveHandler: () => void
12 super(player, options as any) 12 private readonly onMouseEnter: () => void
13 private readonly onMouseLeave: () => void
13 14
14 const self = this 15 private readonly onPlayerCick: (event: Event) => void
15 16
16 function userInactiveHandler () { 17 options_: PlaylistPluginOptions & videojs.ComponentOptions
17 self.close() 18
19 constructor (player: videojs.Player, options?: PlaylistPluginOptions & videojs.ComponentOptions) {
20 super(player, options)
21
22 this.userInactiveHandler = () => {
23 this.close()
18 } 24 }
19 25
20 this.el().addEventListener('mouseenter', () => { 26 this.onMouseEnter = () => {
21 this.player().off('userinactive', userInactiveHandler) 27 this.player().off('userinactive', this.userInactiveHandler)
22 }) 28 }
23 29
24 this.el().addEventListener('mouseleave', () => { 30 this.onMouseLeave = () => {
25 this.player().one('userinactive', userInactiveHandler) 31 this.player().one('userinactive', this.userInactiveHandler)
26 }) 32 }
27 33
28 this.player().on('click', event => { 34 this.onPlayerCick = event => {
29 let current = event.target as HTMLElement 35 let current = event.target as HTMLElement
30 36
31 do { 37 do {
@@ -40,14 +46,31 @@ class PlaylistMenu extends Component {
40 } while (current) 46 } while (current)
41 47
42 this.close() 48 this.close()
43 }) 49 }
50
51 this.el().addEventListener('mouseenter', this.onMouseEnter)
52 this.el().addEventListener('mouseleave', this.onMouseLeave)
53
54 this.player().on('click', this.onPlayerCick)
55 }
56
57 dispose () {
58 this.el().removeEventListener('mouseenter', this.onMouseEnter)
59 this.el().removeEventListener('mouseleave', this.onMouseLeave)
60
61 this.player().off('userinactive', this.userInactiveHandler)
62 this.player().off('click', this.onPlayerCick)
63
64 for (const item of this.menuItems) {
65 item.dispose()
66 }
67
68 super.dispose()
44 } 69 }
45 70
46 createEl () { 71 createEl () {
47 this.menuItems = [] 72 this.menuItems = []
48 73
49 const options = this.getOptions()
50
51 const menu = super.createEl('div', { 74 const menu = super.createEl('div', {
52 className: 'vjs-playlist-menu', 75 className: 'vjs-playlist-menu',
53 innerHTML: '', 76 innerHTML: '',
@@ -61,11 +84,11 @@ class PlaylistMenu extends Component {
61 const headerLeft = super.createEl('div') 84 const headerLeft = super.createEl('div')
62 85
63 const leftTitle = super.createEl('div', { 86 const leftTitle = super.createEl('div', {
64 innerHTML: options.playlist.displayName, 87 innerHTML: this.options_.playlist.displayName,
65 className: 'title' 88 className: 'title'
66 }) 89 })
67 90
68 const playlistChannel = options.playlist.videoChannel 91 const playlistChannel = this.options_.playlist.videoChannel
69 const leftSubtitle = super.createEl('div', { 92 const leftSubtitle = super.createEl('div', {
70 innerHTML: playlistChannel 93 innerHTML: playlistChannel
71 ? this.player().localize('By {1}', [ playlistChannel.displayName ]) 94 ? this.player().localize('By {1}', [ playlistChannel.displayName ])
@@ -86,7 +109,7 @@ class PlaylistMenu extends Component {
86 109
87 const list = super.createEl('ol') 110 const list = super.createEl('ol')
88 111
89 for (const playlistElement of options.elements) { 112 for (const playlistElement of this.options_.elements) {
90 const item = new PlaylistMenuItem(this.player(), { 113 const item = new PlaylistMenuItem(this.player(), {
91 element: playlistElement, 114 element: playlistElement,
92 onClicked: () => this.onItemClicked(playlistElement) 115 onClicked: () => this.onItemClicked(playlistElement)
@@ -100,13 +123,13 @@ class PlaylistMenu extends Component {
100 menu.appendChild(header) 123 menu.appendChild(header)
101 menu.appendChild(list) 124 menu.appendChild(list)
102 125
126 this.update()
127
103 return menu 128 return menu
104 } 129 }
105 130
106 update () { 131 update () {
107 const options = this.getOptions() 132 this.updateSelected(this.options_.getCurrentPosition())
108
109 this.updateSelected(options.getCurrentPosition())
110 } 133 }
111 134
112 open () { 135 open () {
@@ -123,12 +146,8 @@ class PlaylistMenu extends Component {
123 } 146 }
124 } 147 }
125 148
126 private getOptions () {
127 return this.options_ as PlaylistPluginOptions
128 }
129
130 private onItemClicked (element: VideoPlaylistElement) { 149 private onItemClicked (element: VideoPlaylistElement) {
131 this.getOptions().onItemClicked(element) 150 this.options_.onItemClicked(element)
132 } 151 }
133} 152}
134 153
diff --git a/client/src/assets/player/shared/playlist/playlist-plugin.ts b/client/src/assets/player/shared/playlist/playlist-plugin.ts
index 44de0da5a..c00e45843 100644
--- a/client/src/assets/player/shared/playlist/playlist-plugin.ts
+++ b/client/src/assets/player/shared/playlist/playlist-plugin.ts
@@ -8,17 +8,10 @@ const Plugin = videojs.getPlugin('plugin')
8class PlaylistPlugin extends Plugin { 8class PlaylistPlugin extends Plugin {
9 private playlistMenu: PlaylistMenu 9 private playlistMenu: PlaylistMenu
10 private playlistButton: PlaylistButton 10 private playlistButton: PlaylistButton
11 private options: PlaylistPluginOptions
12 11
13 constructor (player: videojs.Player, options?: PlaylistPluginOptions) { 12 constructor (player: videojs.Player, options?: PlaylistPluginOptions) {
14 super(player, options) 13 super(player, options)
15 14
16 this.options = options
17
18 this.player.ready(() => {
19 player.addClass('vjs-playlist')
20 })
21
22 this.playlistMenu = new PlaylistMenu(player, options) 15 this.playlistMenu = new PlaylistMenu(player, options)
23 this.playlistButton = new PlaylistButton(player, { ...options, playlistMenu: this.playlistMenu }) 16 this.playlistButton = new PlaylistButton(player, { ...options, playlistMenu: this.playlistMenu })
24 17
@@ -26,8 +19,16 @@ class PlaylistPlugin extends Plugin {
26 player.addChild(this.playlistButton, options) 19 player.addChild(this.playlistButton, options)
27 } 20 }
28 21
29 updateSelected () { 22 dispose () {
30 this.playlistMenu.updateSelected(this.options.getCurrentPosition()) 23 this.player.removeClass('vjs-playlist')
24
25 this.playlistMenu.dispose()
26 this.playlistButton.dispose()
27
28 this.player.removeChild(this.playlistMenu)
29 this.player.removeChild(this.playlistButton)
30
31 super.dispose()
31 } 32 }
32} 33}
33 34
diff --git a/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts b/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts
index 4fafd27b1..4d6701003 100644
--- a/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts
+++ b/client/src/assets/player/shared/resolutions/peertube-resolutions-plugin.ts
@@ -8,7 +8,16 @@ class PeerTubeResolutionsPlugin extends Plugin {
8 private resolutions: PeerTubeResolution[] = [] 8 private resolutions: PeerTubeResolution[] = []
9 9
10 private autoResolutionChosenId: number 10 private autoResolutionChosenId: number
11 private autoResolutionEnabled = true 11
12 constructor (player: videojs.Player) {
13 super(player)
14
15 player.on('video-change', () => {
16 this.resolutions = []
17
18 this.trigger('resolutions-removed')
19 })
20 }
12 21
13 add (resolutions: PeerTubeResolution[]) { 22 add (resolutions: PeerTubeResolution[]) {
14 for (const r of resolutions) { 23 for (const r of resolutions) {
@@ -18,12 +27,12 @@ class PeerTubeResolutionsPlugin extends Plugin {
18 this.currentSelection = this.getSelected() 27 this.currentSelection = this.getSelected()
19 28
20 this.sort() 29 this.sort()
21 this.trigger('resolutionsAdded') 30 this.trigger('resolutions-added')
22 } 31 }
23 32
24 remove (resolutionIndex: number) { 33 remove (resolutionIndex: number) {
25 this.resolutions = this.resolutions.filter(r => r.id !== resolutionIndex) 34 this.resolutions = this.resolutions.filter(r => r.id !== resolutionIndex)
26 this.trigger('resolutionRemoved') 35 this.trigger('resolutions-removed')
27 } 36 }
28 37
29 getResolutions () { 38 getResolutions () {
@@ -40,10 +49,10 @@ class PeerTubeResolutionsPlugin extends Plugin {
40 49
41 select (options: { 50 select (options: {
42 id: number 51 id: number
43 byEngine: boolean 52 fireCallback: boolean
44 autoResolutionChosenId?: number 53 autoResolutionChosenId?: number
45 }) { 54 }) {
46 const { id, autoResolutionChosenId, byEngine } = options 55 const { id, autoResolutionChosenId, fireCallback } = options
47 56
48 if (this.currentSelection?.id === id && this.autoResolutionChosenId === autoResolutionChosenId) return 57 if (this.currentSelection?.id === id && this.autoResolutionChosenId === autoResolutionChosenId) return
49 58
@@ -55,25 +64,11 @@ class PeerTubeResolutionsPlugin extends Plugin {
55 if (r.selected) { 64 if (r.selected) {
56 this.currentSelection = r 65 this.currentSelection = r
57 66
58 if (!byEngine) r.selectCallback() 67 if (fireCallback) r.selectCallback()
59 } 68 }
60 } 69 }
61 70
62 this.trigger('resolutionChanged') 71 this.trigger('resolutions-changed')
63 }
64
65 disableAutoResolution () {
66 this.autoResolutionEnabled = false
67 this.trigger('autoResolutionEnabledChanged')
68 }
69
70 enabledAutoResolution () {
71 this.autoResolutionEnabled = true
72 this.trigger('autoResolutionEnabledChanged')
73 }
74
75 isAutoResolutionEnabeld () {
76 return this.autoResolutionEnabled
77 } 72 }
78 73
79 private sort () { 74 private sort () {
diff --git a/client/src/assets/player/shared/settings/resolution-menu-button.ts b/client/src/assets/player/shared/settings/resolution-menu-button.ts
index 672411c11..c39894284 100644
--- a/client/src/assets/player/shared/settings/resolution-menu-button.ts
+++ b/client/src/assets/player/shared/settings/resolution-menu-button.ts
@@ -11,12 +11,12 @@ class ResolutionMenuButton extends MenuButton {
11 11
12 this.controlText('Quality') 12 this.controlText('Quality')
13 13
14 player.peertubeResolutions().on('resolutionsAdded', () => this.buildQualities()) 14 player.peertubeResolutions().on('resolutions-added', () => this.update())
15 player.peertubeResolutions().on('resolutionRemoved', () => this.cleanupQualities()) 15 player.peertubeResolutions().on('resolutions-removed', () => this.update())
16 16
17 // For parent 17 // For parent
18 player.peertubeResolutions().on('resolutionChanged', () => { 18 player.peertubeResolutions().on('resolutions-changed', () => {
19 setTimeout(() => this.trigger('labelUpdated')) 19 setTimeout(() => this.trigger('label-updated'))
20 }) 20 })
21 } 21 }
22 22
@@ -37,69 +37,42 @@ class ResolutionMenuButton extends MenuButton {
37 } 37 }
38 38
39 createMenu () { 39 createMenu () {
40 return new Menu(this.player_) 40 const menu: videojs.Menu = new Menu(this.player_, { menuButton: this })
41 } 41 const resolutions = this.player().peertubeResolutions().getResolutions()
42
43 buildCSSClass () {
44 return super.buildCSSClass() + ' vjs-resolution-button'
45 }
46
47 buildWrapperCSSClass () {
48 return 'vjs-resolution-control ' + super.buildWrapperCSSClass()
49 }
50
51 private addClickListener (component: any) {
52 component.on('click', () => {
53 const children = this.menu.children()
54
55 for (const child of children) {
56 if (component !== child) {
57 (child as videojs.MenuItem).selected(false)
58 }
59 }
60 })
61 }
62 42
63 private buildQualities () { 43 for (const r of resolutions) {
64 for (const d of this.player().peertubeResolutions().getResolutions()) { 44 const label = r.label === '0p'
65 const label = d.label === '0p'
66 ? this.player().localize('Audio-only') 45 ? this.player().localize('Audio-only')
67 : d.label 46 : r.label
68 47
69 this.menu.addChild(new ResolutionMenuItem( 48 const component = new ResolutionMenuItem(
70 this.player_, 49 this.player_,
71 { 50 {
72 id: d.id + '', 51 id: r.id + '',
73 resolutionId: d.id, 52 resolutionId: r.id,
74 label, 53 label,
75 selected: d.selected 54 selected: r.selected
76 }) 55 }
77 ) 56 )
78 }
79 57
80 for (const m of this.menu.children()) { 58 menu.addItem(component)
81 this.addClickListener(m)
82 } 59 }
83 60
84 this.trigger('menuChanged') 61 return menu
85 } 62 }
86 63
87 private cleanupQualities () { 64 update () {
88 const resolutions = this.player().peertubeResolutions().getResolutions() 65 super.update()
89
90 this.menu.children().forEach((children: ResolutionMenuItem) => {
91 if (children.resolutionId === undefined) {
92 return
93 }
94 66
95 if (resolutions.find(r => r.id === children.resolutionId)) { 67 this.trigger('menu-changed')
96 return 68 }
97 }
98 69
99 this.menu.removeChild(children) 70 buildCSSClass () {
100 }) 71 return super.buildCSSClass() + ' vjs-resolution-button'
72 }
101 73
102 this.trigger('menuChanged') 74 buildWrapperCSSClass () {
75 return 'vjs-resolution-control ' + super.buildWrapperCSSClass()
103 } 76 }
104} 77}
105 78
diff --git a/client/src/assets/player/shared/settings/resolution-menu-item.ts b/client/src/assets/player/shared/settings/resolution-menu-item.ts
index c59b8b891..86387f533 100644
--- a/client/src/assets/player/shared/settings/resolution-menu-item.ts
+++ b/client/src/assets/player/shared/settings/resolution-menu-item.ts
@@ -10,35 +10,32 @@ class ResolutionMenuItem extends MenuItem {
10 readonly resolutionId: number 10 readonly resolutionId: number
11 private readonly label: string 11 private readonly label: string
12 12
13 private autoResolutionEnabled: boolean
14 private autoResolutionChosen: string 13 private autoResolutionChosen: string
15 14
16 constructor (player: videojs.Player, options?: ResolutionMenuItemOptions) { 15 private updateSelectionHandler: () => void
17 options.selectable = true
18 16
19 super(player, options) 17 constructor (player: videojs.Player, options?: ResolutionMenuItemOptions) {
18 super(player, { ...options, selectable: true })
20 19
21 this.autoResolutionEnabled = true
22 this.autoResolutionChosen = '' 20 this.autoResolutionChosen = ''
23 21
24 this.resolutionId = options.resolutionId 22 this.resolutionId = options.resolutionId
25 this.label = options.label 23 this.label = options.label
26 24
27 player.peertubeResolutions().on('resolutionChanged', () => this.updateSelection()) 25 this.updateSelectionHandler = () => this.updateSelection()
26 player.peertubeResolutions().on('resolutions-changed', this.updateSelectionHandler)
27 }
28
29 dispose () {
30 this.player().peertubeResolutions().off('resolutions-changed', this.updateSelectionHandler)
28 31
29 // We only want to disable the "Auto" item 32 super.dispose()
30 if (this.resolutionId === -1) {
31 player.peertubeResolutions().on('autoResolutionEnabledChanged', () => this.updateAutoResolution())
32 }
33 } 33 }
34 34
35 handleClick (event: any) { 35 handleClick (event: any) {
36 // Auto button disabled?
37 if (this.autoResolutionEnabled === false && this.resolutionId === -1) return
38
39 super.handleClick(event) 36 super.handleClick(event)
40 37
41 this.player().peertubeResolutions().select({ id: this.resolutionId, byEngine: false }) 38 this.player().peertubeResolutions().select({ id: this.resolutionId, fireCallback: true })
42 } 39 }
43 40
44 updateSelection () { 41 updateSelection () {
@@ -51,19 +48,6 @@ class ResolutionMenuItem extends MenuItem {
51 this.selected(this.resolutionId === selectedResolution.id) 48 this.selected(this.resolutionId === selectedResolution.id)
52 } 49 }
53 50
54 updateAutoResolution () {
55 const enabled = this.player().peertubeResolutions().isAutoResolutionEnabeld()
56
57 // Check if the auto resolution is enabled or not
58 if (enabled === false) {
59 this.addClass('disabled')
60 } else {
61 this.removeClass('disabled')
62 }
63
64 this.autoResolutionEnabled = enabled
65 }
66
67 getLabel () { 51 getLabel () {
68 if (this.resolutionId === -1) { 52 if (this.resolutionId === -1) {
69 return this.label + ' <small>' + this.autoResolutionChosen + '</small>' 53 return this.label + ' <small>' + this.autoResolutionChosen + '</small>'
diff --git a/client/src/assets/player/shared/settings/settings-dialog.ts b/client/src/assets/player/shared/settings/settings-dialog.ts
index f5fbbe7ad..ba39d0f45 100644
--- a/client/src/assets/player/shared/settings/settings-dialog.ts
+++ b/client/src/assets/player/shared/settings/settings-dialog.ts
@@ -28,6 +28,18 @@ class SettingsDialog extends Component {
28 'aria-describedby': dialogDescriptionId 28 'aria-describedby': dialogDescriptionId
29 }) 29 })
30 } 30 }
31
32 show () {
33 this.player().addClass('vjs-settings-dialog-opened')
34
35 super.show()
36 }
37
38 hide () {
39 this.player().removeClass('vjs-settings-dialog-opened')
40
41 super.hide()
42 }
31} 43}
32 44
33Component.registerComponent('SettingsDialog', SettingsDialog) 45Component.registerComponent('SettingsDialog', SettingsDialog)
diff --git a/client/src/assets/player/shared/settings/settings-menu-button.ts b/client/src/assets/player/shared/settings/settings-menu-button.ts
index 4cf29866b..9499a43eb 100644
--- a/client/src/assets/player/shared/settings/settings-menu-button.ts
+++ b/client/src/assets/player/shared/settings/settings-menu-button.ts
@@ -71,7 +71,7 @@ class SettingsButton extends Button {
71 } 71 }
72 } 72 }
73 73
74 onDisposeSettingsItem (event: any, name: string) { 74 onDisposeSettingsItem (_event: any, name: string) {
75 if (name === undefined) { 75 if (name === undefined) {
76 const children = this.menu.children() 76 const children = this.menu.children()
77 77
@@ -103,6 +103,8 @@ class SettingsButton extends Button {
103 if (this.isInIframe()) { 103 if (this.isInIframe()) {
104 window.removeEventListener('blur', this.documentClickHandler) 104 window.removeEventListener('blur', this.documentClickHandler)
105 } 105 }
106
107 super.dispose()
106 } 108 }
107 109
108 onAddSettingsItem (event: any, data: any) { 110 onAddSettingsItem (event: any, data: any) {
@@ -249,8 +251,8 @@ class SettingsButton extends Button {
249 } 251 }
250 252
251 resetChildren () { 253 resetChildren () {
252 for (const menuChild of this.menu.children()) { 254 for (const menuChild of this.menu.children() as SettingsMenuItem[]) {
253 (menuChild as SettingsMenuItem).reset() 255 menuChild.reset()
254 } 256 }
255 } 257 }
256 258
@@ -258,8 +260,8 @@ class SettingsButton extends Button {
258 * Hide all the sub menus 260 * Hide all the sub menus
259 */ 261 */
260 hideChildren () { 262 hideChildren () {
261 for (const menuChild of this.menu.children()) { 263 for (const menuChild of this.menu.children() as SettingsMenuItem[]) {
262 (menuChild as SettingsMenuItem).hideSubMenu() 264 menuChild.hideSubMenu()
263 } 265 }
264 } 266 }
265 267
diff --git a/client/src/assets/player/shared/settings/settings-menu-item.ts b/client/src/assets/player/shared/settings/settings-menu-item.ts
index 288e3b233..9916ae27f 100644
--- a/client/src/assets/player/shared/settings/settings-menu-item.ts
+++ b/client/src/assets/player/shared/settings/settings-menu-item.ts
@@ -70,17 +70,22 @@ class SettingsMenuItem extends MenuItem {
70 this.build() 70 this.build()
71 71
72 // Update on rate change 72 // Update on rate change
73 player.on('ratechange', this.submenuClickHandler) 73 if (subMenuName === 'PlaybackRateMenuButton') {
74 player.on('ratechange', this.submenuClickHandler)
75 }
74 76
75 if (subMenuName === 'CaptionsButton') { 77 if (subMenuName === 'CaptionsButton') {
76 // Hack to regenerate captions on HTTP fallback 78 player.on('captions-changed', () => {
77 player.on('captionsChanged', () => { 79 // Wait menu component rebuild
78 setTimeout(() => { 80 setTimeout(() => {
79 this.settingsSubMenuEl_.innerHTML = '' 81 this.rebuildAfterMenuChange()
80 this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el()) 82 }, 150)
81 this.update() 83 })
82 this.bindClickEvents() 84 }
83 }, 0) 85
86 if (subMenuName === 'ResolutionMenuButton') {
87 this.subMenu.on('menu-changed', () => {
88 this.rebuildAfterMenuChange()
84 }) 89 })
85 } 90 }
86 91
@@ -89,6 +94,12 @@ class SettingsMenuItem extends MenuItem {
89 }) 94 })
90 } 95 }
91 96
97 dispose () {
98 this.settingsSubMenuEl_.removeEventListener('transitionend', this.transitionEndHandler)
99
100 super.dispose()
101 }
102
92 eventHandlers () { 103 eventHandlers () {
93 this.submenuClickHandler = this.onSubmenuClick.bind(this) 104 this.submenuClickHandler = this.onSubmenuClick.bind(this)
94 this.transitionEndHandler = this.onTransitionEnd.bind(this) 105 this.transitionEndHandler = this.onTransitionEnd.bind(this)
@@ -190,27 +201,6 @@ class SettingsMenuItem extends MenuItem {
190 (button.el() as HTMLElement).innerHTML = this.player().localize(this.subMenu.controlText()) 201 (button.el() as HTMLElement).innerHTML = this.player().localize(this.subMenu.controlText())
191 } 202 }
192 203
193 /**
194 * Add/remove prefixed event listener for CSS Transition
195 *
196 * @method PrefixedEvent
197 */
198 PrefixedEvent (element: any, type: any, callback: any, action = 'addEvent') {
199 const prefix = [ 'webkit', 'moz', 'MS', 'o', '' ]
200
201 for (let p = 0; p < prefix.length; p++) {
202 if (!prefix[p]) {
203 type = type.toLowerCase()
204 }
205
206 if (action === 'addEvent') {
207 element.addEventListener(prefix[p] + type, callback, false)
208 } else if (action === 'removeEvent') {
209 element.removeEventListener(prefix[p] + type, callback, false)
210 }
211 }
212 }
213
214 onTransitionEnd (event: any) { 204 onTransitionEnd (event: any) {
215 if (event.propertyName !== 'margin-right') { 205 if (event.propertyName !== 'margin-right') {
216 return 206 return
@@ -254,12 +244,7 @@ class SettingsMenuItem extends MenuItem {
254 } 244 }
255 245
256 build () { 246 build () {
257 this.subMenu.on('labelUpdated', () => { 247 this.subMenu.on('label-updated', () => {
258 this.update()
259 })
260 this.subMenu.on('menuChanged', () => {
261 this.bindClickEvents()
262 this.setSize()
263 this.update() 248 this.update()
264 }) 249 })
265 250
@@ -272,25 +257,12 @@ class SettingsMenuItem extends MenuItem {
272 this.setSize() 257 this.setSize()
273 this.bindClickEvents() 258 this.bindClickEvents()
274 259
275 // prefixed event listeners for CSS TransitionEnd 260 this.settingsSubMenuEl_.addEventListener('transitionend', this.transitionEndHandler, false)
276 this.PrefixedEvent(
277 this.settingsSubMenuEl_,
278 'TransitionEnd',
279 this.transitionEndHandler,
280 'addEvent'
281 )
282 } 261 }
283 262
284 update (event?: any) { 263 update (event?: any) {
285 let target: HTMLElement = null
286 const subMenu = this.subMenu.name() 264 const subMenu = this.subMenu.name()
287 265
288 if (event && event.type === 'tap') {
289 target = event.target
290 } else if (event) {
291 target = event.currentTarget
292 }
293
294 // Playback rate menu button doesn't get a vjs-selected class 266 // Playback rate menu button doesn't get a vjs-selected class
295 // or sets options_['selected'] on the selected playback rate. 267 // or sets options_['selected'] on the selected playback rate.
296 // Thus we get the submenu value based on the labelEl of playbackRateMenuButton 268 // Thus we get the submenu value based on the labelEl of playbackRateMenuButton
@@ -321,6 +293,13 @@ class SettingsMenuItem extends MenuItem {
321 } 293 }
322 } 294 }
323 295
296 let target: HTMLElement = null
297 if (event && event.type === 'tap') {
298 target = event.target
299 } else if (event) {
300 target = event.currentTarget
301 }
302
324 if (target && !target.classList.contains('vjs-back-button')) { 303 if (target && !target.classList.contains('vjs-back-button')) {
325 this.settingsButton.hideDialog() 304 this.settingsButton.hideDialog()
326 } 305 }
@@ -369,6 +348,15 @@ class SettingsMenuItem extends MenuItem {
369 } 348 }
370 } 349 }
371 350
351 private rebuildAfterMenuChange () {
352 this.settingsSubMenuEl_.innerHTML = ''
353 this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el())
354 this.update()
355 this.createBackButton()
356 this.setSize()
357 this.bindClickEvents()
358 }
359
372} 360}
373 361
374(SettingsMenuItem as any).prototype.contentElType = 'button' 362(SettingsMenuItem as any).prototype.contentElType = 'button'
diff --git a/client/src/assets/player/shared/stats/stats-card.ts b/client/src/assets/player/shared/stats/stats-card.ts
index 471a5e46c..fad68cec9 100644
--- a/client/src/assets/player/shared/stats/stats-card.ts
+++ b/client/src/assets/player/shared/stats/stats-card.ts
@@ -7,7 +7,7 @@ import { bytes } from '../common'
7interface StatsCardOptions extends videojs.ComponentOptions { 7interface StatsCardOptions extends videojs.ComponentOptions {
8 videoUUID: string 8 videoUUID: string
9 videoIsLive: boolean 9 videoIsLive: boolean
10 mode: 'webtorrent' | 'p2p-media-loader' 10 mode: 'web-video' | 'p2p-media-loader'
11 p2pEnabled: boolean 11 p2pEnabled: boolean
12} 12}
13 13
@@ -34,7 +34,7 @@ class StatsCard extends Component {
34 34
35 updateInterval: any 35 updateInterval: any
36 36
37 mode: 'webtorrent' | 'p2p-media-loader' 37 mode: 'web-video' | 'p2p-media-loader'
38 38
39 metadataStore: any = {} 39 metadataStore: any = {}
40 40
@@ -63,6 +63,9 @@ class StatsCard extends Component {
63 63
64 private liveLatency: InfoElement 64 private liveLatency: InfoElement
65 65
66 private onP2PInfoHandler: (_event: any, data: EventPlayerNetworkInfo) => void
67 private onHTTPInfoHandler: (_event: any, data: EventPlayerNetworkInfo) => void
68
66 createEl () { 69 createEl () {
67 this.containerEl = videojs.dom.createEl('div', { 70 this.containerEl = videojs.dom.createEl('div', {
68 className: 'vjs-stats-content' 71 className: 'vjs-stats-content'
@@ -86,9 +89,7 @@ class StatsCard extends Component {
86 89
87 this.populateInfoBlocks() 90 this.populateInfoBlocks()
88 91
89 this.player_.on('p2pInfo', (event: any, data: EventPlayerNetworkInfo) => { 92 this.onP2PInfoHandler = (_event, data) => {
90 if (!data) return // HTTP fallback
91
92 this.mode = data.source 93 this.mode = data.source
93 94
94 const p2pStats = data.p2p 95 const p2pStats = data.p2p
@@ -105,11 +106,29 @@ class StatsCard extends Component {
105 this.playerNetworkInfo.downloadedFromServer = bytes(httpStats.downloaded).join(' ') 106 this.playerNetworkInfo.downloadedFromServer = bytes(httpStats.downloaded).join(' ')
106 this.playerNetworkInfo.downloadedFromPeers = bytes(p2pStats.downloaded).join(' ') 107 this.playerNetworkInfo.downloadedFromPeers = bytes(p2pStats.downloaded).join(' ')
107 } 108 }
108 }) 109 }
110
111 this.onHTTPInfoHandler = (_event, data) => {
112 this.mode = data.source
113
114 this.playerNetworkInfo.totalDownloaded = bytes(data.http.downloaded).join(' ')
115 }
116
117 this.player().on('p2p-info', this.onP2PInfoHandler)
118 this.player().on('http-info', this.onHTTPInfoHandler)
109 119
110 return this.containerEl 120 return this.containerEl
111 } 121 }
112 122
123 dispose () {
124 if (this.updateInterval) clearInterval(this.updateInterval)
125
126 this.player().off('p2p-info', this.onP2PInfoHandler)
127 this.player().off('http-info', this.onHTTPInfoHandler)
128
129 super.dispose()
130 }
131
113 toggle () { 132 toggle () {
114 if (this.updateInterval) this.hide() 133 if (this.updateInterval) this.hide()
115 else this.show() 134 else this.show()
@@ -122,7 +141,7 @@ class StatsCard extends Component {
122 try { 141 try {
123 const options = this.mode === 'p2p-media-loader' 142 const options = this.mode === 'p2p-media-loader'
124 ? this.buildHLSOptions() 143 ? this.buildHLSOptions()
125 : await this.buildWebTorrentOptions() // Default 144 : await this.buildWebVideoOptions() // Default
126 145
127 this.populateInfoValues(options) 146 this.populateInfoValues(options)
128 } catch (err) { 147 } catch (err) {
@@ -170,8 +189,8 @@ class StatsCard extends Component {
170 } 189 }
171 } 190 }
172 191
173 private async buildWebTorrentOptions () { 192 private async buildWebVideoOptions () {
174 const videoFile = this.player_.webtorrent().getCurrentVideoFile() 193 const videoFile = this.player_.webVideo().getCurrentVideoFile()
175 194
176 if (!this.metadataStore[videoFile.fileUrl]) { 195 if (!this.metadataStore[videoFile.fileUrl]) {
177 this.metadataStore[videoFile.fileUrl] = await fetch(videoFile.metadataUrl).then(res => res.json()) 196 this.metadataStore[videoFile.fileUrl] = await fetch(videoFile.metadataUrl).then(res => res.json())
@@ -194,7 +213,7 @@ class StatsCard extends Component {
194 213
195 const resolution = videoFile?.resolution.label + videoFile?.fps 214 const resolution = videoFile?.resolution.label + videoFile?.fps
196 const buffer = this.timeRangesToString(this.player_.buffered()) 215 const buffer = this.timeRangesToString(this.player_.buffered())
197 const progress = this.player_.webtorrent().getTorrent()?.progress 216 const progress = this.player_.bufferedPercent()
198 217
199 return { 218 return {
200 playerNetworkInfo: this.playerNetworkInfo, 219 playerNetworkInfo: this.playerNetworkInfo,
@@ -284,8 +303,10 @@ class StatsCard extends Component {
284 ? `${(progress * 100).toFixed(1)}% (${(progress * duration).toFixed(1)}s)` 303 ? `${(progress * 100).toFixed(1)}% (${(progress * duration).toFixed(1)}s)`
285 : undefined 304 : undefined
286 305
287 this.setInfoValue(this.playerMode, this.mode || 'HTTP') 306 const p2pEnabled = this.options_.p2pEnabled && this.mode === 'p2p-media-loader'
288 this.setInfoValue(this.p2p, player.localize(this.options_.p2pEnabled ? 'enabled' : 'disabled')) 307
308 this.setInfoValue(this.playerMode, this.mode)
309 this.setInfoValue(this.p2p, player.localize(p2pEnabled ? 'enabled' : 'disabled'))
289 this.setInfoValue(this.uuid, this.options_.videoUUID) 310 this.setInfoValue(this.uuid, this.options_.videoUUID)
290 311
291 this.setInfoValue(this.viewport, frames) 312 this.setInfoValue(this.viewport, frames)
diff --git a/client/src/assets/player/shared/stats/stats-plugin.ts b/client/src/assets/player/shared/stats/stats-plugin.ts
index 8aad80e8a..86684a78c 100644
--- a/client/src/assets/player/shared/stats/stats-plugin.ts
+++ b/client/src/assets/player/shared/stats/stats-plugin.ts
@@ -7,10 +7,6 @@ class StatsForNerdsPlugin extends Plugin {
7 private statsCard: StatsCard 7 private statsCard: StatsCard
8 8
9 constructor (player: videojs.Player, options: StatsCardOptions) { 9 constructor (player: videojs.Player, options: StatsCardOptions) {
10 const settings = {
11 ...options
12 }
13
14 super(player) 10 super(player)
15 11
16 this.player.ready(() => { 12 this.player.ready(() => {
@@ -19,7 +15,17 @@ class StatsForNerdsPlugin extends Plugin {
19 15
20 this.statsCard = new StatsCard(player, options) 16 this.statsCard = new StatsCard(player, options)
21 17
22 player.addChild(this.statsCard, settings) 18 // Copy options
19 player.addChild(this.statsCard)
20 }
21
22 dispose () {
23 if (this.statsCard) {
24 this.statsCard.dispose()
25 this.player.removeChild(this.statsCard)
26 }
27
28 super.dispose()
23 } 29 }
24 30
25 show () { 31 show () {
diff --git a/client/src/assets/player/shared/upnext/end-card.ts b/client/src/assets/player/shared/upnext/end-card.ts
index 61668e407..3589e1fd8 100644
--- a/client/src/assets/player/shared/upnext/end-card.ts
+++ b/client/src/assets/player/shared/upnext/end-card.ts
@@ -1,6 +1,7 @@
1import videojs from 'video.js' 1import videojs from 'video.js'
2import { UpNextPluginOptions } from '../../types'
2 3
3function getMainTemplate (options: any) { 4function getMainTemplate (options: EndCardOptions) {
4 return ` 5 return `
5 <div class="vjs-upnext-top"> 6 <div class="vjs-upnext-top">
6 <span class="vjs-upnext-headtext">${options.headText}</span> 7 <span class="vjs-upnext-headtext">${options.headText}</span>
@@ -23,15 +24,10 @@ function getMainTemplate (options: any) {
23 ` 24 `
24} 25}
25 26
26export interface EndCardOptions extends videojs.ComponentOptions { 27export interface EndCardOptions extends videojs.ComponentOptions, UpNextPluginOptions {
27 next: () => void
28 getTitle: () => string
29 timeout: number
30 cancelText: string 28 cancelText: string
31 headText: string 29 headText: string
32 suspendedText: string 30 suspendedText: string
33 condition: () => boolean
34 suspended: () => boolean
35} 31}
36 32
37const Component = videojs.getComponent('Component') 33const Component = videojs.getComponent('Component')
@@ -52,27 +48,43 @@ class EndCard extends Component {
52 suspendedMessage: HTMLElement 48 suspendedMessage: HTMLElement
53 nextButton: HTMLElement 49 nextButton: HTMLElement
54 50
51 private onEndedHandler: () => void
52 private onPlayingHandler: () => void
53
55 constructor (player: videojs.Player, options: EndCardOptions) { 54 constructor (player: videojs.Player, options: EndCardOptions) {
56 super(player, options) 55 super(player, options)
57 56
58 this.totalTicks = this.options_.timeout / this.interval 57 this.totalTicks = this.options_.timeout / this.interval
59 58
60 player.on('ended', (_: any) => { 59 this.onEndedHandler = () => {
61 if (!this.options_.condition()) return 60 if (!this.options_.isDisplayed()) return
62 61
63 player.addClass('vjs-upnext--showing') 62 player.addClass('vjs-upnext--showing')
64 this.showCard((canceled: boolean) => { 63
64 this.showCard(canceled => {
65 player.removeClass('vjs-upnext--showing') 65 player.removeClass('vjs-upnext--showing')
66
66 this.container.style.display = 'none' 67 this.container.style.display = 'none'
68
67 if (!canceled) { 69 if (!canceled) {
68 this.options_.next() 70 this.options_.next()
69 } 71 }
70 }) 72 })
71 }) 73 }
72 74
73 player.on('playing', () => { 75 this.onPlayingHandler = () => {
74 this.upNextEvents.trigger('playing') 76 this.upNextEvents.trigger('playing')
75 }) 77 }
78
79 player.on([ 'auto-stopped', 'ended' ], this.onEndedHandler)
80 player.on('playing', this.onPlayingHandler)
81 }
82
83 dispose () {
84 if (this.onEndedHandler) this.player().off([ 'auto-stopped', 'ended' ], this.onEndedHandler)
85 if (this.onPlayingHandler) this.player().off('playing', this.onPlayingHandler)
86
87 super.dispose()
76 } 88 }
77 89
78 createEl () { 90 createEl () {
@@ -101,7 +113,7 @@ class EndCard extends Component {
101 return container 113 return container
102 } 114 }
103 115
104 showCard (cb: (value: boolean) => void) { 116 showCard (cb: (canceled: boolean) => void) {
105 let timeout: any 117 let timeout: any
106 118
107 this.autoplayRing.setAttribute('stroke-dasharray', `${this.dashOffsetStart}`) 119 this.autoplayRing.setAttribute('stroke-dasharray', `${this.dashOffsetStart}`)
@@ -109,6 +121,10 @@ class EndCard extends Component {
109 121
110 this.title.innerHTML = this.options_.getTitle() 122 this.title.innerHTML = this.options_.getTitle()
111 123
124 if (this.totalTicks === 0) {
125 return cb(false)
126 }
127
112 this.upNextEvents.one('cancel', () => { 128 this.upNextEvents.one('cancel', () => {
113 clearTimeout(timeout) 129 clearTimeout(timeout)
114 cb(true) 130 cb(true)
@@ -134,7 +150,7 @@ class EndCard extends Component {
134 } 150 }
135 151
136 const update = () => { 152 const update = () => {
137 if (this.options_.suspended()) { 153 if (this.options_.isSuspended()) {
138 this.suspendedMessage.innerText = this.options_.suspendedText 154 this.suspendedMessage.innerText = this.options_.suspendedText
139 goToPercent(0) 155 goToPercent(0)
140 this.ticks = 0 156 this.ticks = 0
diff --git a/client/src/assets/player/shared/upnext/upnext-plugin.ts b/client/src/assets/player/shared/upnext/upnext-plugin.ts
index e12e8c503..0badcd68c 100644
--- a/client/src/assets/player/shared/upnext/upnext-plugin.ts
+++ b/client/src/assets/player/shared/upnext/upnext-plugin.ts
@@ -1,27 +1,25 @@
1import videojs from 'video.js' 1import videojs from 'video.js'
2import { UpNextPluginOptions } from '../../types'
2import { EndCardOptions } from './end-card' 3import { EndCardOptions } from './end-card'
3 4
4const Plugin = videojs.getPlugin('plugin') 5const Plugin = videojs.getPlugin('plugin')
5 6
6class UpNextPlugin extends Plugin { 7class UpNextPlugin extends Plugin {
7 8
8 constructor (player: videojs.Player, options: Partial<EndCardOptions> = {}) { 9 constructor (player: videojs.Player, options: UpNextPluginOptions) {
9 const settings = { 10 super(player)
11
12 const settings: EndCardOptions = {
10 next: options.next, 13 next: options.next,
11 getTitle: options.getTitle, 14 getTitle: options.getTitle,
12 timeout: options.timeout || 5000, 15 timeout: options.timeout,
13 cancelText: options.cancelText || 'Cancel', 16 cancelText: player.localize('Cancel'),
14 headText: options.headText || 'Up Next', 17 headText: player.localize('Up Next'),
15 suspendedText: options.suspendedText || 'Autoplay is suspended', 18 suspendedText: player.localize('Autoplay is suspended'),
16 condition: options.condition, 19 isDisplayed: options.isDisplayed,
17 suspended: options.suspended 20 isSuspended: options.isSuspended
18 } 21 }
19 22
20 super(player)
21
22 // UpNext plugin can be called later, so ensure the player is not disposed
23 if (this.player.isDisposed()) return
24
25 this.player.ready(() => { 23 this.player.ready(() => {
26 player.addClass('vjs-upnext') 24 player.addClass('vjs-upnext')
27 }) 25 })
diff --git a/client/src/assets/player/shared/web-video/web-video-plugin.ts b/client/src/assets/player/shared/web-video/web-video-plugin.ts
new file mode 100644
index 000000000..80e56795b
--- /dev/null
+++ b/client/src/assets/player/shared/web-video/web-video-plugin.ts
@@ -0,0 +1,186 @@
1import debug from 'debug'
2import videojs from 'video.js'
3import { logger } from '@root-helpers/logger'
4import { addQueryParams } from '@shared/core-utils'
5import { VideoFile } from '@shared/models'
6import { PeerTubeResolution, PlayerNetworkInfo, WebVideoPluginOptions } from '../../types'
7
8const debugLogger = debug('peertube:player:web-video-plugin')
9
10const Plugin = videojs.getPlugin('plugin')
11
12class WebVideoPlugin extends Plugin {
13 private readonly videoFiles: VideoFile[]
14
15 private currentVideoFile: VideoFile
16 private videoFileToken: () => string
17
18 private networkInfoInterval: any
19
20 private onErrorHandler: () => void
21 private onPlayHandler: () => void
22
23 constructor (player: videojs.Player, options?: WebVideoPluginOptions) {
24 super(player, options)
25
26 this.videoFiles = options.videoFiles
27 this.videoFileToken = options.videoFileToken
28
29 this.updateVideoFile({ videoFile: this.pickAverageVideoFile(), isUserResolutionChange: false })
30
31 player.ready(() => {
32 this.buildQualities()
33
34 this.setupNetworkInfoInterval()
35
36 if (this.videoFiles.length === 0) {
37 this.player.addClass('disabled')
38 return
39 }
40 })
41 }
42
43 dispose () {
44 clearInterval(this.networkInfoInterval)
45
46 if (this.onErrorHandler) this.player.off('error', this.onErrorHandler)
47 if (this.onPlayHandler) this.player.off('canplay', this.onPlayHandler)
48
49 super.dispose()
50 }
51
52 getCurrentResolutionId () {
53 return this.currentVideoFile.resolution.id
54 }
55
56 updateVideoFile (options: {
57 videoFile: VideoFile
58 isUserResolutionChange: boolean
59 }) {
60 this.currentVideoFile = options.videoFile
61
62 debugLogger('Updating web video file to ' + this.currentVideoFile.fileUrl)
63
64 const paused = this.player.paused()
65 const playbackRate = this.player.playbackRate()
66 const currentTime = this.player.currentTime()
67
68 // Enable error display now this is our last fallback
69 this.onErrorHandler = () => this.player.peertube().displayFatalError()
70 this.player.one('error', this.onErrorHandler)
71
72 let httpUrl = this.currentVideoFile.fileUrl
73
74 if (this.videoFileToken()) {
75 httpUrl = addQueryParams(httpUrl, { videoFileToken: this.videoFileToken() })
76 }
77
78 const oldAutoplayValue = this.player.autoplay()
79 if (options.isUserResolutionChange) {
80 this.player.autoplay(false)
81 this.player.addClass('vjs-updating-resolution')
82 }
83
84 this.player.src(httpUrl)
85
86 this.onPlayHandler = () => {
87 this.player.playbackRate(playbackRate)
88 this.player.currentTime(currentTime)
89
90 this.adaptPosterForAudioOnly()
91
92 if (options.isUserResolutionChange) {
93 this.player.trigger('user-resolution-change')
94 this.player.trigger('web-video-source-change')
95
96 this.tryToPlay()
97 .then(() => {
98 if (paused) this.player.pause()
99
100 this.player.autoplay(oldAutoplayValue)
101 })
102 }
103 }
104
105 this.player.one('canplay', this.onPlayHandler)
106 }
107
108 getCurrentVideoFile () {
109 return this.currentVideoFile
110 }
111
112 private adaptPosterForAudioOnly () {
113 // Audio-only (resolutionId === 0) gets special treatment
114 if (this.currentVideoFile.resolution.id === 0) {
115 this.player.audioPosterMode(true)
116 } else {
117 this.player.audioPosterMode(false)
118 }
119 }
120
121 private tryToPlay () {
122 debugLogger('Try to play manually the video')
123
124 const playPromise = this.player.play()
125 if (playPromise === undefined) return
126
127 return playPromise
128 .catch((err: Error) => {
129 if (err.message.includes('The play() request was interrupted by a call to pause()')) {
130 return
131 }
132
133 logger.warn(err)
134 this.player.pause()
135 this.player.posterImage.show()
136 this.player.removeClass('vjs-has-autoplay')
137 this.player.removeClass('vjs-playing-audio-only-content')
138 })
139 .finally(() => {
140 this.player.removeClass('vjs-updating-resolution')
141 })
142 }
143
144 private pickAverageVideoFile () {
145 if (this.videoFiles.length === 1) return this.videoFiles[0]
146
147 const files = this.videoFiles.filter(f => f.resolution.id !== 0)
148 return files[Math.floor(files.length / 2)]
149 }
150
151 private buildQualities () {
152 const resolutions: PeerTubeResolution[] = this.videoFiles.map(videoFile => ({
153 id: videoFile.resolution.id,
154 label: this.buildQualityLabel(videoFile),
155 height: videoFile.resolution.id,
156 selected: videoFile.id === this.currentVideoFile.id,
157 selectCallback: () => this.updateVideoFile({ videoFile, isUserResolutionChange: true })
158 }))
159
160 this.player.peertubeResolutions().add(resolutions)
161 }
162
163 private buildQualityLabel (file: VideoFile) {
164 let label = file.resolution.label
165
166 if (file.fps && file.fps >= 50) {
167 label += file.fps
168 }
169
170 return label
171 }
172
173 private setupNetworkInfoInterval () {
174 this.networkInfoInterval = setInterval(() => {
175 return this.player.trigger('http-info', {
176 source: 'web-video',
177 http: {
178 downloaded: this.player.bufferedPercent() * this.currentVideoFile.size
179 }
180 } as PlayerNetworkInfo)
181 }, 1000)
182 }
183}
184
185videojs.registerPlugin('webVideo', WebVideoPlugin)
186export { WebVideoPlugin }
diff --git a/client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts b/client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts
deleted file mode 100644
index 74ae17704..000000000
--- a/client/src/assets/player/shared/webtorrent/peertube-chunk-store.ts
+++ /dev/null
@@ -1,234 +0,0 @@
1// From https://github.com/MinEduTDF/idb-chunk-store
2// We use temporary IndexDB (all data are removed on destroy) to avoid RAM issues
3// Thanks @santiagogil and @Feross
4
5import Dexie from 'dexie'
6import { EventEmitter } from 'events'
7import { logger } from '@root-helpers/logger'
8
9class ChunkDatabase extends Dexie {
10 chunks: Dexie.Table<{ id: number, buf: Buffer }, number>
11
12 constructor (dbname: string) {
13 super(dbname)
14
15 this.version(1).stores({
16 chunks: 'id'
17 })
18 }
19}
20
21class ExpirationDatabase extends Dexie {
22 databases: Dexie.Table<{ name: string, expiration: number }, number>
23
24 constructor () {
25 super('webtorrent-expiration')
26
27 this.version(1).stores({
28 databases: 'name,expiration'
29 })
30 }
31}
32
33export class PeertubeChunkStore extends EventEmitter {
34 private static readonly BUFFERING_PUT_MS = 1000
35 private static readonly CLEANER_INTERVAL_MS = 1000 * 60 // 1 minute
36 private static readonly CLEANER_EXPIRATION_MS = 1000 * 60 * 5 // 5 minutes
37
38 chunkLength: number
39
40 private pendingPut: { id: number, buf: Buffer, cb: (err?: Error) => void }[] = []
41 // If the store is full
42 private memoryChunks: { [ id: number ]: Buffer | true } = {}
43 private databaseName: string
44 private putBulkTimeout: any
45 private cleanerInterval: any
46 private db: ChunkDatabase
47 private expirationDB: ExpirationDatabase
48 private readonly length: number
49 private readonly lastChunkLength: number
50 private readonly lastChunkIndex: number
51
52 constructor (chunkLength: number, opts: any) {
53 super()
54
55 this.databaseName = 'webtorrent-chunks-'
56
57 if (!opts) opts = {}
58 if (opts.torrent?.infoHash) this.databaseName += opts.torrent.infoHash
59 else this.databaseName += '-default'
60
61 this.setMaxListeners(100)
62
63 this.chunkLength = Number(chunkLength)
64 if (!this.chunkLength) throw new Error('First argument must be a chunk length')
65
66 this.length = Number(opts.length) || Infinity
67
68 if (this.length !== Infinity) {
69 this.lastChunkLength = (this.length % this.chunkLength) || this.chunkLength
70 this.lastChunkIndex = Math.ceil(this.length / this.chunkLength) - 1
71 }
72
73 this.db = new ChunkDatabase(this.databaseName)
74 // Track databases that expired
75 this.expirationDB = new ExpirationDatabase()
76
77 this.runCleaner()
78 }
79
80 put (index: number, buf: Buffer, cb: (err?: Error) => void) {
81 const isLastChunk = (index === this.lastChunkIndex)
82 if (isLastChunk && buf.length !== this.lastChunkLength) {
83 return this.nextTick(cb, new Error('Last chunk length must be ' + this.lastChunkLength))
84 }
85 if (!isLastChunk && buf.length !== this.chunkLength) {
86 return this.nextTick(cb, new Error('Chunk length must be ' + this.chunkLength))
87 }
88
89 // Specify we have this chunk
90 this.memoryChunks[index] = true
91
92 // Add it to the pending put
93 this.pendingPut.push({ id: index, buf, cb })
94 // If it's already planned, return
95 if (this.putBulkTimeout) return
96
97 // Plan a future bulk insert
98 this.putBulkTimeout = setTimeout(async () => {
99 const processing = this.pendingPut
100 this.pendingPut = []
101 this.putBulkTimeout = undefined
102
103 try {
104 await this.db.transaction('rw', this.db.chunks, () => {
105 return this.db.chunks.bulkPut(processing.map(p => ({ id: p.id, buf: p.buf })))
106 })
107 } catch (err) {
108 logger.info('Cannot bulk insert chunks. Store them in memory.', err)
109
110 processing.forEach(p => {
111 this.memoryChunks[p.id] = p.buf
112 })
113 } finally {
114 processing.forEach(p => p.cb())
115 }
116 }, PeertubeChunkStore.BUFFERING_PUT_MS)
117 }
118
119 get (index: number, opts: any, cb: (err?: Error, buf?: Buffer) => void): void {
120 if (typeof opts === 'function') return this.get(index, null, opts)
121
122 // IndexDB could be slow, use our memory index first
123 const memoryChunk = this.memoryChunks[index]
124 if (memoryChunk === undefined) {
125 const err = new Error('Chunk not found') as any
126 err['notFound'] = true
127
128 return process.nextTick(() => cb(err))
129 }
130
131 // Chunk in memory
132 if (memoryChunk !== true) return cb(null, memoryChunk)
133
134 // Chunk in store
135 this.db.transaction('r', this.db.chunks, async () => {
136 const result = await this.db.chunks.get({ id: index })
137 if (result === undefined) return cb(null, Buffer.alloc(0))
138
139 const buf = result.buf
140 if (!opts) return this.nextTick(cb, null, buf)
141
142 const offset = opts.offset || 0
143 const len = opts.length || (buf.length - offset)
144 return cb(null, buf.slice(offset, len + offset))
145 })
146 .catch(err => {
147 logger.error(err)
148 return cb(err)
149 })
150 }
151
152 close (cb: (err?: Error) => void) {
153 return this.destroy(cb)
154 }
155
156 async destroy (cb: (err?: Error) => void) {
157 try {
158 if (this.pendingPut) {
159 clearTimeout(this.putBulkTimeout)
160 this.pendingPut = null
161 }
162 if (this.cleanerInterval) {
163 clearInterval(this.cleanerInterval)
164 this.cleanerInterval = null
165 }
166
167 if (this.db) {
168 this.db.close()
169
170 await this.dropDatabase(this.databaseName)
171 }
172
173 if (this.expirationDB) {
174 this.expirationDB.close()
175 this.expirationDB = null
176 }
177
178 return cb()
179 } catch (err) {
180 logger.error('Cannot destroy peertube chunk store.', err)
181 return cb(err)
182 }
183 }
184
185 private runCleaner () {
186 this.checkExpiration()
187
188 this.cleanerInterval = setInterval(() => {
189 this.checkExpiration()
190 }, PeertubeChunkStore.CLEANER_INTERVAL_MS)
191 }
192
193 private async checkExpiration () {
194 let databasesToDeleteInfo: { name: string }[] = []
195
196 try {
197 await this.expirationDB.transaction('rw', this.expirationDB.databases, async () => {
198 // Update our database expiration since we are alive
199 await this.expirationDB.databases.put({
200 name: this.databaseName,
201 expiration: new Date().getTime() + PeertubeChunkStore.CLEANER_EXPIRATION_MS
202 })
203
204 const now = new Date().getTime()
205 databasesToDeleteInfo = await this.expirationDB.databases.where('expiration').below(now).toArray()
206 })
207 } catch (err) {
208 logger.error('Cannot update expiration of fetch expired databases.', err)
209 }
210
211 for (const databaseToDeleteInfo of databasesToDeleteInfo) {
212 await this.dropDatabase(databaseToDeleteInfo.name)
213 }
214 }
215
216 private async dropDatabase (databaseName: string) {
217 const dbToDelete = new ChunkDatabase(databaseName)
218 logger.info(`Destroying IndexDB database ${databaseName}`)
219
220 try {
221 await dbToDelete.delete()
222
223 await this.expirationDB.transaction('rw', this.expirationDB.databases, () => {
224 return this.expirationDB.databases.where({ name: databaseName }).delete()
225 })
226 } catch (err) {
227 logger.error(`Cannot delete ${databaseName}.`, err)
228 }
229 }
230
231 private nextTick <T> (cb: (err?: Error, val?: T) => void, err: Error, val?: T) {
232 process.nextTick(() => cb(err, val), undefined)
233 }
234}
diff --git a/client/src/assets/player/shared/webtorrent/video-renderer.ts b/client/src/assets/player/shared/webtorrent/video-renderer.ts
deleted file mode 100644
index a85d7a838..000000000
--- a/client/src/assets/player/shared/webtorrent/video-renderer.ts
+++ /dev/null
@@ -1,134 +0,0 @@
1// Thanks: https://github.com/feross/render-media
2
3const MediaElementWrapper = require('mediasource')
4import { logger } from '@root-helpers/logger'
5import { extname } from 'path'
6const Videostream = require('videostream')
7
8const VIDEOSTREAM_EXTS = [
9 '.m4a',
10 '.m4v',
11 '.mp4'
12]
13
14type RenderMediaOptions = {
15 controls: boolean
16 autoplay: boolean
17}
18
19function renderVideo (
20 file: any,
21 elem: HTMLVideoElement,
22 opts: RenderMediaOptions,
23 callback: (err: Error, renderer: any) => void
24) {
25 validateFile(file)
26
27 return renderMedia(file, elem, opts, callback)
28}
29
30function renderMedia (file: any, elem: HTMLVideoElement, opts: RenderMediaOptions, callback: (err: Error, renderer?: any) => void) {
31 const extension = extname(file.name).toLowerCase()
32 let preparedElem: any
33 let currentTime = 0
34 let renderer: any
35
36 try {
37 if (VIDEOSTREAM_EXTS.includes(extension)) {
38 renderer = useVideostream()
39 } else {
40 renderer = useMediaSource()
41 }
42 } catch (err) {
43 return callback(err)
44 }
45
46 function useVideostream () {
47 prepareElem()
48 preparedElem.addEventListener('error', function onError (err: Error) {
49 preparedElem.removeEventListener('error', onError)
50
51 return callback(err)
52 })
53 preparedElem.addEventListener('loadstart', onLoadStart)
54 return new Videostream(file, preparedElem)
55 }
56
57 function useMediaSource (useVP9 = false) {
58 const codecs = getCodec(file.name, useVP9)
59
60 prepareElem()
61 preparedElem.addEventListener('error', function onError (err: Error) {
62 preparedElem.removeEventListener('error', onError)
63
64 // Try with vp9 before returning an error
65 if (codecs.includes('vp8')) return fallbackToMediaSource(true)
66
67 return callback(err)
68 })
69 preparedElem.addEventListener('loadstart', onLoadStart)
70
71 const wrapper = new MediaElementWrapper(preparedElem)
72 const writable = wrapper.createWriteStream(codecs)
73 file.createReadStream().pipe(writable)
74
75 if (currentTime) preparedElem.currentTime = currentTime
76
77 return wrapper
78 }
79
80 function fallbackToMediaSource (useVP9 = false) {
81 if (useVP9 === true) logger.info('Falling back to media source with VP9 enabled.')
82 else logger.info('Falling back to media source..')
83
84 useMediaSource(useVP9)
85 }
86
87 function prepareElem () {
88 if (preparedElem === undefined) {
89 preparedElem = elem
90
91 preparedElem.addEventListener('progress', function () {
92 currentTime = elem.currentTime
93 })
94 }
95 }
96
97 function onLoadStart () {
98 preparedElem.removeEventListener('loadstart', onLoadStart)
99 if (opts.autoplay) preparedElem.play()
100
101 callback(null, renderer)
102 }
103}
104
105function validateFile (file: any) {
106 if (file == null) {
107 throw new Error('file cannot be null or undefined')
108 }
109 if (typeof file.name !== 'string') {
110 throw new Error('missing or invalid file.name property')
111 }
112 if (typeof file.createReadStream !== 'function') {
113 throw new Error('missing or invalid file.createReadStream property')
114 }
115}
116
117function getCodec (name: string, useVP9 = false) {
118 const ext = extname(name).toLowerCase()
119 if (ext === '.mp4') {
120 return 'video/mp4; codecs="avc1.640029, mp4a.40.5"'
121 }
122
123 if (ext === '.webm') {
124 if (useVP9 === true) return 'video/webm; codecs="vp9, opus"'
125
126 return 'video/webm; codecs="vp8, vorbis"'
127 }
128
129 return undefined
130}
131
132export {
133 renderVideo
134}
diff --git a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts b/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts
deleted file mode 100644
index 3dde44a60..000000000
--- a/client/src/assets/player/shared/webtorrent/webtorrent-plugin.ts
+++ /dev/null
@@ -1,663 +0,0 @@
1import videojs from 'video.js'
2import * as WebTorrent from 'webtorrent'
3import { logger } from '@root-helpers/logger'
4import { isIOS } from '@root-helpers/web-browser'
5import { addQueryParams, timeToInt } from '@shared/core-utils'
6import { VideoFile } from '@shared/models'
7import { getAverageBandwidthInStore, getStoredMute, getStoredVolume, saveAverageBandwidth } from '../../peertube-player-local-storage'
8import { PeerTubeResolution, PlayerNetworkInfo, WebtorrentPluginOptions } from '../../types'
9import { getRtcConfig, videoFileMaxByResolution, videoFileMinByResolution } from '../common'
10import { PeertubeChunkStore } from './peertube-chunk-store'
11import { renderVideo } from './video-renderer'
12
13const CacheChunkStore = require('cache-chunk-store')
14
15type PlayOptions = {
16 forcePlay?: boolean
17 seek?: number
18 delay?: number
19}
20
21const Plugin = videojs.getPlugin('plugin')
22
23class WebTorrentPlugin extends Plugin {
24 readonly videoFiles: VideoFile[]
25
26 private readonly playerElement: HTMLVideoElement
27
28 private readonly autoplay: boolean | string = false
29 private readonly startTime: number = 0
30 private readonly savePlayerSrcFunction: videojs.Player['src']
31 private readonly videoDuration: number
32 private readonly CONSTANTS = {
33 INFO_SCHEDULER: 1000, // Don't change this
34 AUTO_QUALITY_SCHEDULER: 3000, // Check quality every 3 seconds
35 AUTO_QUALITY_THRESHOLD_PERCENT: 30, // Bandwidth should be 30% more important than a resolution bitrate to change to it
36 AUTO_QUALITY_OBSERVATION_TIME: 10000, // Wait 10 seconds after having change the resolution before another check
37 AUTO_QUALITY_HIGHER_RESOLUTION_DELAY: 5000, // Buffering higher resolution during 5 seconds
38 BANDWIDTH_AVERAGE_NUMBER_OF_VALUES: 5 // Last 5 seconds to build average bandwidth
39 }
40
41 private readonly buildWebSeedUrls: (file: VideoFile) => string[]
42
43 private readonly webtorrent = new WebTorrent({
44 tracker: {
45 rtcConfig: getRtcConfig()
46 },
47 dht: false
48 })
49
50 private currentVideoFile: VideoFile
51 private torrent: WebTorrent.Torrent
52
53 private renderer: any
54 private fakeRenderer: any
55 private destroyingFakeRenderer = false
56
57 private autoResolution = true
58 private autoResolutionPossible = true
59 private isAutoResolutionObservation = false
60 private playerRefusedP2P = false
61
62 private requiresAuth: boolean
63 private videoFileToken: () => string
64
65 private torrentInfoInterval: any
66 private autoQualityInterval: any
67 private addTorrentDelay: any
68 private qualityObservationTimer: any
69 private runAutoQualitySchedulerTimer: any
70
71 private downloadSpeeds: number[] = []
72
73 constructor (player: videojs.Player, options?: WebtorrentPluginOptions) {
74 super(player)
75
76 this.startTime = timeToInt(options.startTime)
77
78 // Custom autoplay handled by webtorrent because we lazy play the video
79 this.autoplay = options.autoplay
80
81 this.playerRefusedP2P = options.playerRefusedP2P
82
83 this.videoFiles = options.videoFiles
84 this.videoDuration = options.videoDuration
85
86 this.savePlayerSrcFunction = this.player.src
87 this.playerElement = options.playerElement
88
89 this.requiresAuth = options.requiresAuth
90 this.videoFileToken = options.videoFileToken
91
92 this.buildWebSeedUrls = options.buildWebSeedUrls
93
94 this.player.ready(() => {
95 const playerOptions = this.player.options_
96
97 const volume = getStoredVolume()
98 if (volume !== undefined) this.player.volume(volume)
99
100 const muted = playerOptions.muted !== undefined ? playerOptions.muted : getStoredMute()
101 if (muted !== undefined) this.player.muted(muted)
102
103 this.player.duration(options.videoDuration)
104
105 this.initializePlayer()
106 this.runTorrentInfoScheduler()
107
108 this.player.one('play', () => {
109 // Don't run immediately scheduler, wait some seconds the TCP connections are made
110 this.runAutoQualitySchedulerTimer = setTimeout(() => this.runAutoQualityScheduler(), this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
111 })
112 })
113 }
114
115 dispose () {
116 clearTimeout(this.addTorrentDelay)
117 clearTimeout(this.qualityObservationTimer)
118 clearTimeout(this.runAutoQualitySchedulerTimer)
119
120 clearInterval(this.torrentInfoInterval)
121 clearInterval(this.autoQualityInterval)
122
123 // Don't need to destroy renderer, video player will be destroyed
124 this.flushVideoFile(this.currentVideoFile, false)
125
126 this.destroyFakeRenderer()
127 }
128
129 getCurrentResolutionId () {
130 return this.currentVideoFile ? this.currentVideoFile.resolution.id : -1
131 }
132
133 updateVideoFile (
134 videoFile?: VideoFile,
135 options: {
136 forcePlay?: boolean
137 seek?: number
138 delay?: number
139 } = {},
140 done: () => void = () => { /* empty */ }
141 ) {
142 // Automatically choose the adapted video file
143 if (!videoFile) {
144 const savedAverageBandwidth = getAverageBandwidthInStore()
145 videoFile = savedAverageBandwidth
146 ? this.getAppropriateFile(savedAverageBandwidth)
147 : this.pickAverageVideoFile()
148 }
149
150 if (!videoFile) {
151 throw Error(`Can't update video file since videoFile is undefined.`)
152 }
153
154 // Don't add the same video file once again
155 if (this.currentVideoFile !== undefined && this.currentVideoFile.magnetUri === videoFile.magnetUri) {
156 return
157 }
158
159 // Do not display error to user because we will have multiple fallback
160 this.player.peertube().hideFatalError();
161
162 // Hack to "simulate" src link in video.js >= 6
163 // Without this, we can't play the video after pausing it
164 // https://github.com/videojs/video.js/blob/master/src/js/player.js#L1633
165 (this.player as any).src = () => true
166 const oldPlaybackRate = this.player.playbackRate()
167
168 const previousVideoFile = this.currentVideoFile
169 this.currentVideoFile = videoFile
170
171 // Don't try on iOS that does not support MediaSource
172 // Or don't use P2P if webtorrent is disabled
173 if (isIOS() || this.playerRefusedP2P) {
174 return this.fallbackToHttp(options, () => {
175 this.player.playbackRate(oldPlaybackRate)
176 return done()
177 })
178 }
179
180 this.addTorrent(this.currentVideoFile.magnetUri, previousVideoFile, options, () => {
181 this.player.playbackRate(oldPlaybackRate)
182 return done()
183 })
184
185 this.selectAppropriateResolution(true)
186 }
187
188 updateEngineResolution (resolutionId: number, delay = 0) {
189 // Remember player state
190 const currentTime = this.player.currentTime()
191 const isPaused = this.player.paused()
192
193 // Hide bigPlayButton
194 if (!isPaused) {
195 this.player.bigPlayButton.hide()
196 }
197
198 // Audio-only (resolutionId === 0) gets special treatment
199 if (resolutionId === 0) {
200 // Audio-only: show poster, do not auto-hide controls
201 this.player.addClass('vjs-playing-audio-only-content')
202 this.player.posterImage.show()
203 } else {
204 // Hide poster to have black background
205 this.player.removeClass('vjs-playing-audio-only-content')
206 this.player.posterImage.hide()
207 }
208
209 const newVideoFile = this.videoFiles.find(f => f.resolution.id === resolutionId)
210 const options = {
211 forcePlay: false,
212 delay,
213 seek: currentTime + (delay / 1000)
214 }
215
216 this.updateVideoFile(newVideoFile, options)
217
218 this.player.trigger('engineResolutionChange')
219 }
220
221 flushVideoFile (videoFile: VideoFile, destroyRenderer = true) {
222 if (videoFile !== undefined && this.webtorrent.get(videoFile.magnetUri)) {
223 if (destroyRenderer === true && this.renderer && this.renderer.destroy) this.renderer.destroy()
224
225 this.webtorrent.remove(videoFile.magnetUri)
226 logger.info(`Removed ${videoFile.magnetUri}`)
227 }
228 }
229
230 disableAutoResolution () {
231 this.autoResolution = false
232 this.autoResolutionPossible = false
233 this.player.peertubeResolutions().disableAutoResolution()
234 }
235
236 isAutoResolutionPossible () {
237 return this.autoResolutionPossible
238 }
239
240 getTorrent () {
241 return this.torrent
242 }
243
244 getCurrentVideoFile () {
245 return this.currentVideoFile
246 }
247
248 changeQuality (id: number) {
249 if (id === -1) {
250 if (this.autoResolutionPossible === true) {
251 this.autoResolution = true
252
253 this.selectAppropriateResolution(false)
254 }
255
256 return
257 }
258
259 this.autoResolution = false
260 this.updateEngineResolution(id)
261 this.selectAppropriateResolution(false)
262 }
263
264 private addTorrent (
265 magnetOrTorrentUrl: string,
266 previousVideoFile: VideoFile,
267 options: PlayOptions,
268 done: (err?: Error) => void
269 ) {
270 if (!magnetOrTorrentUrl) return this.fallbackToHttp(options, done)
271
272 logger.info(`Adding ${magnetOrTorrentUrl}.`)
273
274 const oldTorrent = this.torrent
275 const torrentOptions = {
276 // Don't use arrow function: it breaks webtorrent (that uses `new` keyword)
277 store: function (chunkLength: number, storeOpts: any) {
278 return new CacheChunkStore(new PeertubeChunkStore(chunkLength, storeOpts), {
279 max: 100
280 })
281 },
282 urlList: this.buildWebSeedUrls(this.currentVideoFile)
283 }
284
285 this.torrent = this.webtorrent.add(magnetOrTorrentUrl, torrentOptions, torrent => {
286 logger.info(`Added ${magnetOrTorrentUrl}.`)
287
288 if (oldTorrent) {
289 // Pause the old torrent
290 this.stopTorrent(oldTorrent)
291
292 // We use a fake renderer so we download correct pieces of the next file
293 if (options.delay) this.renderFileInFakeElement(torrent.files[0], options.delay)
294 }
295
296 // Render the video in a few seconds? (on resolution change for example, we wait some seconds of the new video resolution)
297 this.addTorrentDelay = setTimeout(() => {
298 // We don't need the fake renderer anymore
299 this.destroyFakeRenderer()
300
301 const paused = this.player.paused()
302
303 this.flushVideoFile(previousVideoFile)
304
305 // Update progress bar (just for the UI), do not wait rendering
306 if (options.seek) this.player.currentTime(options.seek)
307
308 const renderVideoOptions = { autoplay: false, controls: true }
309 renderVideo(torrent.files[0], this.playerElement, renderVideoOptions, (err, renderer) => {
310 this.renderer = renderer
311
312 if (err) return this.fallbackToHttp(options, done)
313
314 return this.tryToPlay(err => {
315 if (err) return done(err)
316
317 if (options.seek) this.seek(options.seek)
318 if (options.forcePlay === false && paused === true) this.player.pause()
319
320 return done()
321 })
322 })
323 }, options.delay || 0)
324 })
325
326 this.torrent.on('error', (err: any) => logger.error(err))
327
328 this.torrent.on('warning', (err: any) => {
329 // We don't support HTTP tracker but we don't care -> we use the web socket tracker
330 if (err.message.indexOf('Unsupported tracker protocol') !== -1) return
331
332 // Users don't care about issues with WebRTC, but developers do so log it in the console
333 if (err.message.indexOf('Ice connection failed') !== -1) {
334 logger.info(err)
335 return
336 }
337
338 // Magnet hash is not up to date with the torrent file, add directly the torrent file
339 if (err.message.indexOf('incorrect info hash') !== -1) {
340 logger.error('Incorrect info hash detected, falling back to torrent file.')
341 const newOptions = { forcePlay: true, seek: options.seek }
342 return this.addTorrent((this.torrent as any)['xs'], previousVideoFile, newOptions, done)
343 }
344
345 // Remote instance is down
346 if (err.message.indexOf('from xs param') !== -1) {
347 this.handleError(err)
348 }
349
350 logger.warn(err)
351 })
352 }
353
354 private tryToPlay (done?: (err?: Error) => void) {
355 if (!done) done = function () { /* empty */ }
356
357 const playPromise = this.player.play()
358 if (playPromise !== undefined) {
359 return playPromise.then(() => done())
360 .catch((err: Error) => {
361 if (err.message.includes('The play() request was interrupted by a call to pause()')) {
362 return
363 }
364
365 logger.warn(err)
366 this.player.pause()
367 this.player.posterImage.show()
368 this.player.removeClass('vjs-has-autoplay')
369 this.player.removeClass('vjs-has-big-play-button-clicked')
370 this.player.removeClass('vjs-playing-audio-only-content')
371
372 return done()
373 })
374 }
375
376 return done()
377 }
378
379 private seek (time: number) {
380 this.player.currentTime(time)
381 this.player.handleTechSeeked_()
382 }
383
384 private getAppropriateFile (averageDownloadSpeed?: number): VideoFile {
385 if (this.videoFiles === undefined) return undefined
386 if (this.videoFiles.length === 1) return this.videoFiles[0]
387
388 const files = this.videoFiles.filter(f => f.resolution.id !== 0)
389 if (files.length === 0) return undefined
390
391 // Don't change the torrent if the player ended
392 if (this.torrent && this.torrent.progress === 1 && this.player.ended()) return this.currentVideoFile
393
394 if (!averageDownloadSpeed) averageDownloadSpeed = this.getAndSaveActualDownloadSpeed()
395
396 // Limit resolution according to player height
397 const playerHeight = this.playerElement.offsetHeight
398
399 // We take the first resolution just above the player height
400 // Example: player height is 530px, we want the 720p file instead of 480p
401 let maxResolution = files[0].resolution.id
402 for (let i = files.length - 1; i >= 0; i--) {
403 const resolutionId = files[i].resolution.id
404 if (resolutionId !== 0 && resolutionId >= playerHeight) {
405 maxResolution = resolutionId
406 break
407 }
408 }
409
410 // Filter videos we can play according to our screen resolution and bandwidth
411 const filteredFiles = files.filter(f => f.resolution.id <= maxResolution)
412 .filter(f => {
413 const fileBitrate = (f.size / this.videoDuration)
414 let threshold = fileBitrate
415
416 // If this is for a higher resolution or an initial load: add a margin
417 if (!this.currentVideoFile || f.resolution.id > this.currentVideoFile.resolution.id) {
418 threshold += ((fileBitrate * this.CONSTANTS.AUTO_QUALITY_THRESHOLD_PERCENT) / 100)
419 }
420
421 return averageDownloadSpeed > threshold
422 })
423
424 // If the download speed is too bad, return the lowest resolution we have
425 if (filteredFiles.length === 0) return videoFileMinByResolution(files)
426
427 return videoFileMaxByResolution(filteredFiles)
428 }
429
430 private getAndSaveActualDownloadSpeed () {
431 const start = Math.max(this.downloadSpeeds.length - this.CONSTANTS.BANDWIDTH_AVERAGE_NUMBER_OF_VALUES, 0)
432 const lastDownloadSpeeds = this.downloadSpeeds.slice(start, this.downloadSpeeds.length)
433 if (lastDownloadSpeeds.length === 0) return -1
434
435 const sum = lastDownloadSpeeds.reduce((a, b) => a + b)
436 const averageBandwidth = Math.round(sum / lastDownloadSpeeds.length)
437
438 // Save the average bandwidth for future use
439 saveAverageBandwidth(averageBandwidth)
440
441 return averageBandwidth
442 }
443
444 private initializePlayer () {
445 this.buildQualities()
446
447 if (this.videoFiles.length === 0) {
448 this.player.addClass('disabled')
449 return
450 }
451
452 if (this.autoplay !== false) {
453 this.player.posterImage.hide()
454
455 return this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
456 }
457
458 // Proxy first play
459 const oldPlay = this.player.play.bind(this.player);
460 (this.player as any).play = () => {
461 this.player.addClass('vjs-has-big-play-button-clicked')
462 this.player.play = oldPlay
463
464 this.updateVideoFile(undefined, { forcePlay: true, seek: this.startTime })
465 }
466 }
467
468 private runAutoQualityScheduler () {
469 this.autoQualityInterval = setInterval(() => {
470
471 // Not initialized or in HTTP fallback
472 if (this.torrent === undefined || this.torrent === null) return
473 if (this.autoResolution === false) return
474 if (this.isAutoResolutionObservation === true) return
475
476 const file = this.getAppropriateFile()
477 let changeResolution = false
478 let changeResolutionDelay = 0
479
480 // Lower resolution
481 if (this.isPlayerWaiting() && file.resolution.id < this.currentVideoFile.resolution.id) {
482 logger.info(`Downgrading automatically the resolution to: ${file.resolution.label}`)
483 changeResolution = true
484 } else if (file.resolution.id > this.currentVideoFile.resolution.id) { // Higher resolution
485 logger.info(`Upgrading automatically the resolution to: ${file.resolution.label}`)
486 changeResolution = true
487 changeResolutionDelay = this.CONSTANTS.AUTO_QUALITY_HIGHER_RESOLUTION_DELAY
488 }
489
490 if (changeResolution === true) {
491 this.updateEngineResolution(file.resolution.id, changeResolutionDelay)
492
493 // Wait some seconds in observation of our new resolution
494 this.isAutoResolutionObservation = true
495
496 this.qualityObservationTimer = setTimeout(() => {
497 this.isAutoResolutionObservation = false
498 }, this.CONSTANTS.AUTO_QUALITY_OBSERVATION_TIME)
499 }
500 }, this.CONSTANTS.AUTO_QUALITY_SCHEDULER)
501 }
502
503 private isPlayerWaiting () {
504 return this.player?.hasClass('vjs-waiting')
505 }
506
507 private runTorrentInfoScheduler () {
508 this.torrentInfoInterval = setInterval(() => {
509 // Not initialized yet
510 if (this.torrent === undefined) return
511
512 // Http fallback
513 if (this.torrent === null) return this.player.trigger('p2pInfo', false)
514
515 // this.webtorrent.downloadSpeed because we need to take into account the potential old torrent too
516 if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed)
517
518 return this.player.trigger('p2pInfo', {
519 source: 'webtorrent',
520 http: {
521 downloadSpeed: 0,
522 downloaded: 0
523 },
524 p2p: {
525 downloadSpeed: this.torrent.downloadSpeed,
526 numPeers: this.torrent.numPeers,
527 uploadSpeed: this.torrent.uploadSpeed,
528 downloaded: this.torrent.downloaded,
529 uploaded: this.torrent.uploaded
530 },
531 bandwidthEstimate: this.webtorrent.downloadSpeed
532 } as PlayerNetworkInfo)
533 }, this.CONSTANTS.INFO_SCHEDULER)
534 }
535
536 private fallbackToHttp (options: PlayOptions, done?: (err?: Error) => void) {
537 const paused = this.player.paused()
538
539 this.disableAutoResolution()
540
541 this.flushVideoFile(this.currentVideoFile, true)
542 this.torrent = null
543
544 // Enable error display now this is our last fallback
545 this.player.one('error', () => this.player.peertube().displayFatalError())
546
547 let httpUrl = this.currentVideoFile.fileUrl
548
549 if (this.requiresAuth && this.videoFileToken) {
550 httpUrl = addQueryParams(httpUrl, { videoFileToken: this.videoFileToken() })
551 }
552
553 this.player.src = this.savePlayerSrcFunction
554 this.player.src(httpUrl)
555
556 this.selectAppropriateResolution(true)
557
558 // We changed the source, so reinit captions
559 this.player.trigger('sourcechange')
560
561 return this.tryToPlay(err => {
562 if (err && done) return done(err)
563
564 if (options.seek) this.seek(options.seek)
565 if (options.forcePlay === false && paused === true) this.player.pause()
566
567 if (done) return done()
568 })
569 }
570
571 private handleError (err: Error | string) {
572 return this.player.trigger('customError', { err })
573 }
574
575 private pickAverageVideoFile () {
576 if (this.videoFiles.length === 1) return this.videoFiles[0]
577
578 const files = this.videoFiles.filter(f => f.resolution.id !== 0)
579 return files[Math.floor(files.length / 2)]
580 }
581
582 private stopTorrent (torrent: WebTorrent.Torrent) {
583 torrent.pause()
584 // Pause does not remove actual peers (in particular the webseed peer)
585 torrent.removePeer((torrent as any)['ws'])
586 }
587
588 private renderFileInFakeElement (file: WebTorrent.TorrentFile, delay: number) {
589 this.destroyingFakeRenderer = false
590
591 const fakeVideoElem = document.createElement('video')
592 renderVideo(file, fakeVideoElem, { autoplay: false, controls: false }, (err, renderer) => {
593 this.fakeRenderer = renderer
594
595 // The renderer returns an error when we destroy it, so skip them
596 if (this.destroyingFakeRenderer === false && err) {
597 logger.error('Cannot render new torrent in fake video element.', err)
598 }
599
600 // Load the future file at the correct time (in delay MS - 2 seconds)
601 fakeVideoElem.currentTime = this.player.currentTime() + (delay - 2000)
602 })
603 }
604
605 private destroyFakeRenderer () {
606 if (this.fakeRenderer) {
607 this.destroyingFakeRenderer = true
608
609 if (this.fakeRenderer.destroy) {
610 try {
611 this.fakeRenderer.destroy()
612 } catch (err) {
613 logger.info('Cannot destroy correctly fake renderer.', err)
614 }
615 }
616 this.fakeRenderer = undefined
617 }
618 }
619
620 private buildQualities () {
621 const resolutions: PeerTubeResolution[] = this.videoFiles.map(file => ({
622 id: file.resolution.id,
623 label: this.buildQualityLabel(file),
624 height: file.resolution.id,
625 selected: false,
626 selectCallback: () => this.changeQuality(file.resolution.id)
627 }))
628
629 resolutions.push({
630 id: -1,
631 label: this.player.localize('Auto'),
632 selected: true,
633 selectCallback: () => this.changeQuality(-1)
634 })
635
636 this.player.peertubeResolutions().add(resolutions)
637 }
638
639 private buildQualityLabel (file: VideoFile) {
640 let label = file.resolution.label
641
642 if (file.fps && file.fps >= 50) {
643 label += file.fps
644 }
645
646 return label
647 }
648
649 private selectAppropriateResolution (byEngine: boolean) {
650 const resolution = this.autoResolution
651 ? -1
652 : this.getCurrentResolutionId()
653
654 const autoResolutionChosen = this.autoResolution
655 ? this.getCurrentResolutionId()
656 : undefined
657
658 this.player.peertubeResolutions().select({ id: resolution, autoResolutionChosenId: autoResolutionChosen, byEngine })
659 }
660}
661
662videojs.registerPlugin('webtorrent', WebTorrentPlugin)
663export { WebTorrentPlugin }
diff --git a/client/src/assets/player/types/index.ts b/client/src/assets/player/types/index.ts
index b73e0b3cb..4bf49f65c 100644
--- a/client/src/assets/player/types/index.ts
+++ b/client/src/assets/player/types/index.ts
@@ -1,2 +1,2 @@
1export * from './manager-options' 1export * from './peertube-player-options'
2export * from './peertube-videojs-typings' 2export * from './peertube-videojs-typings'
diff --git a/client/src/assets/player/types/manager-options.ts b/client/src/assets/player/types/manager-options.ts
deleted file mode 100644
index c14fd7e99..000000000
--- a/client/src/assets/player/types/manager-options.ts
+++ /dev/null
@@ -1,98 +0,0 @@
1import { PluginsManager } from '@root-helpers/plugins-manager'
2import { LiveVideoLatencyMode, VideoFile } from '@shared/models'
3import { PlaylistPluginOptions, VideoJSCaption } from './peertube-videojs-typings'
4
5export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
6
7export type WebtorrentOptions = {
8 videoFiles: VideoFile[]
9}
10
11export type P2PMediaLoaderOptions = {
12 playlistUrl: string
13 segmentsSha256Url: string
14 trackerAnnounce: string[]
15 redundancyBaseUrls: string[]
16 videoFiles: VideoFile[]
17}
18
19export interface CustomizationOptions {
20 startTime: number | string
21 stopTime: number | string
22
23 controls?: boolean
24 controlBar?: boolean
25
26 muted?: boolean
27 loop?: boolean
28 subtitle?: string
29 resume?: string
30
31 peertubeLink: boolean
32
33 playbackRate?: number | string
34}
35
36export interface CommonOptions extends CustomizationOptions {
37 playerElement: HTMLVideoElement
38 onPlayerElementChange: (element: HTMLVideoElement) => void
39
40 autoplay: boolean
41 forceAutoplay: boolean
42
43 p2pEnabled: boolean
44
45 nextVideo?: () => void
46 hasNextVideo?: () => boolean
47
48 previousVideo?: () => void
49 hasPreviousVideo?: () => boolean
50
51 playlist?: PlaylistPluginOptions
52
53 videoDuration: number
54 enableHotkeys: boolean
55 inactivityTimeout: number
56 poster: string
57
58 videoViewIntervalMs: number
59
60 instanceName: string
61
62 theaterButton: boolean
63 captions: boolean
64
65 videoViewUrl: string
66 authorizationHeader?: () => string
67
68 metricsUrl: string
69
70 embedUrl: string
71 embedTitle: string
72
73 isLive: boolean
74 liveOptions?: {
75 latencyMode: LiveVideoLatencyMode
76 }
77
78 language?: string
79
80 videoCaptions: VideoJSCaption[]
81
82 videoUUID: string
83 videoShortUUID: string
84
85 serverUrl: string
86 requiresAuth: boolean
87 videoFileToken: () => string
88
89 errorNotifier: (message: string) => void
90}
91
92export type PeertubePlayerManagerOptions = {
93 common: CommonOptions
94 webtorrent: WebtorrentOptions
95 p2pMediaLoader?: P2PMediaLoaderOptions
96
97 pluginsManager: PluginsManager
98}
diff --git a/client/src/assets/player/types/peertube-player-options.ts b/client/src/assets/player/types/peertube-player-options.ts
new file mode 100644
index 000000000..e1b8c7fab
--- /dev/null
+++ b/client/src/assets/player/types/peertube-player-options.ts
@@ -0,0 +1,117 @@
1import { PluginsManager } from '@root-helpers/plugins-manager'
2import { LiveVideoLatencyMode, VideoFile } from '@shared/models'
3import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
4import { PlaylistPluginOptions, VideoJSCaption, VideoJSStoryboard } from './peertube-videojs-typings'
5
6export type PlayerMode = 'web-video' | 'p2p-media-loader'
7
8export type PeerTubePlayerContructorOptions = {
9 playerElement: () => HTMLVideoElement
10
11 controls: boolean
12 controlBar: boolean
13
14 muted: boolean
15 loop: boolean
16
17 peertubeLink: () => boolean
18
19 playbackRate?: number | string
20
21 enableHotkeys: boolean
22 inactivityTimeout: number
23
24 videoViewIntervalMs: number
25
26 instanceName: string
27
28 theaterButton: boolean
29
30 authorizationHeader: () => string
31
32 metricsUrl: string
33 serverUrl: string
34
35 errorNotifier: (message: string) => void
36
37 // Current web browser language
38 language: string
39
40 pluginsManager: PluginsManager
41}
42
43export type PeerTubePlayerLoadOptions = {
44 mode: PlayerMode
45
46 startTime?: number | string
47 stopTime?: number | string
48
49 autoplay: boolean
50 forceAutoplay: boolean
51
52 poster: string
53 subtitle?: string
54 videoViewUrl: string
55
56 embedUrl: string
57 embedTitle: string
58
59 isLive: boolean
60
61 liveOptions?: {
62 latencyMode: LiveVideoLatencyMode
63 }
64
65 videoCaptions: VideoJSCaption[]
66 storyboard: VideoJSStoryboard
67
68 videoUUID: string
69 videoShortUUID: string
70
71 duration: number
72
73 requiresUserAuth: boolean
74 videoFileToken: () => string
75 requiresPassword: boolean
76 videoPassword: () => string
77
78 nextVideo: {
79 enabled: boolean
80 getVideoTitle: () => string
81 handler?: () => void
82 displayControlBarButton: boolean
83 }
84
85 previousVideo: {
86 enabled: boolean
87 handler?: () => void
88 displayControlBarButton: boolean
89 }
90
91 upnext?: {
92 isEnabled: () => boolean
93 isSuspended: (player: videojs.VideoJsPlayer) => boolean
94 timeout: number
95 }
96
97 dock?: PeerTubeDockPluginOptions
98
99 playlist?: PlaylistPluginOptions
100
101 p2pEnabled: boolean
102
103 hls?: HLSOptions
104 webVideo?: WebVideoOptions
105}
106
107export type WebVideoOptions = {
108 videoFiles: VideoFile[]
109}
110
111export type HLSOptions = {
112 playlistUrl: string
113 segmentsSha256Url: string
114 trackerAnnounce: string[]
115 redundancyBaseUrls: string[]
116 videoFiles: VideoFile[]
117}
diff --git a/client/src/assets/player/types/peertube-videojs-typings.ts b/client/src/assets/player/types/peertube-videojs-typings.ts
index eadf56cfa..f10fc03a8 100644
--- a/client/src/assets/player/types/peertube-videojs-typings.ts
+++ b/client/src/assets/player/types/peertube-videojs-typings.ts
@@ -2,8 +2,11 @@ import { HlsConfig, Level } from 'hls.js'
2import videojs from 'video.js' 2import videojs from 'video.js'
3import { Engine } from '@peertube/p2p-media-loader-hlsjs' 3import { Engine } from '@peertube/p2p-media-loader-hlsjs'
4import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models' 4import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models'
5import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' 5import { BezelsPlugin } from '../shared/bezels/bezels-plugin'
6import { HotkeysOptions } from '../shared/hotkeys/peertube-hotkeys-plugin' 6import { StoryboardPlugin } from '../shared/control-bar/storyboard-plugin'
7import { PeerTubeDockPlugin, PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
8import { HotkeysOptions, PeerTubeHotkeysPlugin } from '../shared/hotkeys/peertube-hotkeys-plugin'
9import { PeerTubeMobilePlugin } from '../shared/mobile/peertube-mobile-plugin'
7import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin' 10import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin'
8import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin' 11import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin'
9import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager' 12import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager'
@@ -12,9 +15,10 @@ import { PlaylistPlugin } from '../shared/playlist/playlist-plugin'
12import { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin' 15import { PeerTubeResolutionsPlugin } from '../shared/resolutions/peertube-resolutions-plugin'
13import { StatsCardOptions } from '../shared/stats/stats-card' 16import { StatsCardOptions } from '../shared/stats/stats-card'
14import { StatsForNerdsPlugin } from '../shared/stats/stats-plugin' 17import { StatsForNerdsPlugin } from '../shared/stats/stats-plugin'
15import { EndCardOptions } from '../shared/upnext/end-card' 18import { UpNextPlugin } from '../shared/upnext/upnext-plugin'
16import { WebTorrentPlugin } from '../shared/webtorrent/webtorrent-plugin' 19import { WebVideoPlugin } from '../shared/web-video/web-video-plugin'
17import { PlayerMode } from './manager-options' 20import { PlayerMode } from './peertube-player-options'
21import { SegmentValidator } from '../shared/p2p-media-loader/segment-validator'
18 22
19declare module 'video.js' { 23declare module 'video.js' {
20 24
@@ -31,33 +35,36 @@ declare module 'video.js' {
31 35
32 handleTechSeeked_ (): void 36 handleTechSeeked_ (): void
33 37
38 textTracks (): TextTrackList & {
39 tracks_: (TextTrack & { id: string, label: string, src: string })[]
40 }
41
34 // Plugins 42 // Plugins
35 43
36 peertube (): PeerTubePlugin 44 peertube (): PeerTubePlugin
37 45
38 webtorrent (): WebTorrentPlugin 46 webVideo (options?: any): WebVideoPlugin
39 47
40 p2pMediaLoader (): P2pMediaLoaderPlugin 48 p2pMediaLoader (options?: any): P2pMediaLoaderPlugin
49 hlsjs (options?: any): any
41 50
42 peertubeResolutions (): PeerTubeResolutionsPlugin 51 peertubeResolutions (): PeerTubeResolutionsPlugin
43 52
44 contextmenuUI (options: any): any 53 contextmenuUI (options?: any): any
45 54
46 bezels (): void 55 bezels (): BezelsPlugin
47 peertubeMobile (): void 56 peertubeMobile (): PeerTubeMobilePlugin
48 peerTubeHotkeysPlugin (options?: HotkeysOptions): void 57 peerTubeHotkeysPlugin (options?: HotkeysOptions): PeerTubeHotkeysPlugin
49 58
50 stats (options?: StatsCardOptions): StatsForNerdsPlugin 59 stats (options?: StatsCardOptions): StatsForNerdsPlugin
51 60
52 textTracks (): TextTrackList & { 61 storyboard (options?: StoryboardOptions): StoryboardPlugin
53 tracks_: (TextTrack & { id: string, label: string, src: string })[]
54 }
55 62
56 peertubeDock (options: PeerTubeDockPluginOptions): void 63 peertubeDock (options?: PeerTubeDockPluginOptions): PeerTubeDockPlugin
57 64
58 upnext (options: Partial<EndCardOptions>): void 65 upnext (options?: UpNextPluginOptions): UpNextPlugin
59 66
60 playlist (): PlaylistPlugin 67 playlist (options?: PlaylistPluginOptions): PlaylistPlugin
61 } 68 }
62} 69}
63 70
@@ -89,33 +96,43 @@ type VideoJSCaption = {
89 src: string 96 src: string
90} 97}
91 98
99type VideoJSStoryboard = {
100 url: string
101 width: number
102 height: number
103 interval: number
104}
105
92type PeerTubePluginOptions = { 106type PeerTubePluginOptions = {
93 mode: PlayerMode 107 hasAutoplay: () => videojs.Autoplay
94 108
95 autoplay: videojs.Autoplay 109 videoViewUrl: () => string
96 videoDuration: number 110 videoViewIntervalMs: number
97 111
98 videoViewUrl: string
99 authorizationHeader?: () => string 112 authorizationHeader?: () => string
100 113
101 subtitle?: string 114 videoDuration: () => number
102 115
103 videoCaptions: VideoJSCaption[] 116 startTime: () => number | string
104 117 stopTime: () => number | string
105 startTime: number | string
106 stopTime: number | string
107 118
108 isLive: boolean 119 videoCaptions: () => VideoJSCaption[]
109 120 isLive: () => boolean
110 videoUUID: string 121 videoUUID: () => string
111 122 subtitle: () => string
112 videoViewIntervalMs: number
113} 123}
114 124
115type MetricsPluginOptions = { 125type MetricsPluginOptions = {
116 mode: PlayerMode 126 mode: () => PlayerMode
117 metricsUrl: string 127 metricsUrl: () => string
118 videoUUID: string 128 videoUUID: () => string
129}
130
131type StoryboardOptions = {
132 url: string
133 width: number
134 height: number
135 interval: number
119} 136}
120 137
121type PlaylistPluginOptions = { 138type PlaylistPluginOptions = {
@@ -128,37 +145,36 @@ type PlaylistPluginOptions = {
128 onItemClicked: (element: VideoPlaylistElement) => void 145 onItemClicked: (element: VideoPlaylistElement) => void
129} 146}
130 147
148type UpNextPluginOptions = {
149 timeout: number
150
151 next: () => void
152 getTitle: () => string
153 isDisplayed: () => boolean
154 isSuspended: () => boolean
155}
156
131type NextPreviousVideoButtonOptions = { 157type NextPreviousVideoButtonOptions = {
132 type: 'next' | 'previous' 158 type: 'next' | 'previous'
133 handler: () => void 159 handler?: () => void
160 isDisplayed: () => boolean
134 isDisabled: () => boolean 161 isDisabled: () => boolean
135} 162}
136 163
137type PeerTubeLinkButtonOptions = { 164type PeerTubeLinkButtonOptions = {
138 shortUUID: string 165 isDisplayed: () => boolean
166 shortUUID: () => string
139 instanceName: string 167 instanceName: string
140} 168}
141 169
142type PeerTubeP2PInfoButtonOptions = { 170type TheaterButtonOptions = {
143 p2pEnabled: boolean 171 isDisplayed: () => boolean
144} 172}
145 173
146type WebtorrentPluginOptions = { 174type WebVideoPluginOptions = {
147 playerElement: HTMLVideoElement
148
149 autoplay: videojs.Autoplay
150 videoDuration: number
151
152 videoFiles: VideoFile[] 175 videoFiles: VideoFile[]
153
154 startTime: number | string 176 startTime: number | string
155
156 playerRefusedP2P: boolean
157
158 requiresAuth: boolean
159 videoFileToken: () => string 177 videoFileToken: () => string
160
161 buildWebSeedUrls: (file: VideoFile) => string[]
162} 178}
163 179
164type P2PMediaLoaderPluginOptions = { 180type P2PMediaLoaderPluginOptions = {
@@ -166,16 +182,17 @@ type P2PMediaLoaderPluginOptions = {
166 type: string 182 type: string
167 src: string 183 src: string
168 184
169 startTime: number | string
170
171 loader: P2PMediaLoader 185 loader: P2PMediaLoader
186 segmentValidator: SegmentValidator
172 187
173 requiresAuth: boolean 188 requiresUserAuth: boolean
174 videoFileToken: () => string 189 videoFileToken: () => string
175} 190}
176 191
177export type P2PMediaLoader = { 192export type P2PMediaLoader = {
178 getEngine(): Engine 193 getEngine(): Engine
194
195 destroy: () => void
179} 196}
180 197
181type VideoJSPluginOptions = { 198type VideoJSPluginOptions = {
@@ -184,7 +201,7 @@ type VideoJSPluginOptions = {
184 peertube: PeerTubePluginOptions 201 peertube: PeerTubePluginOptions
185 metrics: MetricsPluginOptions 202 metrics: MetricsPluginOptions
186 203
187 webtorrent?: WebtorrentPluginOptions 204 webVideo?: WebVideoPluginOptions
188 205
189 p2pMediaLoader?: P2PMediaLoaderPluginOptions 206 p2pMediaLoader?: P2PMediaLoaderPluginOptions
190} 207}
@@ -211,14 +228,14 @@ type AutoResolutionUpdateData = {
211} 228}
212 229
213type PlayerNetworkInfo = { 230type PlayerNetworkInfo = {
214 source: 'webtorrent' | 'p2p-media-loader' 231 source: 'web-video' | 'p2p-media-loader'
215 232
216 http: { 233 http: {
217 downloadSpeed: number 234 downloadSpeed?: number
218 downloaded: number 235 downloaded: number
219 } 236 }
220 237
221 p2p: { 238 p2p?: {
222 downloadSpeed: number 239 downloadSpeed: number
223 uploadSpeed: number 240 uploadSpeed: number
224 downloaded: number 241 downloaded: number
@@ -227,7 +244,7 @@ type PlayerNetworkInfo = {
227 } 244 }
228 245
229 // In bytes 246 // In bytes
230 bandwidthEstimate: number 247 bandwidthEstimate?: number
231} 248}
232 249
233type PlaylistItemOptions = { 250type PlaylistItemOptions = {
@@ -238,6 +255,8 @@ type PlaylistItemOptions = {
238 255
239export { 256export {
240 PlayerNetworkInfo, 257 PlayerNetworkInfo,
258 TheaterButtonOptions,
259 VideoJSStoryboard,
241 PlaylistItemOptions, 260 PlaylistItemOptions,
242 NextPreviousVideoButtonOptions, 261 NextPreviousVideoButtonOptions,
243 ResolutionUpdateData, 262 ResolutionUpdateData,
@@ -246,11 +265,12 @@ export {
246 MetricsPluginOptions, 265 MetricsPluginOptions,
247 VideoJSCaption, 266 VideoJSCaption,
248 PeerTubePluginOptions, 267 PeerTubePluginOptions,
249 WebtorrentPluginOptions, 268 WebVideoPluginOptions,
250 P2PMediaLoaderPluginOptions, 269 P2PMediaLoaderPluginOptions,
251 PeerTubeResolution, 270 PeerTubeResolution,
252 VideoJSPluginOptions, 271 VideoJSPluginOptions,
272 UpNextPluginOptions,
253 LoadedQualityData, 273 LoadedQualityData,
254 PeerTubeLinkButtonOptions, 274 StoryboardOptions,
255 PeerTubeP2PInfoButtonOptions 275 PeerTubeLinkButtonOptions
256} 276}
diff --git a/client/src/root-helpers/video.ts b/client/src/root-helpers/video.ts
index 9022b908b..4a44615fb 100644
--- a/client/src/root-helpers/video.ts
+++ b/client/src/root-helpers/video.ts
@@ -41,14 +41,21 @@ function isP2PEnabled (video: Video, config: HTMLServerConfig, userP2PEnabled: b
41 return userP2PEnabled 41 return userP2PEnabled
42} 42}
43 43
44function videoRequiresAuth (video: Video) { 44function videoRequiresUserAuth (video: Video, videoPassword?: string) {
45 return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]).has(video.privacy.id) 45 return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]).has(video.privacy.id) ||
46 (video.privacy.id === VideoPrivacy.PASSWORD_PROTECTED && !videoPassword)
47
48}
49
50function videoRequiresFileToken (video: Video, videoPassword?: string) {
51 return new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]).has(video.privacy.id)
46} 52}
47 53
48export { 54export {
49 buildVideoOrPlaylistEmbed, 55 buildVideoOrPlaylistEmbed,
50 isP2PEnabled, 56 isP2PEnabled,
51 videoRequiresAuth 57 videoRequiresUserAuth,
58 videoRequiresFileToken
52} 59}
53 60
54// --------------------------------------------------------------------------- 61// ---------------------------------------------------------------------------
diff --git a/client/src/sass/player/control-bar.scss b/client/src/sass/player/control-bar.scss
index 96b3adf66..09a75e2fd 100644
--- a/client/src/sass/player/control-bar.scss
+++ b/client/src/sass/player/control-bar.scss
@@ -12,11 +12,8 @@
12 text-shadow: 0 0 2px rgba(0, 0, 0, 0.5); 12 text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
13 transition: visibility 0.3s, opacity 0.3s !important; 13 transition: visibility 0.3s, opacity 0.3s !important;
14 14
15 &.control-bar-hidden { 15 > button:not(.vjs-hidden):first-child,
16 display: none !important; 16 > button.vjs-hidden + button:not(.vjs-hidden) {
17 }
18
19 > button:first-child {
20 @include margin-left($first-control-bar-element-margin-left); 17 @include margin-left($first-control-bar-element-margin-left);
21 } 18 }
22 19
@@ -79,6 +76,7 @@
79 top: -0.3em; 76 top: -0.3em;
80 } 77 }
81 78
79 // Only used on mobile
82 .vjs-time-tooltip { 80 .vjs-time-tooltip {
83 display: none; 81 display: none;
84 } 82 }
@@ -152,7 +150,7 @@
152 } 150 }
153 } 151 }
154 152
155 .vjs-live-control { 153 .vjs-pt-live-control {
156 padding: 5px 7px; 154 padding: 5px 7px;
157 border-radius: 3px; 155 border-radius: 3px;
158 height: fit-content; 156 height: fit-content;
@@ -230,6 +228,7 @@
230 .vjs-next-video, 228 .vjs-next-video,
231 .vjs-previous-video { 229 .vjs-previous-video {
232 width: $control-bar-button-width - 4px; 230 width: $control-bar-button-width - 4px;
231 cursor: pointer;
233 232
234 &.vjs-disabled { 233 &.vjs-disabled {
235 cursor: default; 234 cursor: default;
diff --git a/client/src/sass/player/index.scss b/client/src/sass/player/index.scss
index 5d0307d95..4bfd67a26 100644
--- a/client/src/sass/player/index.scss
+++ b/client/src/sass/player/index.scss
@@ -10,3 +10,4 @@
10@use './playlist'; 10@use './playlist';
11@use './stats'; 11@use './stats';
12@use './offline-notification'; 12@use './offline-notification';
13@use './storyboard.scss';
diff --git a/client/src/sass/player/mobile.scss b/client/src/sass/player/mobile.scss
index 84d7a00f1..b0019d2c9 100644
--- a/client/src/sass/player/mobile.scss
+++ b/client/src/sass/player/mobile.scss
@@ -6,6 +6,31 @@
6/* Special mobile style */ 6/* Special mobile style */
7 7
8.video-js.vjs-peertube-skin.vjs-is-mobile { 8.video-js.vjs-peertube-skin.vjs-is-mobile {
9 // No hover means we can't display the storyboard/time tooltip on mouse hover
10 // Use the time tooltip in progress control instead
11 .vjs-mouse-display {
12 display: none !important;
13 }
14
15 .vjs-storyboard-sprite-placeholder {
16 display: none;
17 }
18
19 .vjs-progress-control .vjs-sliding {
20
21 .vjs-time-tooltip,
22 .vjs-storyboard-sprite-placeholder {
23 display: block !important;
24
25 visibility: visible !important;
26 }
27
28 .vjs-time-tooltip {
29 color: #fff;
30 background-color: rgba(0, 0, 0, 0.8);
31 }
32 }
33
9 .vjs-control-bar { 34 .vjs-control-bar {
10 .vjs-progress-control .vjs-slider .vjs-play-progress { 35 .vjs-progress-control .vjs-slider .vjs-play-progress {
11 // Always display the circle on mobile 36 // Always display the circle on mobile
@@ -145,7 +170,8 @@
145 } 170 }
146 } 171 }
147 172
148 &.vjs-scrubbing { 173 &.vjs-scrubbing,
174 &.vjs-mobile-sliding {
149 .vjs-mobile-buttons-overlay { 175 .vjs-mobile-buttons-overlay {
150 display: none; 176 display: none;
151 } 177 }
diff --git a/client/src/sass/player/peertube-skin.scss b/client/src/sass/player/peertube-skin.scss
index 4df8dbaf0..572ae7050 100644
--- a/client/src/sass/player/peertube-skin.scss
+++ b/client/src/sass/player/peertube-skin.scss
@@ -84,7 +84,9 @@ body {
84 } 84 }
85 85
86 // Do not display poster when video is starting 86 // Do not display poster when video is starting
87 &.vjs-has-autoplay:not(.vjs-has-started) { 87 // Or if we change resolution manually
88 &.vjs-has-autoplay:not(.vjs-has-started),
89 &.vjs-updating-resolution {
88 .vjs-poster { 90 .vjs-poster {
89 opacity: 0; 91 opacity: 0;
90 visibility: hidden; 92 visibility: hidden;
diff --git a/client/src/sass/player/settings-menu.scss b/client/src/sass/player/settings-menu.scss
index d2346c126..369c827f7 100644
--- a/client/src/sass/player/settings-menu.scss
+++ b/client/src/sass/player/settings-menu.scss
@@ -75,6 +75,7 @@ $setting-transition-easing: ease-out;
75 > .vjs-menu { 75 > .vjs-menu {
76 flex: 1; 76 flex: 1;
77 min-width: 200px; 77 min-width: 200px;
78 padding: 5px 0;
78 } 79 }
79 80
80 > .vjs-menu, 81 > .vjs-menu,
@@ -90,14 +91,6 @@ $setting-transition-easing: ease-out;
90 background-color: rgba(255, 255, 255, 0.2); 91 background-color: rgba(255, 255, 255, 0.2);
91 } 92 }
92 93
93 &:first-child {
94 margin-top: 5px;
95 }
96
97 &:last-child {
98 margin-bottom: 5px;
99 }
100
101 &.disabled { 94 &.disabled {
102 opacity: 0.5; 95 opacity: 0.5;
103 cursor: default !important; 96 cursor: default !important;
diff --git a/client/src/sass/player/storyboard.scss b/client/src/sass/player/storyboard.scss
new file mode 100644
index 000000000..c80d1b59d
--- /dev/null
+++ b/client/src/sass/player/storyboard.scss
@@ -0,0 +1,26 @@
1@use 'sass:math';
2@use '_variables' as *;
3@use '_mixins' as *;
4@use './_player-variables' as *;
5
6// Like the time tooltip
7.video-js .vjs-progress-holder .vjs-storyboard-sprite-placeholder {
8 display: none;
9}
10
11.video-js .vjs-progress-control:hover .vjs-storyboard-sprite-placeholder,
12.video-js .vjs-progress-control:hover .vjs-progress-holder:focus .vjs-storyboard-sprite-placeholder {
13 display: block;
14
15 // Ensure that we maintain a font-size of ~10px.
16 font-size: 0.6em;
17 visibility: visible;
18}
19
20.video-js.vjs-settings-dialog-opened {
21 .vjs-storyboard-sprite-placeholder,
22 .vjs-time-tooltip,
23 .vjs-mouse-display {
24 display: none !important;
25 }
26}
diff --git a/client/src/shims/http.ts b/client/src/shims/http.ts
deleted file mode 100644
index 1b1767aab..000000000
--- a/client/src/shims/http.ts
+++ /dev/null
@@ -1 +0,0 @@
1module.exports = require('stream-http')
diff --git a/client/src/shims/https.ts b/client/src/shims/https.ts
deleted file mode 100644
index f5ef70430..000000000
--- a/client/src/shims/https.ts
+++ /dev/null
@@ -1 +0,0 @@
1module.exports = require('https-browserify')
diff --git a/client/src/shims/stream.ts b/client/src/shims/stream.ts
deleted file mode 100644
index 977fd05a0..000000000
--- a/client/src/shims/stream.ts
+++ /dev/null
@@ -1 +0,0 @@
1module.exports = require('stream-browserify')
diff --git a/client/src/standalone/player/.npmignore b/client/src/standalone/embed-player-api/.npmignore
index 870b6315b..870b6315b 100644
--- a/client/src/standalone/player/.npmignore
+++ b/client/src/standalone/embed-player-api/.npmignore
diff --git a/client/src/standalone/player/README.md b/client/src/standalone/embed-player-api/README.md
index 7b47e8f02..7b47e8f02 100644
--- a/client/src/standalone/player/README.md
+++ b/client/src/standalone/embed-player-api/README.md
diff --git a/client/src/standalone/player/definitions.ts b/client/src/standalone/embed-player-api/definitions.ts
index 495f1a98c..495f1a98c 100644
--- a/client/src/standalone/player/definitions.ts
+++ b/client/src/standalone/embed-player-api/definitions.ts
diff --git a/client/src/standalone/player/events.ts b/client/src/standalone/embed-player-api/events.ts
index 77d21c78c..77d21c78c 100644
--- a/client/src/standalone/player/events.ts
+++ b/client/src/standalone/embed-player-api/events.ts
diff --git a/client/src/standalone/player/package.json b/client/src/standalone/embed-player-api/package.json
index b549fbf52..b549fbf52 100644
--- a/client/src/standalone/player/package.json
+++ b/client/src/standalone/embed-player-api/package.json
diff --git a/client/src/standalone/player/player.ts b/client/src/standalone/embed-player-api/player.ts
index 75487258b..75487258b 100644
--- a/client/src/standalone/player/player.ts
+++ b/client/src/standalone/embed-player-api/player.ts
diff --git a/client/src/standalone/player/tsconfig.json b/client/src/standalone/embed-player-api/tsconfig.json
index eecc63dfb..eecc63dfb 100644
--- a/client/src/standalone/player/tsconfig.json
+++ b/client/src/standalone/embed-player-api/tsconfig.json
diff --git a/client/src/standalone/player/webpack.config.js b/client/src/standalone/embed-player-api/webpack.config.js
index 48d350edf..48d350edf 100644
--- a/client/src/standalone/player/webpack.config.js
+++ b/client/src/standalone/embed-player-api/webpack.config.js
diff --git a/client/src/standalone/videos/embed-api.ts b/client/src/standalone/videos/embed-api.ts
index a99f1edae..6227c378e 100644
--- a/client/src/standalone/videos/embed-api.ts
+++ b/client/src/standalone/videos/embed-api.ts
@@ -1,7 +1,7 @@
1import './embed.scss' 1import './embed.scss'
2import * as Channel from 'jschannel' 2import * as Channel from 'jschannel'
3import { logger } from '../../root-helpers' 3import { logger } from '../../root-helpers'
4import { PeerTubeResolution, PeerTubeTextTrack } from '../player/definitions' 4import { PeerTubeResolution, PeerTubeTextTrack } from '../embed-player-api/definitions'
5import { PeerTubeEmbed } from './embed' 5import { PeerTubeEmbed } from './embed'
6 6
7/** 7/**
@@ -72,15 +72,12 @@ export class PeerTubeEmbedApi {
72 private setResolution (resolutionId: number) { 72 private setResolution (resolutionId: number) {
73 logger.info(`Set resolution ${resolutionId}`) 73 logger.info(`Set resolution ${resolutionId}`)
74 74
75 if (this.isWebtorrent()) { 75 if (this.isWebVideo() && resolutionId === -1) {
76 if (resolutionId === -1 && this.embed.player.webtorrent().isAutoResolutionPossible() === false) return 76 logger.error('Auto resolution cannot be set in web video player mode')
77
78 this.embed.player.webtorrent().changeQuality(resolutionId)
79
80 return 77 return
81 } 78 }
82 79
83 this.embed.player.p2pMediaLoader().getHLSJS().currentLevel = resolutionId 80 this.embed.player.peertubeResolutions().select({ id: resolutionId, fireCallback: true })
84 } 81 }
85 82
86 private getCaptions (): PeerTubeTextTrack[] { 83 private getCaptions (): PeerTubeTextTrack[] {
@@ -152,8 +149,8 @@ export class PeerTubeEmbedApi {
152 // --------------------------------------------------------------------------- 149 // ---------------------------------------------------------------------------
153 150
154 // PeerTube specific capabilities 151 // PeerTube specific capabilities
155 this.embed.player.peertubeResolutions().on('resolutionsAdded', () => this.loadResolutions()) 152 this.embed.player.peertubeResolutions().on('resolutions-added', () => this.loadResolutions())
156 this.embed.player.peertubeResolutions().on('resolutionChanged', () => this.loadResolutions()) 153 this.embed.player.peertubeResolutions().on('resolutions-changed', () => this.loadResolutions())
157 154
158 this.loadResolutions() 155 this.loadResolutions()
159 156
@@ -193,7 +190,7 @@ export class PeerTubeEmbedApi {
193 }) 190 })
194 } 191 }
195 192
196 private isWebtorrent () { 193 private isWebVideo () {
197 return !!this.embed.player.webtorrent 194 return !!this.embed.player.webVideo
198 } 195 }
199} 196}
diff --git a/client/src/standalone/videos/embed.html b/client/src/standalone/videos/embed.html
index 32bf5f655..e2dc02b60 100644
--- a/client/src/standalone/videos/embed.html
+++ b/client/src/standalone/videos/embed.html
@@ -41,9 +41,24 @@
41 <div id="error-content"></div> 41 <div id="error-content"></div>
42 </div> 42 </div>
43 43
44 <div id="video-wrapper"></div> 44 <div id="video-password-block">
45 <!-- eslint-disable-next-line @angular-eslint/template/elements-content -->
46 <h1 id="video-password-title"></h1>
47
48 <div id="video-password-content"></div>
49
50 <form id="video-password-form">
51 <input type="password" id="video-password-input" name="video-password" autocomplete="user-password" required>
52 <button type="submit" id="video-password-submit"> </button>
53 </form>
45 54
46 <div id="placeholder-preview"></div> 55 <div id="video-password-error"></div>
56 <svg xmlns="http://www.w3.org/2000/svg" width="4rem" height="4rem" viewBox="0 0 24 24">
57 <g fill="none" stroke="#c4c4c4" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></g>
58 </svg>
59 </div>
60
61 <div id="video-wrapper"></div>
47 62
48 <script type="text/javascript"> 63 <script type="text/javascript">
49 // Can be called in embed.ts 64 // Can be called in embed.ts
diff --git a/client/src/standalone/videos/embed.scss b/client/src/standalone/videos/embed.scss
index 3631ea7e6..d15887478 100644
--- a/client/src/standalone/videos/embed.scss
+++ b/client/src/standalone/videos/embed.scss
@@ -24,7 +24,7 @@ html,
24body { 24body {
25 height: 100%; 25 height: 100%;
26 margin: 0; 26 margin: 0;
27 background-color: #000; 27 background-color: #0f0f10;
28} 28}
29 29
30#video-wrapper { 30#video-wrapper {
@@ -42,8 +42,10 @@ body {
42 } 42 }
43} 43}
44 44
45#error-block { 45#error-block,
46#video-password-block {
46 display: none; 47 display: none;
48 user-select: none;
47 49
48 flex-direction: column; 50 flex-direction: column;
49 align-content: center; 51 align-content: center;
@@ -86,6 +88,43 @@ body {
86 text-align: center; 88 text-align: center;
87} 89}
88 90
91#video-password-content {
92 @include margin(1rem, 0, 2rem);
93}
94
95#video-password-input,
96#video-password-submit {
97 line-height: 23px;
98 padding: 1rem;
99 margin: 1rem 0.5rem;
100 border: 0;
101 font-weight: 600;
102 border-radius: 3px!important;
103 font-size: 18px;
104 display: inline-block;
105}
106
107#video-password-submit {
108 color: #fff;
109 background-color: #f2690d;
110 cursor: pointer;
111}
112
113#video-password-submit:hover {
114 background-color: #f47825;
115}
116#video-password-error {
117 margin-top: 10px;
118 margin-bottom: 10px;
119 height: 2rem;
120 font-weight: bolder;
121}
122
123#video-password-block svg {
124 margin-left: auto;
125 margin-right: auto;
126}
127
89@media screen and (max-width: 300px) { 128@media screen and (max-width: 300px) {
90 #error-block { 129 #error-block {
91 font-size: 36px; 130 font-size: 36px;
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts
index cc4274b99..78b812ffd 100644
--- a/client/src/standalone/videos/embed.ts
+++ b/client/src/standalone/videos/embed.ts
@@ -1,18 +1,26 @@
1import './embed.scss' 1import './embed.scss'
2import '../../assets/player/shared/dock/peertube-dock-component' 2import '../../assets/player/shared/dock/peertube-dock-component'
3import '../../assets/player/shared/dock/peertube-dock-plugin' 3import '../../assets/player/shared/dock/peertube-dock-plugin'
4import { PeerTubeServerError } from 'src/types'
4import videojs from 'video.js' 5import videojs from 'video.js'
5import { peertubeTranslate } from '../../../../shared/core-utils/i18n' 6import {
6import { HTMLServerConfig, ResultList, VideoDetails, VideoPlaylist, VideoPlaylistElement, VideoState } from '../../../../shared/models' 7 HTMLServerConfig,
7import { PeertubePlayerManager } from '../../assets/player' 8 ResultList,
9 ServerErrorCode,
10 VideoDetails,
11 VideoPlaylist,
12 VideoPlaylistElement,
13 VideoState
14} from '../../../../shared/models'
15import { PeerTubePlayer } from '../../assets/player/peertube-player'
8import { TranslationsManager } from '../../assets/player/translations-manager' 16import { TranslationsManager } from '../../assets/player/translations-manager'
9import { getParamString, logger, videoRequiresAuth } from '../../root-helpers' 17import { getParamString, logger, videoRequiresFileToken } from '../../root-helpers'
10import { PeerTubeEmbedApi } from './embed-api' 18import { PeerTubeEmbedApi } from './embed-api'
11import { 19import {
12 AuthHTTP, 20 AuthHTTP,
13 LiveManager, 21 LiveManager,
14 PeerTubePlugin, 22 PeerTubePlugin,
15 PlayerManagerOptions, 23 PlayerOptionsBuilder,
16 PlaylistFetcher, 24 PlaylistFetcher,
17 PlaylistTracker, 25 PlaylistTracker,
18 Translations, 26 Translations,
@@ -27,18 +35,26 @@ export class PeerTubeEmbed {
27 config: HTMLServerConfig 35 config: HTMLServerConfig
28 36
29 private translationsPromise: Promise<{ [id: string]: string }> 37 private translationsPromise: Promise<{ [id: string]: string }>
30 private PeertubePlayerManagerModulePromise: Promise<any> 38 private PeerTubePlayerManagerModulePromise: Promise<any>
31 39
32 private readonly http: AuthHTTP 40 private readonly http: AuthHTTP
33 private readonly videoFetcher: VideoFetcher 41 private readonly videoFetcher: VideoFetcher
34 private readonly playlistFetcher: PlaylistFetcher 42 private readonly playlistFetcher: PlaylistFetcher
35 private readonly peertubePlugin: PeerTubePlugin 43 private readonly peertubePlugin: PeerTubePlugin
36 private readonly playerHTML: PlayerHTML 44 private readonly playerHTML: PlayerHTML
37 private readonly playerManagerOptions: PlayerManagerOptions 45 private readonly playerOptionsBuilder: PlayerOptionsBuilder
38 private readonly liveManager: LiveManager 46 private readonly liveManager: LiveManager
39 47
48 private peertubePlayer: PeerTubePlayer
49
40 private playlistTracker: PlaylistTracker 50 private playlistTracker: PlaylistTracker
41 51
52 private alreadyInitialized = false
53 private alreadyPlayed = false
54
55 private videoPassword: string
56 private requiresPassword: boolean
57
42 constructor (videoWrapperId: string) { 58 constructor (videoWrapperId: string) {
43 logger.registerServerSending(window.location.origin) 59 logger.registerServerSending(window.location.origin)
44 60
@@ -48,8 +64,9 @@ export class PeerTubeEmbed {
48 this.playlistFetcher = new PlaylistFetcher(this.http) 64 this.playlistFetcher = new PlaylistFetcher(this.http)
49 this.peertubePlugin = new PeerTubePlugin(this.http) 65 this.peertubePlugin = new PeerTubePlugin(this.http)
50 this.playerHTML = new PlayerHTML(videoWrapperId) 66 this.playerHTML = new PlayerHTML(videoWrapperId)
51 this.playerManagerOptions = new PlayerManagerOptions(this.playerHTML, this.videoFetcher, this.peertubePlugin) 67 this.playerOptionsBuilder = new PlayerOptionsBuilder(this.playerHTML, this.videoFetcher, this.peertubePlugin)
52 this.liveManager = new LiveManager(this.playerHTML) 68 this.liveManager = new LiveManager(this.playerHTML)
69 this.requiresPassword = false
53 70
54 try { 71 try {
55 this.config = JSON.parse((window as any)['PeerTubeServerConfig']) 72 this.config = JSON.parse((window as any)['PeerTubeServerConfig'])
@@ -69,14 +86,14 @@ export class PeerTubeEmbed {
69 } 86 }
70 87
71 getScope () { 88 getScope () {
72 return this.playerManagerOptions.getScope() 89 return this.playerOptionsBuilder.getScope()
73 } 90 }
74 91
75 // --------------------------------------------------------------------------- 92 // ---------------------------------------------------------------------------
76 93
77 async init () { 94 async init () {
78 this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language) 95 this.translationsPromise = TranslationsManager.getServerTranslations(window.location.origin, navigator.language)
79 this.PeertubePlayerManagerModulePromise = import('../../assets/player/peertube-player-manager') 96 this.PeerTubePlayerManagerModulePromise = import('../../assets/player/peertube-player')
80 97
81 // Issue when we parsed config from HTML, fallback to API 98 // Issue when we parsed config from HTML, fallback to API
82 if (!this.config) { 99 if (!this.config) {
@@ -90,7 +107,7 @@ export class PeerTubeEmbed {
90 107
91 if (!videoId) return 108 if (!videoId) return
92 109
93 return this.loadVideoAndBuildPlayer({ uuid: videoId, autoplayFromPreviousVideo: false, forceAutoplay: false }) 110 return this.loadVideoAndBuildPlayer({ uuid: videoId, forceAutoplay: false })
94 } 111 }
95 112
96 private async initPlaylist () { 113 private async initPlaylist () {
@@ -125,7 +142,7 @@ export class PeerTubeEmbed {
125 } 142 }
126 143
127 private initializeApi () { 144 private initializeApi () {
128 if (this.playerManagerOptions.hasAPIEnabled()) { 145 if (this.playerOptionsBuilder.hasAPIEnabled()) {
129 if (this.api) { 146 if (this.api) {
130 this.api.reInit() 147 this.api.reInit()
131 return 148 return
@@ -147,7 +164,7 @@ export class PeerTubeEmbed {
147 164
148 this.playlistTracker.setCurrentElement(next) 165 this.playlistTracker.setCurrentElement(next)
149 166
150 return this.loadVideoAndBuildPlayer({ uuid: next.video.uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }) 167 return this.loadVideoAndBuildPlayer({ uuid: next.video.uuid, forceAutoplay: false })
151 } 168 }
152 169
153 async playPreviousPlaylistVideo () { 170 async playPreviousPlaylistVideo () {
@@ -159,7 +176,7 @@ export class PeerTubeEmbed {
159 176
160 this.playlistTracker.setCurrentElement(previous) 177 this.playlistTracker.setCurrentElement(previous)
161 178
162 await this.loadVideoAndBuildPlayer({ uuid: previous.video.uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }) 179 await this.loadVideoAndBuildPlayer({ uuid: previous.video.uuid, forceAutoplay: false })
163 } 180 }
164 181
165 getCurrentPlaylistPosition () { 182 getCurrentPlaylistPosition () {
@@ -170,123 +187,124 @@ export class PeerTubeEmbed {
170 187
171 private async loadVideoAndBuildPlayer (options: { 188 private async loadVideoAndBuildPlayer (options: {
172 uuid: string 189 uuid: string
173 autoplayFromPreviousVideo: boolean
174 forceAutoplay: boolean 190 forceAutoplay: boolean
175 }) { 191 }) {
176 const { uuid, autoplayFromPreviousVideo, forceAutoplay } = options 192 const { uuid, forceAutoplay } = options
177 193
178 try { 194 try {
179 const { videoResponse, captionsPromise } = await this.videoFetcher.loadVideo(uuid) 195 const {
196 videoResponse,
197 captionsPromise,
198 storyboardsPromise
199 } = await this.videoFetcher.loadVideo({ videoId: uuid, videoPassword: this.videoPassword })
180 200
181 return this.buildVideoPlayer({ videoResponse, captionsPromise, autoplayFromPreviousVideo, forceAutoplay }) 201 return this.buildVideoPlayer({ videoResponse, captionsPromise, storyboardsPromise, forceAutoplay })
182 } catch (err) { 202 } catch (err) {
183 this.playerHTML.displayError(err.message, await this.translationsPromise) 203
204 if (await this.handlePasswordError(err)) this.loadVideoAndBuildPlayer({ ...options })
205 else this.playerHTML.displayError(err.message, await this.translationsPromise)
184 } 206 }
185 } 207 }
186 208
187 private async buildVideoPlayer (options: { 209 private async buildVideoPlayer (options: {
188 videoResponse: Response 210 videoResponse: Response
211 storyboardsPromise: Promise<Response>
189 captionsPromise: Promise<Response> 212 captionsPromise: Promise<Response>
190 autoplayFromPreviousVideo: boolean
191 forceAutoplay: boolean 213 forceAutoplay: boolean
192 }) { 214 }) {
193 const { videoResponse, captionsPromise, autoplayFromPreviousVideo, forceAutoplay } = options 215 const { videoResponse, captionsPromise, storyboardsPromise, forceAutoplay } = options
194
195 this.resetPlayerElement()
196 216
197 const videoInfoPromise = videoResponse.json() 217 const videoInfoPromise = videoResponse.json()
198 .then(async (videoInfo: VideoDetails) => { 218 .then(async (videoInfo: VideoDetails) => {
199 this.playerManagerOptions.loadParams(this.config, videoInfo) 219 this.playerOptionsBuilder.loadParams(this.config, videoInfo)
200 220
201 if (!autoplayFromPreviousVideo && !this.playerManagerOptions.hasAutoplay()) {
202 this.playerHTML.buildPlaceholder(videoInfo)
203 }
204 const live = videoInfo.isLive 221 const live = videoInfo.isLive
205 ? await this.videoFetcher.loadLive(videoInfo) 222 ? await this.videoFetcher.loadLive(videoInfo)
206 : undefined 223 : undefined
207 224
208 const videoFileToken = videoRequiresAuth(videoInfo) 225 const videoFileToken = videoRequiresFileToken(videoInfo)
209 ? await this.videoFetcher.loadVideoToken(videoInfo) 226 ? await this.videoFetcher.loadVideoToken(videoInfo, this.videoPassword)
210 : undefined 227 : undefined
211 228
212 return { live, video: videoInfo, videoFileToken } 229 return { live, video: videoInfo, videoFileToken }
213 }) 230 })
214 231
215 const [ { video, live, videoFileToken }, translations, captionsResponse, PeertubePlayerManagerModule ] = await Promise.all([ 232 const [
233 { video, live, videoFileToken },
234 translations,
235 captionsResponse,
236 storyboardsResponse
237 ] = await Promise.all([
216 videoInfoPromise, 238 videoInfoPromise,
217 this.translationsPromise, 239 this.translationsPromise,
218 captionsPromise, 240 captionsPromise,
219 this.PeertubePlayerManagerModulePromise 241 storyboardsPromise,
242 this.buildPlayerIfNeeded()
220 ]) 243 ])
221 244
222 await this.peertubePlugin.loadPlugins(this.config, translations) 245 // If already played, we are in a playlist so we don't want to display the poster between videos
246 if (!this.alreadyPlayed) {
247 this.peertubePlayer.setPoster(window.location.origin + video.previewPath)
248 }
249
250 const playlist = this.playlistTracker
251 ? {
252 onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, forceAutoplay: false }),
223 253
224 const PlayerManager: typeof PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager 254 playlistTracker: this.playlistTracker,
255 playNext: () => this.playNextPlaylistVideo(),
256 playPrevious: () => this.playPreviousPlaylistVideo()
257 }
258 : undefined
225 259
226 const playerOptions = await this.playerManagerOptions.getPlayerOptions({ 260 const loadOptions = await this.playerOptionsBuilder.getPlayerLoadOptions({
227 video, 261 video,
228 captionsResponse, 262 captionsResponse,
229 autoplayFromPreviousVideo,
230 translations, 263 translations,
231 serverConfig: this.config,
232 264
233 authorizationHeader: () => this.http.getHeaderTokenValue(), 265 storyboardsResponse,
234 videoFileToken: () => videoFileToken,
235 266
236 onVideoUpdate: (uuid: string) => this.loadVideoAndBuildPlayer({ uuid, autoplayFromPreviousVideo: true, forceAutoplay: false }), 267 videoFileToken: () => videoFileToken,
268 videoPassword: () => this.videoPassword,
269 requiresPassword: this.requiresPassword,
237 270
238 playlistTracker: this.playlistTracker, 271 playlist,
239 playNextPlaylistVideo: () => this.playNextPlaylistVideo(),
240 playPreviousPlaylistVideo: () => this.playPreviousPlaylistVideo(),
241 272
242 live, 273 live,
243 forceAutoplay 274 forceAutoplay,
275 alreadyPlayed: this.alreadyPlayed
244 }) 276 })
277 await this.peertubePlayer.load(loadOptions)
245 278
246 this.player = await PlayerManager.initialize(this.playerManagerOptions.getMode(), playerOptions, (player: videojs.Player) => { 279 if (!this.alreadyInitialized) {
247 this.player = player 280 this.player = this.peertubePlayer.getPlayer();
248 })
249 281
250 this.player.on('customError', (event: any, data: any) => { 282 (window as any)['videojsPlayer'] = this.player
251 const message = data?.err?.message || ''
252 if (!message.includes('from xs param')) return
253 283
254 this.player.dispose() 284 this.buildCSS()
255 this.playerHTML.removePlayerElement() 285 this.initializeApi()
256 this.playerHTML.displayError('This video is not available because the remote instance is not responding.', translations) 286 }
257 });
258
259 (window as any)['videojsPlayer'] = this.player
260
261 this.buildCSS()
262 this.buildPlayerDock(video)
263 this.initializeApi()
264 287
265 this.playerHTML.removePlaceholder() 288 this.alreadyInitialized = true
266 289
267 if (this.isPlaylistEmbed()) { 290 this.player.one('play', () => {
268 await this.buildPlayerPlaylistUpnext() 291 this.alreadyPlayed = true
292 })
269 293
270 this.player.playlist().updateSelected() 294 if (this.videoPassword) this.playerHTML.removeVideoPasswordBlock()
271
272 this.player.on('stopped', () => {
273 this.playNextPlaylistVideo()
274 })
275 }
276 295
277 if (video.isLive) { 296 if (video.isLive) {
278 this.liveManager.listenForChanges({ 297 this.liveManager.listenForChanges({
279 video, 298 video,
280 onPublishedVideo: () => { 299 onPublishedVideo: () => {
281 this.liveManager.stopListeningForChanges(video) 300 this.liveManager.stopListeningForChanges(video)
282 this.loadVideoAndBuildPlayer({ uuid: video.uuid, autoplayFromPreviousVideo: false, forceAutoplay: true }) 301 this.loadVideoAndBuildPlayer({ uuid: video.uuid, forceAutoplay: true })
283 } 302 }
284 }) 303 })
285 304
286 if (video.state.id === VideoState.WAITING_FOR_LIVE || video.state.id === VideoState.LIVE_ENDED) { 305 if (video.state.id === VideoState.WAITING_FOR_LIVE || video.state.id === VideoState.LIVE_ENDED) {
287 this.liveManager.displayInfo({ state: video.state.id, translations }) 306 this.liveManager.displayInfo({ state: video.state.id, translations })
288 307 this.peertubePlayer.disable()
289 this.disablePlayer()
290 } else { 308 } else {
291 this.correctlyHandleLiveEnding(translations) 309 this.correctlyHandleLiveEnding(translations)
292 } 310 }
@@ -295,74 +313,15 @@ export class PeerTubeEmbed {
295 this.peertubePlugin.getPluginsManager().runHook('action:embed.player.loaded', undefined, { player: this.player, videojs, video }) 313 this.peertubePlugin.getPluginsManager().runHook('action:embed.player.loaded', undefined, { player: this.player, videojs, video })
296 } 314 }
297 315
298 private resetPlayerElement () {
299 if (this.player) {
300 this.player.dispose()
301 this.player = undefined
302 }
303
304 const playerElement = document.createElement('video')
305 playerElement.className = 'video-js vjs-peertube-skin'
306 playerElement.setAttribute('playsinline', 'true')
307
308 this.playerHTML.setPlayerElement(playerElement)
309 this.playerHTML.addPlayerElementToDOM()
310 }
311
312 private async buildPlayerPlaylistUpnext () {
313 const translations = await this.translationsPromise
314
315 this.player.upnext({
316 timeout: 10000, // 10s
317 headText: peertubeTranslate('Up Next', translations),
318 cancelText: peertubeTranslate('Cancel', translations),
319 suspendedText: peertubeTranslate('Autoplay is suspended', translations),
320 getTitle: () => this.playlistTracker.nextVideoTitle(),
321 next: () => this.playNextPlaylistVideo(),
322 condition: () => !!this.playlistTracker.getNextPlaylistElement(),
323 suspended: () => false
324 })
325 }
326
327 private buildPlayerDock (videoInfo: VideoDetails) {
328 if (!this.playerManagerOptions.hasControls()) return
329
330 // On webtorrent fallback, player may have been disposed
331 if (!this.player.player_) return
332
333 const title = this.playerManagerOptions.hasTitle()
334 ? videoInfo.name
335 : undefined
336
337 const description = this.playerManagerOptions.hasWarningTitle() && this.playerManagerOptions.hasP2PEnabled()
338 ? '<span class="text">' + peertubeTranslate('Watching this video may reveal your IP address to others.') + '</span>'
339 : undefined
340
341 if (!title && !description) return
342
343 const availableAvatars = videoInfo.channel.avatars.filter(a => a.width < 50)
344 const avatar = availableAvatars.length !== 0
345 ? availableAvatars[0]
346 : undefined
347
348 this.player.peertubeDock({
349 title,
350 description,
351 avatarUrl: title && avatar
352 ? avatar.path
353 : undefined
354 })
355 }
356
357 private buildCSS () { 316 private buildCSS () {
358 const body = document.getElementById('custom-css') 317 const body = document.getElementById('custom-css')
359 318
360 if (this.playerManagerOptions.hasBigPlayBackgroundColor()) { 319 if (this.playerOptionsBuilder.hasBigPlayBackgroundColor()) {
361 body.style.setProperty('--embedBigPlayBackgroundColor', this.playerManagerOptions.getBigPlayBackgroundColor()) 320 body.style.setProperty('--embedBigPlayBackgroundColor', this.playerOptionsBuilder.getBigPlayBackgroundColor())
362 } 321 }
363 322
364 if (this.playerManagerOptions.hasForegroundColor()) { 323 if (this.playerOptionsBuilder.hasForegroundColor()) {
365 body.style.setProperty('--embedForegroundColor', this.playerManagerOptions.getForegroundColor()) 324 body.style.setProperty('--embedForegroundColor', this.playerOptionsBuilder.getForegroundColor())
366 } 325 }
367 } 326 }
368 327
@@ -384,23 +343,52 @@ export class PeerTubeEmbed {
384 // Display the live ended information 343 // Display the live ended information
385 this.liveManager.displayInfo({ state: VideoState.LIVE_ENDED, translations }) 344 this.liveManager.displayInfo({ state: VideoState.LIVE_ENDED, translations })
386 345
387 this.disablePlayer() 346 this.peertubePlayer.disable()
388 }) 347 })
389 } 348 }
390 349
391 private disablePlayer () { 350 private async handlePasswordError (err: PeerTubeServerError) {
392 if (this.player.isFullscreen()) { 351 let incorrectPassword: boolean = null
393 this.player.exitFullscreen() 352 if (err.serverCode === ServerErrorCode.VIDEO_REQUIRES_PASSWORD) incorrectPassword = false
394 } 353 else if (err.serverCode === ServerErrorCode.INCORRECT_VIDEO_PASSWORD) incorrectPassword = true
395 354
396 // Disable player 355 if (incorrectPassword === null) return false
397 this.player.hasStarted(false)
398 this.player.removeClass('vjs-has-autoplay')
399 this.player.bigPlayButton.hide();
400 356
401 (this.player.el() as HTMLElement).style.pointerEvents = 'none' 357 this.requiresPassword = true
358 this.videoPassword = await this.playerHTML.askVideoPassword({
359 incorrectPassword,
360 translations: await this.translationsPromise
361 })
362 return true
402 } 363 }
403 364
365 private async buildPlayerIfNeeded () {
366 if (this.peertubePlayer) {
367 this.peertubePlayer.enable()
368
369 return
370 }
371
372 const playerElement = document.createElement('video')
373 playerElement.className = 'video-js vjs-peertube-skin'
374 playerElement.setAttribute('playsinline', 'true')
375
376 this.playerHTML.setPlayerElement(playerElement)
377 this.playerHTML.addPlayerElementToDOM()
378
379 const [ { PeerTubePlayer } ] = await Promise.all([
380 this.PeerTubePlayerManagerModulePromise,
381 this.peertubePlugin.loadPlugins(this.config, await this.translationsPromise)
382 ])
383
384 const constructorOptions = this.playerOptionsBuilder.getPlayerConstructorOptions({
385 serverConfig: this.config,
386 authorizationHeader: () => this.http.getHeaderTokenValue()
387 })
388 this.peertubePlayer = new PeerTubePlayer(constructorOptions)
389
390 this.player = this.peertubePlayer.getPlayer()
391 }
404} 392}
405 393
406PeerTubeEmbed.main() 394PeerTubeEmbed.main()
diff --git a/client/src/standalone/videos/shared/auth-http.ts b/client/src/standalone/videos/shared/auth-http.ts
index 95e3b029e..c1e9f7750 100644
--- a/client/src/standalone/videos/shared/auth-http.ts
+++ b/client/src/standalone/videos/shared/auth-http.ts
@@ -18,10 +18,12 @@ export class AuthHTTP {
18 if (this.userOAuthTokens) this.setHeadersFromTokens() 18 if (this.userOAuthTokens) this.setHeadersFromTokens()
19 } 19 }
20 20
21 fetch (url: string, { optionalAuth, method }: { optionalAuth: boolean, method?: string }) { 21 fetch (url: string, { optionalAuth, method }: { optionalAuth: boolean, method?: string }, videoPassword?: string) {
22 const refreshFetchOptions = optionalAuth 22 let refreshFetchOptions: { headers?: Headers } = {}
23 ? { headers: this.headers } 23
24 : {} 24 if (videoPassword) this.headers.set('x-peertube-video-password', videoPassword)
25
26 if (videoPassword || optionalAuth) refreshFetchOptions = { headers: this.headers }
25 27
26 return this.refreshFetch(url.toString(), { ...refreshFetchOptions, method }) 28 return this.refreshFetch(url.toString(), { ...refreshFetchOptions, method })
27 } 29 }
diff --git a/client/src/standalone/videos/shared/index.ts b/client/src/standalone/videos/shared/index.ts
index 928b8e270..dcc522ac6 100644
--- a/client/src/standalone/videos/shared/index.ts
+++ b/client/src/standalone/videos/shared/index.ts
@@ -2,7 +2,7 @@ export * from './auth-http'
2export * from './peertube-plugin' 2export * from './peertube-plugin'
3export * from './live-manager' 3export * from './live-manager'
4export * from './player-html' 4export * from './player-html'
5export * from './player-manager-options' 5export * from './player-options-builder'
6export * from './playlist-fetcher' 6export * from './playlist-fetcher'
7export * from './playlist-tracker' 7export * from './playlist-tracker'
8export * from './translations' 8export * from './translations'
diff --git a/client/src/standalone/videos/shared/player-html.ts b/client/src/standalone/videos/shared/player-html.ts
index d93678c10..0defa0d70 100644
--- a/client/src/standalone/videos/shared/player-html.ts
+++ b/client/src/standalone/videos/shared/player-html.ts
@@ -1,5 +1,4 @@
1import { peertubeTranslate } from '../../../../../shared/core-utils/i18n' 1import { peertubeTranslate } from '../../../../../shared/core-utils/i18n'
2import { VideoDetails } from '../../../../../shared/models'
3import { logger } from '../../../root-helpers' 2import { logger } from '../../../root-helpers'
4import { Translations } from './translations' 3import { Translations } from './translations'
5 4
@@ -55,17 +54,55 @@ export class PlayerHTML {
55 this.wrapperElement.style.display = 'none' 54 this.wrapperElement.style.display = 'none'
56 } 55 }
57 56
58 buildPlaceholder (video: VideoDetails) { 57 async askVideoPassword (options: { incorrectPassword: boolean, translations: Translations }): Promise<string> {
59 const placeholder = this.getPlaceholderElement() 58 const { incorrectPassword, translations } = options
59 return new Promise((resolve) => {
60 60
61 const url = window.location.origin + video.previewPath 61 this.wrapperElement.style.display = 'none'
62 placeholder.style.backgroundImage = `url("${url}")` 62
63 placeholder.style.display = 'block' 63 const translatedTitle = peertubeTranslate('This video is password protected', translations)
64 const translatedMessage = peertubeTranslate('You need a password to watch this video.', translations)
65
66 document.title = translatedTitle
67
68 const videoPasswordBlock = document.getElementById('video-password-block')
69 videoPasswordBlock.style.display = 'flex'
70
71 const videoPasswordTitle = document.getElementById('video-password-title')
72 videoPasswordTitle.innerHTML = translatedTitle
73
74 const videoPasswordMessage = document.getElementById('video-password-content')
75 videoPasswordMessage.innerHTML = translatedMessage
76
77 if (incorrectPassword) {
78 const videoPasswordError = document.getElementById('video-password-error')
79 videoPasswordError.innerHTML = peertubeTranslate('Incorrect password, please enter a correct password', translations)
80 videoPasswordError.style.transform = 'scale(1.2)'
81
82 setTimeout(() => {
83 videoPasswordError.style.transform = 'scale(1)'
84 }, 500)
85 }
86
87 const videoPasswordSubmitButton = document.getElementById('video-password-submit')
88 videoPasswordSubmitButton.innerHTML = peertubeTranslate('Watch Video', translations)
89
90 const videoPasswordInput = document.getElementById('video-password-input') as HTMLInputElement
91 videoPasswordInput.placeholder = peertubeTranslate('Password', translations)
92
93 const videoPasswordForm = document.getElementById('video-password-form')
94 videoPasswordForm.addEventListener('submit', (event) => {
95 event.preventDefault()
96 const videoPassword = videoPasswordInput.value
97 resolve(videoPassword)
98 })
99 })
64 } 100 }
65 101
66 removePlaceholder () { 102 removeVideoPasswordBlock () {
67 const placeholder = this.getPlaceholderElement() 103 const videoPasswordBlock = document.getElementById('video-password-block')
68 placeholder.style.display = 'none' 104 videoPasswordBlock.style.display = 'none'
105 this.wrapperElement.style.display = 'block'
69 } 106 }
70 107
71 displayInformation (text: string, translations: Translations) { 108 displayInformation (text: string, translations: Translations) {
@@ -85,10 +122,6 @@ export class PlayerHTML {
85 this.informationElement = undefined 122 this.informationElement = undefined
86 } 123 }
87 124
88 private getPlaceholderElement () {
89 return document.getElementById('placeholder-preview')
90 }
91
92 private removeElement (element: HTMLElement) { 125 private removeElement (element: HTMLElement) {
93 element.parentElement.removeChild(element) 126 element.parentElement.removeChild(element)
94 } 127 }
diff --git a/client/src/standalone/videos/shared/player-manager-options.ts b/client/src/standalone/videos/shared/player-options-builder.ts
index 43ae22a3b..8a4e32444 100644
--- a/client/src/standalone/videos/shared/player-manager-options.ts
+++ b/client/src/standalone/videos/shared/player-options-builder.ts
@@ -2,6 +2,7 @@ import { peertubeTranslate } from '../../../../../shared/core-utils/i18n'
2import { 2import {
3 HTMLServerConfig, 3 HTMLServerConfig,
4 LiveVideo, 4 LiveVideo,
5 Storyboard,
5 Video, 6 Video,
6 VideoCaption, 7 VideoCaption,
7 VideoDetails, 8 VideoDetails,
@@ -9,7 +10,7 @@ import {
9 VideoState, 10 VideoState,
10 VideoStreamingPlaylistType 11 VideoStreamingPlaylistType
11} from '../../../../../shared/models' 12} from '../../../../../shared/models'
12import { P2PMediaLoaderOptions, PeertubePlayerManagerOptions, PlayerMode, VideoJSCaption } from '../../../assets/player' 13import { HLSOptions, PeerTubePlayerContructorOptions, PeerTubePlayerLoadOptions, PlayerMode, VideoJSCaption } from '../../../assets/player'
13import { 14import {
14 getBoolOrDefault, 15 getBoolOrDefault,
15 getParamString, 16 getParamString,
@@ -18,7 +19,7 @@ import {
18 logger, 19 logger,
19 peertubeLocalStorage, 20 peertubeLocalStorage,
20 UserLocalStorageKeys, 21 UserLocalStorageKeys,
21 videoRequiresAuth 22 videoRequiresUserAuth
22} from '../../../root-helpers' 23} from '../../../root-helpers'
23import { PeerTubePlugin } from './peertube-plugin' 24import { PeerTubePlugin } from './peertube-plugin'
24import { PlayerHTML } from './player-html' 25import { PlayerHTML } from './player-html'
@@ -26,7 +27,7 @@ import { PlaylistTracker } from './playlist-tracker'
26import { Translations } from './translations' 27import { Translations } from './translations'
27import { VideoFetcher } from './video-fetcher' 28import { VideoFetcher } from './video-fetcher'
28 29
29export class PlayerManagerOptions { 30export class PlayerOptionsBuilder {
30 private autoplay: boolean 31 private autoplay: boolean
31 32
32 private controls: boolean 33 private controls: boolean
@@ -140,10 +141,10 @@ export class PlayerManagerOptions {
140 141
141 if (modeParam) { 142 if (modeParam) {
142 if (modeParam === 'p2p-media-loader') this.mode = 'p2p-media-loader' 143 if (modeParam === 'p2p-media-loader') this.mode = 'p2p-media-loader'
143 else this.mode = 'webtorrent' 144 else this.mode = 'web-video'
144 } else { 145 } else {
145 if (Array.isArray(video.streamingPlaylists) && video.streamingPlaylists.length !== 0) this.mode = 'p2p-media-loader' 146 if (Array.isArray(video.streamingPlaylists) && video.streamingPlaylists.length !== 0) this.mode = 'p2p-media-loader'
146 else this.mode = 'webtorrent' 147 else this.mode = 'web-video'
147 } 148 }
148 } catch (err) { 149 } catch (err) {
149 logger.error('Cannot get params from URL.', err) 150 logger.error('Cannot get params from URL.', err)
@@ -152,119 +153,140 @@ export class PlayerManagerOptions {
152 153
153 // --------------------------------------------------------------------------- 154 // ---------------------------------------------------------------------------
154 155
155 async getPlayerOptions (options: { 156 getPlayerConstructorOptions (options: {
157 serverConfig: HTMLServerConfig
158 authorizationHeader: () => string
159 }): PeerTubePlayerContructorOptions {
160 const { serverConfig, authorizationHeader } = options
161
162 return {
163 controls: this.controls,
164 controlBar: this.controlBar,
165
166 muted: this.muted,
167 loop: this.loop,
168
169 playbackRate: this.playbackRate,
170
171 inactivityTimeout: 2500,
172 videoViewIntervalMs: 5000,
173 metricsUrl: window.location.origin + '/api/v1/metrics/playback',
174
175 authorizationHeader,
176
177 playerElement: () => this.playerHTML.getPlayerElement(),
178 enableHotkeys: true,
179
180 peertubeLink: () => this.peertubeLink,
181 instanceName: serverConfig.instance.name,
182
183 theaterButton: false,
184
185 serverUrl: window.location.origin,
186 language: navigator.language,
187
188 pluginsManager: this.peertubePlugin.getPluginsManager(),
189
190 errorNotifier: () => {
191 // Empty, we don't have a notifier in the embed
192 }
193 }
194 }
195
196 async getPlayerLoadOptions (options: {
156 video: VideoDetails 197 video: VideoDetails
157 captionsResponse: Response 198 captionsResponse: Response
199
200 storyboardsResponse: Response
201
158 live?: LiveVideo 202 live?: LiveVideo
159 203
204 alreadyPlayed: boolean
160 forceAutoplay: boolean 205 forceAutoplay: boolean
161 206
162 authorizationHeader: () => string
163 videoFileToken: () => string 207 videoFileToken: () => string
164 208
165 serverConfig: HTMLServerConfig 209 videoPassword: () => string
166 210 requiresPassword: boolean
167 autoplayFromPreviousVideo: boolean
168 211
169 translations: Translations 212 translations: Translations
170 213
171 playlistTracker?: PlaylistTracker 214 playlist?: {
172 playNextPlaylistVideo?: () => any 215 playlistTracker: PlaylistTracker
173 playPreviousPlaylistVideo?: () => any 216 playNext: () => any
174 onVideoUpdate?: (uuid: string) => any 217 playPrevious: () => any
175 }) { 218 onVideoUpdate: (uuid: string) => any
219 }
220 }): Promise<PeerTubePlayerLoadOptions> {
176 const { 221 const {
177 video, 222 video,
178 captionsResponse, 223 captionsResponse,
179 autoplayFromPreviousVideo,
180 videoFileToken, 224 videoFileToken,
225 videoPassword,
226 requiresPassword,
181 translations, 227 translations,
228 alreadyPlayed,
182 forceAutoplay, 229 forceAutoplay,
183 playlistTracker, 230 playlist,
184 live, 231 live,
185 authorizationHeader, 232 storyboardsResponse
186 serverConfig
187 } = options 233 } = options
188 234
189 const videoCaptions = await this.buildCaptions(captionsResponse, translations) 235 const [ videoCaptions, storyboard ] = await Promise.all([
190 236 this.buildCaptions(captionsResponse, translations),
191 const playerOptions: PeertubePlayerManagerOptions = { 237 this.buildStoryboard(storyboardsResponse)
192 common: { 238 ])
193 // Autoplay in playlist mode
194 autoplay: autoplayFromPreviousVideo ? true : this.autoplay,
195 forceAutoplay,
196 239
197 controls: this.controls, 240 return {
198 controlBar: this.controlBar, 241 mode: this.mode,
199
200 muted: this.muted,
201 loop: this.loop,
202 242
203 p2pEnabled: this.p2pEnabled, 243 autoplay: forceAutoplay || alreadyPlayed || this.autoplay,
244 forceAutoplay,
204 245
205 captions: videoCaptions.length !== 0, 246 p2pEnabled: this.p2pEnabled,
206 subtitle: this.subtitle,
207 247
208 startTime: playlistTracker 248 subtitle: this.subtitle,
209 ? playlistTracker.getCurrentElement().startTimestamp
210 : this.startTime,
211 stopTime: playlistTracker
212 ? playlistTracker.getCurrentElement().stopTimestamp
213 : this.stopTime,
214 249
215 playbackRate: this.playbackRate, 250 storyboard,
216 251
217 videoCaptions, 252 startTime: playlist
218 inactivityTimeout: 2500, 253 ? playlist.playlistTracker.getCurrentElement().startTimestamp
219 videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid), 254 : this.startTime,
220 videoViewIntervalMs: 5000, 255 stopTime: playlist
221 metricsUrl: window.location.origin + '/api/v1/metrics/playback', 256 ? playlist.playlistTracker.getCurrentElement().stopTimestamp
257 : this.stopTime,
222 258
223 videoShortUUID: video.shortUUID, 259 videoCaptions,
224 videoUUID: video.uuid, 260 videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid),
225 261
226 playerElement: this.playerHTML.getPlayerElement(), 262 videoShortUUID: video.shortUUID,
227 onPlayerElementChange: (element: HTMLVideoElement) => { 263 videoUUID: video.uuid,
228 this.playerHTML.setPlayerElement(element)
229 },
230 264
231 videoDuration: video.duration, 265 duration: video.duration,
232 enableHotkeys: true,
233 266
234 peertubeLink: this.peertubeLink, 267 poster: window.location.origin + video.previewPath,
235 instanceName: serverConfig.instance.name,
236 268
237 poster: window.location.origin + video.previewPath, 269 embedUrl: window.location.origin + video.embedPath,
238 theaterButton: false, 270 embedTitle: video.name,
239 271
240 serverUrl: window.location.origin, 272 requiresUserAuth: videoRequiresUserAuth(video),
241 language: navigator.language, 273 videoFileToken,
242 embedUrl: window.location.origin + video.embedPath,
243 embedTitle: video.name,
244 274
245 requiresAuth: videoRequiresAuth(video), 275 requiresPassword,
246 authorizationHeader, 276 videoPassword,
247 videoFileToken,
248 277
249 errorNotifier: () => { 278 ...this.buildLiveOptions(video, live),
250 // Empty, we don't have a notifier in the embed
251 },
252 279
253 ...this.buildLiveOptions(video, live), 280 ...this.buildPlaylistOptions(playlist),
254 281
255 ...this.buildPlaylistOptions(options) 282 dock: this.buildDockOptions(video),
256 },
257 283
258 webtorrent: { 284 webVideo: {
259 videoFiles: video.files 285 videoFiles: video.files
260 }, 286 },
261 287
262 ...this.buildP2PMediaLoaderOptions(video), 288 hls: this.buildHLSOptions(video)
263
264 pluginsManager: this.peertubePlugin.getPluginsManager()
265 } 289 }
266
267 return playerOptions
268 } 290 }
269 291
270 private buildLiveOptions (video: VideoDetails, live: LiveVideo) { 292 private buildLiveOptions (video: VideoDetails, live: LiveVideo) {
@@ -278,15 +300,39 @@ export class PlayerManagerOptions {
278 } 300 }
279 } 301 }
280 302
281 private buildPlaylistOptions (options: { 303 private async buildStoryboard (storyboardsResponse: Response) {
282 playlistTracker?: PlaylistTracker 304 const { storyboards } = await storyboardsResponse.json() as { storyboards: Storyboard[] }
283 playNextPlaylistVideo?: () => any 305 if (!storyboards || storyboards.length === 0) return undefined
284 playPreviousPlaylistVideo?: () => any 306
285 onVideoUpdate?: (uuid: string) => any 307 return {
308 url: window.location.origin + storyboards[0].storyboardPath,
309 height: storyboards[0].spriteHeight,
310 width: storyboards[0].spriteWidth,
311 interval: storyboards[0].spriteDuration
312 }
313 }
314
315 private buildPlaylistOptions (options?: {
316 playlistTracker: PlaylistTracker
317 playNext: () => any
318 playPrevious: () => any
319 onVideoUpdate: (uuid: string) => any
286 }) { 320 }) {
287 const { playlistTracker, playNextPlaylistVideo, playPreviousPlaylistVideo, onVideoUpdate } = options 321 if (!options) {
322 return {
323 nextVideo: {
324 enabled: false,
325 displayControlBarButton: false,
326 getVideoTitle: () => ''
327 },
328 previousVideo: {
329 enabled: false,
330 displayControlBarButton: false
331 }
332 }
333 }
288 334
289 if (!playlistTracker) return {} 335 const { playlistTracker, playNext, playPrevious, onVideoUpdate } = options
290 336
291 return { 337 return {
292 playlist: { 338 playlist: {
@@ -302,27 +348,37 @@ export class PlayerManagerOptions {
302 } 348 }
303 }, 349 },
304 350
305 nextVideo: () => playNextPlaylistVideo(), 351 previousVideo: {
306 hasNextVideo: () => playlistTracker.hasNextPlaylistElement(), 352 enabled: playlistTracker.hasPreviousPlaylistElement(),
353 handler: () => playPrevious(),
354 displayControlBarButton: true
355 },
307 356
308 previousVideo: () => playPreviousPlaylistVideo(), 357 nextVideo: {
309 hasPreviousVideo: () => playlistTracker.hasPreviousPlaylistElement() 358 enabled: playlistTracker.hasNextPlaylistElement(),
359 handler: () => playNext(),
360 getVideoTitle: () => playlistTracker.getNextPlaylistElement()?.video?.name,
361 displayControlBarButton: true
362 },
363
364 upnext: {
365 isEnabled: () => true,
366 isSuspended: () => false,
367 timeout: 0
368 }
310 } 369 }
311 } 370 }
312 371
313 private buildP2PMediaLoaderOptions (video: VideoDetails) { 372 private buildHLSOptions (video: VideoDetails): HLSOptions {
314 if (this.mode !== 'p2p-media-loader') return {}
315
316 const hlsPlaylist = video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) 373 const hlsPlaylist = video.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
374 if (!hlsPlaylist) return undefined
317 375
318 return { 376 return {
319 p2pMediaLoader: { 377 playlistUrl: hlsPlaylist.playlistUrl,
320 playlistUrl: hlsPlaylist.playlistUrl, 378 segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
321 segmentsSha256Url: hlsPlaylist.segmentsSha256Url, 379 redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
322 redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl), 380 trackerAnnounce: video.trackerUrls,
323 trackerAnnounce: video.trackerUrls, 381 videoFiles: hlsPlaylist.files
324 videoFiles: hlsPlaylist.files
325 } as P2PMediaLoaderOptions
326 } 382 }
327 } 383 }
328 384
@@ -344,6 +400,35 @@ export class PlayerManagerOptions {
344 400
345 // --------------------------------------------------------------------------- 401 // ---------------------------------------------------------------------------
346 402
403 private buildDockOptions (videoInfo: VideoDetails) {
404 if (!this.hasControls()) return undefined
405
406 const title = this.hasTitle()
407 ? videoInfo.name
408 : undefined
409
410 const description = this.hasWarningTitle() && this.hasP2PEnabled()
411 ? '<span class="text">' + peertubeTranslate('Watching this video may reveal your IP address to others.') + '</span>'
412 : undefined
413
414 if (!title && !description) return
415
416 const availableAvatars = videoInfo.channel.avatars.filter(a => a.width < 50)
417 const avatar = availableAvatars.length !== 0
418 ? availableAvatars[0]
419 : undefined
420
421 return {
422 title,
423 description,
424 avatarUrl: title && avatar
425 ? avatar.path
426 : undefined
427 }
428 }
429
430 // ---------------------------------------------------------------------------
431
347 private isP2PEnabled (config: HTMLServerConfig, video: Video) { 432 private isP2PEnabled (config: HTMLServerConfig, video: Video) {
348 const userP2PEnabled = getBoolOrDefault( 433 const userP2PEnabled = getBoolOrDefault(
349 peertubeLocalStorage.getItem(UserLocalStorageKeys.P2P_ENABLED), 434 peertubeLocalStorage.getItem(UserLocalStorageKeys.P2P_ENABLED),
diff --git a/client/src/standalone/videos/shared/video-fetcher.ts b/client/src/standalone/videos/shared/video-fetcher.ts
index cf6d12831..7fb94fbf3 100644
--- a/client/src/standalone/videos/shared/video-fetcher.ts
+++ b/client/src/standalone/videos/shared/video-fetcher.ts
@@ -1,5 +1,6 @@
1import { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '../../../../../shared/models' 1import { HttpStatusCode, LiveVideo, VideoDetails, VideoToken } from '../../../../../shared/models'
2import { logger } from '../../../root-helpers' 2import { logger } from '../../../root-helpers'
3import { PeerTubeServerError } from '../../../types'
3import { AuthHTTP } from './auth-http' 4import { AuthHTTP } from './auth-http'
4 5
5export class VideoFetcher { 6export class VideoFetcher {
@@ -8,8 +9,8 @@ export class VideoFetcher {
8 9
9 } 10 }
10 11
11 async loadVideo (videoId: string) { 12 async loadVideo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }) {
12 const videoPromise = this.loadVideoInfo(videoId) 13 const videoPromise = this.loadVideoInfo({ videoId, videoPassword })
13 14
14 let videoResponse: Response 15 let videoResponse: Response
15 let isResponseOk: boolean 16 let isResponseOk: boolean
@@ -27,13 +28,17 @@ export class VideoFetcher {
27 if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) { 28 if (videoResponse?.status === HttpStatusCode.NOT_FOUND_404) {
28 throw new Error('This video does not exist.') 29 throw new Error('This video does not exist.')
29 } 30 }
30 31 if (videoResponse?.status === HttpStatusCode.FORBIDDEN_403) {
32 const res = await videoResponse.json()
33 throw new PeerTubeServerError(res.message, res.code)
34 }
31 throw new Error('We cannot fetch the video. Please try again later.') 35 throw new Error('We cannot fetch the video. Please try again later.')
32 } 36 }
33 37
34 const captionsPromise = this.loadVideoCaptions(videoId) 38 const captionsPromise = this.loadVideoCaptions({ videoId, videoPassword })
39 const storyboardsPromise = this.loadStoryboards(videoId)
35 40
36 return { captionsPromise, videoResponse } 41 return { captionsPromise, storyboardsPromise, videoResponse }
37 } 42 }
38 43
39 loadLive (video: VideoDetails) { 44 loadLive (video: VideoDetails) {
@@ -41,8 +46,8 @@ export class VideoFetcher {
41 .then(res => res.json() as Promise<LiveVideo>) 46 .then(res => res.json() as Promise<LiveVideo>)
42 } 47 }
43 48
44 loadVideoToken (video: VideoDetails) { 49 loadVideoToken (video: VideoDetails, videoPassword?: string) {
45 return this.http.fetch(this.getVideoTokenUrl(video.uuid), { optionalAuth: true, method: 'POST' }) 50 return this.http.fetch(this.getVideoTokenUrl(video.uuid), { optionalAuth: true, method: 'POST' }, videoPassword)
46 .then(res => res.json() as Promise<VideoToken>) 51 .then(res => res.json() as Promise<VideoToken>)
47 .then(token => token.files.token) 52 .then(token => token.files.token)
48 } 53 }
@@ -51,12 +56,12 @@ export class VideoFetcher {
51 return this.getVideoUrl(videoUUID) + '/views' 56 return this.getVideoUrl(videoUUID) + '/views'
52 } 57 }
53 58
54 private loadVideoInfo (videoId: string): Promise<Response> { 59 private loadVideoInfo ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise<Response> {
55 return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true }) 60 return this.http.fetch(this.getVideoUrl(videoId), { optionalAuth: true }, videoPassword)
56 } 61 }
57 62
58 private loadVideoCaptions (videoId: string): Promise<Response> { 63 private loadVideoCaptions ({ videoId, videoPassword }: { videoId: string, videoPassword?: string }): Promise<Response> {
59 return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }) 64 return this.http.fetch(this.getVideoUrl(videoId) + '/captions', { optionalAuth: true }, videoPassword)
60 } 65 }
61 66
62 private getVideoUrl (id: string) { 67 private getVideoUrl (id: string) {
@@ -67,6 +72,14 @@ export class VideoFetcher {
67 return window.location.origin + '/api/v1/videos/live/' + videoId 72 return window.location.origin + '/api/v1/videos/live/' + videoId
68 } 73 }
69 74
75 private loadStoryboards (videoUUID: string): Promise<Response> {
76 return this.http.fetch(this.getStoryboardsUrl(videoUUID), { optionalAuth: true })
77 }
78
79 private getStoryboardsUrl (videoId: string) {
80 return window.location.origin + '/api/v1/videos/' + videoId + '/storyboards'
81 }
82
70 private getVideoTokenUrl (id: string) { 83 private getVideoTokenUrl (id: string) {
71 return this.getVideoUrl(id) + '/token' 84 return this.getVideoUrl(id) + '/token'
72 } 85 }
diff --git a/client/src/standalone/videos/test-embed.ts b/client/src/standalone/videos/test-embed.ts
index b34df11ee..b7a283c4d 100644
--- a/client/src/standalone/videos/test-embed.ts
+++ b/client/src/standalone/videos/test-embed.ts
@@ -1,6 +1,6 @@
1import './test-embed.scss' 1import './test-embed.scss'
2import { PeerTubeResolution, PlayerEventType } from '../player/definitions' 2import { PeerTubeResolution, PlayerEventType } from '../embed-player-api/definitions'
3import { PeerTubePlayer } from '../player/player' 3import { PeerTubePlayer } from '../embed-player-api/player'
4import { logger } from '../../root-helpers' 4import { logger } from '../../root-helpers'
5 5
6window.addEventListener('load', async () => { 6window.addEventListener('load', async () => {
diff --git a/client/src/types/index.ts b/client/src/types/index.ts
index 5508515fd..60564496c 100644
--- a/client/src/types/index.ts
+++ b/client/src/types/index.ts
@@ -1,4 +1,5 @@
1export * from './client-script.model' 1export * from './client-script.model'
2export * from './server-error.model'
2export * from './job-state-client.type' 3export * from './job-state-client.type'
3export * from './job-type-client.type' 4export * from './job-type-client.type'
4export * from './link.type' 5export * from './link.type'
diff --git a/client/src/types/server-error.model.ts b/client/src/types/server-error.model.ts
new file mode 100644
index 000000000..4a57287fe
--- /dev/null
+++ b/client/src/types/server-error.model.ts
@@ -0,0 +1,11 @@
1import { ServerErrorCode } from '@shared/models/index'
2
3export class PeerTubeServerError extends Error {
4 serverCode: ServerErrorCode
5
6 constructor (message: string, serverCode: ServerErrorCode) {
7 super(message)
8 this.name = 'CustomError'
9 this.serverCode = serverCode
10 }
11}
diff --git a/client/tsconfig.json b/client/tsconfig.json
index 785ed1c6c..5dee39362 100644
--- a/client/tsconfig.json
+++ b/client/tsconfig.json
@@ -61,18 +61,9 @@
61 "fs": [ 61 "fs": [
62 "src/shims/noop.ts" 62 "src/shims/noop.ts"
63 ], 63 ],
64 "http": [
65 "src/shims/http.ts"
66 ],
67 "https": [
68 "src/shims/https.ts"
69 ],
70 "path": [ 64 "path": [
71 "src/shims/path.ts" 65 "src/shims/path.ts"
72 ], 66 ],
73 "stream": [
74 "src/shims/stream.ts"
75 ],
76 "crypto": [ 67 "crypto": [
77 "src/shims/noop.ts" 68 "src/shims/noop.ts"
78 ] 69 ]
@@ -89,8 +80,7 @@
89 ], 80 ],
90 "exclude": [ 81 "exclude": [
91 "../node_modules", 82 "../node_modules",
92 "../server", 83 "../server"
93 "node_modules"
94 ], 84 ],
95 "angularCompilerOptions": { 85 "angularCompilerOptions": {
96 "strictInjectionParameters": true, 86 "strictInjectionParameters": true,
diff --git a/client/webpack/webpack.video-embed.js b/client/webpack/webpack.video-embed.js
index e25677872..47d440c25 100644
--- a/client/webpack/webpack.video-embed.js
+++ b/client/webpack/webpack.video-embed.js
@@ -10,7 +10,7 @@ module.exports = function () {
10 const configuration = { 10 const configuration = {
11 entry: { 11 entry: {
12 'video-embed': './src/standalone/videos/embed.ts', 12 'video-embed': './src/standalone/videos/embed.ts',
13 'player': './src/standalone/player/player.ts', 13 'player': './src/standalone/embed-player-api/player.ts',
14 'test-embed': './src/standalone/videos/test-embed.ts' 14 'test-embed': './src/standalone/videos/test-embed.ts'
15 }, 15 },
16 16
@@ -36,10 +36,7 @@ module.exports = function () {
36 36
37 fallback: { 37 fallback: {
38 fs: [ path.resolve('src/shims/noop.ts') ], 38 fs: [ path.resolve('src/shims/noop.ts') ],
39 http: [ path.resolve('src/shims/http.ts') ],
40 https: [ path.resolve('src/shims/https.ts') ],
41 path: [ path.resolve('src/shims/path.ts') ], 39 path: [ path.resolve('src/shims/path.ts') ],
42 stream: [ path.resolve('src/shims/stream.ts') ],
43 crypto: [ path.resolve('src/shims/noop.ts') ] 40 crypto: [ path.resolve('src/shims/noop.ts') ]
44 } 41 }
45 }, 42 },
diff --git a/client/yarn.lock b/client/yarn.lock
index aeb13a7b5..5c9f4bf42 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -1647,6 +1647,14 @@
1647 "@formatjs/intl-localematcher" "0.2.32" 1647 "@formatjs/intl-localematcher" "0.2.32"
1648 tslib "^2.4.0" 1648 tslib "^2.4.0"
1649 1649
1650"@formatjs/ecma402-abstract@1.16.0":
1651 version "1.16.0"
1652 resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.16.0.tgz#15a0baa8401880d4010eb93440d996e896ca251c"
1653 integrity sha512-qIH2cmG/oHGrVdApbqDf6/YR+B2A4NdkBjKLeq369OMVkqMFsC5oPSP1xpiyL1cAn+PbNEZHxwOVMYD/C76c6g==
1654 dependencies:
1655 "@formatjs/intl-localematcher" "0.3.0"
1656 tslib "^2.4.0"
1657
1650"@formatjs/fast-memoize@2.0.1": 1658"@formatjs/fast-memoize@2.0.1":
1651 version "2.0.1" 1659 version "2.0.1"
1652 resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-2.0.1.tgz#f15aaa73caad5562899c69bdcad8db82adcd3b0b" 1660 resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-2.0.1.tgz#f15aaa73caad5562899c69bdcad8db82adcd3b0b"
@@ -1671,6 +1679,30 @@
1671 "@formatjs/ecma402-abstract" "1.15.0" 1679 "@formatjs/ecma402-abstract" "1.15.0"
1672 tslib "^2.4.0" 1680 tslib "^2.4.0"
1673 1681
1682"@formatjs/intl-enumerator@1.3.1":
1683 version "1.3.1"
1684 resolved "https://registry.yarnpkg.com/@formatjs/intl-enumerator/-/intl-enumerator-1.3.1.tgz#32f0a3b5aece244977aad16fe1cd5c205defc7f8"
1685 integrity sha512-UMuD1tNRb8JC+mZo0KeuSntJNie0+TLxXl/1QxRIRMR7z2UuJgphrK/UTUibAx9hjywL1qGdNNhD6QX//pvNyA==
1686 dependencies:
1687 tslib "^2.4.0"
1688
1689"@formatjs/intl-getcanonicallocales@2.2.1":
1690 version "2.2.1"
1691 resolved "https://registry.yarnpkg.com/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-2.2.1.tgz#0d70251b16bec06c1c4a40b37892ea5b51e3a2bb"
1692 integrity sha512-KooqmyY+Mhq3ioASPzoU6p6Cy9Mx+cWSVQSP6lF+vEW2tiaN90ti08cp82p1dzFschenduOYgPKrNcBpsDi6+g==
1693 dependencies:
1694 tslib "^2.4.0"
1695
1696"@formatjs/intl-locale@^3.3.1":
1697 version "3.3.1"
1698 resolved "https://registry.yarnpkg.com/@formatjs/intl-locale/-/intl-locale-3.3.1.tgz#2b86e85319913e0bedfcd64884be33bc4bcef73e"
1699 integrity sha512-Rg3BLIjMzVxBcZCsPhvwIrcfc+UVEzPZPKnvoOLh6KNNWrzWnRo0ORQEE/KRDyvYZxAmALV/GHCcRl+qlchKuw==
1700 dependencies:
1701 "@formatjs/ecma402-abstract" "1.16.0"
1702 "@formatjs/intl-enumerator" "1.3.1"
1703 "@formatjs/intl-getcanonicallocales" "2.2.1"
1704 tslib "^2.4.0"
1705
1674"@formatjs/intl-localematcher@0.2.32": 1706"@formatjs/intl-localematcher@0.2.32":
1675 version "0.2.32" 1707 version "0.2.32"
1676 resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.2.32.tgz#00d4d307cd7d514b298e15a11a369b86c8933ec1" 1708 resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.2.32.tgz#00d4d307cd7d514b298e15a11a369b86c8933ec1"
@@ -1678,6 +1710,22 @@
1678 dependencies: 1710 dependencies:
1679 tslib "^2.4.0" 1711 tslib "^2.4.0"
1680 1712
1713"@formatjs/intl-localematcher@0.3.0":
1714 version "0.3.0"
1715 resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.3.0.tgz#9ad570d90d302b60bcbe78efd5fcd7593c440579"
1716 integrity sha512-NFoxXX3dtZ6B53NlCErq181NxN/noMZOWKHfcEPQRNfV0a19THxyjxu2RTSNS3532wGm6fOdid5qsBQWg0Rhtw==
1717 dependencies:
1718 tslib "^2.4.0"
1719
1720"@formatjs/intl-pluralrules@^5.2.2":
1721 version "5.2.2"
1722 resolved "https://registry.yarnpkg.com/@formatjs/intl-pluralrules/-/intl-pluralrules-5.2.2.tgz#6322d20a6d0172459e4faf4b0f06603c931673aa"
1723 integrity sha512-mEbnbRzsSCIYqaBmrmUlOsPu5MG6KfMcnzekPzUrUucX2dNiI1KWBGHK6IoXl5c8zx60L1NXJ6cSQ7akoc15SQ==
1724 dependencies:
1725 "@formatjs/ecma402-abstract" "1.15.0"
1726 "@formatjs/intl-localematcher" "0.2.32"
1727 tslib "^2.4.0"
1728
1681"@gar/promisify@^1.1.3": 1729"@gar/promisify@^1.1.3":
1682 version "1.1.3" 1730 version "1.1.3"
1683 resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" 1731 resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6"
@@ -2169,13 +2217,6 @@
2169 "@tufjs/canonical-json" "1.0.0" 2217 "@tufjs/canonical-json" "1.0.0"
2170 minimatch "^9.0.0" 2218 minimatch "^9.0.0"
2171 2219
2172"@types/bittorrent-protocol@*":
2173 version "3.1.2"
2174 resolved "https://registry.yarnpkg.com/@types/bittorrent-protocol/-/bittorrent-protocol-3.1.2.tgz#884cf1589fa8b1f7a6cc39bd516922a96ce08221"
2175 integrity sha512-7k9nivNeG7Sc8wVuBs+XjBp2u7pH8tqW3BB93/SAg3xht/cZEK+Rqkj79xSyJqyj86eA0F6n85EKkkyGki8afg==
2176 dependencies:
2177 "@types/node" "*"
2178
2179"@types/body-parser@*": 2220"@types/body-parser@*":
2180 version "1.19.2" 2221 version "1.19.2"
2181 resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0" 2222 resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0"
@@ -2346,13 +2387,6 @@
2346 resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.194.tgz#b71eb6f7a0ff11bff59fc987134a093029258a76" 2387 resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.194.tgz#b71eb6f7a0ff11bff59fc987134a093029258a76"
2347 integrity sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g== 2388 integrity sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==
2348 2389
2349"@types/magnet-uri@*":
2350 version "5.1.3"
2351 resolved "https://registry.yarnpkg.com/@types/magnet-uri/-/magnet-uri-5.1.3.tgz#cdf974721012bd758c0f559cabcad7bab87f9008"
2352 integrity sha512-FvJN1yYdLhvU6zWJ2YnWQ2GnpFLsA8bt+85WY0tLh6ehzGNrvBorjlcc53/zY43r/IKn+ctFs1nt7andwGnQCQ==
2353 dependencies:
2354 "@types/node" "*"
2355
2356"@types/markdown-it@^12.0.1": 2390"@types/markdown-it@^12.0.1":
2357 version "12.2.3" 2391 version "12.2.3"
2358 resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-12.2.3.tgz#0d6f6e5e413f8daaa26522904597be3d6cd93b51" 2392 resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-12.2.3.tgz#0d6f6e5e413f8daaa26522904597be3d6cd93b51"
@@ -2411,22 +2445,6 @@
2411 resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" 2445 resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301"
2412 integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== 2446 integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==
2413 2447
2414"@types/parse-torrent-file@*":
2415 version "4.0.3"
2416 resolved "https://registry.yarnpkg.com/@types/parse-torrent-file/-/parse-torrent-file-4.0.3.tgz#045b023426d168e0253c932cb782b231b1ee2d62"
2417 integrity sha512-dFkPnJPKiFWiGX+HXmyTVt2js3k0d9dThmUxX8nfGC22hbyZ5BTmetsEl45sQhHLcFo43njVrIKMXM3F1ahXRw==
2418 dependencies:
2419 "@types/node" "*"
2420
2421"@types/parse-torrent@*":
2422 version "5.8.4"
2423 resolved "https://registry.yarnpkg.com/@types/parse-torrent/-/parse-torrent-5.8.4.tgz#c095834a9a815507c59014a79517ad403e4329d0"
2424 integrity sha512-FdKs5yN5iYO5Cu9gVz1Zl30CbZe6HTsqloWmCf+LfbImgSzlsUkov2+npQWCQSQ3zi/a2G5C824K0UpZ2sRufA==
2425 dependencies:
2426 "@types/magnet-uri" "*"
2427 "@types/node" "*"
2428 "@types/parse-torrent-file" "*"
2429
2430"@types/prop-types@*": 2448"@types/prop-types@*":
2431 version "15.7.5" 2449 version "15.7.5"
2432 resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" 2450 resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
@@ -2510,13 +2528,6 @@
2510 dependencies: 2528 dependencies:
2511 "@types/node" "*" 2529 "@types/node" "*"
2512 2530
2513"@types/simple-peer@*":
2514 version "9.11.5"
2515 resolved "https://registry.yarnpkg.com/@types/simple-peer/-/simple-peer-9.11.5.tgz#6baa00edbbd0f632f8561e8fb03b4d21d62f076e"
2516 integrity sha512-haXgWcAa3Y3Sn+T8lzkE4ErQUpYzhW6Cz2lh00RhQTyWt+xZ3s87wJPztUxlqSdFRqGhe2MQIBd0XsyHP3No4w==
2517 dependencies:
2518 "@types/node" "*"
2519
2520"@types/sockjs@^0.3.33": 2531"@types/sockjs@^0.3.33":
2521 version "0.3.33" 2532 version "0.3.33"
2522 resolved "https://registry.yarnpkg.com/@types/sockjs/-/sockjs-0.3.33.tgz#570d3a0b99ac995360e3136fd6045113b1bd236f" 2533 resolved "https://registry.yarnpkg.com/@types/sockjs/-/sockjs-0.3.33.tgz#570d3a0b99ac995360e3136fd6045113b1bd236f"
@@ -2534,16 +2545,6 @@
2534 resolved "https://registry.yarnpkg.com/@types/video.js/-/video.js-7.3.51.tgz#ce69e02681ed6ed8abe61bb3802dd032a74d63e8" 2545 resolved "https://registry.yarnpkg.com/@types/video.js/-/video.js-7.3.51.tgz#ce69e02681ed6ed8abe61bb3802dd032a74d63e8"
2535 integrity sha512-xLlt/ZfCuWYBvG2MRn018RvaEplcK6dI63aOiVUeeAWFyjx3Br1hL749ndFgbrvNdY4m9FoHG1FQ/PB6IpfSAQ== 2546 integrity sha512-xLlt/ZfCuWYBvG2MRn018RvaEplcK6dI63aOiVUeeAWFyjx3Br1hL749ndFgbrvNdY4m9FoHG1FQ/PB6IpfSAQ==
2536 2547
2537"@types/webtorrent@^0.109.0":
2538 version "0.109.3"
2539 resolved "https://registry.yarnpkg.com/@types/webtorrent/-/webtorrent-0.109.3.tgz#95df708d98bcea235b37f49a9a348b11f3511670"
2540 integrity sha512-EJLsxMEcEjPXHcBqL6TRAbUwIpxAul5ULrXHJ0zwig7Oe70FS6dAzCWLq4MBafX3QrQG1DzGAS0fS8iJEOjD0g==
2541 dependencies:
2542 "@types/bittorrent-protocol" "*"
2543 "@types/node" "*"
2544 "@types/parse-torrent" "*"
2545 "@types/simple-peer" "*"
2546
2547"@types/which@^2.0.1": 2548"@types/which@^2.0.1":
2548 version "2.0.2" 2549 version "2.0.2"
2549 resolved "https://registry.yarnpkg.com/@types/which/-/which-2.0.2.tgz#54541d02d6b1daee5ec01ac0d1b37cecf37db1ae" 2550 resolved "https://registry.yarnpkg.com/@types/which/-/which-2.0.2.tgz#54541d02d6b1daee5ec01ac0d1b37cecf37db1ae"
@@ -3076,14 +3077,6 @@
3076 resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-2.0.4.tgz#3982ee6f8b42845437fc4d391e93ac5d9da52f0f" 3077 resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-2.0.4.tgz#3982ee6f8b42845437fc4d391e93ac5d9da52f0f"
3077 integrity sha512-0xRgjgDLdz6G7+vvDLlaRpFatJaJ69uTalZLRSMX5B3VUrDmXcrVA3+6fXXQgmYz7bY9AAgs348XQdmtLsK41A== 3078 integrity sha512-0xRgjgDLdz6G7+vvDLlaRpFatJaJ69uTalZLRSMX5B3VUrDmXcrVA3+6fXXQgmYz7bY9AAgs348XQdmtLsK41A==
3078 3079
3079"@webtorrent/http-node@^1.3.0":
3080 version "1.3.0"
3081 resolved "https://registry.yarnpkg.com/@webtorrent/http-node/-/http-node-1.3.0.tgz#bd8aacf13f08bb19ee25b5f5364e8d261eaa5c3c"
3082 integrity sha512-GWZQKroPES4z91Ijx6zsOsb7+USOxjy66s8AoTWg0HiBBdfnbtf9aeh3Uav0MgYn4BL8Q7tVSUpd0gGpngKGEQ==
3083 dependencies:
3084 freelist "^1.0.3"
3085 http-parser-js "^0.4.3"
3086
3087"@xmldom/xmldom@^0.8.3", "@xmldom/xmldom@^0.8.7": 3080"@xmldom/xmldom@^0.8.3", "@xmldom/xmldom@^0.8.7":
3088 version "0.8.7" 3081 version "0.8.7"
3089 resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.7.tgz#8b1e39c547013941974d83ad5e9cf5042071a9a0" 3082 resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.7.tgz#8b1e39c547013941974d83ad5e9cf5042071a9a0"
@@ -3157,7 +3150,7 @@ acorn@^8.0.4, acorn@^8.5.0, acorn@^8.7.1, acorn@^8.8.0:
3157 resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" 3150 resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a"
3158 integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== 3151 integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==
3159 3152
3160addr-to-ip-port@^1.0.1, addr-to-ip-port@^1.5.4: 3153addr-to-ip-port@^1.0.1:
3161 version "1.5.4" 3154 version "1.5.4"
3162 resolved "https://registry.yarnpkg.com/addr-to-ip-port/-/addr-to-ip-port-1.5.4.tgz#9542b1c6219fdb8c9ce6cc72c14ee880ab7ddd88" 3155 resolved "https://registry.yarnpkg.com/addr-to-ip-port/-/addr-to-ip-port-1.5.4.tgz#9542b1c6219fdb8c9ce6cc72c14ee880ab7ddd88"
3163 integrity sha512-ByxmJgv8vjmDcl3IDToxL2yrWFrRtFpZAToY0f46XFXl8zS081t7El5MXIodwm7RC6DhHBRoOSMLFSPKCtHukg== 3156 integrity sha512-ByxmJgv8vjmDcl3IDToxL2yrWFrRtFpZAToY0f46XFXl8zS081t7El5MXIodwm7RC6DhHBRoOSMLFSPKCtHukg==
@@ -3535,11 +3528,6 @@ axobject-query@3.1.1:
3535 dependencies: 3528 dependencies:
3536 deep-equal "^2.0.5" 3529 deep-equal "^2.0.5"
3537 3530
3538b4a@^1.3.1:
3539 version "1.6.4"
3540 resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.6.4.tgz#ef1c1422cae5ce6535ec191baeed7567443f36c9"
3541 integrity sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==
3542
3543babel-loader@9.1.2, babel-loader@^9.1.0: 3531babel-loader@9.1.2, babel-loader@^9.1.0:
3544 version "9.1.2" 3532 version "9.1.2"
3545 resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-9.1.2.tgz#a16a080de52d08854ee14570469905a5fc00d39c" 3533 resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-9.1.2.tgz#a16a080de52d08854ee14570469905a5fc00d39c"
@@ -3608,16 +3596,11 @@ batch@0.6.1:
3608 resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" 3596 resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16"
3609 integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw== 3597 integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==
3610 3598
3611bencode@^2.0.0, bencode@^2.0.1, bencode@^2.0.2, bencode@^2.0.3: 3599bencode@^2.0.1:
3612 version "2.0.3" 3600 version "2.0.3"
3613 resolved "https://registry.yarnpkg.com/bencode/-/bencode-2.0.3.tgz#89b9c80ea1b8573554915a7d0c15f62b0aa7fc52" 3601 resolved "https://registry.yarnpkg.com/bencode/-/bencode-2.0.3.tgz#89b9c80ea1b8573554915a7d0c15f62b0aa7fc52"
3614 integrity sha512-D/vrAD4dLVX23NalHwb8dSvsUsxeRPO8Y7ToKA015JQYq69MLDOMkC0uGZYA/MPpltLO8rt8eqFC2j8DxjTZ/w== 3602 integrity sha512-D/vrAD4dLVX23NalHwb8dSvsUsxeRPO8Y7ToKA015JQYq69MLDOMkC0uGZYA/MPpltLO8rt8eqFC2j8DxjTZ/w==
3615 3603
3616bep53-range@^1.1.0:
3617 version "1.1.1"
3618 resolved "https://registry.yarnpkg.com/bep53-range/-/bep53-range-1.1.1.tgz#20fd125b00a413254a77d42f63a43750ca7e64ac"
3619 integrity sha512-ct6s33iiwRCUPp9KXnJ4QMWDgHIgaw36caK/5XEQ9L8dCzSQlJt1Vk6VmHh1VD4AlGCAI4C2zmtfItifBBPrhQ==
3620
3621big-integer@^1.6.17: 3604big-integer@^1.6.17:
3622 version "1.6.51" 3605 version "1.6.51"
3623 resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686" 3606 resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686"
@@ -3633,11 +3616,6 @@ binary-extensions@^2.0.0:
3633 resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" 3616 resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
3634 integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== 3617 integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
3635 3618
3636binary-search@^1.3.4:
3637 version "1.3.6"
3638 resolved "https://registry.yarnpkg.com/binary-search/-/binary-search-1.3.6.tgz#e32426016a0c5092f0f3598836a1c7da3560565c"
3639 integrity sha512-nbE1WxOTTrUWIfsfZ4aHGYu5DOuNkbxGokjV6Z2kxfJK3uaAb8zNK1muzOeipoLHZjInT4Br88BHpzevc681xA==
3640
3641binary@~0.3.0: 3619binary@~0.3.0:
3642 version "0.3.0" 3620 version "0.3.0"
3643 resolved "https://registry.yarnpkg.com/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79" 3621 resolved "https://registry.yarnpkg.com/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79"
@@ -3646,54 +3624,11 @@ binary@~0.3.0:
3646 buffers "~0.1.1" 3624 buffers "~0.1.1"
3647 chainsaw "~0.1.0" 3625 chainsaw "~0.1.0"
3648 3626
3649bitfield@^4.0.0, bitfield@^4.1.0:
3650 version "4.1.0"
3651 resolved "https://registry.yarnpkg.com/bitfield/-/bitfield-4.1.0.tgz#77f3ef4e915e58adaf758b23cbff156959e0fd8e"
3652 integrity sha512-6cEDG3K+PK9f+B7WyhWYjp09bqSa+uaAaecVA7Y5giFixyVe1s6HKGnvOqYNR4Mi4fBMjfDPLBpHkKvzzgP7kg==
3653
3654bittorrent-dht@^10.0.4, bittorrent-dht@^10.0.7:
3655 version "10.0.7"
3656 resolved "https://registry.yarnpkg.com/bittorrent-dht/-/bittorrent-dht-10.0.7.tgz#fbe0f56349e7aab951d6d8625e0f78495ad74684"
3657 integrity sha512-o6elCANGteECXz82LFqG1Ov2fG4uNzfUU7pBMx9ixxKUh99ZXNrhbiNLRNN2F2vBnqKSN7SHlUW4LJ5Z2u1eKw==
3658 dependencies:
3659 bencode "^2.0.3"
3660 debug "^4.3.4"
3661 k-bucket "^5.1.0"
3662 k-rpc "^5.1.0"
3663 last-one-wins "^1.0.4"
3664 lru "^3.1.0"
3665 randombytes "^2.1.0"
3666 record-cache "^1.2.0"
3667 simple-sha1 "^3.1.0"
3668
3669bittorrent-lsd@^1.1.1:
3670 version "1.1.1"
3671 resolved "https://registry.yarnpkg.com/bittorrent-lsd/-/bittorrent-lsd-1.1.1.tgz#427044bfcc05d0c2f286b6d1db70a91c04daa0c9"
3672 integrity sha512-dWxU2Mr2lU6jzIKgZrTsXgeXDCIcYpR1b6f2n89fn7juwPAYbNU04OgWjcQPLiNliY0filsX5CQAWntVErpk+Q==
3673 dependencies:
3674 chrome-dgram "^3.0.6"
3675 debug "^4.2.0"
3676
3677bittorrent-peerid@^1.3.3: 3627bittorrent-peerid@^1.3.3:
3678 version "1.3.6" 3628 version "1.3.6"
3679 resolved "https://registry.yarnpkg.com/bittorrent-peerid/-/bittorrent-peerid-1.3.6.tgz#3688705a64937a8176ac2ded1178fc7bd91b61db" 3629 resolved "https://registry.yarnpkg.com/bittorrent-peerid/-/bittorrent-peerid-1.3.6.tgz#3688705a64937a8176ac2ded1178fc7bd91b61db"
3680 integrity sha512-VyLcUjVMEOdSpHaCG/7odvCdLbAB1y3l9A2V6WIje24uV7FkJPrQrH/RrlFmKxP89pFVDEnE+YlHaFujlFIZsg== 3630 integrity sha512-VyLcUjVMEOdSpHaCG/7odvCdLbAB1y3l9A2V6WIje24uV7FkJPrQrH/RrlFmKxP89pFVDEnE+YlHaFujlFIZsg==
3681 3631
3682bittorrent-protocol@^3.5.5:
3683 version "3.5.5"
3684 resolved "https://registry.yarnpkg.com/bittorrent-protocol/-/bittorrent-protocol-3.5.5.tgz#d89233da11996d8978146f8b80ed91fec9e0e9b8"
3685 integrity sha512-cfzO//WtJGNLHXS58a4exJCSq1U0dkP2DZCQxgADInYFPdOfV1EmtpEN9toLOluVCXJRYAdwW5H6Li/hrn697A==
3686 dependencies:
3687 bencode "^2.0.2"
3688 bitfield "^4.0.0"
3689 debug "^4.3.4"
3690 randombytes "^2.1.0"
3691 rc4 "^0.1.5"
3692 readable-stream "^3.6.0"
3693 simple-sha1 "^3.1.0"
3694 speedometer "^1.1.0"
3695 unordered-array-remove "^1.0.2"
3696
3697bittorrent-tracker@^9.19.0: 3632bittorrent-tracker@^9.19.0:
3698 version "9.19.0" 3633 version "9.19.0"
3699 resolved "https://registry.yarnpkg.com/bittorrent-tracker/-/bittorrent-tracker-9.19.0.tgz#2266bfa8a45a57b09f8d8b184710ba531712d8ef" 3634 resolved "https://registry.yarnpkg.com/bittorrent-tracker/-/bittorrent-tracker-9.19.0.tgz#2266bfa8a45a57b09f8d8b184710ba531712d8ef"
@@ -3735,23 +3670,6 @@ bl@^4.0.3, bl@^4.1.0:
3735 inherits "^2.0.4" 3670 inherits "^2.0.4"
3736 readable-stream "^3.4.0" 3671 readable-stream "^3.4.0"
3737 3672
3738blob-to-buffer@^1.2.9:
3739 version "1.2.9"
3740 resolved "https://registry.yarnpkg.com/blob-to-buffer/-/blob-to-buffer-1.2.9.tgz#a17fd6c1c564011408f8971e451544245daaa84a"
3741 integrity sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA==
3742
3743block-iterator@^1.0.1:
3744 version "1.1.1"
3745 resolved "https://registry.yarnpkg.com/block-iterator/-/block-iterator-1.1.1.tgz#3c8a94e083febf8da59d8baad1006ffee1a74694"
3746 integrity sha512-DrjdVWZemVO4iBf4tiOXjUrY5cNesjzy0t7sIiu2rdl8cOCHRxAgKjSJFc3vBZYYMMmshUAxajl8QQh/uxXTKQ==
3747
3748block-stream2@^2.0.0:
3749 version "2.1.0"
3750 resolved "https://registry.yarnpkg.com/block-stream2/-/block-stream2-2.1.0.tgz#ac0c5ef4298b3857796e05be8ebed72196fa054b"
3751 integrity sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==
3752 dependencies:
3753 readable-stream "^3.4.0"
3754
3755bluebird@~3.4.1: 3673bluebird@~3.4.1:
3756 version "3.4.7" 3674 version "3.4.7"
3757 resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3" 3675 resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3"
@@ -3827,11 +3745,6 @@ browser-stdout@1.3.1:
3827 resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" 3745 resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60"
3828 integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== 3746 integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==
3829 3747
3830browserify-package-json@^1.0.0:
3831 version "1.0.1"
3832 resolved "https://registry.yarnpkg.com/browserify-package-json/-/browserify-package-json-1.0.1.tgz#98dde8aa5c561fd6d3fe49bbaa102b74b396fdea"
3833 integrity sha512-CikZxJGNyNOBERbeALo0NUUeJgHs5NyEvuYChX/PcsBV91TAvEq4hYDaWSenSieT8XwAutNnS3FGvyzIMOughQ==
3834
3835browserslist@4.21.5, browserslist@^4.14.5, browserslist@^4.21.3, browserslist@^4.21.5: 3748browserslist@4.21.5, browserslist@^4.14.5, browserslist@^4.21.3, browserslist@^4.21.5:
3836 version "4.21.5" 3749 version "4.21.5"
3837 resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.5.tgz#75c5dae60063ee641f977e00edd3cfb2fb7af6a7" 3750 resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.5.tgz#75c5dae60063ee641f977e00edd3cfb2fb7af6a7"
@@ -3853,29 +3766,11 @@ browserstack-local@^1.5.1:
3853 ps-tree "=1.2.0" 3766 ps-tree "=1.2.0"
3854 temp-fs "^0.9.9" 3767 temp-fs "^0.9.9"
3855 3768
3856buffer-alloc-unsafe@^1.1.0:
3857 version "1.1.0"
3858 resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0"
3859 integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==
3860
3861buffer-alloc@^1.1.0:
3862 version "1.2.0"
3863 resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec"
3864 integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==
3865 dependencies:
3866 buffer-alloc-unsafe "^1.1.0"
3867 buffer-fill "^1.0.0"
3868
3869buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3: 3769buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3:
3870 version "0.2.13" 3770 version "0.2.13"
3871 resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" 3771 resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
3872 integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== 3772 integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==
3873 3773
3874buffer-fill@^1.0.0:
3875 version "1.0.0"
3876 resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c"
3877 integrity sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==
3878
3879buffer-from@^1.0.0: 3774buffer-from@^1.0.0:
3880 version "1.1.2" 3775 version "1.1.2"
3881 resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" 3776 resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
@@ -3914,11 +3809,6 @@ bufferutil@^4.0.3:
3914 dependencies: 3809 dependencies:
3915 node-gyp-build "^4.3.0" 3810 node-gyp-build "^4.3.0"
3916 3811
3917builtin-status-codes@^3.0.0:
3918 version "3.0.0"
3919 resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8"
3920 integrity sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==
3921
3922builtins@^5.0.0: 3812builtins@^5.0.0:
3923 version "5.0.1" 3813 version "5.0.1"
3924 resolved "https://registry.yarnpkg.com/builtins/-/builtins-5.0.1.tgz#87f6db9ab0458be728564fa81d876d8d74552fa9" 3814 resolved "https://registry.yarnpkg.com/builtins/-/builtins-5.0.1.tgz#87f6db9ab0458be728564fa81d876d8d74552fa9"
@@ -4010,14 +3900,6 @@ cacache@^17.0.0:
4010 tar "^6.1.11" 3900 tar "^6.1.11"
4011 unique-filename "^3.0.0" 3901 unique-filename "^3.0.0"
4012 3902
4013cache-chunk-store@^3.0.0, cache-chunk-store@^3.2.2:
4014 version "3.2.2"
4015 resolved "https://registry.yarnpkg.com/cache-chunk-store/-/cache-chunk-store-3.2.2.tgz#19bb55d61252cd2174da4686548d52bc2dd44120"
4016 integrity sha512-2lJdWbgHFFxcSth9s2wpId3CR3v1YC63KjP4T9WhpW7LWlY7Hiiei3QwwqzkWqlJTfR8lSy9F5kRQECeyj+yQA==
4017 dependencies:
4018 lru "^3.1.0"
4019 queue-microtask "^1.2.3"
4020
4021cacheable-lookup@^7.0.0: 3903cacheable-lookup@^7.0.0:
4022 version "7.0.0" 3904 version "7.0.0"
4023 resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz#3476a8215d046e5a3202a9209dd13fec1f933a27" 3905 resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz#3476a8215d046e5a3202a9209dd13fec1f933a27"
@@ -4183,7 +4065,7 @@ chownr@^2.0.0:
4183 resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" 4065 resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece"
4184 integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== 4066 integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==
4185 4067
4186chrome-dgram@^3.0.2, chrome-dgram@^3.0.6: 4068chrome-dgram@^3.0.6:
4187 version "3.0.6" 4069 version "3.0.6"
4188 resolved "https://registry.yarnpkg.com/chrome-dgram/-/chrome-dgram-3.0.6.tgz#2288b5c7471f66f073691206d36319dda713cf55" 4070 resolved "https://registry.yarnpkg.com/chrome-dgram/-/chrome-dgram-3.0.6.tgz#2288b5c7471f66f073691206d36319dda713cf55"
4189 integrity sha512-bqBsUuaOiXiqxXt/zA/jukNJJ4oaOtc7ciwqJpZVEaaXwwxqgI2/ZdG02vXYWUhHGziDlvGMQWk0qObgJwVYKA== 4071 integrity sha512-bqBsUuaOiXiqxXt/zA/jukNJJ4oaOtc7ciwqJpZVEaaXwwxqgI2/ZdG02vXYWUhHGziDlvGMQWk0qObgJwVYKA==
@@ -4191,13 +4073,6 @@ chrome-dgram@^3.0.2, chrome-dgram@^3.0.6:
4191 inherits "^2.0.4" 4073 inherits "^2.0.4"
4192 run-series "^1.1.9" 4074 run-series "^1.1.9"
4193 4075
4194chrome-dns@^1.0.0:
4195 version "1.0.1"
4196 resolved "https://registry.yarnpkg.com/chrome-dns/-/chrome-dns-1.0.1.tgz#6870af680a40d2c4b2efc2154a378793f5a4ce4b"
4197 integrity sha512-HqsYJgIc8ljJJOqOzLphjAs79EUuWSX3nzZi2LNkzlw3GIzAeZbaSektC8iT/tKvLqZq8yl1GJu5o6doA4TRbg==
4198 dependencies:
4199 chrome-net "^3.3.2"
4200
4201chrome-launcher@^0.15.0: 4076chrome-launcher@^0.15.0:
4202 version "0.15.2" 4077 version "0.15.2"
4203 resolved "https://registry.yarnpkg.com/chrome-launcher/-/chrome-launcher-0.15.2.tgz#4e6404e32200095fdce7f6a1e1004f9bd36fa5da" 4078 resolved "https://registry.yarnpkg.com/chrome-launcher/-/chrome-launcher-0.15.2.tgz#4e6404e32200095fdce7f6a1e1004f9bd36fa5da"
@@ -4208,13 +4083,6 @@ chrome-launcher@^0.15.0:
4208 is-wsl "^2.2.0" 4083 is-wsl "^2.2.0"
4209 lighthouse-logger "^1.0.0" 4084 lighthouse-logger "^1.0.0"
4210 4085
4211chrome-net@^3.3.2, chrome-net@^3.3.4:
4212 version "3.3.4"
4213 resolved "https://registry.yarnpkg.com/chrome-net/-/chrome-net-3.3.4.tgz#0e604a31d226ebfb8d2d1c381cab47d35309825d"
4214 integrity sha512-Jzy2EnzmE+ligqIZUsmWnck9RBXLuUy6CaKyuNMtowFG3ZvLt8d+WBJCTPEludV0DHpIKjAOlwjFmTaEdfdWCw==
4215 dependencies:
4216 inherits "^2.0.1"
4217
4218chrome-trace-event@^1.0.2: 4086chrome-trace-event@^1.0.2:
4219 version "1.0.3" 4087 version "1.0.3"
4220 resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac" 4088 resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac"
@@ -4240,14 +4108,6 @@ chromium-bidi@0.4.9:
4240 dependencies: 4108 dependencies:
4241 mitt "3.0.0" 4109 mitt "3.0.0"
4242 4110
4243chunk-store-stream@^4.3.0:
4244 version "4.3.0"
4245 resolved "https://registry.yarnpkg.com/chunk-store-stream/-/chunk-store-stream-4.3.0.tgz#3de5f4dfe19729366c29bb7ed52d139f9af29f0e"
4246 integrity sha512-qby+/RXoiMoTVtPiylWZt7KFF1jy6M829TzMi2hxZtBIH9ptV19wxcft6zGiXLokJgCbuZPGNGab6DWHqiSEKw==
4247 dependencies:
4248 block-stream2 "^2.0.0"
4249 readable-stream "^3.6.0"
4250
4251ci-info@^3.2.0: 4111ci-info@^3.2.0:
4252 version "3.8.0" 4112 version "3.8.0"
4253 resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91" 4113 resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91"
@@ -4580,11 +4440,6 @@ cosmiconfig@^8.1.3:
4580 parse-json "^5.0.0" 4440 parse-json "^5.0.0"
4581 path-type "^4.0.0" 4441 path-type "^4.0.0"
4582 4442
4583cpus@^1.0.3:
4584 version "1.0.3"
4585 resolved "https://registry.yarnpkg.com/cpus/-/cpus-1.0.3.tgz#4ef6deea461968d6329d07dd01205685df2934a2"
4586 integrity sha512-PXHBvGLuL69u55IkLa5e5838fLhIMHxmkV4ge42a8alGyn7BtawYgI0hQ849EedvtHIOLNNH3i6eQU1BiE9SUA==
4587
4588crc-32@^1.2.0: 4443crc-32@^1.2.0:
4589 version "1.2.2" 4444 version "1.2.2"
4590 resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff" 4445 resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff"
@@ -4598,23 +4453,6 @@ crc32-stream@^4.0.2:
4598 crc-32 "^1.2.0" 4453 crc-32 "^1.2.0"
4599 readable-stream "^3.4.0" 4454 readable-stream "^3.4.0"
4600 4455
4601create-torrent@^5.0.4:
4602 version "5.0.9"
4603 resolved "https://registry.yarnpkg.com/create-torrent/-/create-torrent-5.0.9.tgz#850f198f7568e3d0e1e73b6858d43d44659a69d0"
4604 integrity sha512-WQ/bMe+aCBSa5EonIkgw7CTM/1JnJDQuLJhA78omSWvuEbXDwaUy0rG3a+IYt+EiO+rdTLxdsBwrsn/wfWOMQA==
4605 dependencies:
4606 bencode "^2.0.3"
4607 block-iterator "^1.0.1"
4608 fast-readable-async-iterator "^1.1.1"
4609 is-file "^1.0.0"
4610 join-async-iterator "^1.1.1"
4611 junk "^3.1.0"
4612 minimist "^1.2.7"
4613 piece-length "^2.0.1"
4614 queue-microtask "^1.2.3"
4615 run-parallel "^1.2.0"
4616 simple-sha1 "^3.1.0"
4617
4618critters@0.0.16: 4456critters@0.0.16:
4619 version "0.0.16" 4457 version "0.0.16"
4620 resolved "https://registry.yarnpkg.com/critters/-/critters-0.0.16.tgz#ffa2c5561a65b43c53b940036237ce72dcebfe93" 4458 resolved "https://registry.yarnpkg.com/critters/-/critters-0.0.16.tgz#ffa2c5561a65b43c53b940036237ce72dcebfe93"
@@ -4764,7 +4602,7 @@ debug@2.6.9, debug@^2.6.9:
4764 dependencies: 4602 dependencies:
4765 ms "2.0.0" 4603 ms "2.0.0"
4766 4604
4767debug@4, debug@4.3.4, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2: 4605debug@4, debug@4.3.4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2:
4768 version "4.3.4" 4606 version "4.3.4"
4769 resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" 4607 resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
4770 integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== 4608 integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
@@ -4951,11 +4789,6 @@ devtools@8.10.5:
4951 uuid "^9.0.0" 4789 uuid "^9.0.0"
4952 which "^3.0.0" 4790 which "^3.0.0"
4953 4791
4954dexie@^3.2.2:
4955 version "3.2.3"
4956 resolved "https://registry.yarnpkg.com/dexie/-/dexie-3.2.3.tgz#f35c91ca797599df8e771b998e9ae9669c877f8c"
4957 integrity sha512-iHayBd4UYryDCVUNa3PMsJMEnd8yjyh5p7a+RFeC8i8n476BC9wMhVvqiImq5zJZJf5Tuer+s4SSj+AA3x+ZbQ==
4958
4959diff-sequences@^29.4.3: 4792diff-sequences@^29.4.3:
4960 version "29.4.3" 4793 version "29.4.3"
4961 resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.4.3.tgz#9314bc1fabe09267ffeca9cbafc457d8499a13f2" 4794 resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.4.3.tgz#9314bc1fabe09267ffeca9cbafc457d8499a13f2"
@@ -5172,7 +5005,7 @@ encoding@^0.1.13:
5172 dependencies: 5005 dependencies:
5173 iconv-lite "^0.6.2" 5006 iconv-lite "^0.6.2"
5174 5007
5175end-of-stream@^1.1.0, end-of-stream@^1.4.1, end-of-stream@^1.4.4: 5008end-of-stream@^1.1.0, end-of-stream@^1.4.1:
5176 version "1.4.4" 5009 version "1.4.4"
5177 resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" 5010 resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
5178 integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== 5011 integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
@@ -5410,7 +5243,7 @@ escalade@^3.1.1:
5410 resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" 5243 resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
5411 integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== 5244 integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
5412 5245
5413escape-html@^1.0.3, escape-html@~1.0.3: 5246escape-html@~1.0.3:
5414 version "1.0.3" 5247 version "1.0.3"
5415 resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" 5248 resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
5416 integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== 5249 integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
@@ -5770,11 +5603,6 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
5770 resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" 5603 resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
5771 integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== 5604 integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
5772 5605
5773fast-fifo@^1.1.0:
5774 version "1.2.0"
5775 resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.2.0.tgz#2ee038da2468e8623066dee96958b0c1763aa55a"
5776 integrity sha512-NcvQXt7Cky1cNau15FWy64IjuO8X0JijhTBBrJj1YlxlDfRkJXNaK9RFUjwpfDPzMdv7wB38jr53l9tkNLxnWg==
5777
5778fast-glob@3.2.7: 5606fast-glob@3.2.7:
5779 version "3.2.7" 5607 version "3.2.7"
5780 resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.7.tgz#fd6cb7a2d7e9aa7a7846111e85a196d6b2f766a1" 5608 resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.7.tgz#fd6cb7a2d7e9aa7a7846111e85a196d6b2f766a1"
@@ -5807,11 +5635,6 @@ fast-levenshtein@^2.0.6:
5807 resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" 5635 resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
5808 integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== 5636 integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
5809 5637
5810fast-readable-async-iterator@^1.1.1:
5811 version "1.1.1"
5812 resolved "https://registry.yarnpkg.com/fast-readable-async-iterator/-/fast-readable-async-iterator-1.1.1.tgz#77dfbb5262b278bb123c4d8d3219b1bb881b857c"
5813 integrity sha512-xEHkLUEmStETI+15zhglJLO9TjXxNkkp2ldEfYVZdcqxFhM172EfGl1irI6mVlTxXspYKH1/kjevnt/XSsPeFA==
5814
5815fastest-levenshtein@^1.0.12, fastest-levenshtein@^1.0.16: 5638fastest-levenshtein@^1.0.12, fastest-levenshtein@^1.0.16:
5816 version "1.0.16" 5639 version "1.0.16"
5817 resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" 5640 resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5"
@@ -6017,11 +5840,6 @@ fraction.js@^4.2.0:
6017 resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950" 5840 resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950"
6018 integrity sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA== 5841 integrity sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==
6019 5842
6020freelist@^1.0.3:
6021 version "1.0.3"
6022 resolved "https://registry.yarnpkg.com/freelist/-/freelist-1.0.3.tgz#006775509f3935701784d3ed2fc9f12c9df1bab2"
6023 integrity sha512-Ji7fEnMdZDGbS5oXElpRJsn9jPvBR8h/037D3bzreNmS8809cISq/2D9//JbA/TaZmkkN8cmecXwmQHmM+NHhg==
6024
6025fresh@0.5.2: 5843fresh@0.5.2:
6026 version "0.5.2" 5844 version "0.5.2"
6027 resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" 5845 resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
@@ -6032,18 +5850,6 @@ from@~0:
6032 resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe" 5850 resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe"
6033 integrity sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g== 5851 integrity sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==
6034 5852
6035fs-chunk-store@^2.0.5:
6036 version "2.0.5"
6037 resolved "https://registry.yarnpkg.com/fs-chunk-store/-/fs-chunk-store-2.0.5.tgz#1dd4bdbb371239ac6f7234af6cd4386c72315059"
6038 integrity sha512-z3c2BmyaHdQTtIVXJDQOvwZVWN2gNU//0IYKK2LuPr+cZyGoIrgDwI4iDASaTUyQbOBtyg/k6GuDZepB6jQIPw==
6039 dependencies:
6040 queue-microtask "^1.2.2"
6041 random-access-file "^2.0.1"
6042 randombytes "^2.0.3"
6043 rimraf "^3.0.0"
6044 run-parallel "^1.1.2"
6045 thunky "^1.0.1"
6046
6047fs-constants@^1.0.0: 5853fs-constants@^1.0.0:
6048 version "1.0.0" 5854 version "1.0.0"
6049 resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" 5855 resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
@@ -6192,11 +5998,6 @@ get-port@^6.1.2:
6192 resolved "https://registry.yarnpkg.com/get-port/-/get-port-6.1.2.tgz#c1228abb67ba0e17fb346da33b15187833b9c08a" 5998 resolved "https://registry.yarnpkg.com/get-port/-/get-port-6.1.2.tgz#c1228abb67ba0e17fb346da33b15187833b9c08a"
6193 integrity sha512-BrGGraKm2uPqurfGVj/z97/zv8dPleC6x9JBNRTrDNtCkkRF4rPwrQXFgL7+I+q8QSdU4ntLQX2D7KIxSy8nGw== 5999 integrity sha512-BrGGraKm2uPqurfGVj/z97/zv8dPleC6x9JBNRTrDNtCkkRF4rPwrQXFgL7+I+q8QSdU4ntLQX2D7KIxSy8nGw==
6194 6000
6195get-stdin@^8.0.0:
6196 version "8.0.0"
6197 resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-8.0.0.tgz#cbad6a73feb75f6eeb22ba9e01f89aa28aa97a53"
6198 integrity sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==
6199
6200get-stream@^3.0.0: 6001get-stream@^3.0.0:
6201 version "3.0.0" 6002 version "3.0.0"
6202 resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" 6003 resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
@@ -6686,11 +6487,6 @@ http-parser-js@>=0.5.1:
6686 resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.8.tgz#af23090d9ac4e24573de6f6aecc9d84a48bf20e3" 6487 resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.8.tgz#af23090d9ac4e24573de6f6aecc9d84a48bf20e3"
6687 integrity sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q== 6488 integrity sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==
6688 6489
6689http-parser-js@^0.4.3:
6690 version "0.4.13"
6691 resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.4.13.tgz#3bd6d6fde6e3172c9334c3b33b6c193d80fe1137"
6692 integrity sha512-u8u5ZaG0Tr/VvHlucK2ufMuOp4/5bvwgneXle+y228K5rMbJOlVjThONcaAw3ikAy8b2OO9RfEucdMHFz3UWMA==
6693
6694http-proxy-agent@5.0.0, http-proxy-agent@^5.0.0: 6490http-proxy-agent@5.0.0, http-proxy-agent@^5.0.0:
6695 version "5.0.0" 6491 version "5.0.0"
6696 resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" 6492 resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43"
@@ -6736,11 +6532,6 @@ http2-wrapper@^2.1.10:
6736 quick-lru "^5.1.1" 6532 quick-lru "^5.1.1"
6737 resolve-alpn "^1.2.0" 6533 resolve-alpn "^1.2.0"
6738 6534
6739https-browserify@^1.0.0:
6740 version "1.0.0"
6741 resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73"
6742 integrity sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==
6743
6744https-proxy-agent@5.0.1, https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1: 6535https-proxy-agent@5.0.1, https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1:
6745 version "5.0.1" 6536 version "5.0.1"
6746 resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" 6537 resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
@@ -6815,13 +6606,6 @@ image-size@~0.5.0:
6815 resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c" 6606 resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c"
6816 integrity sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ== 6607 integrity sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==
6817 6608
6818immediate-chunk-store@^2.2.0:
6819 version "2.2.0"
6820 resolved "https://registry.yarnpkg.com/immediate-chunk-store/-/immediate-chunk-store-2.2.0.tgz#f56d30ecc7171f6cfcf632b0eb8395a89f92c03c"
6821 integrity sha512-1bHBna0hCa6arRXicu91IiL9RvvkbNYLVq+mzWdaLGZC3hXvX4doh8e1dLhMKez5siu63CYgO5NrGJbRX5lbPA==
6822 dependencies:
6823 queue-microtask "^1.2.3"
6824
6825immutable@^4.0.0: 6609immutable@^4.0.0:
6826 version "4.3.0" 6610 version "4.3.0"
6827 resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.0.tgz#eb1738f14ffb39fd068b1dbe1296117484dd34be" 6611 resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.0.tgz#eb1738f14ffb39fd068b1dbe1296117484dd34be"
@@ -6886,7 +6670,7 @@ inflight@^1.0.4:
6886 once "^1.3.0" 6670 once "^1.3.0"
6887 wrappy "1" 6671 wrappy "1"
6888 6672
6889inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.3, inherits@~2.0.4: 6673inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.3:
6890 version "2.0.4" 6674 version "2.0.4"
6891 resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" 6675 resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
6892 integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== 6676 integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -6982,13 +6766,6 @@ ip-regex@^4.1.0:
6982 resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-4.3.0.tgz#687275ab0f57fa76978ff8f4dddc8a23d5990db5" 6766 resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-4.3.0.tgz#687275ab0f57fa76978ff8f4dddc8a23d5990db5"
6983 integrity sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q== 6767 integrity sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==
6984 6768
6985ip-set@^2.1.0:
6986 version "2.1.0"
6987 resolved "https://registry.yarnpkg.com/ip-set/-/ip-set-2.1.0.tgz#9a47b9f5d220c38bc7fe5db8efc4baa45b0a0a35"
6988 integrity sha512-JdHz4tSMx1IeFj8yEcQU0i58qiSkOlmZXkZ8+HJ0ROV5KcgLRDO9F703oJ1GeZCvqggrcCbmagD/V7hghY62wA==
6989 dependencies:
6990 ip "^1.1.5"
6991
6992ip@^1.1.5: 6769ip@^1.1.5:
6993 version "1.1.8" 6770 version "1.1.8"
6994 resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48" 6771 resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48"
@@ -7036,11 +6813,6 @@ is-arrayish@^0.2.1:
7036 resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" 6813 resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
7037 integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== 6814 integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==
7038 6815
7039is-ascii@^1.0.0:
7040 version "1.0.0"
7041 resolved "https://registry.yarnpkg.com/is-ascii/-/is-ascii-1.0.0.tgz#f02ad0259a0921cd199ff21ce1b09e0f6b4e3929"
7042 integrity sha512-CXMaB/+EWCSGlLPs7ZlXRBpaPRRSRnrOfq0N3+RGeCZfqQaHQtiDLlkPCn63+LCkRUc1iRE0AXiI+sm2/Hi3qQ==
7043
7044is-bigint@^1.0.1: 6816is-bigint@^1.0.1:
7045 version "1.0.4" 6817 version "1.0.4"
7046 resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" 6818 resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3"
@@ -7092,11 +6864,6 @@ is-extglob@^2.1.1:
7092 resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" 6864 resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
7093 integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== 6865 integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
7094 6866
7095is-file@^1.0.0:
7096 version "1.0.0"
7097 resolved "https://registry.yarnpkg.com/is-file/-/is-file-1.0.0.tgz#28a44cfbd9d3db193045f22b65fce8edf9620596"
7098 integrity sha512-ZGMuc+xA8mRnrXtmtf2l/EkIW2zaD2LSBWlaOVEF6yH4RTndHob65V4SwWWdtGKVthQfXPVKsXqw4TDUjbVxVQ==
7099
7100is-fullwidth-code-point@^1.0.0: 6867is-fullwidth-code-point@^1.0.0:
7101 version "1.0.0" 6868 version "1.0.0"
7102 resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" 6869 resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb"
@@ -7437,11 +7204,6 @@ jest-worker@^27.4.5:
7437 merge-stream "^2.0.0" 7204 merge-stream "^2.0.0"
7438 supports-color "^8.0.0" 7205 supports-color "^8.0.0"
7439 7206
7440join-async-iterator@^1.1.1:
7441 version "1.1.1"
7442 resolved "https://registry.yarnpkg.com/join-async-iterator/-/join-async-iterator-1.1.1.tgz#7d2857d7f4066267861888d264769e842110d07e"
7443 integrity sha512-ATse+nuNeKZ9K1y27LKdvPe/GCe9R/u9dw9vI248e+vILeRK3IcJP4JUPAlSmKRCDK0cKhEwfmiw4Skqx7UnGQ==
7444
7445js-tokens@^4.0.0: 7207js-tokens@^4.0.0:
7446 version "4.0.0" 7208 version "4.0.0"
7447 resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" 7209 resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@@ -7543,37 +7305,6 @@ jsonparse@^1.3.1:
7543 resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" 7305 resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
7544 integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== 7306 integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==
7545 7307
7546junk@^3.1.0:
7547 version "3.1.0"
7548 resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1"
7549 integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==
7550
7551k-bucket@^5.0.0, k-bucket@^5.1.0:
7552 version "5.1.0"
7553 resolved "https://registry.yarnpkg.com/k-bucket/-/k-bucket-5.1.0.tgz#db2c9e72bd168b432e3f3e8fc092e2ccb61bff89"
7554 integrity sha512-Fac7iINEovXIWU20GPnOMLUbjctiS+cnmyjC4zAUgvs3XPf1vo9akfCHkigftSic/jiKqKl+KA3a/vFcJbHyCg==
7555 dependencies:
7556 randombytes "^2.1.0"
7557
7558k-rpc-socket@^1.7.2:
7559 version "1.11.1"
7560 resolved "https://registry.yarnpkg.com/k-rpc-socket/-/k-rpc-socket-1.11.1.tgz#f14b4b240a716c6cad7b6434b21716dbd7c7b0e8"
7561 integrity sha512-8xtA8oqbZ6v1Niryp2/g4GxW16EQh5MvrUylQoOG+zcrDff5CKttON2XUXvMwlIHq4/2zfPVFiinAccJ+WhxoA==
7562 dependencies:
7563 bencode "^2.0.0"
7564 chrome-dgram "^3.0.2"
7565 chrome-dns "^1.0.0"
7566 chrome-net "^3.3.2"
7567
7568k-rpc@^5.1.0:
7569 version "5.1.0"
7570 resolved "https://registry.yarnpkg.com/k-rpc/-/k-rpc-5.1.0.tgz#af2052de2e84994d55da3032175da5dad8640174"
7571 integrity sha512-FGc+n70Hcjoa/X2JTwP+jMIOpBz+pkRffHnSl9yrYiwUxg3FIgD50+u1ePfJUOnRCnx6pbjmVk5aAeB1wIijuQ==
7572 dependencies:
7573 k-bucket "^5.0.0"
7574 k-rpc-socket "^1.7.2"
7575 randombytes "^2.0.5"
7576
7577karma-source-map-support@1.4.0: 7308karma-source-map-support@1.4.0:
7578 version "1.4.0" 7309 version "1.4.0"
7579 resolved "https://registry.yarnpkg.com/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz#58526ceccf7e8730e56effd97a4de8d712ac0d6b" 7310 resolved "https://registry.yarnpkg.com/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz#58526ceccf7e8730e56effd97a4de8d712ac0d6b"
@@ -7613,11 +7344,6 @@ ky@^0.33.0:
7613 resolved "https://registry.yarnpkg.com/ky/-/ky-0.33.3.tgz#bf1ad322a3f2c3428c13cfa4b3af95e6c4a2f543" 7344 resolved "https://registry.yarnpkg.com/ky/-/ky-0.33.3.tgz#bf1ad322a3f2c3428c13cfa4b3af95e6c4a2f543"
7614 integrity sha512-CasD9OCEQSFIam2U8efFK81Yeg8vNMTBUqtMOHlrcWQHqUX3HeCl9Dr31u4toV7emlH8Mymk5+9p0lL6mKb/Xw== 7345 integrity sha512-CasD9OCEQSFIam2U8efFK81Yeg8vNMTBUqtMOHlrcWQHqUX3HeCl9Dr31u4toV7emlH8Mymk5+9p0lL6mKb/Xw==
7615 7346
7616last-one-wins@^1.0.4:
7617 version "1.0.4"
7618 resolved "https://registry.yarnpkg.com/last-one-wins/-/last-one-wins-1.0.4.tgz#c1bfd0cbcb46790ec9156b8d1aee8fcb86cda22a"
7619 integrity sha512-t+KLJFkHPQk8lfN6WBOiGkiUXoub+gnb2XTYI2P3aiISL+94xgZ1vgz1SXN/N4hthuOoLXarXfBZPUruyjQtfA==
7620
7621launch-editor@^2.6.0: 7347launch-editor@^2.6.0:
7622 version "2.6.0" 7348 version "2.6.0"
7623 resolved "https://registry.yarnpkg.com/launch-editor/-/launch-editor-2.6.0.tgz#4c0c1a6ac126c572bd9ff9a30da1d2cae66defd7" 7349 resolved "https://registry.yarnpkg.com/launch-editor/-/launch-editor-2.6.0.tgz#4c0c1a6ac126c572bd9ff9a30da1d2cae66defd7"
@@ -7687,11 +7413,6 @@ lighthouse-logger@^1.0.0:
7687 debug "^2.6.9" 7413 debug "^2.6.9"
7688 marky "^1.2.2" 7414 marky "^1.2.2"
7689 7415
7690limiter@^1.1.5:
7691 version "1.1.5"
7692 resolved "https://registry.yarnpkg.com/limiter/-/limiter-1.1.5.tgz#8f92a25b3b16c6131293a0cc834b4a838a2aa7c2"
7693 integrity sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==
7694
7695lines-and-columns@^1.1.6: 7416lines-and-columns@^1.1.6:
7696 version "1.2.4" 7417 version "1.2.4"
7697 resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" 7418 resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
@@ -7724,17 +7445,6 @@ listenercount@~1.0.1:
7724 resolved "https://registry.yarnpkg.com/listenercount/-/listenercount-1.0.1.tgz#84c8a72ab59c4725321480c975e6508342e70937" 7445 resolved "https://registry.yarnpkg.com/listenercount/-/listenercount-1.0.1.tgz#84c8a72ab59c4725321480c975e6508342e70937"
7725 integrity sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ== 7446 integrity sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==
7726 7447
7727load-ip-set@^2.2.1:
7728 version "2.2.1"
7729 resolved "https://registry.yarnpkg.com/load-ip-set/-/load-ip-set-2.2.1.tgz#9496ab8aa14ebf81aeb7c8bb38e7abdf50af3563"
7730 integrity sha512-G3hQXehU2LTOp52e+lPffpK4EvidfjwbvHaGqmFcp4ptiZagR4xFdL+D08kMX906dxeqZyWhfonEjdUxrWcldg==
7731 dependencies:
7732 ip-set "^2.1.0"
7733 netmask "^2.0.1"
7734 once "^1.4.0"
7735 simple-get "^4.0.0"
7736 split "^1.0.1"
7737
7738load-json-file@^1.0.0: 7448load-json-file@^1.0.0:
7739 version "1.1.0" 7449 version "1.1.0"
7740 resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" 7450 resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
@@ -7943,14 +7653,6 @@ lru@^3.1.0:
7943 dependencies: 7653 dependencies:
7944 inherits "^2.0.1" 7654 inherits "^2.0.1"
7945 7655
7946lt_donthave@^1.0.1:
7947 version "1.0.1"
7948 resolved "https://registry.yarnpkg.com/lt_donthave/-/lt_donthave-1.0.1.tgz#a160e08bdf15b9e092172063688855a6c031d8b3"
7949 integrity sha512-PfOXfDN9GnUjlNHjjxKQuMxPC8s12iSrnmg+Ff1BU1uLn7S1BFAKzpZCu6Gwg3WsCUvTZrZoDSHvy6B/j+N4/Q==
7950 dependencies:
7951 debug "^4.2.0"
7952 unordered-array-remove "^1.0.2"
7953
7954m3u8-parser@4.8.0, m3u8-parser@^4.7.1: 7656m3u8-parser@4.8.0, m3u8-parser@^4.7.1:
7955 version "4.8.0" 7657 version "4.8.0"
7956 resolved "https://registry.yarnpkg.com/m3u8-parser/-/m3u8-parser-4.8.0.tgz#4a2d591fdf6f2579d12a327081198df8af83083d" 7658 resolved "https://registry.yarnpkg.com/m3u8-parser/-/m3u8-parser-4.8.0.tgz#4a2d591fdf6f2579d12a327081198df8af83083d"
@@ -7967,14 +7669,6 @@ magic-string@0.30.0:
7967 dependencies: 7669 dependencies:
7968 "@jridgewell/sourcemap-codec" "^1.4.13" 7670 "@jridgewell/sourcemap-codec" "^1.4.13"
7969 7671
7970magnet-uri@^6.2.0:
7971 version "6.2.0"
7972 resolved "https://registry.yarnpkg.com/magnet-uri/-/magnet-uri-6.2.0.tgz#10f7be050bf23452df210838239b118463c3eeff"
7973 integrity sha512-O9AgdDwT771fnUj0giPYu/rACpz8173y8UXCSOdLITjOVfBenZ9H9q3FqQmveK+ORUMuD+BkKNSZP8C3+IMAKQ==
7974 dependencies:
7975 bep53-range "^1.1.0"
7976 thirty-two "^1.0.2"
7977
7978mailparser-mit@^1.0.0: 7672mailparser-mit@^1.0.0:
7979 version "1.0.0" 7673 version "1.0.0"
7980 resolved "https://registry.yarnpkg.com/mailparser-mit/-/mailparser-mit-1.0.0.tgz#19df8436c2a02e1d34a03ec518a2eb065e0a94a4" 7674 resolved "https://registry.yarnpkg.com/mailparser-mit/-/mailparser-mit-1.0.0.tgz#19df8436c2a02e1d34a03ec518a2eb065e0a94a4"
@@ -8101,15 +7795,6 @@ media-typer@0.3.0:
8101 resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" 7795 resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
8102 integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== 7796 integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
8103 7797
8104mediasource@^2.2.2, mediasource@^2.4.0:
8105 version "2.4.0"
8106 resolved "https://registry.yarnpkg.com/mediasource/-/mediasource-2.4.0.tgz#7b03378054c41400374e9bade50aa0d7a758c39b"
8107 integrity sha512-SKUMrbFMHgiCUZFOWZcL0aiF/KgHx9SPIKzxrl6+7nMUMDK/ZnOmJdY/9wKzYeM0g3mybt3ueg+W+/mrYfmeFQ==
8108 dependencies:
8109 inherits "^2.0.4"
8110 readable-stream "^3.6.0"
8111 to-arraybuffer "^1.0.1"
8112
8113mem@^1.1.0: 7798mem@^1.1.0:
8114 version "1.1.0" 7799 version "1.1.0"
8115 resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76" 7800 resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76"
@@ -8124,13 +7809,6 @@ memfs@^3.4.12, memfs@^3.4.3:
8124 dependencies: 7809 dependencies:
8125 fs-monkey "^1.0.3" 7810 fs-monkey "^1.0.3"
8126 7811
8127memory-chunk-store@^1.3.5:
8128 version "1.3.5"
8129 resolved "https://registry.yarnpkg.com/memory-chunk-store/-/memory-chunk-store-1.3.5.tgz#700f712415895600bc5466007333efa19f1de07c"
8130 integrity sha512-E1Xc1U4ifk/FkC2ZsWhCaW1xg9HbE/OBmQTLe2Tr9c27YPSLbW7kw1cnb3kQWD1rDtErFJHa7mB9EVrs7aTx9g==
8131 dependencies:
8132 queue-microtask "^1.2.3"
8133
8134meow@^9.0.0: 7812meow@^9.0.0:
8135 version "9.0.0" 7813 version "9.0.0"
8136 resolved "https://registry.yarnpkg.com/meow/-/meow-9.0.0.tgz#cd9510bc5cac9dee7d03c73ee1f9ad959f4ea364" 7814 resolved "https://registry.yarnpkg.com/meow/-/meow-9.0.0.tgz#cd9510bc5cac9dee7d03c73ee1f9ad959f4ea364"
@@ -8194,11 +7872,6 @@ mime@1.6.0, mime@^1.4.1, mime@^1.6.0:
8194 resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" 7872 resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
8195 integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== 7873 integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
8196 7874
8197mime@^3.0.0:
8198 version "3.0.0"
8199 resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7"
8200 integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==
8201
8202mimic-fn@^1.0.0: 7875mimic-fn@^1.0.0:
8203 version "1.2.0" 7876 version "1.2.0"
8204 resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" 7877 resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022"
@@ -8306,7 +7979,7 @@ minimist-options@4.1.0:
8306 is-plain-obj "^1.1.0" 7979 is-plain-obj "^1.1.0"
8307 kind-of "^6.0.3" 7980 kind-of "^6.0.3"
8308 7981
8309minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.7: 7982minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6:
8310 version "1.2.8" 7983 version "1.2.8"
8311 resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" 7984 resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
8312 integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== 7985 integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
@@ -8453,23 +8126,6 @@ mousetrap@^1.6.5:
8453 resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.5.tgz#8a766d8c272b08393d5f56074e0b5ec183485bf9" 8126 resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.5.tgz#8a766d8c272b08393d5f56074e0b5ec183485bf9"
8454 integrity sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA== 8127 integrity sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==
8455 8128
8456mp4-box-encoding@^1.3.0:
8457 version "1.4.1"
8458 resolved "https://registry.yarnpkg.com/mp4-box-encoding/-/mp4-box-encoding-1.4.1.tgz#19b31804c896bc1adf1c21b497bcf951aa3b9098"
8459 integrity sha512-2/PRtGGiqPc/VEhbm7xAQ+gbb7yzHjjMAv6MpAifr5pCpbh3fQUdj93uNgwPiTppAGu8HFKe3PeU+OdRyAxStA==
8460 dependencies:
8461 uint64be "^2.0.2"
8462
8463mp4-stream@^3.0.0:
8464 version "3.1.3"
8465 resolved "https://registry.yarnpkg.com/mp4-stream/-/mp4-stream-3.1.3.tgz#79b8a19900337203a9bd607a02eccc64419a379c"
8466 integrity sha512-DUT8f0x2jHbZjNMdqe9h6lZdt6RENWTTdGn8z3TXa4uEsoltuNY9lCCij84mdm0q7xcV0E2W25WRxlKBMo4hSw==
8467 dependencies:
8468 mp4-box-encoding "^1.3.0"
8469 next-event "^1.0.0"
8470 queue-microtask "^1.2.2"
8471 readable-stream "^3.0.6"
8472
8473mpd-parser@0.22.1, mpd-parser@^0.22.1: 8129mpd-parser@0.22.1, mpd-parser@^0.22.1:
8474 version "0.22.1" 8130 version "0.22.1"
8475 resolved "https://registry.yarnpkg.com/mpd-parser/-/mpd-parser-0.22.1.tgz#bc2bf7d3e56368e4b0121035b055675401871521" 8131 resolved "https://registry.yarnpkg.com/mpd-parser/-/mpd-parser-0.22.1.tgz#bc2bf7d3e56368e4b0121035b055675401871521"
@@ -8508,14 +8164,6 @@ multicast-dns@^7.2.5:
8508 dns-packet "^5.2.2" 8164 dns-packet "^5.2.2"
8509 thunky "^1.0.2" 8165 thunky "^1.0.2"
8510 8166
8511multistream@^4.1.0:
8512 version "4.1.0"
8513 resolved "https://registry.yarnpkg.com/multistream/-/multistream-4.1.0.tgz#7bf00dfd119556fbc153cff3de4c6d477909f5a8"
8514 integrity sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==
8515 dependencies:
8516 once "^1.4.0"
8517 readable-stream "^3.6.0"
8518
8519mute-stream@0.0.8: 8167mute-stream@0.0.8:
8520 version "0.0.8" 8168 version "0.0.8"
8521 resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" 8169 resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
@@ -8544,11 +8192,6 @@ nanoid@^3.3.6:
8544 resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" 8192 resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
8545 integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== 8193 integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
8546 8194
8547napi-macros@^2.0.0:
8548 version "2.2.2"
8549 resolved "https://registry.yarnpkg.com/napi-macros/-/napi-macros-2.2.2.tgz#817fef20c3e0e40a963fbf7b37d1600bd0201044"
8550 integrity sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g==
8551
8552natural-compare-lite@^1.4.0: 8195natural-compare-lite@^1.4.0:
8553 version "1.4.0" 8196 version "1.4.0"
8554 resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4" 8197 resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4"
@@ -8578,16 +8221,6 @@ neo-async@^2.6.2:
8578 resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" 8221 resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
8579 integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== 8222 integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
8580 8223
8581netmask@^2.0.1:
8582 version "2.0.2"
8583 resolved "https://registry.yarnpkg.com/netmask/-/netmask-2.0.2.tgz#8b01a07644065d536383835823bc52004ebac5e7"
8584 integrity sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==
8585
8586next-event@^1.0.0:
8587 version "1.0.0"
8588 resolved "https://registry.yarnpkg.com/next-event/-/next-event-1.0.0.tgz#e7778acde2e55802e0ad1879c39cf6f75eda61d8"
8589 integrity sha512-IXGPhl/yAiUU597gz+k5OYxYZkmLSWTcPPcpQjWABud9OK6m/ZNLrVdcEu4e7NgmOObFIhgZVg1jecPYT/6AoA==
8590
8591ngx-uploadx@^6.1.0: 8224ngx-uploadx@^6.1.0:
8592 version "6.1.0" 8225 version "6.1.0"
8593 resolved "https://registry.yarnpkg.com/ngx-uploadx/-/ngx-uploadx-6.1.0.tgz#40f00c352ba5a1af5b4bbe78a6a7572518314f8c" 8226 resolved "https://registry.yarnpkg.com/ngx-uploadx/-/ngx-uploadx-6.1.0.tgz#40f00c352ba5a1af5b4bbe78a6a7572518314f8c"
@@ -8642,7 +8275,7 @@ node-forge@^1:
8642 resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" 8275 resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3"
8643 integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== 8276 integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==
8644 8277
8645node-gyp-build@^4.2.0, node-gyp-build@^4.2.2, node-gyp-build@^4.3.0: 8278node-gyp-build@^4.2.2, node-gyp-build@^4.3.0:
8646 version "4.6.0" 8279 version "4.6.0"
8647 resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.6.0.tgz#0c52e4cbf54bbd28b709820ef7b6a3c2d6209055" 8280 resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.6.0.tgz#0c52e4cbf54bbd28b709820ef7b6a3c2d6209055"
8648 integrity sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ== 8281 integrity sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==
@@ -9103,13 +8736,6 @@ p-try@^2.0.0:
9103 resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" 8736 resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
9104 integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== 8737 integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
9105 8738
9106package-json-versionify@^1.0.4:
9107 version "1.0.4"
9108 resolved "https://registry.yarnpkg.com/package-json-versionify/-/package-json-versionify-1.0.4.tgz#5860587a944873a6b7e6d26e8e51ffb22315bf17"
9109 integrity sha512-mtKKtCeSZMtWcc5hHJS6OlEGP7J9g7WN6vWCCZi2hCXFag/Zmjokh6WFFTQb9TuMnBcZpRjhhMQyOyglPCAahw==
9110 dependencies:
9111 browserify-package-json "^1.0.0"
9112
9113pacote@15.1.3: 8739pacote@15.1.3:
9114 version "15.1.3" 8740 version "15.1.3"
9115 resolved "https://registry.yarnpkg.com/pacote/-/pacote-15.1.3.tgz#4c0e7fb5e7ab3b27fb3f86514b451ad4c4f64e9d" 8741 resolved "https://registry.yarnpkg.com/pacote/-/pacote-15.1.3.tgz#4c0e7fb5e7ab3b27fb3f86514b451ad4c4f64e9d"
@@ -9186,19 +8812,6 @@ parse-srcset@^1.0.2:
9186 resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1" 8812 resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1"
9187 integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q== 8813 integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==
9188 8814
9189parse-torrent@^9.1.5:
9190 version "9.1.5"
9191 resolved "https://registry.yarnpkg.com/parse-torrent/-/parse-torrent-9.1.5.tgz#fcae5f360d9baf617d9a2de68e74d5de4c8099fd"
9192 integrity sha512-K8FXRwTOaZMI0/xuv0dpng1MVHZRtMJ0jRWBJ3qZWVNTrC1MzWUxm9QwaXDz/2qPhV2XC4UIHI92IGHwseAwaA==
9193 dependencies:
9194 bencode "^2.0.2"
9195 blob-to-buffer "^1.2.9"
9196 get-stdin "^8.0.0"
9197 magnet-uri "^6.2.0"
9198 queue-microtask "^1.2.3"
9199 simple-get "^4.0.1"
9200 simple-sha1 "^3.1.0"
9201
9202parse5-html-rewriting-stream@7.0.0: 8815parse5-html-rewriting-stream@7.0.0:
9203 version "7.0.0" 8816 version "7.0.0"
9204 resolved "https://registry.yarnpkg.com/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-7.0.0.tgz#e376d3e762d2950ccbb6bb59823fc1d7e9fdac36" 8817 resolved "https://registry.yarnpkg.com/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-7.0.0.tgz#e376d3e762d2950ccbb6bb59823fc1d7e9fdac36"
@@ -9355,11 +8968,6 @@ picomatch@2.3.1, picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch
9355 resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" 8968 resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
9356 integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== 8969 integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
9357 8970
9358piece-length@^2.0.1:
9359 version "2.0.1"
9360 resolved "https://registry.yarnpkg.com/piece-length/-/piece-length-2.0.1.tgz#dbed4e78976955f34466d0a65304d0cb21914ac9"
9361 integrity sha512-dBILiDmm43y0JPISWEmVGKBETQjwJe6mSU9GND+P9KW0SJGUwoU/odyH1nbalOP9i8WSYuqf1lQnaj92Bhw+Ug==
9362
9363pify@^2.0.0: 8971pify@^2.0.0:
9364 version "2.3.0" 8972 version "2.3.0"
9365 resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" 8973 resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
@@ -9612,11 +9220,6 @@ pump@^3.0.0:
9612 end-of-stream "^1.1.0" 9220 end-of-stream "^1.1.0"
9613 once "^1.3.1" 9221 once "^1.3.1"
9614 9222
9615punycode@1.3.2:
9616 version "1.3.2"
9617 resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d"
9618 integrity sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==
9619
9620punycode@^2.1.0: 9223punycode@^2.1.0:
9621 version "2.3.0" 9224 version "2.3.0"
9622 resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" 9225 resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f"
@@ -9667,26 +9270,11 @@ query-selector-shadow-dom@^1.0.0:
9667 resolved "https://registry.yarnpkg.com/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz#1c7b0058eff4881ac44f45d8f84ede32e9a2f349" 9270 resolved "https://registry.yarnpkg.com/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz#1c7b0058eff4881ac44f45d8f84ede32e9a2f349"
9668 integrity sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw== 9271 integrity sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==
9669 9272
9670querystring@0.2.0:
9671 version "0.2.0"
9672 resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620"
9673 integrity sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==
9674
9675querystring@^0.2.1:
9676 version "0.2.1"
9677 resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.1.tgz#40d77615bb09d16902a85c3e38aa8b5ed761c2dd"
9678 integrity sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==
9679
9680queue-microtask@^1.2.2, queue-microtask@^1.2.3: 9273queue-microtask@^1.2.2, queue-microtask@^1.2.3:
9681 version "1.2.3" 9274 version "1.2.3"
9682 resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" 9275 resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
9683 integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== 9276 integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
9684 9277
9685queue-tick@^1.0.0, queue-tick@^1.0.1:
9686 version "1.0.1"
9687 resolved "https://registry.yarnpkg.com/queue-tick/-/queue-tick-1.0.1.tgz#f6f07ac82c1fd60f82e098b417a80e52f1f4c142"
9688 integrity sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==
9689
9690quick-lru@^4.0.1: 9278quick-lru@^4.0.1:
9691 version "4.0.1" 9279 version "4.0.1"
9692 resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" 9280 resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f"
@@ -9697,29 +9285,12 @@ quick-lru@^5.1.1:
9697 resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" 9285 resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932"
9698 integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== 9286 integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==
9699 9287
9700random-access-file@^2.0.1:
9701 version "2.2.1"
9702 resolved "https://registry.yarnpkg.com/random-access-file/-/random-access-file-2.2.1.tgz#071d086d8a92cc65abbd32b42aeba6d1d845d68d"
9703 integrity sha512-RGU0xmDqdOyEiynob1KYSeh8+9c9Td1MJ74GT1viMEYAn8SJ9oBtWCXLsYZukCF46yududHOdM449uRYbzBrZQ==
9704 dependencies:
9705 mkdirp-classic "^0.5.2"
9706 random-access-storage "^1.1.1"
9707
9708random-access-storage@^1.1.1:
9709 version "1.4.3"
9710 resolved "https://registry.yarnpkg.com/random-access-storage/-/random-access-storage-1.4.3.tgz#277d07005107562dfea84798eb9a6acd47d64b7f"
9711 integrity sha512-D5e2iIC5dNENWyBxsjhEnNOMCwZZ64TARK6dyMN+3g4OTC4MJxyjh9hKLjTGoNhDOPrgjI+YlFEHFnrp/cSnzQ==
9712 dependencies:
9713 events "^3.3.0"
9714 inherits "^2.0.3"
9715 queue-tick "^1.0.0"
9716
9717random-iterate@^1.0.1: 9288random-iterate@^1.0.1:
9718 version "1.0.1" 9289 version "1.0.1"
9719 resolved "https://registry.yarnpkg.com/random-iterate/-/random-iterate-1.0.1.tgz#f7d97d92dee6665ec5f6da08c7f963cad4b2ac99" 9290 resolved "https://registry.yarnpkg.com/random-iterate/-/random-iterate-1.0.1.tgz#f7d97d92dee6665ec5f6da08c7f963cad4b2ac99"
9720 integrity sha512-Jdsdnezu913Ot8qgKgSgs63XkAjEsnMcS1z+cC6D6TNXsUXsMxy0RpclF2pzGZTEiTXL9BiArdGTEexcv4nqcA== 9291 integrity sha512-Jdsdnezu913Ot8qgKgSgs63XkAjEsnMcS1z+cC6D6TNXsUXsMxy0RpclF2pzGZTEiTXL9BiArdGTEexcv4nqcA==
9721 9292
9722randombytes@^2.0.3, randombytes@^2.0.5, randombytes@^2.1.0: 9293randombytes@^2.1.0:
9723 version "2.1.0" 9294 version "2.1.0"
9724 resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" 9295 resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
9725 integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== 9296 integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==
@@ -9731,13 +9302,6 @@ range-parser@^1.2.1, range-parser@~1.2.1:
9731 resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" 9302 resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
9732 integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== 9303 integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
9733 9304
9734range-slice-stream@^2.0.0:
9735 version "2.0.0"
9736 resolved "https://registry.yarnpkg.com/range-slice-stream/-/range-slice-stream-2.0.0.tgz#1f25fc7a2cacf9ccd140c46f9cf670a1a7fe3ce6"
9737 integrity sha512-PPYLwZ63lXi6Tv2EZ8w3M4FzC0rVqvxivaOVS8pXSp5FMIHFnvi4MWHL3UdFLhwSy50aNtJsgjY0mBC6oFL26Q==
9738 dependencies:
9739 readable-stream "^3.0.2"
9740
9741raw-body@2.5.1: 9305raw-body@2.5.1:
9742 version "2.5.1" 9306 version "2.5.1"
9743 resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" 9307 resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857"
@@ -9756,11 +9320,6 @@ raw-loader@^4.0.2:
9756 loader-utils "^2.0.0" 9320 loader-utils "^2.0.0"
9757 schema-utils "^3.0.0" 9321 schema-utils "^3.0.0"
9758 9322
9759rc4@^0.1.5:
9760 version "0.1.5"
9761 resolved "https://registry.yarnpkg.com/rc4/-/rc4-0.1.5.tgz#08c6e04a0168f6eb621c22ab6cb1151bd9f4a64d"
9762 integrity sha512-xdDTNV90z5x5u25Oc871Xnvu7yAr4tV7Eluh0VSvrhUkry39q1k+zkz7xroqHbRq+8PiazySHJPArqifUvz9VA==
9763
9764react-is@^18.0.0: 9323react-is@^18.0.0:
9765 version "18.2.0" 9324 version "18.2.0"
9766 resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" 9325 resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
@@ -9869,7 +9428,7 @@ readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable
9869 string_decoder "~1.1.1" 9428 string_decoder "~1.1.1"
9870 util-deprecate "~1.0.1" 9429 util-deprecate "~1.0.1"
9871 9430
9872readable-stream@^3.0.2, readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.5.0, readable-stream@^3.6.0: 9431readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0:
9873 version "3.6.2" 9432 version "3.6.2"
9874 resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" 9433 resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967"
9875 integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== 9434 integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==
@@ -9899,13 +9458,6 @@ rechoir@^0.8.0:
9899 dependencies: 9458 dependencies:
9900 resolve "^1.20.0" 9459 resolve "^1.20.0"
9901 9460
9902record-cache@^1.2.0:
9903 version "1.2.0"
9904 resolved "https://registry.yarnpkg.com/record-cache/-/record-cache-1.2.0.tgz#e601bc4f164d58330cc00055e27aa4682291c882"
9905 integrity sha512-kyy3HWCez2WrotaL3O4fTn0rsIdfRKOdQQcEJ9KpvmKmbffKVvwsloX063EgRUlpJIXHiDQFhJcTbZequ2uTZw==
9906 dependencies:
9907 b4a "^1.3.1"
9908
9909recursive-readdir@^2.2.3: 9461recursive-readdir@^2.2.3:
9910 version "2.2.3" 9462 version "2.2.3"
9911 resolved "https://registry.yarnpkg.com/recursive-readdir/-/recursive-readdir-2.2.3.tgz#e726f328c0d69153bcabd5c322d3195252379372" 9463 resolved "https://registry.yarnpkg.com/recursive-readdir/-/recursive-readdir-2.2.3.tgz#e726f328c0d69153bcabd5c322d3195252379372"
@@ -9988,17 +9540,6 @@ relateurl@^0.2.7:
9988 resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" 9540 resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
9989 integrity sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog== 9541 integrity sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==
9990 9542
9991render-media@^4.1.0:
9992 version "4.1.0"
9993 resolved "https://registry.yarnpkg.com/render-media/-/render-media-4.1.0.tgz#9188376822653d7e56c2d789d157c81e74fee0cb"
9994 integrity sha512-F5BMWDmgATEoyPCtKjmGNTGN1ghoZlfRQ3MJh8dS/MrvIUIxupiof/Y9uahChipXcqQ57twVbgMmyQmuO1vokw==
9995 dependencies:
9996 debug "^4.2.0"
9997 is-ascii "^1.0.0"
9998 mediasource "^2.4.0"
9999 stream-to-blob-url "^3.0.2"
10000 videostream "^3.2.2"
10001
10002renderkid@^3.0.0: 9543renderkid@^3.0.0:
10003 version "3.0.0" 9544 version "3.0.0"
10004 resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-3.0.0.tgz#5fd823e4d6951d37358ecc9a58b1f06836b6268a" 9545 resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-3.0.0.tgz#5fd823e4d6951d37358ecc9a58b1f06836b6268a"
@@ -10170,14 +9711,7 @@ run-async@^3.0.0:
10170 resolved "https://registry.yarnpkg.com/run-async/-/run-async-3.0.0.tgz#42a432f6d76c689522058984384df28be379daad" 9711 resolved "https://registry.yarnpkg.com/run-async/-/run-async-3.0.0.tgz#42a432f6d76c689522058984384df28be379daad"
10171 integrity sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q== 9712 integrity sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==
10172 9713
10173run-parallel-limit@^1.1.0: 9714run-parallel@^1.1.9, run-parallel@^1.2.0:
10174 version "1.1.0"
10175 resolved "https://registry.yarnpkg.com/run-parallel-limit/-/run-parallel-limit-1.1.0.tgz#be80e936f5768623a38a963262d6bef8ff11e7ba"
10176 integrity sha512-jJA7irRNM91jaKc3Hcl1npHsFLOXOoTkPCUL1JEa1R82O2miplXXRaGdjW/KM/98YQWDhJLiSs793CnXfblJUw==
10177 dependencies:
10178 queue-microtask "^1.2.2"
10179
10180run-parallel@^1.1.2, run-parallel@^1.1.9, run-parallel@^1.2.0:
10181 version "1.2.0" 9715 version "1.2.0"
10182 resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" 9716 resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
10183 integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== 9717 integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==
@@ -10189,11 +9723,6 @@ run-series@^1.1.9:
10189 resolved "https://registry.yarnpkg.com/run-series/-/run-series-1.1.9.tgz#15ba9cb90e6a6c054e67c98e1dc063df0ecc113a" 9723 resolved "https://registry.yarnpkg.com/run-series/-/run-series-1.1.9.tgz#15ba9cb90e6a6c054e67c98e1dc063df0ecc113a"
10190 integrity sha512-Arc4hUN896vjkqCYrUXquBFtRZdv1PfLbTYP71efP6butxyQ0kWpiNJyAgsxscmQg1cqvHY32/UCBzXedTpU2g== 9724 integrity sha512-Arc4hUN896vjkqCYrUXquBFtRZdv1PfLbTYP71efP6butxyQ0kWpiNJyAgsxscmQg1cqvHY32/UCBzXedTpU2g==
10191 9725
10192rusha@^0.8.13:
10193 version "0.8.14"
10194 resolved "https://registry.yarnpkg.com/rusha/-/rusha-0.8.14.tgz#a977d0de9428406138b7bb90d3de5dcd024e2f68"
10195 integrity sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA==
10196
10197rust-result@^1.0.0: 9726rust-result@^1.0.0:
10198 version "1.0.0" 9727 version "1.0.0"
10199 resolved "https://registry.yarnpkg.com/rust-result/-/rust-result-1.0.0.tgz#34c75b2e6dc39fe5875e5bdec85b5e0f91536f72" 9728 resolved "https://registry.yarnpkg.com/rust-result/-/rust-result-1.0.0.tgz#34c75b2e6dc39fe5875e5bdec85b5e0f91536f72"
@@ -10498,12 +10027,12 @@ sigstore@^1.3.0:
10498 make-fetch-happen "^11.0.1" 10027 make-fetch-happen "^11.0.1"
10499 tuf-js "^1.1.3" 10028 tuf-js "^1.1.3"
10500 10029
10501simple-concat@^1.0.0, simple-concat@^1.0.1: 10030simple-concat@^1.0.0:
10502 version "1.0.1" 10031 version "1.0.1"
10503 resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" 10032 resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f"
10504 integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== 10033 integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==
10505 10034
10506simple-get@^4.0.0, simple-get@^4.0.1: 10035simple-get@^4.0.0:
10507 version "4.0.1" 10036 version "4.0.1"
10508 resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543" 10037 resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543"
10509 integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== 10038 integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==
@@ -10525,14 +10054,6 @@ simple-peer@^9.11.0, simple-peer@^9.11.1:
10525 randombytes "^2.1.0" 10054 randombytes "^2.1.0"
10526 readable-stream "^3.6.0" 10055 readable-stream "^3.6.0"
10527 10056
10528simple-sha1@^3.0.1, simple-sha1@^3.1.0:
10529 version "3.1.0"
10530 resolved "https://registry.yarnpkg.com/simple-sha1/-/simple-sha1-3.1.0.tgz#40cac8436dfaf9924332fc46a5c7bca45f656131"
10531 integrity sha512-ArTptMRC1v08H8ihPD6l0wesKvMfF9e8XL5rIHPanI7kGOsSsbY514MwVu6X1PITHCTB2F08zB7cyEbfc4wQjg==
10532 dependencies:
10533 queue-microtask "^1.2.2"
10534 rusha "^0.8.13"
10535
10536simple-websocket@^9.1.0: 10057simple-websocket@^9.1.0:
10537 version "9.1.0" 10058 version "9.1.0"
10538 resolved "https://registry.yarnpkg.com/simple-websocket/-/simple-websocket-9.1.0.tgz#91cbb39eafefbe7e66979da6c639109352786a7f" 10059 resolved "https://registry.yarnpkg.com/simple-websocket/-/simple-websocket-9.1.0.tgz#91cbb39eafefbe7e66979da6c639109352786a7f"
@@ -10727,19 +10248,6 @@ spdy@^4.0.2:
10727 select-hose "^2.0.0" 10248 select-hose "^2.0.0"
10728 spdy-transport "^3.0.0" 10249 spdy-transport "^3.0.0"
10729 10250
10730speed-limiter@^1.0.2:
10731 version "1.0.2"
10732 resolved "https://registry.yarnpkg.com/speed-limiter/-/speed-limiter-1.0.2.tgz#e4632f476a1d25d32557aad7bd089b3a0d948116"
10733 integrity sha512-Ax+TbUOho84bWUc3AKqWtkIvAIVws7d6QI4oJkgH4yQ5Yil+lR3vjd/7qd51dHKGzS5bFxg0++QwyNRN7s6rZA==
10734 dependencies:
10735 limiter "^1.1.5"
10736 streamx "^2.10.3"
10737
10738speedometer@^1.1.0:
10739 version "1.1.0"
10740 resolved "https://registry.yarnpkg.com/speedometer/-/speedometer-1.1.0.tgz#a30b13abda45687a1a76977012c060f2ac8a7934"
10741 integrity sha512-z/wAiTESw2XVPssY2XRcme4niTc4S5FkkJ4gknudtVoc33Zil8TdTxHy5torRcgqMqksJV2Yz8HQcvtbsnw0mQ==
10742
10743split2@^4.1.0: 10251split2@^4.1.0:
10744 version "4.2.0" 10252 version "4.2.0"
10745 resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" 10253 resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4"
@@ -10752,13 +10260,6 @@ split@0.3:
10752 dependencies: 10260 dependencies:
10753 through "2" 10261 through "2"
10754 10262
10755split@^1.0.1:
10756 version "1.0.1"
10757 resolved "https://registry.yarnpkg.com/split/-/split-1.0.1.tgz#605bd9be303aa59fb35f9229fbea0ddec9ea07d9"
10758 integrity sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==
10759 dependencies:
10760 through "2"
10761
10762sprintf-js@~1.0.2: 10263sprintf-js@~1.0.2:
10763 version "1.0.3" 10264 version "1.0.3"
10764 resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" 10265 resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
@@ -10802,14 +10303,6 @@ stop-iteration-iterator@^1.0.0:
10802 dependencies: 10303 dependencies:
10803 internal-slot "^1.0.4" 10304 internal-slot "^1.0.4"
10804 10305
10805stream-browserify@^3.0.0:
10806 version "3.0.0"
10807 resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f"
10808 integrity sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==
10809 dependencies:
10810 inherits "~2.0.4"
10811 readable-stream "^3.5.0"
10812
10813stream-buffers@^3.0.2: 10306stream-buffers@^3.0.2:
10814 version "3.0.2" 10307 version "3.0.2"
10815 resolved "https://registry.yarnpkg.com/stream-buffers/-/stream-buffers-3.0.2.tgz#5249005a8d5c2d00b3a32e6e0a6ea209dc4f3521" 10308 resolved "https://registry.yarnpkg.com/stream-buffers/-/stream-buffers-3.0.2.tgz#5249005a8d5c2d00b3a32e6e0a6ea209dc4f3521"
@@ -10822,43 +10315,6 @@ stream-combiner@~0.0.4:
10822 dependencies: 10315 dependencies:
10823 duplexer "~0.1.1" 10316 duplexer "~0.1.1"
10824 10317
10825stream-http@^3.0.0:
10826 version "3.2.0"
10827 resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-3.2.0.tgz#1872dfcf24cb15752677e40e5c3f9cc1926028b5"
10828 integrity sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A==
10829 dependencies:
10830 builtin-status-codes "^3.0.0"
10831 inherits "^2.0.4"
10832 readable-stream "^3.6.0"
10833 xtend "^4.0.2"
10834
10835stream-to-blob-url@^3.0.2:
10836 version "3.0.2"
10837 resolved "https://registry.yarnpkg.com/stream-to-blob-url/-/stream-to-blob-url-3.0.2.tgz#5574d139e2a6d1435945476f0a9469947f2da4fb"
10838 integrity sha512-PS6wT2ZyyR38Cy+lE6PBEI1ZmO2HdzZoLeDGG0zZbYikCZd0dh8FUoSeFzgWLItpBYw1WJmPVRLpykRV+lAWLQ==
10839 dependencies:
10840 stream-to-blob "^2.0.0"
10841
10842stream-to-blob@^2.0.0, stream-to-blob@^2.0.1:
10843 version "2.0.1"
10844 resolved "https://registry.yarnpkg.com/stream-to-blob/-/stream-to-blob-2.0.1.tgz#59ab71d7a7f0bfb899570e886e44d39f4ac4381a"
10845 integrity sha512-GXlqXt3svqwIVWoICenix5Poxi4KbCF0BdXXUbpU1X4vq1V8wmjiEIU3aFJzCGNFpKxfbnG0uoowS3nKUgSPYg==
10846
10847stream-with-known-length-to-buffer@^1.0.4:
10848 version "1.0.4"
10849 resolved "https://registry.yarnpkg.com/stream-with-known-length-to-buffer/-/stream-with-known-length-to-buffer-1.0.4.tgz#6a8aec53f27b8f481f962337c951aa3916fb60d1"
10850 integrity sha512-ztP79ug6S+I7td0Nd2GBeIKCm+vA54c+e60FY87metz5n/l6ydPELd2lxsljz8OpIhsRM9HkIiAwz85+S5G5/A==
10851 dependencies:
10852 once "^1.4.0"
10853
10854streamx@^2.10.3:
10855 version "2.13.2"
10856 resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.13.2.tgz#9de43569a1cd54980d128673b3c1429b79afff1c"
10857 integrity sha512-+TWqixPhGDXEG9L/XczSbhfkmwAtGs3BJX5QNU6cvno+pOLKeszByWcnaTu6dg8efsTYqR8ZZuXWHhZfgrxMvA==
10858 dependencies:
10859 fast-fifo "^1.1.0"
10860 queue-tick "^1.0.1"
10861
10862"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: 10318"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
10863 version "4.2.3" 10319 version "4.2.3"
10864 resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" 10320 resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
@@ -10921,7 +10377,7 @@ string.prototype.trimstart@^1.0.6:
10921 define-properties "^1.1.4" 10377 define-properties "^1.1.4"
10922 es-abstract "^1.20.4" 10378 es-abstract "^1.20.4"
10923 10379
10924string2compact@^1.3.0, string2compact@^1.3.2: 10380string2compact@^1.3.0:
10925 version "1.3.2" 10381 version "1.3.2"
10926 resolved "https://registry.yarnpkg.com/string2compact/-/string2compact-1.3.2.tgz#c9d11a13f368404b8025425cc53f9916de1d0b8b" 10382 resolved "https://registry.yarnpkg.com/string2compact/-/string2compact-1.3.2.tgz#c9d11a13f368404b8025425cc53f9916de1d0b8b"
10927 integrity sha512-3XUxUgwhj7Eqh2djae35QHZZT4mN3fsO7kagZhSGmhhlrQagVvWSFuuFIWnpxFS0CdTB2PlQcaL16RDi14I8uw== 10383 integrity sha512-3XUxUgwhj7Eqh2djae35QHZZT4mN3fsO7kagZhSGmhhlrQagVvWSFuuFIWnpxFS0CdTB2PlQcaL16RDi14I8uw==
@@ -11258,31 +10714,16 @@ text-table@0.2.0, text-table@^0.2.0:
11258 resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" 10714 resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
11259 integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== 10715 integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==
11260 10716
11261thirty-two@^1.0.2:
11262 version "1.0.2"
11263 resolved "https://registry.yarnpkg.com/thirty-two/-/thirty-two-1.0.2.tgz#4ca2fffc02a51290d2744b9e3f557693ca6b627a"
11264 integrity sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==
11265
11266through@2, through@^2.3.4, through@^2.3.6, through@^2.3.8, through@~2.3, through@~2.3.1: 10717through@2, through@^2.3.4, through@^2.3.6, through@^2.3.8, through@~2.3, through@~2.3.1:
11267 version "2.3.8" 10718 version "2.3.8"
11268 resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" 10719 resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
11269 integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== 10720 integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
11270 10721
11271throughput@^1.0.1: 10722thunky@^1.0.2:
11272 version "1.0.1"
11273 resolved "https://registry.yarnpkg.com/throughput/-/throughput-1.0.1.tgz#f8474cfc8f2f0eb740410bc23fa920b0bdba6d53"
11274 integrity sha512-4Mvv5P4xyVz6RM07wS3tGyZ/kPAiKtLeqznq3hK4pxDiTUSyQ5xeFlBiWxflCWexvSnxo2aAfedzKajJqihz4Q==
11275
11276thunky@^1.0.1, thunky@^1.0.2:
11277 version "1.1.0" 10723 version "1.1.0"
11278 resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d" 10724 resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d"
11279 integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA== 10725 integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==
11280 10726
11281timeout-refresh@^1.0.0:
11282 version "1.0.3"
11283 resolved "https://registry.yarnpkg.com/timeout-refresh/-/timeout-refresh-1.0.3.tgz#7024a8ce0a09a57acc2ea86002048e6c0bff7375"
11284 integrity sha512-Mz0CX4vBGM5lj8ttbIFt7o4ZMxk/9rgudJRh76EvB7xXZMur7T/cjRiH2w4Fmkq0zxf2QpM8IFvOSRn8FEu3gA==
11285
11286tmp@0.2.1, tmp@~0.2.1: 10727tmp@0.2.1, tmp@~0.2.1:
11287 version "0.2.1" 10728 version "0.2.1"
11288 resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" 10729 resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14"
@@ -11297,11 +10738,6 @@ tmp@^0.0.33:
11297 dependencies: 10738 dependencies:
11298 os-tmpdir "~1.0.2" 10739 os-tmpdir "~1.0.2"
11299 10740
11300to-arraybuffer@^1.0.1:
11301 version "1.0.1"
11302 resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43"
11303 integrity sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==
11304
11305to-fast-properties@^2.0.0: 10741to-fast-properties@^2.0.0:
11306 version "2.0.0" 10742 version "2.0.0"
11307 resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" 10743 resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
@@ -11324,22 +10760,6 @@ tokenizr@^1.6.4:
11324 resolved "https://registry.yarnpkg.com/tokenizr/-/tokenizr-1.6.9.tgz#67e7fc575fb73ae1145afe166e5e21a27d725b0d" 10760 resolved "https://registry.yarnpkg.com/tokenizr/-/tokenizr-1.6.9.tgz#67e7fc575fb73ae1145afe166e5e21a27d725b0d"
11325 integrity sha512-JeEey5bD1S0hsUEANaEKqqa4HDRfZRy8I1Enx+Rpb7wD1nDMqN53g6I9nhAOejPCfG5m0gOE73H4jT3NuK29Cw== 10761 integrity sha512-JeEey5bD1S0hsUEANaEKqqa4HDRfZRy8I1Enx+Rpb7wD1nDMqN53g6I9nhAOejPCfG5m0gOE73H4jT3NuK29Cw==
11326 10762
11327torrent-discovery@^9.4.13:
11328 version "9.4.15"
11329 resolved "https://registry.yarnpkg.com/torrent-discovery/-/torrent-discovery-9.4.15.tgz#95f983543d3e5259857116532cecca4aa979e494"
11330 integrity sha512-71nx+TpLaF27mbsSj/tZTr588Dfk7XVzx+Rf1+nrxfXqe8qn5dIlRhgA+yY4cg8Ib69vWwkKFhAzbRqg8z42aw==
11331 dependencies:
11332 bittorrent-dht "^10.0.7"
11333 bittorrent-lsd "^1.1.1"
11334 bittorrent-tracker "^9.19.0"
11335 debug "^4.3.4"
11336 run-parallel "^1.2.0"
11337
11338torrent-piece@^2.0.1:
11339 version "2.0.1"
11340 resolved "https://registry.yarnpkg.com/torrent-piece/-/torrent-piece-2.0.1.tgz#a1a50fffa589d9bf9560e38837230708bc3afdc6"
11341 integrity sha512-JLSOyvQVLI6JTWqioY4vFL0JkEUKQcaHQsU3loxkCvPTSttw8ePs2tFwsP4XIjw99Fz8EdOzt/4faykcbnPbCQ==
11342
11343totalist@^1.0.0: 10763totalist@^1.0.0:
11344 version "1.1.0" 10764 version "1.1.0"
11345 resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df" 10765 resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df"
@@ -11511,13 +10931,6 @@ uglify-js@^3.0.6:
11511 resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.4.tgz#61678cf5fa3f5b7eb789bb345df29afb8257c22c" 10931 resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.4.tgz#61678cf5fa3f5b7eb789bb345df29afb8257c22c"
11512 integrity sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g== 10932 integrity sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==
11513 10933
11514uint64be@^2.0.2:
11515 version "2.0.2"
11516 resolved "https://registry.yarnpkg.com/uint64be/-/uint64be-2.0.2.tgz#ef4a179752fe8f9ddaa29544ecfc13490031e8e5"
11517 integrity sha512-9QqdvpGQTXgxthP+lY4e/gIBy+RuqcBaC6JVwT5I3bDLgT/btL6twZMR0pI3/Fgah9G/pdwzIprE5gL6v9UvyQ==
11518 dependencies:
11519 buffer-alloc "^1.1.0"
11520
11521unbox-primitive@^1.0.2: 10934unbox-primitive@^1.0.2:
11522 version "1.0.2" 10935 version "1.0.2"
11523 resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" 10936 resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e"
@@ -11597,11 +11010,6 @@ unordered-array-remove@^1.0.2:
11597 resolved "https://registry.yarnpkg.com/unordered-array-remove/-/unordered-array-remove-1.0.2.tgz#c546e8f88e317a0cf2644c97ecb57dba66d250ef" 11010 resolved "https://registry.yarnpkg.com/unordered-array-remove/-/unordered-array-remove-1.0.2.tgz#c546e8f88e317a0cf2644c97ecb57dba66d250ef"
11598 integrity sha512-45YsfD6svkgaCBNyvD+dFHm4qFX9g3wRSIVgWVPtm2OCnphvPxzJoe20ATsiNpNJrmzHifnxm+BN5F7gFT/4gw== 11011 integrity sha512-45YsfD6svkgaCBNyvD+dFHm4qFX9g3wRSIVgWVPtm2OCnphvPxzJoe20ATsiNpNJrmzHifnxm+BN5F7gFT/4gw==
11599 11012
11600unordered-set@^2.0.1:
11601 version "2.0.1"
11602 resolved "https://registry.yarnpkg.com/unordered-set/-/unordered-set-2.0.1.tgz#4cd0fe27b8814bcf5d6073e5f0966ec7a50841e6"
11603 integrity sha512-eUmNTPzdx+q/WvOHW0bgGYLWvWHNT3PTKEQLg0MAQhc0AHASHVHoP/9YytYd4RBVariqno/mEUhVZN98CmD7bg==
11604
11605unpipe@1.0.0, unpipe@~1.0.0: 11013unpipe@1.0.0, unpipe@~1.0.0:
11606 version "1.0.0" 11014 version "1.0.0"
11607 resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" 11015 resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
@@ -11648,33 +11056,6 @@ url-toolkit@^2.2.1:
11648 resolved "https://registry.yarnpkg.com/url-toolkit/-/url-toolkit-2.2.5.tgz#58406b18e12c58803e14624df5e374f638b0f607" 11056 resolved "https://registry.yarnpkg.com/url-toolkit/-/url-toolkit-2.2.5.tgz#58406b18e12c58803e14624df5e374f638b0f607"
11649 integrity sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg== 11057 integrity sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg==
11650 11058
11651url@^0.11.0:
11652 version "0.11.0"
11653 resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
11654 integrity sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ==
11655 dependencies:
11656 punycode "1.3.2"
11657 querystring "0.2.0"
11658
11659ut_metadata@^3.5.2:
11660 version "3.5.2"
11661 resolved "https://registry.yarnpkg.com/ut_metadata/-/ut_metadata-3.5.2.tgz#2351c9348759e929978fa6a08d56ef6f584749e7"
11662 integrity sha512-3XZZuJSeoIUyMYSuDbTbVtP4KAVGHPfU8nmHFkr8LJc+THCaUXwnu/2AV+LCSLarET/hL9IlbNfYTGrt6fOVuQ==
11663 dependencies:
11664 bencode "^2.0.1"
11665 bitfield "^4.0.0"
11666 debug "^4.2.0"
11667 simple-sha1 "^3.0.1"
11668
11669ut_pex@^3.0.2:
11670 version "3.0.2"
11671 resolved "https://registry.yarnpkg.com/ut_pex/-/ut_pex-3.0.2.tgz#cd794d4fe02ebfa82704d41854c76c8d8187eea0"
11672 integrity sha512-3xM88t+AVU5GR0sIY3tmRMLUS+YKiwStc7U7+ZFQ+UHQpX7BjVJOomhmtm0Bs+8R2n812Dt2ymXm01EqDrOOpQ==
11673 dependencies:
11674 bencode "^2.0.2"
11675 compact2string "^1.4.1"
11676 string2compact "^1.3.2"
11677
11678utf-8-validate@^5.0.5: 11059utf-8-validate@^5.0.5:
11679 version "5.0.10" 11060 version "5.0.10"
11680 resolved "https://registry.yarnpkg.com/utf-8-validate/-/utf-8-validate-5.0.10.tgz#d7d10ea39318171ca982718b6b96a8d2442571a2" 11061 resolved "https://registry.yarnpkg.com/utf-8-validate/-/utf-8-validate-5.0.10.tgz#d7d10ea39318171ca982718b6b96a8d2442571a2"
@@ -11697,17 +11078,6 @@ utils-merge@1.0.1:
11697 resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" 11078 resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
11698 integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== 11079 integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
11699 11080
11700utp-native@^2.5.3:
11701 version "2.5.3"
11702 resolved "https://registry.yarnpkg.com/utp-native/-/utp-native-2.5.3.tgz#7c04c2a8c2858716555a77d10adb9819e3119b25"
11703 integrity sha512-sWTrWYXPhhWJh+cS2baPzhaZc89zwlWCfwSthUjGhLkZztyPhcQllo+XVVCbNGi7dhyRlxkWxN4NKU6FbA9Y8w==
11704 dependencies:
11705 napi-macros "^2.0.0"
11706 node-gyp-build "^4.2.0"
11707 readable-stream "^3.0.2"
11708 timeout-refresh "^1.0.0"
11709 unordered-set "^2.0.1"
11710
11711uue@^3.1.0: 11081uue@^3.1.0:
11712 version "3.1.2" 11082 version "3.1.2"
11713 resolved "https://registry.yarnpkg.com/uue/-/uue-3.1.2.tgz#e99368414e87200012eb37de4dbaebaa1c742ad2" 11083 resolved "https://registry.yarnpkg.com/uue/-/uue-3.1.2.tgz#e99368414e87200012eb37de4dbaebaa1c742ad2"
@@ -11782,18 +11152,6 @@ videojs-vtt.js@^0.15.4:
11782 dependencies: 11152 dependencies:
11783 global "^4.3.1" 11153 global "^4.3.1"
11784 11154
11785videostream@^3.2.2, videostream@~3.2.1:
11786 version "3.2.2"
11787 resolved "https://registry.yarnpkg.com/videostream/-/videostream-3.2.2.tgz#e3e8d44f5159892f8f31ad35cbf9302d7a6e6afc"
11788 integrity sha512-4tz23yGGeATmbzj/ZnUm6wgQ4E1lzmMXu2mUA/c0G6adtWKxm1Di5YejdZdRsK6SdkLjKjhplFFYT7r+UUDKvA==
11789 dependencies:
11790 binary-search "^1.3.4"
11791 mediasource "^2.2.2"
11792 mp4-box-encoding "^1.3.0"
11793 mp4-stream "^3.0.0"
11794 pump "^3.0.0"
11795 range-slice-stream "^2.0.0"
11796
11797vite@4.3.1: 11155vite@4.3.1:
11798 version "4.3.1" 11156 version "4.3.1"
11799 resolved "https://registry.yarnpkg.com/vite/-/vite-4.3.1.tgz#9badb1377f995632cdcf05f32103414db6fbb95a" 11157 resolved "https://registry.yarnpkg.com/vite/-/vite-4.3.1.tgz#9badb1377f995632cdcf05f32103414db6fbb95a"
@@ -12110,63 +11468,6 @@ websocket-extensions@>=0.1.1:
12110 resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" 11468 resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42"
12111 integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== 11469 integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==
12112 11470
12113webtorrent@1.8.26:
12114 version "1.8.26"
12115 resolved "https://registry.yarnpkg.com/webtorrent/-/webtorrent-1.8.26.tgz#c40313f3329d2bdfe8ae23365c17dd77825a829d"
12116 integrity sha512-1bbCIDtbk4OA7xXmT87t6jDhnng6RNC9d7HNpRyvxF0GQTrIz1fB3oDnNcbOim9Upjy1GDqxAOe0Mejmc86TUg==
12117 dependencies:
12118 "@webtorrent/http-node" "^1.3.0"
12119 addr-to-ip-port "^1.5.4"
12120 bitfield "^4.1.0"
12121 bittorrent-dht "^10.0.4"
12122 bittorrent-protocol "^3.5.5"
12123 cache-chunk-store "^3.2.2"
12124 chrome-net "^3.3.4"
12125 chunk-store-stream "^4.3.0"
12126 cpus "^1.0.3"
12127 create-torrent "^5.0.4"
12128 debug "^4.3.4"
12129 end-of-stream "^1.4.4"
12130 escape-html "^1.0.3"
12131 fs-chunk-store "^2.0.5"
12132 immediate-chunk-store "^2.2.0"
12133 load-ip-set "^2.2.1"
12134 lt_donthave "^1.0.1"
12135 memory-chunk-store "^1.3.5"
12136 mime "^3.0.0"
12137 multistream "^4.1.0"
12138 package-json-versionify "^1.0.4"
12139 parse-torrent "^9.1.5"
12140 pump "^3.0.0"
12141 queue-microtask "^1.2.3"
12142 random-iterate "^1.0.1"
12143 randombytes "^2.1.0"
12144 range-parser "^1.2.1"
12145 render-media "^4.1.0"
12146 run-parallel "^1.2.0"
12147 run-parallel-limit "^1.1.0"
12148 simple-concat "^1.0.1"
12149 simple-get "^4.0.1"
12150 simple-peer "^9.11.1"
12151 simple-sha1 "^3.1.0"
12152 speed-limiter "^1.0.2"
12153 stream-to-blob "^2.0.1"
12154 stream-to-blob-url "^3.0.2"
12155 stream-with-known-length-to-buffer "^1.0.4"
12156 throughput "^1.0.1"
12157 torrent-discovery "^9.4.13"
12158 torrent-piece "^2.0.1"
12159 unordered-array-remove "^1.0.2"
12160 ut_metadata "^3.5.2"
12161 ut_pex "^3.0.2"
12162 optionalDependencies:
12163 utp-native "^2.5.3"
12164
12165whatwg-fetch@^3.0.0:
12166 version "3.6.2"
12167 resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz#dced24f37f2624ed0281725d51d0e2e3fe677f8c"
12168 integrity sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==
12169
12170whatwg-url@^5.0.0: 11471whatwg-url@^5.0.0:
12171 version "5.0.0" 11472 version "5.0.0"
12172 resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" 11473 resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
@@ -12329,11 +11630,6 @@ xmlhttprequest-ssl@~2.0.0:
12329 resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz#91360c86b914e67f44dce769180027c0da618c67" 11630 resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz#91360c86b914e67f44dce769180027c0da618c67"
12330 integrity sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A== 11631 integrity sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==
12331 11632
12332xtend@^4.0.2:
12333 version "4.0.2"
12334 resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
12335 integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
12336
12337y18n@^3.2.1: 11633y18n@^3.2.1:
12338 version "3.2.2" 11634 version "3.2.2"
12339 resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.2.tgz#85c901bd6470ce71fc4bb723ad209b70f7f28696" 11635 resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.2.tgz#85c901bd6470ce71fc4bb723ad209b70f7f28696"
diff --git a/config/default.yaml b/config/default.yaml
index 5d0eab4f5..e590ab300 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -130,12 +130,13 @@ storage:
130 tmp_persistent: 'storage/tmp-persistent/' # As tmp but the directory is not cleaned up between PeerTube restarts 130 tmp_persistent: 'storage/tmp-persistent/' # As tmp but the directory is not cleaned up between PeerTube restarts
131 bin: 'storage/bin/' 131 bin: 'storage/bin/'
132 avatars: 'storage/avatars/' 132 avatars: 'storage/avatars/'
133 videos: 'storage/videos/' 133 web_videos: 'storage/web-videos/'
134 streaming_playlists: 'storage/streaming-playlists/' 134 streaming_playlists: 'storage/streaming-playlists/'
135 redundancy: 'storage/redundancy/' 135 redundancy: 'storage/redundancy/'
136 logs: 'storage/logs/' 136 logs: 'storage/logs/'
137 previews: 'storage/previews/' 137 previews: 'storage/previews/'
138 thumbnails: 'storage/thumbnails/' 138 thumbnails: 'storage/thumbnails/'
139 storyboards: 'storage/storyboards/'
139 torrents: 'storage/torrents/' 140 torrents: 'storage/torrents/'
140 captions: 'storage/captions/' 141 captions: 'storage/captions/'
141 cache: 'storage/cache/' 142 cache: 'storage/cache/'
@@ -200,9 +201,9 @@ object_storage:
200 # Useful when you want to use a CDN/external proxy 201 # Useful when you want to use a CDN/external proxy
201 base_url: '' # Example: 'https://mirror.example.com' 202 base_url: '' # Example: 'https://mirror.example.com'
202 203
203 # Same settings but for webtorrent videos 204 # Same settings but for web videos
204 videos: 205 web_videos:
205 bucket_name: 'videos' 206 bucket_name: 'web-videos'
206 prefix: '' 207 prefix: ''
207 base_url: '' 208 base_url: ''
208 209
@@ -396,6 +397,8 @@ cache:
396 size: 500 # Max number of video captions/subtitles you want to cache 397 size: 500 # Max number of video captions/subtitles you want to cache
397 torrents: 398 torrents:
398 size: 500 # Max number of video torrents you want to cache 399 size: 500 # Max number of video torrents you want to cache
400 storyboards:
401 size: 500 # Max number of video storyboards you want to cache
399 402
400admin: 403admin:
401 # Used to generate the root user at first startup 404 # Used to generate the root user at first startup
@@ -477,18 +480,18 @@ transcoding:
477 # Transcode and keep original resolution, even if it's above your maximum enabled resolution 480 # Transcode and keep original resolution, even if it's above your maximum enabled resolution
478 always_transcode_original_resolution: true 481 always_transcode_original_resolution: true
479 482
480 # Generate videos in a WebTorrent format (what we do since the first PeerTube release) 483 # Generate videos in a web compatible format
481 # If you also enabled the hls format, it will multiply videos storage by 2 484 # If you also enabled the hls format, it will multiply videos storage by 2
482 # If disabled, breaks federation with PeerTube instances < 2.1 485 # If disabled, breaks federation with PeerTube instances < 2.1
483 webtorrent: 486 web_videos:
484 enabled: false 487 enabled: false
485 488
486 # /!\ Requires ffmpeg >= 4.1 489 # /!\ Requires ffmpeg >= 4.1
487 # Generate HLS playlists and fragmented MP4 files. Better playback than with WebTorrent: 490 # Generate HLS playlists and fragmented MP4 files. Better playback than with Web Videos:
488 # * Resolution change is smoother 491 # * Resolution change is smoother
489 # * Faster playback in particular with long videos 492 # * Faster playback in particular with long videos
490 # * More stable playback (less bugs/infinite loading) 493 # * More stable playback (less bugs/infinite loading)
491 # If you also enabled the webtorrent format, it will multiply videos storage by 2 494 # If you also enabled the web videos format, it will multiply videos storage by 2
492 hls: 495 hls:
493 enabled: true 496 enabled: true
494 497
diff --git a/config/production.yaml.example b/config/production.yaml.example
index 5514f1af6..884300ddb 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -128,12 +128,13 @@ storage:
128 tmp_persistent: '/var/www/peertube/storage/tmp-persistent/' # As tmp but the directory is not cleaned up between PeerTube restarts 128 tmp_persistent: '/var/www/peertube/storage/tmp-persistent/' # As tmp but the directory is not cleaned up between PeerTube restarts
129 bin: '/var/www/peertube/storage/bin/' 129 bin: '/var/www/peertube/storage/bin/'
130 avatars: '/var/www/peertube/storage/avatars/' 130 avatars: '/var/www/peertube/storage/avatars/'
131 videos: '/var/www/peertube/storage/videos/' 131 web_videos: '/var/www/peertube/storage/web-videos/'
132 streaming_playlists: '/var/www/peertube/storage/streaming-playlists/' 132 streaming_playlists: '/var/www/peertube/storage/streaming-playlists/'
133 redundancy: '/var/www/peertube/storage/redundancy/' 133 redundancy: '/var/www/peertube/storage/redundancy/'
134 logs: '/var/www/peertube/storage/logs/' 134 logs: '/var/www/peertube/storage/logs/'
135 previews: '/var/www/peertube/storage/previews/' 135 previews: '/var/www/peertube/storage/previews/'
136 thumbnails: '/var/www/peertube/storage/thumbnails/' 136 thumbnails: '/var/www/peertube/storage/thumbnails/'
137 storyboards: '/var/www/peertube/storage/storyboards/'
137 torrents: '/var/www/peertube/storage/torrents/' 138 torrents: '/var/www/peertube/storage/torrents/'
138 captions: '/var/www/peertube/storage/captions/' 139 captions: '/var/www/peertube/storage/captions/'
139 cache: '/var/www/peertube/storage/cache/' 140 cache: '/var/www/peertube/storage/cache/'
@@ -198,9 +199,9 @@ object_storage:
198 # Useful when you want to use a CDN/external proxy 199 # Useful when you want to use a CDN/external proxy
199 base_url: '' # Example: 'https://mirror.example.com' 200 base_url: '' # Example: 'https://mirror.example.com'
200 201
201 # Same settings but for webtorrent videos 202 # Same settings but for web videos
202 videos: 203 web_videos:
203 bucket_name: 'videos' 204 bucket_name: 'web-videos'
204 prefix: '' 205 prefix: ''
205 base_url: '' 206 base_url: ''
206 207
@@ -406,6 +407,8 @@ cache:
406 size: 500 # Max number of video captions/subtitles you want to cache 407 size: 500 # Max number of video captions/subtitles you want to cache
407 torrents: 408 torrents:
408 size: 500 # Max number of video torrents you want to cache 409 size: 500 # Max number of video torrents you want to cache
410 storyboards:
411 size: 500 # Max number of video storyboards you want to cache
409 412
410admin: 413admin:
411 # Used to generate the root user at first startup 414 # Used to generate the root user at first startup
@@ -487,18 +490,18 @@ transcoding:
487 # Transcode and keep original resolution, even if it's above your maximum enabled resolution 490 # Transcode and keep original resolution, even if it's above your maximum enabled resolution
488 always_transcode_original_resolution: true 491 always_transcode_original_resolution: true
489 492
490 # Generate videos in a WebTorrent format (what we do since the first PeerTube release) 493 # Generate videos in a web compatible format
491 # If you also enabled the hls format, it will multiply videos storage by 2 494 # If you also enabled the hls format, it will multiply videos storage by 2
492 # If disabled, breaks federation with PeerTube instances < 2.1 495 # If disabled, breaks federation with PeerTube instances < 2.1
493 webtorrent: 496 web_videos:
494 enabled: false 497 enabled: false
495 498
496 # /!\ Requires ffmpeg >= 4.1 499 # /!\ Requires ffmpeg >= 4.1
497 # Generate HLS playlists and fragmented MP4 files. Better playback than with WebTorrent: 500 # Generate HLS playlists and fragmented MP4 files. Better playback than with Web Videos:
498 # * Resolution change is smoother 501 # * Resolution change is smoother
499 # * Faster playback in particular with long videos 502 # * Faster playback in particular with long videos
500 # * More stable playback (less bugs/infinite loading) 503 # * More stable playback (less bugs/infinite loading)
501 # If you also enabled the webtorrent format, it will multiply videos storage by 2 504 # If you also enabled the web videos format, it will multiply videos storage by 2
502 hls: 505 hls:
503 enabled: true 506 enabled: true
504 507
@@ -596,7 +599,6 @@ video_studio:
596 # If enabled, users can create transcoding tasks as they wish 599 # If enabled, users can create transcoding tasks as they wish
597 enabled: false 600 enabled: false
598 601
599
600 # Enable remote runners to transcode studio tasks 602 # Enable remote runners to transcode studio tasks
601 # If enabled, your instance won't transcode the videos itself 603 # If enabled, your instance won't transcode the videos itself
602 # At least 1 remote runner must be configured to transcode your videos 604 # At least 1 remote runner must be configured to transcode your videos
diff --git a/config/test-1.yaml b/config/test-1.yaml
index 7b62e3d0c..b86b48438 100644
--- a/config/test-1.yaml
+++ b/config/test-1.yaml
@@ -13,12 +13,13 @@ storage:
13 tmp_persistent: 'test1/tmp-persistent/' 13 tmp_persistent: 'test1/tmp-persistent/'
14 bin: 'test1/bin/' 14 bin: 'test1/bin/'
15 avatars: 'test1/avatars/' 15 avatars: 'test1/avatars/'
16 videos: 'test1/videos/' 16 web_videos: 'test1/web-videos/'
17 streaming_playlists: 'test1/streaming-playlists/' 17 streaming_playlists: 'test1/streaming-playlists/'
18 redundancy: 'test1/redundancy/' 18 redundancy: 'test1/redundancy/'
19 logs: 'test1/logs/' 19 logs: 'test1/logs/'
20 previews: 'test1/previews/' 20 previews: 'test1/previews/'
21 thumbnails: 'test1/thumbnails/' 21 thumbnails: 'test1/thumbnails/'
22 storyboards: 'test1/storyboards/'
22 torrents: 'test1/torrents/' 23 torrents: 'test1/torrents/'
23 captions: 'test1/captions/' 24 captions: 'test1/captions/'
24 cache: 'test1/cache/' 25 cache: 'test1/cache/'
diff --git a/config/test-2.yaml b/config/test-2.yaml
index ba36369a6..266e44c17 100644
--- a/config/test-2.yaml
+++ b/config/test-2.yaml
@@ -13,12 +13,13 @@ storage:
13 tmp_persistent: 'test2/tmp-persistent/' 13 tmp_persistent: 'test2/tmp-persistent/'
14 bin: 'test2/bin/' 14 bin: 'test2/bin/'
15 avatars: 'test2/avatars/' 15 avatars: 'test2/avatars/'
16 videos: 'test2/videos/' 16 web_videos: 'test2/web-videos/'
17 streaming_playlists: 'test2/streaming-playlists/' 17 streaming_playlists: 'test2/streaming-playlists/'
18 redundancy: 'test2/redundancy/' 18 redundancy: 'test2/redundancy/'
19 logs: 'test2/logs/' 19 logs: 'test2/logs/'
20 previews: 'test2/previews/' 20 previews: 'test2/previews/'
21 thumbnails: 'test2/thumbnails/' 21 thumbnails: 'test2/thumbnails/'
22 storyboards: 'test2/storyboards/'
22 torrents: 'test2/torrents/' 23 torrents: 'test2/torrents/'
23 captions: 'test2/captions/' 24 captions: 'test2/captions/'
24 cache: 'test2/cache/' 25 cache: 'test2/cache/'
diff --git a/config/test-3.yaml b/config/test-3.yaml
index 6adec7953..b31d37765 100644
--- a/config/test-3.yaml
+++ b/config/test-3.yaml
@@ -13,12 +13,13 @@ storage:
13 tmp_persistent: 'test3/tmp-persistent/' 13 tmp_persistent: 'test3/tmp-persistent/'
14 bin: 'test3/bin/' 14 bin: 'test3/bin/'
15 avatars: 'test3/avatars/' 15 avatars: 'test3/avatars/'
16 videos: 'test3/videos/' 16 web_videos: 'test3/web-videos/'
17 streaming_playlists: 'test3/streaming-playlists/' 17 streaming_playlists: 'test3/streaming-playlists/'
18 redundancy: 'test3/redundancy/' 18 redundancy: 'test3/redundancy/'
19 logs: 'test3/logs/' 19 logs: 'test3/logs/'
20 previews: 'test3/previews/' 20 previews: 'test3/previews/'
21 thumbnails: 'test3/thumbnails/' 21 thumbnails: 'test3/thumbnails/'
22 storyboards: 'test3/storyboards/'
22 torrents: 'test3/torrents/' 23 torrents: 'test3/torrents/'
23 captions: 'test3/captions/' 24 captions: 'test3/captions/'
24 cache: 'test3/cache/' 25 cache: 'test3/cache/'
diff --git a/config/test-4.yaml b/config/test-4.yaml
index f042aee46..d73b09b56 100644
--- a/config/test-4.yaml
+++ b/config/test-4.yaml
@@ -13,12 +13,13 @@ storage:
13 tmp_persistent: 'test4/tmp-persistent/' 13 tmp_persistent: 'test4/tmp-persistent/'
14 bin: 'test4/bin/' 14 bin: 'test4/bin/'
15 avatars: 'test4/avatars/' 15 avatars: 'test4/avatars/'
16 videos: 'test4/videos/' 16 web_videos: 'test4/web-videos/'
17 streaming_playlists: 'test4/streaming-playlists/' 17 streaming_playlists: 'test4/streaming-playlists/'
18 redundancy: 'test4/redundancy/' 18 redundancy: 'test4/redundancy/'
19 logs: 'test4/logs/' 19 logs: 'test4/logs/'
20 previews: 'test4/previews/' 20 previews: 'test4/previews/'
21 thumbnails: 'test4/thumbnails/' 21 thumbnails: 'test4/thumbnails/'
22 storyboards: 'test4/storyboards/'
22 torrents: 'test4/torrents/' 23 torrents: 'test4/torrents/'
23 captions: 'test4/captions/' 24 captions: 'test4/captions/'
24 cache: 'test4/cache/' 25 cache: 'test4/cache/'
diff --git a/config/test-5.yaml b/config/test-5.yaml
index ad90fec04..56cdc5242 100644
--- a/config/test-5.yaml
+++ b/config/test-5.yaml
@@ -13,12 +13,13 @@ storage:
13 tmp_persistent: 'test5/tmp-persistent/' 13 tmp_persistent: 'test5/tmp-persistent/'
14 bin: 'test5/bin/' 14 bin: 'test5/bin/'
15 avatars: 'test5/avatars/' 15 avatars: 'test5/avatars/'
16 videos: 'test5/videos/' 16 web_videos: 'test5/web-videos/'
17 streaming_playlists: 'test5/streaming-playlists/' 17 streaming_playlists: 'test5/streaming-playlists/'
18 redundancy: 'test5/redundancy/' 18 redundancy: 'test5/redundancy/'
19 logs: 'test5/logs/' 19 logs: 'test5/logs/'
20 previews: 'test5/previews/' 20 previews: 'test5/previews/'
21 thumbnails: 'test5/thumbnails/' 21 thumbnails: 'test5/thumbnails/'
22 storyboards: 'test5/storyboards/'
22 torrents: 'test5/torrents/' 23 torrents: 'test5/torrents/'
23 captions: 'test5/captions/' 24 captions: 'test5/captions/'
24 cache: 'test5/cache/' 25 cache: 'test5/cache/'
diff --git a/config/test-6.yaml b/config/test-6.yaml
index a579f1f01..0e212c699 100644
--- a/config/test-6.yaml
+++ b/config/test-6.yaml
@@ -13,12 +13,13 @@ storage:
13 tmp_persistent: 'test6/tmp-persistent/' 13 tmp_persistent: 'test6/tmp-persistent/'
14 bin: 'test6/bin/' 14 bin: 'test6/bin/'
15 avatars: 'test6/avatars/' 15 avatars: 'test6/avatars/'
16 videos: 'test6/videos/' 16 web_videos: 'test6/web-videos/'
17 streaming_playlists: 'test6/streaming-playlists/' 17 streaming_playlists: 'test6/streaming-playlists/'
18 redundancy: 'test6/redundancy/' 18 redundancy: 'test6/redundancy/'
19 logs: 'test6/logs/' 19 logs: 'test6/logs/'
20 previews: 'test6/previews/' 20 previews: 'test6/previews/'
21 thumbnails: 'test6/thumbnails/' 21 thumbnails: 'test6/thumbnails/'
22 storyboards: 'test6/storyboards/'
22 torrents: 'test6/torrents/' 23 torrents: 'test6/torrents/'
23 captions: 'test6/captions/' 24 captions: 'test6/captions/'
24 cache: 'test6/cache/' 25 cache: 'test6/cache/'
diff --git a/config/test.yaml b/config/test.yaml
index 361064af1..cc642327c 100644
--- a/config/test.yaml
+++ b/config/test.yaml
@@ -73,6 +73,8 @@ cache:
73 size: 1 73 size: 1
74 torrents: 74 torrents:
75 size: 1 75 size: 1
76 storyboards:
77 size: 1
76 78
77signup: 79signup:
78 enabled: true 80 enabled: true
@@ -95,7 +97,7 @@ transcoding:
95 1080p: true 97 1080p: true
96 1440p: true 98 1440p: true
97 2160p: true 99 2160p: true
98 webtorrent: 100 web_videos:
99 enabled: true 101 enabled: true
100 hls: 102 hls:
101 enabled: true 103 enabled: true
diff --git a/package.json b/package.json
index 223156098..7573232a1 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,7 @@
49 "regenerate-thumbnails": "node ./dist/scripts/regenerate-thumbnails.js", 49 "regenerate-thumbnails": "node ./dist/scripts/regenerate-thumbnails.js",
50 "create-import-video-file-job": "node ./dist/scripts/create-import-video-file-job.js", 50 "create-import-video-file-job": "node ./dist/scripts/create-import-video-file-job.js",
51 "create-move-video-storage-job": "node ./dist/scripts/create-move-video-storage-job.js", 51 "create-move-video-storage-job": "node ./dist/scripts/create-move-video-storage-job.js",
52 "create-generate-storyboard-job": "node ./dist/scripts/create-generate-storyboard-job.js",
52 "test": "bash ./scripts/test.sh", 53 "test": "bash ./scripts/test.sh",
53 "generate-cli-doc": "bash ./scripts/generate-cli-doc.sh", 54 "generate-cli-doc": "bash ./scripts/generate-cli-doc.sh",
54 "generate-types-package": "ts-node ./packages/types/generate-package.ts", 55 "generate-types-package": "ts-node ./packages/types/generate-package.ts",
@@ -77,6 +78,7 @@
77 "@aws-sdk/client-s3": "^3.190.0", 78 "@aws-sdk/client-s3": "^3.190.0",
78 "@aws-sdk/lib-storage": "^3.190.0", 79 "@aws-sdk/lib-storage": "^3.190.0",
79 "@aws-sdk/node-http-handler": "^3.190.0", 80 "@aws-sdk/node-http-handler": "^3.190.0",
81 "@aws-sdk/s3-request-presigner": "^3.345.0",
80 "@babel/parser": "^7.17.8", 82 "@babel/parser": "^7.17.8",
81 "@node-oauth/oauth2-server": "^4.2.0", 83 "@node-oauth/oauth2-server": "^4.2.0",
82 "@opentelemetry/api": "^1.1.0", 84 "@opentelemetry/api": "^1.1.0",
diff --git a/packages/peertube-runner/package.json b/packages/peertube-runner/package.json
index a57acf189..1c525691a 100644
--- a/packages/peertube-runner/package.json
+++ b/packages/peertube-runner/package.json
@@ -1,6 +1,6 @@
1{ 1{
2 "name": "@peertube/peertube-runner", 2 "name": "@peertube/peertube-runner",
3 "version": "0.0.4", 3 "version": "0.0.5",
4 "main": "dist/peertube-runner.js", 4 "main": "dist/peertube-runner.js",
5 "bin": "dist/peertube-runner.js", 5 "bin": "dist/peertube-runner.js",
6 "license": "AGPL-3.0", 6 "license": "AGPL-3.0",
diff --git a/packages/peertube-runner/server/process/shared/common.ts b/packages/peertube-runner/server/process/shared/common.ts
index dbeb9dfc1..a9b37bbc4 100644
--- a/packages/peertube-runner/server/process/shared/common.ts
+++ b/packages/peertube-runner/server/process/shared/common.ts
@@ -35,49 +35,48 @@ export async function downloadInputFile (options: {
35 return destination 35 return destination
36} 36}
37 37
38export async function updateTranscodingProgress (options: { 38export function scheduleTranscodingProgress (options: {
39 server: PeerTubeServer 39 server: PeerTubeServer
40 runnerToken: string 40 runnerToken: string
41 job: JobWithToken 41 job: JobWithToken
42 progress: number 42 progressGetter: () => number
43}) { 43}) {
44 const { server, job, runnerToken, progress } = options 44 const { job, server, progressGetter, runnerToken } = options
45
46 return server.runnerJobs.update({ jobToken: job.jobToken, jobUUID: job.uuid, runnerToken, progress })
47}
48
49// ---------------------------------------------------------------------------
50
51export function buildFFmpegVOD (options: {
52 server: PeerTubeServer
53 runnerToken: string
54 job: JobWithToken
55}) {
56 const { server, job, runnerToken } = options
57 45
58 const updateInterval = ConfigManager.Instance.isTestInstance() 46 const updateInterval = ConfigManager.Instance.isTestInstance()
59 ? 500 47 ? 500
60 : 60000 48 : 60000
61 49
62 let progress: number 50 const update = () => {
51 server.runnerJobs.update({ jobToken: job.jobToken, jobUUID: job.uuid, runnerToken, progress: progressGetter() })
52 .catch(err => logger.error({ err }, 'Cannot send job progress'))
53 }
63 54
64 const interval = setInterval(() => { 55 const interval = setInterval(() => {
65 updateTranscodingProgress({ server, job, runnerToken, progress }) 56 update()
66 .catch(err => logger.error({ err }, 'Cannot send job progress'))
67 }, updateInterval) 57 }, updateInterval)
68 58
59 update()
60
61 return interval
62}
63
64// ---------------------------------------------------------------------------
65
66export function buildFFmpegVOD (options: {
67 onJobProgress: (progress: number) => void
68}) {
69 const { onJobProgress } = options
70
69 return new FFmpegVOD({ 71 return new FFmpegVOD({
70 ...getCommonFFmpegOptions(), 72 ...getCommonFFmpegOptions(),
71 73
72 onError: () => clearInterval(interval),
73 onEnd: () => clearInterval(interval),
74
75 updateJobProgress: arg => { 74 updateJobProgress: arg => {
76 if (arg < 0 || arg > 100) { 75 const progress = arg < 0 || arg > 100
77 progress = undefined 76 ? undefined
78 } else { 77 : arg
79 progress = arg 78
80 } 79 onJobProgress(progress)
81 } 80 }
82 }) 81 })
83} 82}
diff --git a/packages/peertube-runner/server/process/shared/process-studio.ts b/packages/peertube-runner/server/process/shared/process-studio.ts
index 9c745d031..afd9347fe 100644
--- a/packages/peertube-runner/server/process/shared/process-studio.ts
+++ b/packages/peertube-runner/server/process/shared/process-studio.ts
@@ -5,26 +5,42 @@ import { join } from 'path'
5import { buildUUID } from '@shared/extra-utils' 5import { buildUUID } from '@shared/extra-utils'
6import { 6import {
7 RunnerJobStudioTranscodingPayload, 7 RunnerJobStudioTranscodingPayload,
8 VideoStudioTranscodingSuccess,
9 VideoStudioTask, 8 VideoStudioTask,
10 VideoStudioTaskCutPayload, 9 VideoStudioTaskCutPayload,
11 VideoStudioTaskIntroPayload, 10 VideoStudioTaskIntroPayload,
12 VideoStudioTaskOutroPayload, 11 VideoStudioTaskOutroPayload,
13 VideoStudioTaskPayload, 12 VideoStudioTaskPayload,
14 VideoStudioTaskWatermarkPayload 13 VideoStudioTaskWatermarkPayload,
14 VideoStudioTranscodingSuccess
15} from '@shared/models' 15} from '@shared/models'
16import { ConfigManager } from '../../../shared/config-manager' 16import { ConfigManager } from '../../../shared/config-manager'
17import { buildFFmpegEdition, downloadInputFile, JobWithToken, ProcessOptions } from './common' 17import { buildFFmpegEdition, downloadInputFile, JobWithToken, ProcessOptions, scheduleTranscodingProgress } from './common'
18 18
19export async function processStudioTranscoding (options: ProcessOptions<RunnerJobStudioTranscodingPayload>) { 19export async function processStudioTranscoding (options: ProcessOptions<RunnerJobStudioTranscodingPayload>) {
20 const { server, job, runnerToken } = options 20 const { server, job, runnerToken } = options
21 const payload = job.payload 21 const payload = job.payload
22 22
23 let inputPath: string
23 let outputPath: string 24 let outputPath: string
24 const inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job }) 25 let tmpInputFilePath: string
25 let tmpInputFilePath = inputPath 26
27 let tasksProgress = 0
28
29 const updateProgressInterval = scheduleTranscodingProgress({
30 job,
31 server,
32 runnerToken,
33 progressGetter: () => tasksProgress
34 })
26 35
27 try { 36 try {
37 logger.info(`Downloading input file ${payload.input.videoFileUrl} for job ${job.jobToken}`)
38
39 inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
40 tmpInputFilePath = inputPath
41
42 logger.info(`Input file ${payload.input.videoFileUrl} downloaded for job ${job.jobToken}. Running studio transcoding tasks.`)
43
28 for (const task of payload.tasks) { 44 for (const task of payload.tasks) {
29 const outputFilename = 'output-edition-' + buildUUID() + '.mp4' 45 const outputFilename = 'output-edition-' + buildUUID() + '.mp4'
30 outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), outputFilename) 46 outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), outputFilename)
@@ -41,6 +57,8 @@ export async function processStudioTranscoding (options: ProcessOptions<RunnerJo
41 57
42 // For the next iteration 58 // For the next iteration
43 tmpInputFilePath = outputPath 59 tmpInputFilePath = outputPath
60
61 tasksProgress += Math.floor(100 / payload.tasks.length)
44 } 62 }
45 63
46 const successBody: VideoStudioTranscodingSuccess = { 64 const successBody: VideoStudioTranscodingSuccess = {
@@ -54,8 +72,9 @@ export async function processStudioTranscoding (options: ProcessOptions<RunnerJo
54 payload: successBody 72 payload: successBody
55 }) 73 })
56 } finally { 74 } finally {
57 await remove(tmpInputFilePath) 75 if (tmpInputFilePath) await remove(tmpInputFilePath)
58 await remove(outputPath) 76 if (outputPath) await remove(outputPath)
77 if (updateProgressInterval) clearInterval(updateProgressInterval)
59 } 78 }
60} 79}
61 80
diff --git a/packages/peertube-runner/server/process/shared/process-vod.ts b/packages/peertube-runner/server/process/shared/process-vod.ts
index 22489afd5..f7c076b27 100644
--- a/packages/peertube-runner/server/process/shared/process-vod.ts
+++ b/packages/peertube-runner/server/process/shared/process-vod.ts
@@ -1,4 +1,5 @@
1import { remove } from 'fs-extra' 1import { remove } from 'fs-extra'
2import { logger } from 'packages/peertube-runner/shared'
2import { join } from 'path' 3import { join } from 'path'
3import { buildUUID } from '@shared/extra-utils' 4import { buildUUID } from '@shared/extra-utils'
4import { 5import {
@@ -10,19 +11,36 @@ import {
10 VODWebVideoTranscodingSuccess 11 VODWebVideoTranscodingSuccess
11} from '@shared/models' 12} from '@shared/models'
12import { ConfigManager } from '../../../shared/config-manager' 13import { ConfigManager } from '../../../shared/config-manager'
13import { buildFFmpegVOD, downloadInputFile, ProcessOptions } from './common' 14import { buildFFmpegVOD, downloadInputFile, ProcessOptions, scheduleTranscodingProgress } from './common'
14 15
15export async function processWebVideoTranscoding (options: ProcessOptions<RunnerJobVODWebVideoTranscodingPayload>) { 16export async function processWebVideoTranscoding (options: ProcessOptions<RunnerJobVODWebVideoTranscodingPayload>) {
16 const { server, job, runnerToken } = options 17 const { server, job, runnerToken } = options
17 const payload = job.payload
18 18
19 const inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job }) 19 const payload = job.payload
20 20
21 const ffmpegVod = buildFFmpegVOD({ job, server, runnerToken }) 21 let ffmpegProgress: number
22 let inputPath: string
22 23
23 const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`) 24 const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`)
24 25
26 const updateProgressInterval = scheduleTranscodingProgress({
27 job,
28 server,
29 runnerToken,
30 progressGetter: () => ffmpegProgress
31 })
32
25 try { 33 try {
34 logger.info(`Downloading input file ${payload.input.videoFileUrl} for web video transcoding job ${job.jobToken}`)
35
36 inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
37
38 logger.info(`Downloaded input file ${payload.input.videoFileUrl} for job ${job.jobToken}. Running web video transcoding.`)
39
40 const ffmpegVod = buildFFmpegVOD({
41 onJobProgress: progress => { ffmpegProgress = progress }
42 })
43
26 await ffmpegVod.transcode({ 44 await ffmpegVod.transcode({
27 type: 'video', 45 type: 'video',
28 46
@@ -47,8 +65,9 @@ export async function processWebVideoTranscoding (options: ProcessOptions<Runner
47 payload: successBody 65 payload: successBody
48 }) 66 })
49 } finally { 67 } finally {
50 await remove(inputPath) 68 if (inputPath) await remove(inputPath)
51 await remove(outputPath) 69 if (outputPath) await remove(outputPath)
70 if (updateProgressInterval) clearInterval(updateProgressInterval)
52 } 71 }
53} 72}
54 73
@@ -56,16 +75,32 @@ export async function processHLSTranscoding (options: ProcessOptions<RunnerJobVO
56 const { server, job, runnerToken } = options 75 const { server, job, runnerToken } = options
57 const payload = job.payload 76 const payload = job.payload
58 77
59 const inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job }) 78 let ffmpegProgress: number
60 const uuid = buildUUID() 79 let inputPath: string
61 80
81 const uuid = buildUUID()
62 const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `${uuid}-${payload.output.resolution}.m3u8`) 82 const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `${uuid}-${payload.output.resolution}.m3u8`)
63 const videoFilename = `${uuid}-${payload.output.resolution}-fragmented.mp4` 83 const videoFilename = `${uuid}-${payload.output.resolution}-fragmented.mp4`
64 const videoPath = join(join(ConfigManager.Instance.getTranscodingDirectory(), videoFilename)) 84 const videoPath = join(join(ConfigManager.Instance.getTranscodingDirectory(), videoFilename))
65 85
66 const ffmpegVod = buildFFmpegVOD({ job, server, runnerToken }) 86 const updateProgressInterval = scheduleTranscodingProgress({
87 job,
88 server,
89 runnerToken,
90 progressGetter: () => ffmpegProgress
91 })
67 92
68 try { 93 try {
94 logger.info(`Downloading input file ${payload.input.videoFileUrl} for HLS transcoding job ${job.jobToken}`)
95
96 inputPath = await downloadInputFile({ url: payload.input.videoFileUrl, runnerToken, job })
97
98 logger.info(`Downloaded input file ${payload.input.videoFileUrl} for job ${job.jobToken}. Running HLS transcoding.`)
99
100 const ffmpegVod = buildFFmpegVOD({
101 onJobProgress: progress => { ffmpegProgress = progress }
102 })
103
69 await ffmpegVod.transcode({ 104 await ffmpegVod.transcode({
70 type: 'hls', 105 type: 'hls',
71 copyCodecs: false, 106 copyCodecs: false,
@@ -91,9 +126,10 @@ export async function processHLSTranscoding (options: ProcessOptions<RunnerJobVO
91 payload: successBody 126 payload: successBody
92 }) 127 })
93 } finally { 128 } finally {
94 await remove(inputPath) 129 if (inputPath) await remove(inputPath)
95 await remove(outputPath) 130 if (outputPath) await remove(outputPath)
96 await remove(videoPath) 131 if (videoPath) await remove(videoPath)
132 if (updateProgressInterval) clearInterval(updateProgressInterval)
97 } 133 }
98} 134}
99 135
@@ -101,14 +137,37 @@ export async function processAudioMergeTranscoding (options: ProcessOptions<Runn
101 const { server, job, runnerToken } = options 137 const { server, job, runnerToken } = options
102 const payload = job.payload 138 const payload = job.payload
103 139
104 const audioPath = await downloadInputFile({ url: payload.input.audioFileUrl, runnerToken, job }) 140 let ffmpegProgress: number
105 const inputPath = await downloadInputFile({ url: payload.input.previewFileUrl, runnerToken, job }) 141 let audioPath: string
142 let inputPath: string
106 143
107 const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`) 144 const outputPath = join(ConfigManager.Instance.getTranscodingDirectory(), `output-${buildUUID()}.mp4`)
108 145
109 const ffmpegVod = buildFFmpegVOD({ job, server, runnerToken }) 146 const updateProgressInterval = scheduleTranscodingProgress({
147 job,
148 server,
149 runnerToken,
150 progressGetter: () => ffmpegProgress
151 })
110 152
111 try { 153 try {
154 logger.info(
155 `Downloading input files ${payload.input.audioFileUrl} and ${payload.input.previewFileUrl} ` +
156 `for audio merge transcoding job ${job.jobToken}`
157 )
158
159 audioPath = await downloadInputFile({ url: payload.input.audioFileUrl, runnerToken, job })
160 inputPath = await downloadInputFile({ url: payload.input.previewFileUrl, runnerToken, job })
161
162 logger.info(
163 `Downloaded input files ${payload.input.audioFileUrl} and ${payload.input.previewFileUrl} ` +
164 `for job ${job.jobToken}. Running audio merge transcoding.`
165 )
166
167 const ffmpegVod = buildFFmpegVOD({
168 onJobProgress: progress => { ffmpegProgress = progress }
169 })
170
112 await ffmpegVod.transcode({ 171 await ffmpegVod.transcode({
113 type: 'merge-audio', 172 type: 'merge-audio',
114 173
@@ -134,8 +193,9 @@ export async function processAudioMergeTranscoding (options: ProcessOptions<Runn
134 payload: successBody 193 payload: successBody
135 }) 194 })
136 } finally { 195 } finally {
137 await remove(audioPath) 196 if (audioPath) await remove(audioPath)
138 await remove(inputPath) 197 if (inputPath) await remove(inputPath)
139 await remove(outputPath) 198 if (outputPath) await remove(outputPath)
199 if (updateProgressInterval) clearInterval(updateProgressInterval)
140 } 200 }
141} 201}
diff --git a/packages/peertube-runner/server/server.ts b/packages/peertube-runner/server/server.ts
index e76131c74..5fa86fa1a 100644
--- a/packages/peertube-runner/server/server.ts
+++ b/packages/peertube-runner/server/server.ts
@@ -1,7 +1,7 @@
1import { ensureDir, readdir, remove } from 'fs-extra' 1import { ensureDir, readdir, remove } from 'fs-extra'
2import { join } from 'path' 2import { join } from 'path'
3import { io, Socket } from 'socket.io-client' 3import { io, Socket } from 'socket.io-client'
4import { pick, wait } from '@shared/core-utils' 4import { pick, shuffle, wait } from '@shared/core-utils'
5import { PeerTubeProblemDocument, ServerErrorCode } from '@shared/models' 5import { PeerTubeProblemDocument, ServerErrorCode } from '@shared/models'
6import { PeerTubeServer as PeerTubeServerCommand } from '@shared/server-commands' 6import { PeerTubeServer as PeerTubeServerCommand } from '@shared/server-commands'
7import { ConfigManager } from '../shared' 7import { ConfigManager } from '../shared'
@@ -175,7 +175,7 @@ export class RunnerServer {
175 175
176 let hadAvailableJob = false 176 let hadAvailableJob = false
177 177
178 for (const server of this.servers) { 178 for (const server of shuffle([ ...this.servers ])) {
179 try { 179 try {
180 logger.info('Checking available jobs on ' + server.url) 180 logger.info('Checking available jobs on ' + server.url)
181 181
diff --git a/scripts/create-generate-storyboard-job.ts b/scripts/create-generate-storyboard-job.ts
new file mode 100644
index 000000000..47c08edac
--- /dev/null
+++ b/scripts/create-generate-storyboard-job.ts
@@ -0,0 +1,85 @@
1import { program } from 'commander'
2import { toCompleteUUID } from '@server/helpers/custom-validators/misc'
3import { initDatabaseModels } from '@server/initializers/database'
4import { JobQueue } from '@server/lib/job-queue'
5import { VideoModel } from '@server/models/video/video'
6import { StoryboardModel } from '@server/models/video/storyboard'
7
8program
9 .description('Generate videos storyboard')
10 .option('-v, --video [videoUUID]', 'Generate the storyboard of a specific video')
11 .option('-a, --all-videos', 'Generate missing storyboards of local videos')
12 .parse(process.argv)
13
14const options = program.opts()
15
16if (!options['video'] && !options['allVideos']) {
17 console.error('You need to choose videos for storyboard generation.')
18 process.exit(-1)
19}
20
21run()
22 .then(() => process.exit(0))
23 .catch(err => {
24 console.error(err)
25 process.exit(-1)
26 })
27
28async function run () {
29 await initDatabaseModels(true)
30
31 JobQueue.Instance.init()
32
33 let ids: number[] = []
34
35 if (options['video']) {
36 const video = await VideoModel.load(toCompleteUUID(options['video']))
37
38 if (!video) {
39 console.error('Unknown video ' + options['video'])
40 process.exit(-1)
41 }
42
43 if (video.remote === true) {
44 console.error('Cannot process a remote video')
45 process.exit(-1)
46 }
47
48 if (video.isLive) {
49 console.error('Cannot process live video')
50 process.exit(-1)
51 }
52
53 ids.push(video.id)
54 } else {
55 ids = await listLocalMissingStoryboards()
56 }
57
58 for (const id of ids) {
59 const videoFull = await VideoModel.load(id)
60
61 if (videoFull.isLive) continue
62
63 await JobQueue.Instance.createJob({
64 type: 'generate-video-storyboard',
65 payload: {
66 videoUUID: videoFull.uuid,
67 federate: true
68 }
69 })
70
71 console.log(`Created generate-storyboard job for ${videoFull.name}.`)
72 }
73}
74
75async function listLocalMissingStoryboards () {
76 const ids = await VideoModel.listLocalIds()
77 const results: number[] = []
78
79 for (const id of ids) {
80 const storyboard = await StoryboardModel.loadByVideo(id)
81 if (!storyboard) results.push(id)
82 }
83
84 return results
85}
diff --git a/scripts/create-move-video-storage-job.ts b/scripts/create-move-video-storage-job.ts
index c402115f0..8537114eb 100644
--- a/scripts/create-move-video-storage-job.ts
+++ b/scripts/create-move-video-storage-job.ts
@@ -1,4 +1,5 @@
1import { program } from 'commander' 1import { program } from 'commander'
2import { toCompleteUUID } from '@server/helpers/custom-validators/misc'
2import { CONFIG } from '@server/initializers/config' 3import { CONFIG } from '@server/initializers/config'
3import { initDatabaseModels } from '@server/initializers/database' 4import { initDatabaseModels } from '@server/initializers/database'
4import { JobQueue } from '@server/lib/job-queue' 5import { JobQueue } from '@server/lib/job-queue'
@@ -32,7 +33,10 @@ if (options['toObjectStorage'] && !CONFIG.OBJECT_STORAGE.ENABLED) {
32 33
33run() 34run()
34 .then(() => process.exit(0)) 35 .then(() => process.exit(0))
35 .catch(err => console.error(err)) 36 .catch(err => {
37 console.error(err)
38 process.exit(-1)
39 })
36 40
37async function run () { 41async function run () {
38 await initDatabaseModels(true) 42 await initDatabaseModels(true)
@@ -42,7 +46,7 @@ async function run () {
42 let ids: number[] = [] 46 let ids: number[] = []
43 47
44 if (options['video']) { 48 if (options['video']) {
45 const video = await VideoModel.load(options['video']) 49 const video = await VideoModel.load(toCompleteUUID(options['video']))
46 50
47 if (!video) { 51 if (!video) {
48 console.error('Unknown video ' + options['video']) 52 console.error('Unknown video ' + options['video'])
diff --git a/scripts/i18n/create-custom-files.ts b/scripts/i18n/create-custom-files.ts
index 72136614c..696a097b1 100755
--- a/scripts/i18n/create-custom-files.ts
+++ b/scripts/i18n/create-custom-files.ts
@@ -69,7 +69,13 @@ const playerKeys = {
69 '{1} from servers · {2} from peers': '{1} from servers · {2} from peers', 69 '{1} from servers · {2} from peers': '{1} from servers · {2} from peers',
70 'Previous video': 'Previous video', 70 'Previous video': 'Previous video',
71 'Video page (new window)': 'Video page (new window)', 71 'Video page (new window)': 'Video page (new window)',
72 'Next video': 'Next video' 72 'Next video': 'Next video',
73 'This video is password protected': 'This video is password protected',
74 'You need a password to watch this video.': 'You need a password to watch this video.',
75 'Incorrect password, please enter a correct password': 'Incorrect password, please enter a correct password',
76 'Cancel': 'Cancel',
77 'Up Next': 'Up Next',
78 'Autoplay is suspended': 'Autoplay is suspended'
73} 79}
74Object.assign(playerKeys, videojs) 80Object.assign(playerKeys, videojs)
75 81
diff --git a/scripts/migrations/peertube-4.2.ts b/scripts/migrations/peertube-4.2.ts
index 513c629ef..d8929692b 100644
--- a/scripts/migrations/peertube-4.2.ts
+++ b/scripts/migrations/peertube-4.2.ts
@@ -78,7 +78,7 @@ async function fillAvatarSizeIfNeeded (accountOrChannel: MAccountDefault | MChan
78 78
79 console.log('Filling size of avatars of %s.', accountOrChannel.name) 79 console.log('Filling size of avatars of %s.', accountOrChannel.name)
80 80
81 const { width, height } = await getImageSize(join(CONFIG.STORAGE.ACTOR_IMAGES, avatar.filename)) 81 const { width, height } = await getImageSize(join(CONFIG.STORAGE.ACTOR_IMAGES_DIR, avatar.filename))
82 avatar.width = width 82 avatar.width = width
83 avatar.height = height 83 avatar.height = height
84 84
@@ -107,8 +107,8 @@ async function generateSmallerAvatar (actor: MActorDefault) {
107 const sourceFilename = bigAvatar.filename 107 const sourceFilename = bigAvatar.filename
108 108
109 const newImageName = buildUUID() + getLowercaseExtension(sourceFilename) 109 const newImageName = buildUUID() + getLowercaseExtension(sourceFilename)
110 const source = join(CONFIG.STORAGE.ACTOR_IMAGES, sourceFilename) 110 const source = join(CONFIG.STORAGE.ACTOR_IMAGES_DIR, sourceFilename)
111 const destination = join(CONFIG.STORAGE.ACTOR_IMAGES, newImageName) 111 const destination = join(CONFIG.STORAGE.ACTOR_IMAGES_DIR, newImageName)
112 112
113 await processImage({ path: source, destination, newSize: imageSize, keepOriginal: true }) 113 await processImage({ path: source, destination, newSize: imageSize, keepOriginal: true })
114 114
diff --git a/scripts/prune-storage.ts b/scripts/prune-storage.ts
index d19594a60..9a73a8600 100755
--- a/scripts/prune-storage.ts
+++ b/scripts/prune-storage.ts
@@ -37,8 +37,8 @@ async function run () {
37 console.log('Detecting files to remove, it could take a while...') 37 console.log('Detecting files to remove, it could take a while...')
38 38
39 toDelete = toDelete.concat( 39 toDelete = toDelete.concat(
40 await pruneDirectory(DIRECTORIES.VIDEOS.PUBLIC, doesWebTorrentFileExist()), 40 await pruneDirectory(DIRECTORIES.VIDEOS.PUBLIC, doesWebVideoFileExist()),
41 await pruneDirectory(DIRECTORIES.VIDEOS.PRIVATE, doesWebTorrentFileExist()), 41 await pruneDirectory(DIRECTORIES.VIDEOS.PRIVATE, doesWebVideoFileExist()),
42 42
43 await pruneDirectory(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, doesHLSPlaylistExist()), 43 await pruneDirectory(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, doesHLSPlaylistExist()),
44 await pruneDirectory(DIRECTORIES.HLS_STREAMING_PLAYLIST.PUBLIC, doesHLSPlaylistExist()), 44 await pruneDirectory(DIRECTORIES.HLS_STREAMING_PLAYLIST.PUBLIC, doesHLSPlaylistExist()),
@@ -50,7 +50,7 @@ async function run () {
50 await pruneDirectory(CONFIG.STORAGE.PREVIEWS_DIR, doesThumbnailExist(true, ThumbnailType.PREVIEW)), 50 await pruneDirectory(CONFIG.STORAGE.PREVIEWS_DIR, doesThumbnailExist(true, ThumbnailType.PREVIEW)),
51 await pruneDirectory(CONFIG.STORAGE.THUMBNAILS_DIR, doesThumbnailExist(false, ThumbnailType.MINIATURE)), 51 await pruneDirectory(CONFIG.STORAGE.THUMBNAILS_DIR, doesThumbnailExist(false, ThumbnailType.MINIATURE)),
52 52
53 await pruneDirectory(CONFIG.STORAGE.ACTOR_IMAGES, doesActorImageExist) 53 await pruneDirectory(CONFIG.STORAGE.ACTOR_IMAGES_DIR, doesActorImageExist)
54 ) 54 )
55 55
56 const tmpFiles = await readdir(CONFIG.STORAGE.TMP_DIR) 56 const tmpFiles = await readdir(CONFIG.STORAGE.TMP_DIR)
@@ -93,12 +93,12 @@ async function pruneDirectory (directory: string, existFun: ExistFun) {
93 return toDelete 93 return toDelete
94} 94}
95 95
96function doesWebTorrentFileExist () { 96function doesWebVideoFileExist () {
97 return (filePath: string) => { 97 return (filePath: string) => {
98 // Don't delete private directory 98 // Don't delete private directory
99 if (filePath === DIRECTORIES.VIDEOS.PRIVATE) return true 99 if (filePath === DIRECTORIES.VIDEOS.PRIVATE) return true
100 100
101 return VideoFileModel.doesOwnedWebTorrentVideoFileExist(basename(filePath)) 101 return VideoFileModel.doesOwnedWebVideoFileExist(basename(filePath))
102 } 102 }
103} 103}
104 104
diff --git a/scripts/release-embed-api.sh b/scripts/release-embed-api.sh
index ae76a65f5..41c84ed38 100755
--- a/scripts/release-embed-api.sh
+++ b/scripts/release-embed-api.sh
@@ -2,7 +2,7 @@
2 2
3set -eu 3set -eu
4 4
5cd client/src/standalone/player 5cd client/src/standalone/embed-player-api
6 6
7rm -rf dist build && tsc -p . && ../../../node_modules/.bin/webpack --config ./webpack.config.js 7rm -rf dist build && tsc -p . && ../../../node_modules/.bin/webpack --config ./webpack.config.js
8 8
diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh
index 86135fcc9..64c7e1581 100755
--- a/scripts/upgrade.sh
+++ b/scripts/upgrade.sh
@@ -43,10 +43,11 @@ if [ -x "$(command -v pg_dump)" ]; then
43 DB_USER=$(node -e "console.log(require('js-yaml').load(fs.readFileSync('$PEERTUBE_PATH/config/production.yaml', 'utf8'))['database']['username'])") 43 DB_USER=$(node -e "console.log(require('js-yaml').load(fs.readFileSync('$PEERTUBE_PATH/config/production.yaml', 'utf8'))['database']['username'])")
44 DB_PASS=$(node -e "console.log(require('js-yaml').load(fs.readFileSync('$PEERTUBE_PATH/config/production.yaml', 'utf8'))['database']['password'])") 44 DB_PASS=$(node -e "console.log(require('js-yaml').load(fs.readFileSync('$PEERTUBE_PATH/config/production.yaml', 'utf8'))['database']['password'])")
45 DB_HOST=$(node -e "console.log(require('js-yaml').load(fs.readFileSync('$PEERTUBE_PATH/config/production.yaml', 'utf8'))['database']['hostname'])") 45 DB_HOST=$(node -e "console.log(require('js-yaml').load(fs.readFileSync('$PEERTUBE_PATH/config/production.yaml', 'utf8'))['database']['hostname'])")
46 DB_PORT=$(node -e "console.log(require('js-yaml').load(fs.readFileSync('$PEERTUBE_PATH/config/production.yaml', 'utf8'))['database']['port'])")
46 DB_SUFFIX=$(node -e "console.log(require('js-yaml').load(fs.readFileSync('$PEERTUBE_PATH/config/production.yaml', 'utf8'))['database']['suffix'])") 47 DB_SUFFIX=$(node -e "console.log(require('js-yaml').load(fs.readFileSync('$PEERTUBE_PATH/config/production.yaml', 'utf8'))['database']['suffix'])")
47 DB_NAME=$(node -e "console.log(require('js-yaml').load(fs.readFileSync('$PEERTUBE_PATH/config/production.yaml', 'utf8'))['database']['name'] || '')") 48 DB_NAME=$(node -e "console.log(require('js-yaml').load(fs.readFileSync('$PEERTUBE_PATH/config/production.yaml', 'utf8'))['database']['name'] || '')")
48 49
49 PGPASSWORD=$DB_PASS pg_dump -U $DB_USER -h $DB_HOST -F c "${DB_NAME:-peertube${DB_SUFFIX}}" -f "$SQL_BACKUP_PATH" 50 PGPASSWORD=$DB_PASS pg_dump -U $DB_USER -p $DB_PORT -h $DB_HOST -F c "${DB_NAME:-peertube${DB_SUFFIX}}" -f "$SQL_BACKUP_PATH"
50else 51else
51 echo "pg_dump not found. Cannot make a SQL backup!" 52 echo "pg_dump not found. Cannot make a SQL backup!"
52fi 53fi
diff --git a/server.ts b/server.ts
index a7a723b24..e25322b66 100644
--- a/server.ts
+++ b/server.ts
@@ -21,7 +21,7 @@ import { checkMissedConfig, checkFFmpeg, checkNodeVersion } from './server/initi
21 21
22// Do not use barrels because we don't want to load all modules here (we need to initialize database first) 22// Do not use barrels because we don't want to load all modules here (we need to initialize database first)
23import { CONFIG } from './server/initializers/config' 23import { CONFIG } from './server/initializers/config'
24import { API_VERSION, FILES_CACHE, WEBSERVER, loadLanguages } from './server/initializers/constants' 24import { API_VERSION, WEBSERVER, loadLanguages } from './server/initializers/constants'
25import { logger } from './server/helpers/logger' 25import { logger } from './server/helpers/logger'
26 26
27const missed = checkMissedConfig() 27const missed = checkMissedConfig()
@@ -101,7 +101,6 @@ loadLanguages()
101import { installApplication } from './server/initializers/installer' 101import { installApplication } from './server/initializers/installer'
102import { Emailer } from './server/lib/emailer' 102import { Emailer } from './server/lib/emailer'
103import { JobQueue } from './server/lib/job-queue' 103import { JobQueue } from './server/lib/job-queue'
104import { VideosPreviewCache, VideosCaptionCache } from './server/lib/files-cache'
105import { 104import {
106 activityPubRouter, 105 activityPubRouter,
107 apiRouter, 106 apiRouter,
@@ -143,7 +142,6 @@ import { Hooks } from './server/lib/plugins/hooks'
143import { PluginManager } from './server/lib/plugins/plugin-manager' 142import { PluginManager } from './server/lib/plugins/plugin-manager'
144import { LiveManager } from './server/lib/live' 143import { LiveManager } from './server/lib/live'
145import { HttpStatusCode } from './shared/models/http/http-error-codes' 144import { HttpStatusCode } from './shared/models/http/http-error-codes'
146import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache'
147import { ServerConfigManager } from '@server/lib/server-config-manager' 145import { ServerConfigManager } from '@server/lib/server-config-manager'
148import { VideoViewsManager } from '@server/lib/views/video-views-manager' 146import { VideoViewsManager } from '@server/lib/views/video-views-manager'
149import { isTestOrDevInstance } from './server/helpers/core-utils' 147import { isTestOrDevInstance } from './server/helpers/core-utils'
@@ -312,11 +310,6 @@ async function startApplication () {
312 ServerConfigManager.Instance.init() 310 ServerConfigManager.Instance.init()
313 ]) 311 ])
314 312
315 // Caches initializations
316 VideosPreviewCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE, FILES_CACHE.PREVIEWS.MAX_AGE)
317 VideosCaptionCache.Instance.init(CONFIG.CACHE.VIDEO_CAPTIONS.SIZE, FILES_CACHE.VIDEO_CAPTIONS.MAX_AGE)
318 VideosTorrentCache.Instance.init(CONFIG.CACHE.TORRENTS.SIZE, FILES_CACHE.TORRENTS.MAX_AGE)
319
320 // Enable Schedulers 313 // Enable Schedulers
321 ActorFollowScheduler.Instance.enable() 314 ActorFollowScheduler.Instance.enable()
322 RemoveOldJobsScheduler.Instance.enable() 315 RemoveOldJobsScheduler.Instance.enable()
diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts
index 750e3091c..c47c61f52 100644
--- a/server/controllers/activitypub/client.ts
+++ b/server/controllers/activitypub/client.ts
@@ -4,6 +4,7 @@ import { activityPubCollectionPagination } from '@server/lib/activitypub/collect
4import { activityPubContextify } from '@server/lib/activitypub/context' 4import { activityPubContextify } from '@server/lib/activitypub/context'
5import { getServerActor } from '@server/models/application/application' 5import { getServerActor } from '@server/models/application/application'
6import { MAccountId, MActorId, MChannelId, MVideoId } from '@server/types/models' 6import { MAccountId, MActorId, MChannelId, MVideoId } from '@server/types/models'
7import { VideoCommentObject } from '@shared/models'
7import { VideoPrivacy, VideoRateType } from '../../../shared/models/videos' 8import { VideoPrivacy, VideoRateType } from '../../../shared/models/videos'
8import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model' 9import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
9import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants' 10import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
@@ -33,7 +34,6 @@ import { videoPlaylistElementAPGetValidator, videoPlaylistsGetValidator } from '
33import { AccountModel } from '../../models/account/account' 34import { AccountModel } from '../../models/account/account'
34import { AccountVideoRateModel } from '../../models/account/account-video-rate' 35import { AccountVideoRateModel } from '../../models/account/account-video-rate'
35import { ActorFollowModel } from '../../models/actor/actor-follow' 36import { ActorFollowModel } from '../../models/actor/actor-follow'
36import { VideoCaptionModel } from '../../models/video/video-caption'
37import { VideoCommentModel } from '../../models/video/video-comment' 37import { VideoCommentModel } from '../../models/video/video-comment'
38import { VideoPlaylistModel } from '../../models/video/video-playlist' 38import { VideoPlaylistModel } from '../../models/video/video-playlist'
39import { VideoShareModel } from '../../models/video/video-share' 39import { VideoShareModel } from '../../models/video/video-share'
@@ -242,14 +242,13 @@ async function videoController (req: express.Request, res: express.Response) {
242 if (redirectIfNotOwned(video.url, res)) return 242 if (redirectIfNotOwned(video.url, res)) return
243 243
244 // We need captions to render AP object 244 // We need captions to render AP object
245 const captions = await VideoCaptionModel.listVideoCaptions(video.id) 245 const videoAP = await video.lightAPToFullAP(undefined)
246 const videoWithCaptions = Object.assign(video, { VideoCaptions: captions })
247 246
248 const audience = getAudience(videoWithCaptions.VideoChannel.Account.Actor, videoWithCaptions.privacy === VideoPrivacy.PUBLIC) 247 const audience = getAudience(videoAP.VideoChannel.Account.Actor, videoAP.privacy === VideoPrivacy.PUBLIC)
249 const videoObject = audiencify(await videoWithCaptions.toActivityPubObject(), audience) 248 const videoObject = audiencify(await videoAP.toActivityPubObject(), audience)
250 249
251 if (req.path.endsWith('/activity')) { 250 if (req.path.endsWith('/activity')) {
252 const data = buildCreateActivity(videoWithCaptions.url, video.VideoChannel.Account.Actor, videoObject, audience) 251 const data = buildCreateActivity(videoAP.url, video.VideoChannel.Account.Actor, videoObject, audience)
253 return activityPubResponse(activityPubContextify(data, 'Video'), res) 252 return activityPubResponse(activityPubContextify(data, 'Video'), res)
254 } 253 }
255 254
@@ -355,7 +354,7 @@ async function videoCommentController (req: express.Request, res: express.Respon
355 videoCommentObject = audiencify(videoCommentObject, audience) 354 videoCommentObject = audiencify(videoCommentObject, audience)
356 355
357 if (req.path.endsWith('/activity')) { 356 if (req.path.endsWith('/activity')) {
358 const data = buildCreateActivity(videoComment.url, videoComment.Account.Actor, videoCommentObject, audience) 357 const data = buildCreateActivity(videoComment.url, videoComment.Account.Actor, videoCommentObject as VideoCommentObject, audience)
359 return activityPubResponse(activityPubContextify(data, 'Comment'), res) 358 return activityPubResponse(activityPubContextify(data, 'Comment'), res)
360 } 359 }
361 } 360 }
diff --git a/server/controllers/activitypub/outbox.ts b/server/controllers/activitypub/outbox.ts
index 681a5660c..4175cf276 100644
--- a/server/controllers/activitypub/outbox.ts
+++ b/server/controllers/activitypub/outbox.ts
@@ -63,6 +63,7 @@ async function buildActivities (actor: MActorLight, start: number, count: number
63 63
64 activities.push(announceActivity) 64 activities.push(announceActivity)
65 } else { 65 } else {
66 // FIXME: only use the video URL to reduce load. Breaks compat with PeerTube < 6.0.0
66 const videoObject = await video.toActivityPubObject() 67 const videoObject = await video.toActivityPubObject()
67 const createActivity = buildCreateActivity(video.url, byActor, videoObject, createActivityAudience) 68 const createActivity = buildCreateActivity(video.url, byActor, videoObject, createActivityAudience)
68 69
diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts
index 96f36bf6f..49cd7559a 100644
--- a/server/controllers/api/accounts.ts
+++ b/server/controllers/api/accounts.ts
@@ -2,7 +2,6 @@ import express from 'express'
2import { pickCommonVideoQuery } from '@server/helpers/query' 2import { pickCommonVideoQuery } from '@server/helpers/query'
3import { ActorFollowModel } from '@server/models/actor/actor-follow' 3import { ActorFollowModel } from '@server/models/actor/actor-follow'
4import { getServerActor } from '@server/models/application/application' 4import { getServerActor } from '@server/models/application/application'
5import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils'
6import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync' 5import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
7import { buildNSFWFilter, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' 6import { buildNSFWFilter, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
8import { getFormattedObjects } from '../../helpers/utils' 7import { getFormattedObjects } from '../../helpers/utils'
@@ -36,6 +35,7 @@ import {
36import { commonVideoPlaylistFiltersValidator, videoPlaylistsSearchValidator } from '../../middlewares/validators/videos/video-playlists' 35import { commonVideoPlaylistFiltersValidator, videoPlaylistsSearchValidator } from '../../middlewares/validators/videos/video-playlists'
37import { AccountModel } from '../../models/account/account' 36import { AccountModel } from '../../models/account/account'
38import { AccountVideoRateModel } from '../../models/account/account-video-rate' 37import { AccountVideoRateModel } from '../../models/account/account-video-rate'
38import { guessAdditionalAttributesFromQuery } from '../../models/video/formatter'
39import { VideoModel } from '../../models/video/video' 39import { VideoModel } from '../../models/video/video'
40import { VideoChannelModel } from '../../models/video/video-channel' 40import { VideoChannelModel } from '../../models/video/video-channel'
41import { VideoPlaylistModel } from '../../models/video/video-playlist' 41import { VideoPlaylistModel } from '../../models/video/video-playlist'
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index 228eae109..0980ec10a 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -190,6 +190,9 @@ function customConfig (): CustomConfig {
190 }, 190 },
191 torrents: { 191 torrents: {
192 size: CONFIG.CACHE.TORRENTS.SIZE 192 size: CONFIG.CACHE.TORRENTS.SIZE
193 },
194 storyboards: {
195 size: CONFIG.CACHE.STORYBOARDS.SIZE
193 } 196 }
194 }, 197 },
195 signup: { 198 signup: {
@@ -239,8 +242,8 @@ function customConfig (): CustomConfig {
239 '2160p': CONFIG.TRANSCODING.RESOLUTIONS['2160p'] 242 '2160p': CONFIG.TRANSCODING.RESOLUTIONS['2160p']
240 }, 243 },
241 alwaysTranscodeOriginalResolution: CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION, 244 alwaysTranscodeOriginalResolution: CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION,
242 webtorrent: { 245 webVideos: {
243 enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED 246 enabled: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED
244 }, 247 },
245 hls: { 248 hls: {
246 enabled: CONFIG.TRANSCODING.HLS.ENABLED 249 enabled: CONFIG.TRANSCODING.HLS.ENABLED
diff --git a/server/controllers/api/index.ts b/server/controllers/api/index.ts
index 31f1a56f9..38bd135d0 100644
--- a/server/controllers/api/index.ts
+++ b/server/controllers/api/index.ts
@@ -1,8 +1,7 @@
1import cors from 'cors' 1import cors from 'cors'
2import express from 'express' 2import express from 'express'
3 3import { logger } from '@server/helpers/logger'
4import { HttpStatusCode } from '../../../shared/models' 4import { HttpStatusCode } from '../../../shared/models'
5import { badRequest } from '../../helpers/express-utils'
6import { abuseRouter } from './abuse' 5import { abuseRouter } from './abuse'
7import { accountsRouter } from './accounts' 6import { accountsRouter } from './accounts'
8import { blocklistRouter } from './blocklist' 7import { blocklistRouter } from './blocklist'
@@ -64,3 +63,11 @@ export { apiRouter }
64function pong (req: express.Request, res: express.Response) { 63function pong (req: express.Request, res: express.Response) {
65 return res.send('pong').status(HttpStatusCode.OK_200).end() 64 return res.send('pong').status(HttpStatusCode.OK_200).end()
66} 65}
66
67function badRequest (req: express.Request, res: express.Response) {
68 logger.debug(`API express handler not found: bad PeerTube request for ${req.method} - ${req.originalUrl}`)
69
70 return res.type('json')
71 .status(HttpStatusCode.BAD_REQUEST_400)
72 .end()
73}
diff --git a/server/controllers/api/runners/jobs-files.ts b/server/controllers/api/runners/jobs-files.ts
index 4e69fb902..cb4eff570 100644
--- a/server/controllers/api/runners/jobs-files.ts
+++ b/server/controllers/api/runners/jobs-files.ts
@@ -1,6 +1,6 @@
1import express from 'express' 1import express from 'express'
2import { logger, loggerTagsFactory } from '@server/helpers/logger' 2import { logger, loggerTagsFactory } from '@server/helpers/logger'
3import { proxifyHLS, proxifyWebTorrentFile } from '@server/lib/object-storage' 3import { proxifyHLS, proxifyWebVideoFile } from '@server/lib/object-storage'
4import { VideoPathManager } from '@server/lib/video-path-manager' 4import { VideoPathManager } from '@server/lib/video-path-manager'
5import { getStudioTaskFilePath } from '@server/lib/video-studio' 5import { getStudioTaskFilePath } from '@server/lib/video-studio'
6import { apiRateLimiter, asyncMiddleware } from '@server/middlewares' 6import { apiRateLimiter, asyncMiddleware } from '@server/middlewares'
@@ -70,7 +70,7 @@ async function getMaxQualityVideoFile (req: express.Request, res: express.Respon
70 } 70 }
71 71
72 // Web video 72 // Web video
73 return proxifyWebTorrentFile({ 73 return proxifyWebVideoFile({
74 req, 74 req,
75 res, 75 res,
76 filename: file.filename 76 filename: file.filename
diff --git a/server/controllers/api/search/search-videos.ts b/server/controllers/api/search/search-videos.ts
index 1d7a7b7bc..034a63ace 100644
--- a/server/controllers/api/search/search-videos.ts
+++ b/server/controllers/api/search/search-videos.ts
@@ -8,7 +8,6 @@ import { getOrCreateAPVideo } from '@server/lib/activitypub/videos'
8import { Hooks } from '@server/lib/plugins/hooks' 8import { Hooks } from '@server/lib/plugins/hooks'
9import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search' 9import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search'
10import { getServerActor } from '@server/models/application/application' 10import { getServerActor } from '@server/models/application/application'
11import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils'
12import { HttpStatusCode, ResultList, Video } from '@shared/models' 11import { HttpStatusCode, ResultList, Video } from '@shared/models'
13import { VideosSearchQueryAfterSanitize } from '../../../../shared/models/search' 12import { VideosSearchQueryAfterSanitize } from '../../../../shared/models/search'
14import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils' 13import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils'
@@ -25,6 +24,7 @@ import {
25 videosSearchSortValidator, 24 videosSearchSortValidator,
26 videosSearchValidator 25 videosSearchValidator
27} from '../../../middlewares' 26} from '../../../middlewares'
27import { guessAdditionalAttributesFromQuery } from '../../../models/video/formatter'
28import { VideoModel } from '../../../models/video/video' 28import { VideoModel } from '../../../models/video/video'
29import { MVideoAccountLightBlacklistAllFiles } from '../../../types/models' 29import { MVideoAccountLightBlacklistAllFiles } from '../../../types/models'
30import { searchLocalUrl } from './shared' 30import { searchLocalUrl } from './shared'
diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts
index 218091d91..4753308e8 100644
--- a/server/controllers/api/users/me.ts
+++ b/server/controllers/api/users/me.ts
@@ -213,19 +213,14 @@ async function updateMe (req: express.Request, res: express.Response) {
213 'noInstanceConfigWarningModal', 213 'noInstanceConfigWarningModal',
214 'noAccountSetupWarningModal', 214 'noAccountSetupWarningModal',
215 'noWelcomeModal', 215 'noWelcomeModal',
216 'emailPublic' 216 'emailPublic',
217 'p2pEnabled'
217 ] 218 ]
218 219
219 for (const key of keysToUpdate) { 220 for (const key of keysToUpdate) {
220 if (body[key] !== undefined) user.set(key, body[key]) 221 if (body[key] !== undefined) user.set(key, body[key])
221 } 222 }
222 223
223 if (body.p2pEnabled !== undefined) {
224 user.set('p2pEnabled', body.p2pEnabled)
225 } else if (body.webTorrentEnabled !== undefined) { // FIXME: deprecated in 4.1
226 user.set('p2pEnabled', body.webTorrentEnabled)
227 }
228
229 if (body.email !== undefined) { 224 if (body.email !== undefined) {
230 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) { 225 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
231 user.pendingEmail = body.email 226 user.pendingEmail = body.email
diff --git a/server/controllers/api/users/my-subscriptions.ts b/server/controllers/api/users/my-subscriptions.ts
index 6e2aa3711..c4360f59d 100644
--- a/server/controllers/api/users/my-subscriptions.ts
+++ b/server/controllers/api/users/my-subscriptions.ts
@@ -3,7 +3,7 @@ import express from 'express'
3import { handlesToNameAndHost } from '@server/helpers/actors' 3import { handlesToNameAndHost } from '@server/helpers/actors'
4import { pickCommonVideoQuery } from '@server/helpers/query' 4import { pickCommonVideoQuery } from '@server/helpers/query'
5import { sendUndoFollow } from '@server/lib/activitypub/send' 5import { sendUndoFollow } from '@server/lib/activitypub/send'
6import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils' 6import { Hooks } from '@server/lib/plugins/hooks'
7import { VideoChannelModel } from '@server/models/video/video-channel' 7import { VideoChannelModel } from '@server/models/video/video-channel'
8import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' 8import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
9import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils' 9import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils'
@@ -29,8 +29,8 @@ import {
29 videosSortValidator 29 videosSortValidator
30} from '../../../middlewares/validators' 30} from '../../../middlewares/validators'
31import { ActorFollowModel } from '../../../models/actor/actor-follow' 31import { ActorFollowModel } from '../../../models/actor/actor-follow'
32import { guessAdditionalAttributesFromQuery } from '../../../models/video/formatter'
32import { VideoModel } from '../../../models/video/video' 33import { VideoModel } from '../../../models/video/video'
33import { Hooks } from '@server/lib/plugins/hooks'
34 34
35const mySubscriptionsRouter = express.Router() 35const mySubscriptionsRouter = express.Router()
36 36
diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts
index cdafa31dc..3d7ef31ee 100644
--- a/server/controllers/api/video-channel.ts
+++ b/server/controllers/api/video-channel.ts
@@ -4,7 +4,6 @@ import { getBiggestActorImage } from '@server/lib/actor-image'
4import { Hooks } from '@server/lib/plugins/hooks' 4import { Hooks } from '@server/lib/plugins/hooks'
5import { ActorFollowModel } from '@server/models/actor/actor-follow' 5import { ActorFollowModel } from '@server/models/actor/actor-follow'
6import { getServerActor } from '@server/models/application/application' 6import { getServerActor } from '@server/models/application/application'
7import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils'
8import { MChannelBannerAccountDefault } from '@server/types/models' 7import { MChannelBannerAccountDefault } from '@server/types/models'
9import { ActorImageType, HttpStatusCode, VideoChannelCreate, VideoChannelUpdate, VideosImportInChannelCreate } from '@shared/models' 8import { ActorImageType, HttpStatusCode, VideoChannelCreate, VideoChannelUpdate, VideosImportInChannelCreate } from '@shared/models'
10import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../helpers/audit-logger' 9import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../helpers/audit-logger'
@@ -48,6 +47,7 @@ import {
48import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image' 47import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image'
49import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists' 48import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists'
50import { AccountModel } from '../../models/account/account' 49import { AccountModel } from '../../models/account/account'
50import { guessAdditionalAttributesFromQuery } from '../../models/video/formatter'
51import { VideoModel } from '../../models/video/video' 51import { VideoModel } from '../../models/video/video'
52import { VideoChannelModel } from '../../models/video/video-channel' 52import { VideoChannelModel } from '../../models/video/video-channel'
53import { VideoPlaylistModel } from '../../models/video/video-playlist' 53import { VideoPlaylistModel } from '../../models/video/video-playlist'
diff --git a/server/controllers/api/video-playlist.ts b/server/controllers/api/video-playlist.ts
index fe00034ed..73362e1e3 100644
--- a/server/controllers/api/video-playlist.ts
+++ b/server/controllers/api/video-playlist.ts
@@ -1,6 +1,6 @@
1import express from 'express' 1import express from 'express'
2import { join } from 'path'
3import { scheduleRefreshIfNeeded } from '@server/lib/activitypub/playlists' 2import { scheduleRefreshIfNeeded } from '@server/lib/activitypub/playlists'
3import { VideoMiniaturePermanentFileCache } from '@server/lib/files-cache'
4import { Hooks } from '@server/lib/plugins/hooks' 4import { Hooks } from '@server/lib/plugins/hooks'
5import { getServerActor } from '@server/models/application/application' 5import { getServerActor } from '@server/models/application/application'
6import { MVideoPlaylistFull, MVideoPlaylistThumbnail, MVideoThumbnail } from '@server/types/models' 6import { MVideoPlaylistFull, MVideoPlaylistThumbnail, MVideoThumbnail } from '@server/types/models'
@@ -18,12 +18,11 @@ import { resetSequelizeInstance } from '../../helpers/database-utils'
18import { createReqFiles } from '../../helpers/express-utils' 18import { createReqFiles } from '../../helpers/express-utils'
19import { logger } from '../../helpers/logger' 19import { logger } from '../../helpers/logger'
20import { getFormattedObjects } from '../../helpers/utils' 20import { getFormattedObjects } from '../../helpers/utils'
21import { CONFIG } from '../../initializers/config'
22import { MIMETYPES, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constants' 21import { MIMETYPES, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constants'
23import { sequelizeTypescript } from '../../initializers/database' 22import { sequelizeTypescript } from '../../initializers/database'
24import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send' 23import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send'
25import { getLocalVideoPlaylistActivityPubUrl, getLocalVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url' 24import { getLocalVideoPlaylistActivityPubUrl, getLocalVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url'
26import { updatePlaylistMiniatureFromExisting } from '../../lib/thumbnail' 25import { updateLocalPlaylistMiniatureFromExisting } from '../../lib/thumbnail'
27import { 26import {
28 apiRateLimiter, 27 apiRateLimiter,
29 asyncMiddleware, 28 asyncMiddleware,
@@ -178,7 +177,7 @@ async function addVideoPlaylist (req: express.Request, res: express.Response) {
178 177
179 const thumbnailField = req.files['thumbnailfile'] 178 const thumbnailField = req.files['thumbnailfile']
180 const thumbnailModel = thumbnailField 179 const thumbnailModel = thumbnailField
181 ? await updatePlaylistMiniatureFromExisting({ 180 ? await updateLocalPlaylistMiniatureFromExisting({
182 inputPath: thumbnailField[0].path, 181 inputPath: thumbnailField[0].path,
183 playlist: videoPlaylist, 182 playlist: videoPlaylist,
184 automaticallyGenerated: false 183 automaticallyGenerated: false
@@ -220,7 +219,7 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
220 219
221 const thumbnailField = req.files['thumbnailfile'] 220 const thumbnailField = req.files['thumbnailfile']
222 const thumbnailModel = thumbnailField 221 const thumbnailModel = thumbnailField
223 ? await updatePlaylistMiniatureFromExisting({ 222 ? await updateLocalPlaylistMiniatureFromExisting({
224 inputPath: thumbnailField[0].path, 223 inputPath: thumbnailField[0].path,
225 playlist: videoPlaylistInstance, 224 playlist: videoPlaylistInstance,
226 automaticallyGenerated: false 225 automaticallyGenerated: false
@@ -496,8 +495,13 @@ async function generateThumbnailForPlaylist (videoPlaylist: MVideoPlaylistThumbn
496 return 495 return
497 } 496 }
498 497
499 const inputPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoMiniature.filename) 498 // Ensure the file is on disk
500 const thumbnailModel = await updatePlaylistMiniatureFromExisting({ 499 const videoMiniaturePermanentFileCache = new VideoMiniaturePermanentFileCache()
500 const inputPath = videoMiniature.isOwned()
501 ? videoMiniature.getPath()
502 : await videoMiniaturePermanentFileCache.downloadRemoteFile(videoMiniature)
503
504 const thumbnailModel = await updateLocalPlaylistMiniatureFromExisting({
501 inputPath, 505 inputPath,
502 playlist: videoPlaylist, 506 playlist: videoPlaylist,
503 automaticallyGenerated: true, 507 automaticallyGenerated: true,
diff --git a/server/controllers/api/videos/files.ts b/server/controllers/api/videos/files.ts
index 6d9c0b843..67b60ff63 100644
--- a/server/controllers/api/videos/files.ts
+++ b/server/controllers/api/videos/files.ts
@@ -2,7 +2,8 @@ import express from 'express'
2import toInt from 'validator/lib/toInt' 2import toInt from 'validator/lib/toInt'
3import { logger, loggerTagsFactory } from '@server/helpers/logger' 3import { logger, loggerTagsFactory } from '@server/helpers/logger'
4import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' 4import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
5import { removeAllWebTorrentFiles, removeHLSFile, removeHLSPlaylist, removeWebTorrentFile } from '@server/lib/video-file' 5import { updatePlaylistAfterFileChange } from '@server/lib/hls'
6import { removeAllWebVideoFiles, removeHLSFile, removeHLSPlaylist, removeWebVideoFile } from '@server/lib/video-file'
6import { VideoFileModel } from '@server/models/video/video-file' 7import { VideoFileModel } from '@server/models/video/video-file'
7import { HttpStatusCode, UserRight } from '@shared/models' 8import { HttpStatusCode, UserRight } from '@shared/models'
8import { 9import {
@@ -12,11 +13,10 @@ import {
12 videoFileMetadataGetValidator, 13 videoFileMetadataGetValidator,
13 videoFilesDeleteHLSFileValidator, 14 videoFilesDeleteHLSFileValidator,
14 videoFilesDeleteHLSValidator, 15 videoFilesDeleteHLSValidator,
15 videoFilesDeleteWebTorrentFileValidator, 16 videoFilesDeleteWebVideoFileValidator,
16 videoFilesDeleteWebTorrentValidator, 17 videoFilesDeleteWebVideoValidator,
17 videosGetValidator 18 videosGetValidator
18} from '../../../middlewares' 19} from '../../../middlewares'
19import { updatePlaylistAfterFileChange } from '@server/lib/hls'
20 20
21const lTags = loggerTagsFactory('api', 'video') 21const lTags = loggerTagsFactory('api', 'video')
22const filesRouter = express.Router() 22const filesRouter = express.Router()
@@ -40,17 +40,19 @@ filesRouter.delete('/:id/hls/:videoFileId',
40 asyncMiddleware(removeHLSFileController) 40 asyncMiddleware(removeHLSFileController)
41) 41)
42 42
43filesRouter.delete('/:id/webtorrent', 43filesRouter.delete(
44 [ '/:id/webtorrent', '/:id/web-videos' ], // TODO: remove webtorrent in V7
44 authenticate, 45 authenticate,
45 ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES), 46 ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
46 asyncMiddleware(videoFilesDeleteWebTorrentValidator), 47 asyncMiddleware(videoFilesDeleteWebVideoValidator),
47 asyncMiddleware(removeAllWebTorrentFilesController) 48 asyncMiddleware(removeAllWebVideoFilesController)
48) 49)
49filesRouter.delete('/:id/webtorrent/:videoFileId', 50filesRouter.delete(
51 [ '/:id/webtorrent/:videoFileId', '/:id/web-videos/:videoFileId' ], // TODO: remove webtorrent in V7
50 authenticate, 52 authenticate,
51 ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES), 53 ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
52 asyncMiddleware(videoFilesDeleteWebTorrentFileValidator), 54 asyncMiddleware(videoFilesDeleteWebVideoFileValidator),
53 asyncMiddleware(removeWebTorrentFileController) 55 asyncMiddleware(removeWebVideoFileController)
54) 56)
55 57
56// --------------------------------------------------------------------------- 58// ---------------------------------------------------------------------------
@@ -96,24 +98,24 @@ async function removeHLSFileController (req: express.Request, res: express.Respo
96 98
97// --------------------------------------------------------------------------- 99// ---------------------------------------------------------------------------
98 100
99async function removeAllWebTorrentFilesController (req: express.Request, res: express.Response) { 101async function removeAllWebVideoFilesController (req: express.Request, res: express.Response) {
100 const video = res.locals.videoAll 102 const video = res.locals.videoAll
101 103
102 logger.info('Deleting WebTorrent files of %s.', video.url, lTags(video.uuid)) 104 logger.info('Deleting Web Video files of %s.', video.url, lTags(video.uuid))
103 105
104 await removeAllWebTorrentFiles(video) 106 await removeAllWebVideoFiles(video)
105 await federateVideoIfNeeded(video, false, undefined) 107 await federateVideoIfNeeded(video, false, undefined)
106 108
107 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 109 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
108} 110}
109 111
110async function removeWebTorrentFileController (req: express.Request, res: express.Response) { 112async function removeWebVideoFileController (req: express.Request, res: express.Response) {
111 const video = res.locals.videoAll 113 const video = res.locals.videoAll
112 114
113 const videoFileId = +req.params.videoFileId 115 const videoFileId = +req.params.videoFileId
114 logger.info('Deleting WebTorrent file %d of %s.', videoFileId, video.url, lTags(video.uuid)) 116 logger.info('Deleting Web Video file %d of %s.', videoFileId, video.url, lTags(video.uuid))
115 117
116 await removeWebTorrentFile(video, videoFileId) 118 await removeWebVideoFile(video, videoFileId)
117 await federateVideoIfNeeded(video, false, undefined) 119 await federateVideoIfNeeded(video, false, undefined)
118 120
119 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 121 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts
index 6a50aaf4e..defe9efd4 100644
--- a/server/controllers/api/videos/import.ts
+++ b/server/controllers/api/videos/import.ts
@@ -14,7 +14,7 @@ import { getSecureTorrentName } from '../../../helpers/utils'
14import { CONFIG } from '../../../initializers/config' 14import { CONFIG } from '../../../initializers/config'
15import { MIMETYPES } from '../../../initializers/constants' 15import { MIMETYPES } from '../../../initializers/constants'
16import { JobQueue } from '../../../lib/job-queue/job-queue' 16import { JobQueue } from '../../../lib/job-queue/job-queue'
17import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail' 17import { updateLocalVideoMiniatureFromExisting } from '../../../lib/thumbnail'
18import { 18import {
19 asyncMiddleware, 19 asyncMiddleware,
20 asyncRetryTransactionMiddleware, 20 asyncRetryTransactionMiddleware,
@@ -120,6 +120,7 @@ async function handleTorrentImport (req: express.Request, res: express.Response,
120 videoChannel: res.locals.videoChannel, 120 videoChannel: res.locals.videoChannel,
121 tags: body.tags || undefined, 121 tags: body.tags || undefined,
122 user, 122 user,
123 videoPasswords: body.videoPasswords,
123 videoImportAttributes: { 124 videoImportAttributes: {
124 magnetUri, 125 magnetUri,
125 torrentName, 126 torrentName,
@@ -192,7 +193,7 @@ async function processThumbnail (req: express.Request, video: MVideoThumbnail) {
192 if (thumbnailField) { 193 if (thumbnailField) {
193 const thumbnailPhysicalFile = thumbnailField[0] 194 const thumbnailPhysicalFile = thumbnailField[0]
194 195
195 return updateVideoMiniatureFromExisting({ 196 return updateLocalVideoMiniatureFromExisting({
196 inputPath: thumbnailPhysicalFile.path, 197 inputPath: thumbnailPhysicalFile.path,
197 video, 198 video,
198 type: ThumbnailType.MINIATURE, 199 type: ThumbnailType.MINIATURE,
@@ -208,7 +209,7 @@ async function processPreview (req: express.Request, video: MVideoThumbnail): Pr
208 if (previewField) { 209 if (previewField) {
209 const previewPhysicalFile = previewField[0] 210 const previewPhysicalFile = previewField[0]
210 211
211 return updateVideoMiniatureFromExisting({ 212 return updateLocalVideoMiniatureFromExisting({
212 inputPath: previewPhysicalFile.path, 213 inputPath: previewPhysicalFile.path,
213 video, 214 video,
214 type: ThumbnailType.PREVIEW, 215 type: ThumbnailType.PREVIEW,
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index a34325e79..520d8cbbb 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -3,7 +3,6 @@ import { pickCommonVideoQuery } from '@server/helpers/query'
3import { doJSONRequest } from '@server/helpers/requests' 3import { doJSONRequest } from '@server/helpers/requests'
4import { openapiOperationDoc } from '@server/middlewares/doc' 4import { openapiOperationDoc } from '@server/middlewares/doc'
5import { getServerActor } from '@server/models/application/application' 5import { getServerActor } from '@server/models/application/application'
6import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils'
7import { MVideoAccountLight } from '@server/types/models' 6import { MVideoAccountLight } from '@server/types/models'
8import { HttpStatusCode } from '../../../../shared/models' 7import { HttpStatusCode } from '../../../../shared/models'
9import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 8import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
@@ -31,6 +30,7 @@ import {
31 videosRemoveValidator, 30 videosRemoveValidator,
32 videosSortValidator 31 videosSortValidator
33} from '../../../middlewares' 32} from '../../../middlewares'
33import { guessAdditionalAttributesFromQuery } from '../../../models/video/formatter'
34import { VideoModel } from '../../../models/video/video' 34import { VideoModel } from '../../../models/video/video'
35import { blacklistRouter } from './blacklist' 35import { blacklistRouter } from './blacklist'
36import { videoCaptionsRouter } from './captions' 36import { videoCaptionsRouter } from './captions'
@@ -41,12 +41,14 @@ import { liveRouter } from './live'
41import { ownershipVideoRouter } from './ownership' 41import { ownershipVideoRouter } from './ownership'
42import { rateVideoRouter } from './rate' 42import { rateVideoRouter } from './rate'
43import { statsRouter } from './stats' 43import { statsRouter } from './stats'
44import { storyboardRouter } from './storyboard'
44import { studioRouter } from './studio' 45import { studioRouter } from './studio'
45import { tokenRouter } from './token' 46import { tokenRouter } from './token'
46import { transcodingRouter } from './transcoding' 47import { transcodingRouter } from './transcoding'
47import { updateRouter } from './update' 48import { updateRouter } from './update'
48import { uploadRouter } from './upload' 49import { uploadRouter } from './upload'
49import { viewRouter } from './view' 50import { viewRouter } from './view'
51import { videoPasswordRouter } from './passwords'
50 52
51const auditLogger = auditLoggerFactory('videos') 53const auditLogger = auditLoggerFactory('videos')
52const videosRouter = express.Router() 54const videosRouter = express.Router()
@@ -68,6 +70,8 @@ videosRouter.use('/', updateRouter)
68videosRouter.use('/', filesRouter) 70videosRouter.use('/', filesRouter)
69videosRouter.use('/', transcodingRouter) 71videosRouter.use('/', transcodingRouter)
70videosRouter.use('/', tokenRouter) 72videosRouter.use('/', tokenRouter)
73videosRouter.use('/', videoPasswordRouter)
74videosRouter.use('/', storyboardRouter)
71 75
72videosRouter.get('/categories', 76videosRouter.get('/categories',
73 openapiOperationDoc({ operationId: 'getCategories' }), 77 openapiOperationDoc({ operationId: 'getCategories' }),
diff --git a/server/controllers/api/videos/live.ts b/server/controllers/api/videos/live.ts
index de047d4ec..e19e8c652 100644
--- a/server/controllers/api/videos/live.ts
+++ b/server/controllers/api/videos/live.ts
@@ -18,13 +18,14 @@ import { VideoLiveModel } from '@server/models/video/video-live'
18import { VideoLiveSessionModel } from '@server/models/video/video-live-session' 18import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
19import { MVideoDetails, MVideoFullLight, MVideoLive } from '@server/types/models' 19import { MVideoDetails, MVideoFullLight, MVideoLive } from '@server/types/models'
20import { buildUUID, uuidToShort } from '@shared/extra-utils' 20import { buildUUID, uuidToShort } from '@shared/extra-utils'
21import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoState } from '@shared/models' 21import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, UserRight, VideoPrivacy, VideoState } from '@shared/models'
22import { logger } from '../../../helpers/logger' 22import { logger } from '../../../helpers/logger'
23import { sequelizeTypescript } from '../../../initializers/database' 23import { sequelizeTypescript } from '../../../initializers/database'
24import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail' 24import { updateLocalVideoMiniatureFromExisting } from '../../../lib/thumbnail'
25import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares' 25import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, optionalAuthenticate } from '../../../middlewares'
26import { VideoModel } from '../../../models/video/video' 26import { VideoModel } from '../../../models/video/video'
27import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting' 27import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting'
28import { VideoPasswordModel } from '@server/models/video/video-password'
28 29
29const liveRouter = express.Router() 30const liveRouter = express.Router()
30 31
@@ -165,7 +166,7 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
165 video, 166 video,
166 files: req.files, 167 files: req.files,
167 fallback: type => { 168 fallback: type => {
168 return updateVideoMiniatureFromExisting({ 169 return updateLocalVideoMiniatureFromExisting({
169 inputPath: ASSETS_PATH.DEFAULT_LIVE_BACKGROUND, 170 inputPath: ASSETS_PATH.DEFAULT_LIVE_BACKGROUND,
170 video, 171 video,
171 type, 172 type,
@@ -202,6 +203,10 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
202 203
203 await federateVideoIfNeeded(videoCreated, true, t) 204 await federateVideoIfNeeded(videoCreated, true, t)
204 205
206 if (videoInfo.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
207 await VideoPasswordModel.addPasswords(videoInfo.videoPasswords, video.id, t)
208 }
209
205 logger.info('Video live %s with uuid %s created.', videoInfo.name, videoCreated.uuid) 210 logger.info('Video live %s with uuid %s created.', videoInfo.name, videoCreated.uuid)
206 211
207 return { videoCreated } 212 return { videoCreated }
diff --git a/server/controllers/api/videos/passwords.ts b/server/controllers/api/videos/passwords.ts
new file mode 100644
index 000000000..d11cf5bcc
--- /dev/null
+++ b/server/controllers/api/videos/passwords.ts
@@ -0,0 +1,105 @@
1import express from 'express'
2
3import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
4import { getFormattedObjects } from '../../../helpers/utils'
5import {
6 asyncMiddleware,
7 asyncRetryTransactionMiddleware,
8 authenticate,
9 setDefaultPagination,
10 setDefaultSort
11} from '../../../middlewares'
12import {
13 listVideoPasswordValidator,
14 paginationValidator,
15 removeVideoPasswordValidator,
16 updateVideoPasswordListValidator,
17 videoPasswordsSortValidator
18} from '../../../middlewares/validators'
19import { VideoPasswordModel } from '@server/models/video/video-password'
20import { logger, loggerTagsFactory } from '@server/helpers/logger'
21import { Transaction } from 'sequelize'
22import { getVideoWithAttributes } from '@server/helpers/video'
23
24const lTags = loggerTagsFactory('api', 'video')
25const videoPasswordRouter = express.Router()
26
27videoPasswordRouter.get('/:videoId/passwords',
28 authenticate,
29 paginationValidator,
30 videoPasswordsSortValidator,
31 setDefaultSort,
32 setDefaultPagination,
33 asyncMiddleware(listVideoPasswordValidator),
34 asyncMiddleware(listVideoPasswords)
35)
36
37videoPasswordRouter.put('/:videoId/passwords',
38 authenticate,
39 asyncMiddleware(updateVideoPasswordListValidator),
40 asyncMiddleware(updateVideoPasswordList)
41)
42
43videoPasswordRouter.delete('/:videoId/passwords/:passwordId',
44 authenticate,
45 asyncMiddleware(removeVideoPasswordValidator),
46 asyncRetryTransactionMiddleware(removeVideoPassword)
47)
48
49// ---------------------------------------------------------------------------
50
51export {
52 videoPasswordRouter
53}
54
55// ---------------------------------------------------------------------------
56
57async function listVideoPasswords (req: express.Request, res: express.Response) {
58 const options = {
59 videoId: res.locals.videoAll.id,
60 start: req.query.start,
61 count: req.query.count,
62 sort: req.query.sort
63 }
64
65 const resultList = await VideoPasswordModel.listPasswords(options)
66
67 return res.json(getFormattedObjects(resultList.data, resultList.total))
68}
69
70async function updateVideoPasswordList (req: express.Request, res: express.Response) {
71 const videoInstance = getVideoWithAttributes(res)
72 const videoId = videoInstance.id
73
74 const passwordArray = req.body.passwords as string[]
75
76 await VideoPasswordModel.sequelize.transaction(async (t: Transaction) => {
77 await VideoPasswordModel.deleteAllPasswords(videoId, t)
78 await VideoPasswordModel.addPasswords(passwordArray, videoId, t)
79 })
80
81 logger.info(
82 `Video passwords for video with name %s and uuid %s have been updated`,
83 videoInstance.name,
84 videoInstance.uuid,
85 lTags(videoInstance.uuid)
86 )
87
88 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
89}
90
91async function removeVideoPassword (req: express.Request, res: express.Response) {
92 const videoInstance = getVideoWithAttributes(res)
93 const password = res.locals.videoPassword
94
95 await VideoPasswordModel.deletePassword(password.id)
96 logger.info(
97 'Password with id %d of video named %s and uuid %s has been deleted.',
98 password.id,
99 videoInstance.name,
100 videoInstance.uuid,
101 lTags(videoInstance.uuid)
102 )
103
104 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
105}
diff --git a/server/controllers/api/videos/storyboard.ts b/server/controllers/api/videos/storyboard.ts
new file mode 100644
index 000000000..47a22011d
--- /dev/null
+++ b/server/controllers/api/videos/storyboard.ts
@@ -0,0 +1,29 @@
1import express from 'express'
2import { getVideoWithAttributes } from '@server/helpers/video'
3import { StoryboardModel } from '@server/models/video/storyboard'
4import { asyncMiddleware, videosGetValidator } from '../../../middlewares'
5
6const storyboardRouter = express.Router()
7
8storyboardRouter.get('/:id/storyboards',
9 asyncMiddleware(videosGetValidator),
10 asyncMiddleware(listStoryboards)
11)
12
13// ---------------------------------------------------------------------------
14
15export {
16 storyboardRouter
17}
18
19// ---------------------------------------------------------------------------
20
21async function listStoryboards (req: express.Request, res: express.Response) {
22 const video = getVideoWithAttributes(res)
23
24 const storyboards = await StoryboardModel.listStoryboardsOf(video)
25
26 return res.json({
27 storyboards: storyboards.map(s => s.toFormattedJSON())
28 })
29}
diff --git a/server/controllers/api/videos/token.ts b/server/controllers/api/videos/token.ts
index 22387c3e8..e961ffd9e 100644
--- a/server/controllers/api/videos/token.ts
+++ b/server/controllers/api/videos/token.ts
@@ -1,13 +1,14 @@
1import express from 'express' 1import express from 'express'
2import { VideoTokensManager } from '@server/lib/video-tokens-manager' 2import { VideoTokensManager } from '@server/lib/video-tokens-manager'
3import { VideoToken } from '@shared/models' 3import { VideoPrivacy, VideoToken } from '@shared/models'
4import { asyncMiddleware, authenticate, videosCustomGetValidator } from '../../../middlewares' 4import { asyncMiddleware, optionalAuthenticate, videoFileTokenValidator, videosCustomGetValidator } from '../../../middlewares'
5 5
6const tokenRouter = express.Router() 6const tokenRouter = express.Router()
7 7
8tokenRouter.post('/:id/token', 8tokenRouter.post('/:id/token',
9 authenticate, 9 optionalAuthenticate,
10 asyncMiddleware(videosCustomGetValidator('only-video')), 10 asyncMiddleware(videosCustomGetValidator('only-video')),
11 videoFileTokenValidator,
11 generateToken 12 generateToken
12) 13)
13 14
@@ -22,12 +23,11 @@ export {
22function generateToken (req: express.Request, res: express.Response) { 23function generateToken (req: express.Request, res: express.Response) {
23 const video = res.locals.onlyVideo 24 const video = res.locals.onlyVideo
24 25
25 const { token, expires } = VideoTokensManager.Instance.create({ videoUUID: video.uuid, user: res.locals.oauth.token.User }) 26 const files = video.privacy === VideoPrivacy.PASSWORD_PROTECTED
27 ? VideoTokensManager.Instance.createForPasswordProtectedVideo({ videoUUID: video.uuid })
28 : VideoTokensManager.Instance.createForAuthUser({ videoUUID: video.uuid, user: res.locals.oauth.token.User })
26 29
27 return res.json({ 30 return res.json({
28 files: { 31 files
29 token,
30 expires
31 }
32 } as VideoToken) 32 } as VideoToken)
33} 33}
diff --git a/server/controllers/api/videos/update.ts b/server/controllers/api/videos/update.ts
index ddab428d4..28ec2cf37 100644
--- a/server/controllers/api/videos/update.ts
+++ b/server/controllers/api/videos/update.ts
@@ -2,13 +2,12 @@ import express from 'express'
2import { Transaction } from 'sequelize/types' 2import { Transaction } from 'sequelize/types'
3import { changeVideoChannelShare } from '@server/lib/activitypub/share' 3import { changeVideoChannelShare } from '@server/lib/activitypub/share'
4import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' 4import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
5import { VideoPathManager } from '@server/lib/video-path-manager'
6import { setVideoPrivacy } from '@server/lib/video-privacy' 5import { setVideoPrivacy } from '@server/lib/video-privacy'
7import { openapiOperationDoc } from '@server/middlewares/doc' 6import { openapiOperationDoc } from '@server/middlewares/doc'
8import { FilteredModelAttributes } from '@server/types' 7import { FilteredModelAttributes } from '@server/types'
9import { MVideoFullLight } from '@server/types/models' 8import { MVideoFullLight } from '@server/types/models'
10import { forceNumber } from '@shared/core-utils' 9import { forceNumber } from '@shared/core-utils'
11import { HttpStatusCode, VideoUpdate } from '@shared/models' 10import { HttpStatusCode, VideoPrivacy, VideoUpdate } from '@shared/models'
12import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 11import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
13import { resetSequelizeInstance } from '../../../helpers/database-utils' 12import { resetSequelizeInstance } from '../../../helpers/database-utils'
14import { createReqFiles } from '../../../helpers/express-utils' 13import { createReqFiles } from '../../../helpers/express-utils'
@@ -20,6 +19,9 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
20import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares' 19import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares'
21import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' 20import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
22import { VideoModel } from '../../../models/video/video' 21import { VideoModel } from '../../../models/video/video'
22import { VideoPathManager } from '@server/lib/video-path-manager'
23import { VideoPasswordModel } from '@server/models/video/video-password'
24import { exists } from '@server/helpers/custom-validators/misc'
23 25
24const lTags = loggerTagsFactory('api', 'video') 26const lTags = loggerTagsFactory('api', 'video')
25const auditLogger = auditLoggerFactory('videos') 27const auditLogger = auditLoggerFactory('videos')
@@ -176,6 +178,16 @@ async function updateVideoPrivacy (options: {
176 const newPrivacy = forceNumber(videoInfoToUpdate.privacy) 178 const newPrivacy = forceNumber(videoInfoToUpdate.privacy)
177 setVideoPrivacy(videoInstance, newPrivacy) 179 setVideoPrivacy(videoInstance, newPrivacy)
178 180
181 // Delete passwords if video is not anymore password protected
182 if (videoInstance.privacy === VideoPrivacy.PASSWORD_PROTECTED && newPrivacy !== VideoPrivacy.PASSWORD_PROTECTED) {
183 await VideoPasswordModel.deleteAllPasswords(videoInstance.id, transaction)
184 }
185
186 if (newPrivacy === VideoPrivacy.PASSWORD_PROTECTED && exists(videoInfoToUpdate.videoPasswords)) {
187 await VideoPasswordModel.deleteAllPasswords(videoInstance.id, transaction)
188 await VideoPasswordModel.addPasswords(videoInfoToUpdate.videoPasswords, videoInstance.id, transaction)
189 }
190
179 // Unfederate the video if the new privacy is not compatible with federation 191 // Unfederate the video if the new privacy is not compatible with federation
180 if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) { 192 if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) {
181 await VideoModel.sendDelete(videoInstance, { transaction }) 193 await VideoModel.sendDelete(videoInstance, { transaction })
diff --git a/server/controllers/api/videos/upload.ts b/server/controllers/api/videos/upload.ts
index 885ac8b81..27fef0b1a 100644
--- a/server/controllers/api/videos/upload.ts
+++ b/server/controllers/api/videos/upload.ts
@@ -14,14 +14,14 @@ import { openapiOperationDoc } from '@server/middlewares/doc'
14import { VideoSourceModel } from '@server/models/video/video-source' 14import { VideoSourceModel } from '@server/models/video/video-source'
15import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models' 15import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models'
16import { uuidToShort } from '@shared/extra-utils' 16import { uuidToShort } from '@shared/extra-utils'
17import { HttpStatusCode, VideoCreate, VideoState } from '@shared/models' 17import { HttpStatusCode, VideoCreate, VideoPrivacy, VideoState } from '@shared/models'
18import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 18import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
19import { createReqFiles } from '../../../helpers/express-utils' 19import { createReqFiles } from '../../../helpers/express-utils'
20import { logger, loggerTagsFactory } from '../../../helpers/logger' 20import { logger, loggerTagsFactory } from '../../../helpers/logger'
21import { MIMETYPES } from '../../../initializers/constants' 21import { MIMETYPES } from '../../../initializers/constants'
22import { sequelizeTypescript } from '../../../initializers/database' 22import { sequelizeTypescript } from '../../../initializers/database'
23import { Hooks } from '../../../lib/plugins/hooks' 23import { Hooks } from '../../../lib/plugins/hooks'
24import { generateVideoMiniature } from '../../../lib/thumbnail' 24import { generateLocalVideoMiniature } from '../../../lib/thumbnail'
25import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' 25import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
26import { 26import {
27 asyncMiddleware, 27 asyncMiddleware,
@@ -33,6 +33,7 @@ import {
33} from '../../../middlewares' 33} from '../../../middlewares'
34import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' 34import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
35import { VideoModel } from '../../../models/video/video' 35import { VideoModel } from '../../../models/video/video'
36import { VideoPasswordModel } from '@server/models/video/video-password'
36 37
37const lTags = loggerTagsFactory('api', 'video') 38const lTags = loggerTagsFactory('api', 'video')
38const auditLogger = auditLoggerFactory('videos') 39const auditLogger = auditLoggerFactory('videos')
@@ -62,13 +63,13 @@ uploadRouter.post('/upload-resumable',
62 authenticate, 63 authenticate,
63 reqVideoFileAddResumable, 64 reqVideoFileAddResumable,
64 asyncMiddleware(videosAddResumableInitValidator), 65 asyncMiddleware(videosAddResumableInitValidator),
65 uploadx.upload 66 (req, res) => uploadx.upload(req, res) // Prevent next() call, explicitely tell to uploadx it's the end
66) 67)
67 68
68uploadRouter.delete('/upload-resumable', 69uploadRouter.delete('/upload-resumable',
69 authenticate, 70 authenticate,
70 asyncMiddleware(deleteUploadResumableCache), 71 asyncMiddleware(deleteUploadResumableCache),
71 uploadx.upload 72 (req, res) => uploadx.upload(req, res) // Prevent next() call, explicitely tell to uploadx it's the end
72) 73)
73 74
74uploadRouter.put('/upload-resumable', 75uploadRouter.put('/upload-resumable',
@@ -110,7 +111,7 @@ async function addVideoLegacy (req: express.Request, res: express.Response) {
110async function addVideoResumable (req: express.Request, res: express.Response) { 111async function addVideoResumable (req: express.Request, res: express.Response) {
111 const videoPhysicalFile = res.locals.videoFileResumable 112 const videoPhysicalFile = res.locals.videoFileResumable
112 const videoInfo = videoPhysicalFile.metadata 113 const videoInfo = videoPhysicalFile.metadata
113 const files = { previewfile: videoInfo.previewfile } 114 const files = { previewfile: videoInfo.previewfile, thumbnailfile: videoInfo.thumbnailfile }
114 115
115 const response = await addVideo({ req, res, videoPhysicalFile, videoInfo, files }) 116 const response = await addVideo({ req, res, videoPhysicalFile, videoInfo, files })
116 await Redis.Instance.setUploadSession(req.query.upload_id, response) 117 await Redis.Instance.setUploadSession(req.query.upload_id, response)
@@ -152,7 +153,7 @@ async function addVideo (options: {
152 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({ 153 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
153 video, 154 video,
154 files, 155 files,
155 fallback: type => generateVideoMiniature({ video, videoFile, type }) 156 fallback: type => generateLocalVideoMiniature({ video, videoFile, type })
156 }) 157 })
157 158
158 const { videoCreated } = await sequelizeTypescript.transaction(async t => { 159 const { videoCreated } = await sequelizeTypescript.transaction(async t => {
@@ -195,6 +196,10 @@ async function addVideo (options: {
195 transaction: t 196 transaction: t
196 }) 197 })
197 198
199 if (videoInfo.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
200 await VideoPasswordModel.addPasswords(videoInfo.videoPasswords, video.id, t)
201 }
202
198 auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON())) 203 auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
199 logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid, lTags(videoCreated.uuid)) 204 logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid, lTags(videoCreated.uuid))
200 205
@@ -230,6 +235,15 @@ async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVide
230 }, 235 },
231 236
232 { 237 {
238 type: 'generate-video-storyboard' as 'generate-video-storyboard',
239 payload: {
240 videoUUID: video.uuid,
241 // No need to federate, we process these jobs sequentially
242 federate: false
243 }
244 },
245
246 {
233 type: 'notify', 247 type: 'notify',
234 payload: { 248 payload: {
235 action: 'new-video', 249 action: 'new-video',
diff --git a/server/controllers/download.ts b/server/controllers/download.ts
index d675a2d6c..4b94e34bd 100644
--- a/server/controllers/download.ts
+++ b/server/controllers/download.ts
@@ -1,11 +1,12 @@
1import cors from 'cors' 1import cors from 'cors'
2import express from 'express' 2import express from 'express'
3import { logger } from '@server/helpers/logger' 3import { logger } from '@server/helpers/logger'
4import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache' 4import { VideoTorrentsSimpleFileCache } from '@server/lib/files-cache'
5import { generateHLSFilePresignedUrl, generateWebVideoPresignedUrl } from '@server/lib/object-storage'
5import { Hooks } from '@server/lib/plugins/hooks' 6import { Hooks } from '@server/lib/plugins/hooks'
6import { VideoPathManager } from '@server/lib/video-path-manager' 7import { VideoPathManager } from '@server/lib/video-path-manager'
7import { MStreamingPlaylist, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' 8import { MStreamingPlaylist, MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
8import { addQueryParams, forceNumber } from '@shared/core-utils' 9import { forceNumber } from '@shared/core-utils'
9import { HttpStatusCode, VideoStorage, VideoStreamingPlaylistType } from '@shared/models' 10import { HttpStatusCode, VideoStorage, VideoStreamingPlaylistType } from '@shared/models'
10import { STATIC_DOWNLOAD_PATHS } from '../initializers/constants' 11import { STATIC_DOWNLOAD_PATHS } from '../initializers/constants'
11import { asyncMiddleware, optionalAuthenticate, videosDownloadValidator } from '../middlewares' 12import { asyncMiddleware, optionalAuthenticate, videosDownloadValidator } from '../middlewares'
@@ -42,7 +43,7 @@ export {
42// --------------------------------------------------------------------------- 43// ---------------------------------------------------------------------------
43 44
44async function downloadTorrent (req: express.Request, res: express.Response) { 45async function downloadTorrent (req: express.Request, res: express.Response) {
45 const result = await VideosTorrentCache.Instance.getFilePath(req.params.filename) 46 const result = await VideoTorrentsSimpleFileCache.Instance.getFilePath(req.params.filename)
46 if (!result) { 47 if (!result) {
47 return res.fail({ 48 return res.fail({
48 status: HttpStatusCode.NOT_FOUND_404, 49 status: HttpStatusCode.NOT_FOUND_404,
@@ -94,16 +95,16 @@ async function downloadVideoFile (req: express.Request, res: express.Response) {
94 95
95 if (!checkAllowResult(res, allowParameters, allowedResult)) return 96 if (!checkAllowResult(res, allowParameters, allowedResult)) return
96 97
98 // Express uses basename on filename parameter
99 const videoName = video.name.replace(/[/\\]/g, '_')
100 const downloadFilename = `${videoName}-${videoFile.resolution}p${videoFile.extname}`
101
97 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) { 102 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
98 return redirectToObjectStorage({ req, res, video, file: videoFile }) 103 return redirectToObjectStorage({ req, res, video, file: videoFile, downloadFilename })
99 } 104 }
100 105
101 await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), path => { 106 await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), path => {
102 // Express uses basename on filename parameter 107 return res.download(path, downloadFilename)
103 const videoName = video.name.replace(/[/\\]/g, '_')
104 const filename = `${videoName}-${videoFile.resolution}p${videoFile.extname}`
105
106 return res.download(path, filename)
107 }) 108 })
108} 109}
109 110
@@ -136,14 +137,14 @@ async function downloadHLSVideoFile (req: express.Request, res: express.Response
136 137
137 if (!checkAllowResult(res, allowParameters, allowedResult)) return 138 if (!checkAllowResult(res, allowParameters, allowedResult)) return
138 139
140 const downloadFilename = `${video.name}-${videoFile.resolution}p-${streamingPlaylist.getStringType()}${videoFile.extname}`
141
139 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) { 142 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
140 return redirectToObjectStorage({ req, res, video, file: videoFile }) 143 return redirectToObjectStorage({ req, res, video, streamingPlaylist, file: videoFile, downloadFilename })
141 } 144 }
142 145
143 await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(streamingPlaylist), path => { 146 await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(streamingPlaylist), path => {
144 const filename = `${video.name}-${videoFile.resolution}p-${streamingPlaylist.getStringType()}${videoFile.extname}` 147 return res.download(path, downloadFilename)
145
146 return res.download(path, filename)
147 }) 148 })
148} 149}
149 150
@@ -192,19 +193,21 @@ function checkAllowResult (res: express.Response, allowParameters: any, result?:
192 return true 193 return true
193} 194}
194 195
195function redirectToObjectStorage (options: { 196async function redirectToObjectStorage (options: {
196 req: express.Request 197 req: express.Request
197 res: express.Response 198 res: express.Response
198 video: MVideo 199 video: MVideo
199 file: MVideoFile 200 file: MVideoFile
201 streamingPlaylist?: MStreamingPlaylistVideo
202 downloadFilename: string
200}) { 203}) {
201 const { req, res, video, file } = options 204 const { res, video, streamingPlaylist, file, downloadFilename } = options
202 205
203 const baseUrl = file.getObjectStorageUrl(video) 206 const url = streamingPlaylist
207 ? await generateHLSFilePresignedUrl({ streamingPlaylist, file, downloadFilename })
208 : await generateWebVideoPresignedUrl({ file, downloadFilename })
204 209
205 const url = video.hasPrivateStaticPath() && req.query.videoFileToken 210 logger.debug('Generating pre-signed URL %s for video %s', url, video.uuid)
206 ? addQueryParams(baseUrl, { videoFileToken: req.query.videoFileToken })
207 : baseUrl
208 211
209 return res.redirect(url) 212 return res.redirect(url)
210} 213}
diff --git a/server/controllers/feeds/shared/video-feed-utils.ts b/server/controllers/feeds/shared/video-feed-utils.ts
index 3175cea59..b154e04fa 100644
--- a/server/controllers/feeds/shared/video-feed-utils.ts
+++ b/server/controllers/feeds/shared/video-feed-utils.ts
@@ -2,7 +2,7 @@ import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown'
2import { CONFIG } from '@server/initializers/config' 2import { CONFIG } from '@server/initializers/config'
3import { WEBSERVER } from '@server/initializers/constants' 3import { WEBSERVER } from '@server/initializers/constants'
4import { getServerActor } from '@server/models/application/application' 4import { getServerActor } from '@server/models/application/application'
5import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils' 5import { getCategoryLabel } from '@server/models/video/formatter'
6import { DisplayOnlyForFollowerOptions } from '@server/models/video/sql/video' 6import { DisplayOnlyForFollowerOptions } from '@server/models/video/sql/video'
7import { VideoModel } from '@server/models/video/video' 7import { VideoModel } from '@server/models/video/video'
8import { MThumbnail, MUserDefault } from '@server/types/models' 8import { MThumbnail, MUserDefault } from '@server/types/models'
diff --git a/server/controllers/lazy-static.ts b/server/controllers/lazy-static.ts
index b082e41f6..dad30365c 100644
--- a/server/controllers/lazy-static.ts
+++ b/server/controllers/lazy-static.ts
@@ -1,14 +1,28 @@
1import cors from 'cors' 1import cors from 'cors'
2import express from 'express' 2import express from 'express'
3import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache' 3import { CONFIG } from '@server/initializers/config'
4import { MActorImage } from '@server/types/models'
5import { HttpStatusCode } from '../../shared/models/http/http-error-codes' 4import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
6import { logger } from '../helpers/logger' 5import { FILES_CACHE, LAZY_STATIC_PATHS, STATIC_MAX_AGE } from '../initializers/constants'
7import { ACTOR_IMAGES_SIZE, LAZY_STATIC_PATHS, STATIC_MAX_AGE } from '../initializers/constants' 6import {
8import { VideosCaptionCache, VideosPreviewCache } from '../lib/files-cache' 7 AvatarPermanentFileCache,
9import { actorImagePathUnsafeCache, downloadActorImageFromWorker } from '../lib/local-actor' 8 VideoCaptionsSimpleFileCache,
9 VideoMiniaturePermanentFileCache,
10 VideoPreviewsSimpleFileCache,
11 VideoStoryboardsSimpleFileCache,
12 VideoTorrentsSimpleFileCache
13} from '../lib/files-cache'
10import { asyncMiddleware, handleStaticError } from '../middlewares' 14import { asyncMiddleware, handleStaticError } from '../middlewares'
11import { ActorImageModel } from '../models/actor/actor-image' 15
16// ---------------------------------------------------------------------------
17// Cache initializations
18// ---------------------------------------------------------------------------
19
20VideoPreviewsSimpleFileCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE, FILES_CACHE.PREVIEWS.MAX_AGE)
21VideoCaptionsSimpleFileCache.Instance.init(CONFIG.CACHE.VIDEO_CAPTIONS.SIZE, FILES_CACHE.VIDEO_CAPTIONS.MAX_AGE)
22VideoTorrentsSimpleFileCache.Instance.init(CONFIG.CACHE.TORRENTS.SIZE, FILES_CACHE.TORRENTS.MAX_AGE)
23VideoStoryboardsSimpleFileCache.Instance.init(CONFIG.CACHE.STORYBOARDS.SIZE, FILES_CACHE.STORYBOARDS.MAX_AGE)
24
25// ---------------------------------------------------------------------------
12 26
13const lazyStaticRouter = express.Router() 27const lazyStaticRouter = express.Router()
14 28
@@ -27,12 +41,24 @@ lazyStaticRouter.use(
27) 41)
28 42
29lazyStaticRouter.use( 43lazyStaticRouter.use(
44 LAZY_STATIC_PATHS.THUMBNAILS + ':filename',
45 asyncMiddleware(getThumbnail),
46 handleStaticError
47)
48
49lazyStaticRouter.use(
30 LAZY_STATIC_PATHS.PREVIEWS + ':filename', 50 LAZY_STATIC_PATHS.PREVIEWS + ':filename',
31 asyncMiddleware(getPreview), 51 asyncMiddleware(getPreview),
32 handleStaticError 52 handleStaticError
33) 53)
34 54
35lazyStaticRouter.use( 55lazyStaticRouter.use(
56 LAZY_STATIC_PATHS.STORYBOARDS + ':filename',
57 asyncMiddleware(getStoryboard),
58 handleStaticError
59)
60
61lazyStaticRouter.use(
36 LAZY_STATIC_PATHS.VIDEO_CAPTIONS + ':filename', 62 LAZY_STATIC_PATHS.VIDEO_CAPTIONS + ':filename',
37 asyncMiddleware(getVideoCaption), 63 asyncMiddleware(getVideoCaption),
38 handleStaticError 64 handleStaticError
@@ -53,88 +79,48 @@ export {
53} 79}
54 80
55// --------------------------------------------------------------------------- 81// ---------------------------------------------------------------------------
82const avatarPermanentFileCache = new AvatarPermanentFileCache()
56 83
57async function getActorImage (req: express.Request, res: express.Response, next: express.NextFunction) { 84function getActorImage (req: express.Request, res: express.Response, next: express.NextFunction) {
58 const filename = req.params.filename 85 const filename = req.params.filename
59 86
60 if (actorImagePathUnsafeCache.has(filename)) { 87 return avatarPermanentFileCache.lazyServe({ filename, res, next })
61 return res.sendFile(actorImagePathUnsafeCache.get(filename), { maxAge: STATIC_MAX_AGE.SERVER }) 88}
62 }
63
64 const image = await ActorImageModel.loadByName(filename)
65 if (!image) return res.status(HttpStatusCode.NOT_FOUND_404).end()
66
67 if (image.onDisk === false) {
68 if (!image.fileUrl) return res.status(HttpStatusCode.NOT_FOUND_404).end()
69
70 logger.info('Lazy serve remote actor image %s.', image.fileUrl)
71
72 try {
73 await downloadActorImageFromWorker({
74 filename: image.filename,
75 fileUrl: image.fileUrl,
76 size: getActorImageSize(image),
77 type: image.type
78 })
79 } catch (err) {
80 logger.warn('Cannot process remote actor image %s.', image.fileUrl, { err })
81 return res.status(HttpStatusCode.NOT_FOUND_404).end()
82 }
83
84 image.onDisk = true
85 image.save()
86 .catch(err => logger.error('Cannot save new actor image disk state.', { err }))
87 }
88
89 const path = image.getPath()
90
91 actorImagePathUnsafeCache.set(filename, path)
92
93 return res.sendFile(path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER }, (err: any) => {
94 if (!err) return
95
96 // It seems this actor image is not on the disk anymore
97 if (err.status === HttpStatusCode.NOT_FOUND_404 && !image.isOwned()) {
98 logger.error('Cannot lazy serve actor image %s.', filename, { err })
99 89
100 actorImagePathUnsafeCache.delete(filename) 90// ---------------------------------------------------------------------------
91const videoMiniaturePermanentFileCache = new VideoMiniaturePermanentFileCache()
101 92
102 image.onDisk = false 93function getThumbnail (req: express.Request, res: express.Response, next: express.NextFunction) {
103 image.save() 94 const filename = req.params.filename
104 .catch(err => logger.error('Cannot save new actor image disk state.', { err }))
105 }
106 95
107 return next(err) 96 return videoMiniaturePermanentFileCache.lazyServe({ filename, res, next })
108 })
109} 97}
110 98
111function getActorImageSize (image: MActorImage): { width: number, height: number } { 99// ---------------------------------------------------------------------------
112 if (image.width && image.height) { 100
113 return { 101async function getPreview (req: express.Request, res: express.Response) {
114 height: image.height, 102 const result = await VideoPreviewsSimpleFileCache.Instance.getFilePath(req.params.filename)
115 width: image.width 103 if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()
116 }
117 }
118 104
119 return ACTOR_IMAGES_SIZE[image.type][0] 105 return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER })
120} 106}
121 107
122async function getPreview (req: express.Request, res: express.Response) { 108async function getStoryboard (req: express.Request, res: express.Response) {
123 const result = await VideosPreviewCache.Instance.getFilePath(req.params.filename) 109 const result = await VideoStoryboardsSimpleFileCache.Instance.getFilePath(req.params.filename)
124 if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end() 110 if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()
125 111
126 return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER }) 112 return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER })
127} 113}
128 114
129async function getVideoCaption (req: express.Request, res: express.Response) { 115async function getVideoCaption (req: express.Request, res: express.Response) {
130 const result = await VideosCaptionCache.Instance.getFilePath(req.params.filename) 116 const result = await VideoCaptionsSimpleFileCache.Instance.getFilePath(req.params.filename)
131 if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end() 117 if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()
132 118
133 return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER }) 119 return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER })
134} 120}
135 121
136async function getTorrent (req: express.Request, res: express.Response) { 122async function getTorrent (req: express.Request, res: express.Response) {
137 const result = await VideosTorrentCache.Instance.getFilePath(req.params.filename) 123 const result = await VideoTorrentsSimpleFileCache.Instance.getFilePath(req.params.filename)
138 if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end() 124 if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()
139 125
140 // Torrents still use the old naming convention (video uuid + .torrent) 126 // Torrents still use the old naming convention (video uuid + .torrent)
diff --git a/server/controllers/misc.ts b/server/controllers/misc.ts
index 4c8af2adc..163352ac5 100644
--- a/server/controllers/misc.ts
+++ b/server/controllers/misc.ts
@@ -120,8 +120,8 @@ async function generateNodeinfo (req: express.Request, res: express.Response) {
120 hls: { 120 hls: {
121 enabled: CONFIG.TRANSCODING.HLS.ENABLED 121 enabled: CONFIG.TRANSCODING.HLS.ENABLED
122 }, 122 },
123 webtorrent: { 123 web_videos: {
124 enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED 124 enabled: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED
125 }, 125 },
126 enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('vod') 126 enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('vod')
127 }, 127 },
diff --git a/server/controllers/object-storage-proxy.ts b/server/controllers/object-storage-proxy.ts
index 8e2cc4af9..d0c59bf93 100644
--- a/server/controllers/object-storage-proxy.ts
+++ b/server/controllers/object-storage-proxy.ts
@@ -1,11 +1,11 @@
1import cors from 'cors' 1import cors from 'cors'
2import express from 'express' 2import express from 'express'
3import { OBJECT_STORAGE_PROXY_PATHS } from '@server/initializers/constants' 3import { OBJECT_STORAGE_PROXY_PATHS } from '@server/initializers/constants'
4import { proxifyHLS, proxifyWebTorrentFile } from '@server/lib/object-storage' 4import { proxifyHLS, proxifyWebVideoFile } from '@server/lib/object-storage'
5import { 5import {
6 asyncMiddleware, 6 asyncMiddleware,
7 ensureCanAccessPrivateVideoHLSFiles, 7 ensureCanAccessPrivateVideoHLSFiles,
8 ensureCanAccessVideoPrivateWebTorrentFiles, 8 ensureCanAccessVideoPrivateWebVideoFiles,
9 ensurePrivateObjectStorageProxyIsEnabled, 9 ensurePrivateObjectStorageProxyIsEnabled,
10 optionalAuthenticate 10 optionalAuthenticate
11} from '@server/middlewares' 11} from '@server/middlewares'
@@ -15,11 +15,12 @@ const objectStorageProxyRouter = express.Router()
15 15
16objectStorageProxyRouter.use(cors()) 16objectStorageProxyRouter.use(cors())
17 17
18objectStorageProxyRouter.get(OBJECT_STORAGE_PROXY_PATHS.PRIVATE_WEBSEED + ':filename', 18objectStorageProxyRouter.get(
19 [ OBJECT_STORAGE_PROXY_PATHS.PRIVATE_WEB_VIDEOS + ':filename', OBJECT_STORAGE_PROXY_PATHS.LEGACY_PRIVATE_WEB_VIDEOS + ':filename' ],
19 ensurePrivateObjectStorageProxyIsEnabled, 20 ensurePrivateObjectStorageProxyIsEnabled,
20 optionalAuthenticate, 21 optionalAuthenticate,
21 asyncMiddleware(ensureCanAccessVideoPrivateWebTorrentFiles), 22 asyncMiddleware(ensureCanAccessVideoPrivateWebVideoFiles),
22 asyncMiddleware(proxifyWebTorrentController) 23 asyncMiddleware(proxifyWebVideoController)
23) 24)
24 25
25objectStorageProxyRouter.get(OBJECT_STORAGE_PROXY_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + ':videoUUID/:filename', 26objectStorageProxyRouter.get(OBJECT_STORAGE_PROXY_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + ':videoUUID/:filename',
@@ -35,10 +36,10 @@ export {
35 objectStorageProxyRouter 36 objectStorageProxyRouter
36} 37}
37 38
38function proxifyWebTorrentController (req: express.Request, res: express.Response) { 39function proxifyWebVideoController (req: express.Request, res: express.Response) {
39 const filename = req.params.filename 40 const filename = req.params.filename
40 41
41 return proxifyWebTorrentFile({ req, res, filename }) 42 return proxifyWebVideoFile({ req, res, filename })
42} 43}
43 44
44function proxifyHLSController (req: express.Request, res: express.Response) { 45function proxifyHLSController (req: express.Request, res: express.Response) {
diff --git a/server/controllers/static.ts b/server/controllers/static.ts
index 9baff94c0..97caa8292 100644
--- a/server/controllers/static.ts
+++ b/server/controllers/static.ts
@@ -6,7 +6,7 @@ import { injectQueryToPlaylistUrls } from '@server/lib/hls'
6import { 6import {
7 asyncMiddleware, 7 asyncMiddleware,
8 ensureCanAccessPrivateVideoHLSFiles, 8 ensureCanAccessPrivateVideoHLSFiles,
9 ensureCanAccessVideoPrivateWebTorrentFiles, 9 ensureCanAccessVideoPrivateWebVideoFiles,
10 handleStaticError, 10 handleStaticError,
11 optionalAuthenticate 11 optionalAuthenticate
12} from '@server/middlewares' 12} from '@server/middlewares'
@@ -21,21 +21,21 @@ const staticRouter = express.Router()
21staticRouter.use(cors()) 21staticRouter.use(cors())
22 22
23// --------------------------------------------------------------------------- 23// ---------------------------------------------------------------------------
24// WebTorrent/Classic videos 24// Web videos/Classic videos
25// --------------------------------------------------------------------------- 25// ---------------------------------------------------------------------------
26 26
27const privateWebTorrentStaticMiddlewares = CONFIG.STATIC_FILES.PRIVATE_FILES_REQUIRE_AUTH === true 27const privateWebVideoStaticMiddlewares = CONFIG.STATIC_FILES.PRIVATE_FILES_REQUIRE_AUTH === true
28 ? [ optionalAuthenticate, asyncMiddleware(ensureCanAccessVideoPrivateWebTorrentFiles) ] 28 ? [ optionalAuthenticate, asyncMiddleware(ensureCanAccessVideoPrivateWebVideoFiles) ]
29 : [] 29 : []
30 30
31staticRouter.use( 31staticRouter.use(
32 STATIC_PATHS.PRIVATE_WEBSEED, 32 [ STATIC_PATHS.PRIVATE_WEB_VIDEOS, STATIC_PATHS.LEGACY_PRIVATE_WEB_VIDEOS ],
33 ...privateWebTorrentStaticMiddlewares, 33 ...privateWebVideoStaticMiddlewares,
34 express.static(DIRECTORIES.VIDEOS.PRIVATE, { fallthrough: false }), 34 express.static(DIRECTORIES.VIDEOS.PRIVATE, { fallthrough: false }),
35 handleStaticError 35 handleStaticError
36) 36)
37staticRouter.use( 37staticRouter.use(
38 STATIC_PATHS.WEBSEED, 38 [ STATIC_PATHS.WEB_VIDEOS, STATIC_PATHS.LEGACY_WEB_VIDEOS ],
39 express.static(DIRECTORIES.VIDEOS.PUBLIC, { fallthrough: false }), 39 express.static(DIRECTORIES.VIDEOS.PUBLIC, { fallthrough: false }),
40 handleStaticError 40 handleStaticError
41) 41)
@@ -72,7 +72,7 @@ staticRouter.use(
72 handleStaticError 72 handleStaticError
73) 73)
74 74
75// Thumbnails path for express 75// FIXME: deprecated in v6, to remove
76const thumbnailsPhysicalPath = CONFIG.STORAGE.THUMBNAILS_DIR 76const thumbnailsPhysicalPath = CONFIG.STORAGE.THUMBNAILS_DIR
77staticRouter.use( 77staticRouter.use(
78 STATIC_PATHS.THUMBNAILS, 78 STATIC_PATHS.THUMBNAILS,
diff --git a/server/helpers/custom-validators/activitypub/misc.ts b/server/helpers/custom-validators/activitypub/misc.ts
index 279ad83dc..7df47cf15 100644
--- a/server/helpers/custom-validators/activitypub/misc.ts
+++ b/server/helpers/custom-validators/activitypub/misc.ts
@@ -51,7 +51,8 @@ function setValidAttributedTo (obj: any) {
51 } 51 }
52 52
53 obj.attributedTo = obj.attributedTo.filter(a => { 53 obj.attributedTo = obj.attributedTo.filter(a => {
54 return (a.type === 'Group' || a.type === 'Person') && isActivityPubUrlValid(a.id) 54 return isActivityPubUrlValid(a) ||
55 ((a.type === 'Group' || a.type === 'Person') && isActivityPubUrlValid(a.id))
55 }) 56 })
56 57
57 return true 58 return true
diff --git a/server/helpers/custom-validators/activitypub/videos.ts b/server/helpers/custom-validators/activitypub/videos.ts
index 97b3577af..573a29754 100644
--- a/server/helpers/custom-validators/activitypub/videos.ts
+++ b/server/helpers/custom-validators/activitypub/videos.ts
@@ -1,6 +1,6 @@
1import validator from 'validator' 1import validator from 'validator'
2import { logger } from '@server/helpers/logger' 2import { logger } from '@server/helpers/logger'
3import { ActivityTrackerUrlObject, ActivityVideoFileMetadataUrlObject } from '@shared/models' 3import { ActivityPubStoryboard, ActivityTrackerUrlObject, ActivityVideoFileMetadataUrlObject, VideoObject } from '@shared/models'
4import { LiveVideoLatencyMode, VideoState } from '../../../../shared/models/videos' 4import { LiveVideoLatencyMode, VideoState } from '../../../../shared/models/videos'
5import { ACTIVITY_PUB, CONSTRAINTS_FIELDS } from '../../../initializers/constants' 5import { ACTIVITY_PUB, CONSTRAINTS_FIELDS } from '../../../initializers/constants'
6import { peertubeTruncate } from '../../core-utils' 6import { peertubeTruncate } from '../../core-utils'
@@ -48,6 +48,10 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
48 logger.debug('Video has invalid icons', { video }) 48 logger.debug('Video has invalid icons', { video })
49 return false 49 return false
50 } 50 }
51 if (!setValidStoryboard(video)) {
52 logger.debug('Video has invalid preview (storyboard)', { video })
53 return false
54 }
51 55
52 // Default attributes 56 // Default attributes
53 if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED 57 if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED
@@ -201,3 +205,36 @@ function setRemoteVideoContent (video: any) {
201 205
202 return true 206 return true
203} 207}
208
209function setValidStoryboard (video: VideoObject) {
210 if (!video.preview) return true
211 if (!Array.isArray(video.preview)) return false
212
213 video.preview = video.preview.filter(p => isStorybordValid(p))
214
215 return true
216}
217
218function isStorybordValid (preview: ActivityPubStoryboard) {
219 if (!preview) return false
220
221 if (
222 preview.type !== 'Image' ||
223 !isArray(preview.rel) ||
224 !preview.rel.includes('storyboard')
225 ) {
226 return false
227 }
228
229 preview.url = preview.url.filter(u => {
230 return u.mediaType === 'image/jpeg' &&
231 isActivityPubUrlValid(u.href) &&
232 validator.isInt(u.width + '', { min: 0 }) &&
233 validator.isInt(u.height + '', { min: 0 }) &&
234 validator.isInt(u.tileWidth + '', { min: 0 }) &&
235 validator.isInt(u.tileHeight + '', { min: 0 }) &&
236 isActivityPubVideoDurationValid(u.tileDuration)
237 })
238
239 return preview.url.length !== 0
240}
diff --git a/server/helpers/custom-validators/metrics.ts b/server/helpers/custom-validators/metrics.ts
index 533f8988d..44a863630 100644
--- a/server/helpers/custom-validators/metrics.ts
+++ b/server/helpers/custom-validators/metrics.ts
@@ -1,5 +1,6 @@
1function isValidPlayerMode (value: any) { 1function isValidPlayerMode (value: any) {
2 return value === 'webtorrent' || value === 'p2p-media-loader' 2 // TODO: remove webtorrent in v7
3 return value === 'webtorrent' || value === 'web-video' || value === 'p2p-media-loader'
3} 4}
4 5
5// --------------------------------------------------------------------------- 6// ---------------------------------------------------------------------------
diff --git a/server/helpers/custom-validators/video-transcoding.ts b/server/helpers/custom-validators/video-transcoding.ts
index cf792f996..220530de4 100644
--- a/server/helpers/custom-validators/video-transcoding.ts
+++ b/server/helpers/custom-validators/video-transcoding.ts
@@ -2,7 +2,7 @@ import { exists } from './misc'
2 2
3function isValidCreateTranscodingType (value: any) { 3function isValidCreateTranscodingType (value: any) {
4 return exists(value) && 4 return exists(value) &&
5 (value === 'hls' || value === 'webtorrent') 5 (value === 'hls' || value === 'webtorrent' || value === 'web-video') // TODO: remove webtorrent in v7
6} 6}
7 7
8// --------------------------------------------------------------------------- 8// ---------------------------------------------------------------------------
diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts
index 5f75ec27c..91109217c 100644
--- a/server/helpers/custom-validators/videos.ts
+++ b/server/helpers/custom-validators/videos.ts
@@ -1,7 +1,7 @@
1import { UploadFilesForCheck } from 'express' 1import { Response, Request, UploadFilesForCheck } from 'express'
2import { decode as magnetUriDecode } from 'magnet-uri' 2import { decode as magnetUriDecode } from 'magnet-uri'
3import validator from 'validator' 3import validator from 'validator'
4import { VideoFilter, VideoInclude, VideoPrivacy, VideoRateType } from '@shared/models' 4import { HttpStatusCode, VideoFilter, VideoInclude, VideoPrivacy, VideoRateType } from '@shared/models'
5import { 5import {
6 CONSTRAINTS_FIELDS, 6 CONSTRAINTS_FIELDS,
7 MIMETYPES, 7 MIMETYPES,
@@ -13,6 +13,7 @@ import {
13 VIDEO_STATES 13 VIDEO_STATES
14} from '../../initializers/constants' 14} from '../../initializers/constants'
15import { exists, isArray, isDateValid, isFileValid } from './misc' 15import { exists, isArray, isDateValid, isFileValid } from './misc'
16import { getVideoWithAttributes } from '@server/helpers/video'
16 17
17const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS 18const VIDEOS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEOS
18 19
@@ -110,6 +111,10 @@ function isVideoPrivacyValid (value: number) {
110 return VIDEO_PRIVACIES[value] !== undefined 111 return VIDEO_PRIVACIES[value] !== undefined
111} 112}
112 113
114function isVideoReplayPrivacyValid (value: number) {
115 return VIDEO_PRIVACIES[value] !== undefined && value !== VideoPrivacy.PASSWORD_PROTECTED
116}
117
113function isScheduleVideoUpdatePrivacyValid (value: number) { 118function isScheduleVideoUpdatePrivacyValid (value: number) {
114 return value === VideoPrivacy.UNLISTED || value === VideoPrivacy.PUBLIC || value === VideoPrivacy.INTERNAL 119 return value === VideoPrivacy.UNLISTED || value === VideoPrivacy.PUBLIC || value === VideoPrivacy.INTERNAL
115} 120}
@@ -141,6 +146,49 @@ function isVideoMagnetUriValid (value: string) {
141 return parsed && isVideoFileInfoHashValid(parsed.infoHash) 146 return parsed && isVideoFileInfoHashValid(parsed.infoHash)
142} 147}
143 148
149function isPasswordValid (password: string) {
150 return password.length >= CONSTRAINTS_FIELDS.VIDEO_PASSWORD.LENGTH.min &&
151 password.length < CONSTRAINTS_FIELDS.VIDEO_PASSWORD.LENGTH.max
152}
153
154function isValidPasswordProtectedPrivacy (req: Request, res: Response) {
155 const fail = (message: string) => {
156 res.fail({
157 status: HttpStatusCode.BAD_REQUEST_400,
158 message
159 })
160 return false
161 }
162
163 let privacy: VideoPrivacy
164 const video = getVideoWithAttributes(res)
165
166 if (exists(req.body?.privacy)) privacy = req.body.privacy
167 else if (exists(video?.privacy)) privacy = video.privacy
168
169 if (privacy !== VideoPrivacy.PASSWORD_PROTECTED) return true
170
171 if (!exists(req.body.videoPasswords) && !exists(req.body.passwords)) return fail('Video passwords are missing.')
172
173 const passwords = req.body.videoPasswords || req.body.passwords
174
175 if (passwords.length === 0) return fail('At least one video password is required.')
176
177 if (new Set(passwords).size !== passwords.length) return fail('Duplicate video passwords are not allowed.')
178
179 for (const password of passwords) {
180 if (typeof password !== 'string') {
181 return fail('Video password should be a string.')
182 }
183
184 if (!isPasswordValid(password)) {
185 return fail('Invalid video password. Password length should be at least 2 characters and no more than 100 characters.')
186 }
187 }
188
189 return true
190}
191
144// --------------------------------------------------------------------------- 192// ---------------------------------------------------------------------------
145 193
146export { 194export {
@@ -164,9 +212,12 @@ export {
164 isVideoDurationValid, 212 isVideoDurationValid,
165 isVideoTagValid, 213 isVideoTagValid,
166 isVideoPrivacyValid, 214 isVideoPrivacyValid,
215 isVideoReplayPrivacyValid,
167 isVideoFileResolutionValid, 216 isVideoFileResolutionValid,
168 isVideoFileSizeValid, 217 isVideoFileSizeValid,
169 isVideoImageValid, 218 isVideoImageValid,
170 isVideoSupportValid, 219 isVideoSupportValid,
171 isVideoFilterValid 220 isVideoFilterValid,
221 isPasswordValid,
222 isValidPasswordProtectedPrivacy
172} 223}
diff --git a/server/helpers/express-utils.ts b/server/helpers/express-utils.ts
index 82dd4c178..783097e55 100644
--- a/server/helpers/express-utils.ts
+++ b/server/helpers/express-utils.ts
@@ -1,7 +1,6 @@
1import express, { RequestHandler } from 'express' 1import express, { RequestHandler } from 'express'
2import multer, { diskStorage } from 'multer' 2import multer, { diskStorage } from 'multer'
3import { getLowercaseExtension } from '@shared/core-utils' 3import { getLowercaseExtension } from '@shared/core-utils'
4import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
5import { CONFIG } from '../initializers/config' 4import { CONFIG } from '../initializers/config'
6import { REMOTE_SCHEME } from '../initializers/constants' 5import { REMOTE_SCHEME } from '../initializers/constants'
7import { isArray } from './custom-validators/misc' 6import { isArray } from './custom-validators/misc'
@@ -59,12 +58,6 @@ function getHostWithPort (host: string) {
59 return host 58 return host
60} 59}
61 60
62function badRequest (_req: express.Request, res: express.Response) {
63 return res.type('json')
64 .status(HttpStatusCode.BAD_REQUEST_400)
65 .end()
66}
67
68function createReqFiles ( 61function createReqFiles (
69 fieldNames: string[], 62 fieldNames: string[],
70 mimeTypes: { [id: string]: string | string[] }, 63 mimeTypes: { [id: string]: string | string[] },
@@ -126,7 +119,6 @@ export {
126 getHostWithPort, 119 getHostWithPort,
127 createAnyReqFiles, 120 createAnyReqFiles,
128 isUserAbleToSearchRemoteURI, 121 isUserAbleToSearchRemoteURI,
129 badRequest,
130 createReqFiles, 122 createReqFiles,
131 cleanUpReqFiles, 123 cleanUpReqFiles,
132 getCountVideos 124 getCountVideos
diff --git a/server/helpers/image-utils.ts b/server/helpers/image-utils.ts
index f86f7216d..7b77e694a 100644
--- a/server/helpers/image-utils.ts
+++ b/server/helpers/image-utils.ts
@@ -51,7 +51,7 @@ async function generateImageFromVideoFile (options: {
51 const pendingImagePath = join(folder, pendingImageName) 51 const pendingImagePath = join(folder, pendingImageName)
52 52
53 try { 53 try {
54 await generateThumbnailFromVideo({ fromPath, folder, imageName }) 54 await generateThumbnailFromVideo({ fromPath, output: pendingImagePath })
55 55
56 const destination = join(folder, imageName) 56 const destination = join(folder, imageName)
57 await processImage({ path: pendingImagePath, destination, newSize: size }) 57 await processImage({ path: pendingImagePath, destination, newSize: size })
diff --git a/server/helpers/promise-cache.ts b/server/helpers/promise-cache.ts
index 07e8a9962..303bab976 100644
--- a/server/helpers/promise-cache.ts
+++ b/server/helpers/promise-cache.ts
@@ -1,4 +1,4 @@
1export class PromiseCache <A, R> { 1export class CachePromiseFactory <A, R> {
2 private readonly running = new Map<string, Promise<R>>() 2 private readonly running = new Map<string, Promise<R>>()
3 3
4 constructor ( 4 constructor (
@@ -8,14 +8,32 @@ export class PromiseCache <A, R> {
8 } 8 }
9 9
10 run (arg: A) { 10 run (arg: A) {
11 return this.runWithContext(null, arg)
12 }
13
14 runWithContext (ctx: any, arg: A) {
11 const key = this.keyBuilder(arg) 15 const key = this.keyBuilder(arg)
12 16
13 if (this.running.has(key)) return this.running.get(key) 17 if (this.running.has(key)) return this.running.get(key)
14 18
15 const p = this.fn(arg) 19 const p = this.fn.apply(ctx || this, [ arg ])
16 20
17 this.running.set(key, p) 21 this.running.set(key, p)
18 22
19 return p.finally(() => this.running.delete(key)) 23 return p.finally(() => this.running.delete(key))
20 } 24 }
21} 25}
26
27export function CachePromise (options: {
28 keyBuilder: (...args: any[]) => string
29}) {
30 return function (_target, _key, descriptor: PropertyDescriptor) {
31 const promiseCache = new CachePromiseFactory(descriptor.value, options.keyBuilder)
32
33 descriptor.value = function () {
34 if (arguments.length !== 1) throw new Error('Cache promise only support methods with 1 argument')
35
36 return promiseCache.runWithContext(this, arguments[0])
37 }
38 }
39}
diff --git a/server/helpers/query.ts b/server/helpers/query.ts
index 10efae41c..c0f78368f 100644
--- a/server/helpers/query.ts
+++ b/server/helpers/query.ts
@@ -23,7 +23,8 @@ function pickCommonVideoQuery (query: VideosCommonQueryAfterSanitize) {
23 'include', 23 'include',
24 'skipCount', 24 'skipCount',
25 'hasHLSFiles', 25 'hasHLSFiles',
26 'hasWebtorrentFiles', 26 'hasWebtorrentFiles', // TODO: Remove in v7
27 'hasWebVideoFiles',
27 'search', 28 'search',
28 'excludeAlreadyWatched' 29 'excludeAlreadyWatched'
29 ]) 30 ])
diff --git a/server/initializers/checker-after-init.ts b/server/initializers/checker-after-init.ts
index 68dea909d..5ef72058b 100644
--- a/server/initializers/checker-after-init.ts
+++ b/server/initializers/checker-after-init.ts
@@ -1,4 +1,5 @@
1import config from 'config' 1import config from 'config'
2import { readFileSync, writeFileSync } from 'fs-extra'
2import { URL } from 'url' 3import { URL } from 'url'
3import { uniqify } from '@shared/core-utils' 4import { uniqify } from '@shared/core-utils'
4import { getFFmpegVersion } from '@shared/ffmpeg' 5import { getFFmpegVersion } from '@shared/ffmpeg'
@@ -10,7 +11,7 @@ import { logger } from '../helpers/logger'
10import { ApplicationModel, getServerActor } from '../models/application/application' 11import { ApplicationModel, getServerActor } from '../models/application/application'
11import { OAuthClientModel } from '../models/oauth/oauth-client' 12import { OAuthClientModel } from '../models/oauth/oauth-client'
12import { UserModel } from '../models/user/user' 13import { UserModel } from '../models/user/user'
13import { CONFIG, isEmailEnabled } from './config' 14import { CONFIG, getLocalConfigFilePath, isEmailEnabled, reloadConfig } from './config'
14import { WEBSERVER } from './constants' 15import { WEBSERVER } from './constants'
15 16
16async function checkActivityPubUrls () { 17async function checkActivityPubUrls () {
@@ -37,10 +38,7 @@ function checkConfig () {
37 const configFiles = config.util.getConfigSources().map(s => s.name).join(' -> ') 38 const configFiles = config.util.getConfigSources().map(s => s.name).join(' -> ')
38 logger.info('Using following configuration file hierarchy: %s.', configFiles) 39 logger.info('Using following configuration file hierarchy: %s.', configFiles)
39 40
40 // Moved configuration keys 41 checkRemovedConfigKeys()
41 if (config.has('services.csp-logger')) {
42 logger.warn('services.csp-logger configuration has been renamed to csp.report_uri. Please update your configuration file.')
43 }
44 42
45 checkSecretsConfig() 43 checkSecretsConfig()
46 checkEmailConfig() 44 checkEmailConfig()
@@ -104,6 +102,34 @@ export {
104 102
105// --------------------------------------------------------------------------- 103// ---------------------------------------------------------------------------
106 104
105function checkRemovedConfigKeys () {
106 // Moved configuration keys
107 if (config.has('services.csp-logger')) {
108 logger.warn('services.csp-logger configuration has been renamed to csp.report_uri. Please update your configuration file.')
109 }
110
111 if (config.has('transcoding.webtorrent.enabled')) {
112 const localConfigPath = getLocalConfigFilePath()
113
114 const content = readFileSync(localConfigPath, { encoding: 'utf-8' })
115 if (!content.includes('"webtorrent"')) {
116 throw new Error('Please rename transcoding.webtorrent.enabled key to transcoding.web_videos.enabled in your configuration file')
117 }
118
119 try {
120 logger.info(
121 'Replacing "transcoding.webtorrent.enabled" key to "transcoding.web_videos.enabled" in your local configuration ' + localConfigPath
122 )
123
124 writeFileSync(localConfigPath, content.replace('"webtorrent"', '"web_videos"'), { encoding: 'utf-8' })
125
126 reloadConfig()
127 } catch (err) {
128 logger.error('Cannot write new configuration to file ' + localConfigPath, { err })
129 }
130 }
131}
132
107function checkSecretsConfig () { 133function checkSecretsConfig () {
108 if (!CONFIG.SECRETS.PEERTUBE) { 134 if (!CONFIG.SECRETS.PEERTUBE) {
109 throw new Error('secrets.peertube is missing in config. Generate one using `openssl rand -hex 32`') 135 throw new Error('secrets.peertube is missing in config. Generate one using `openssl rand -hex 32`')
@@ -191,15 +217,15 @@ function checkStorageConfig () {
191 } 217 }
192 } 218 }
193 219
194 if (CONFIG.STORAGE.VIDEOS_DIR === CONFIG.STORAGE.REDUNDANCY_DIR) { 220 if (CONFIG.STORAGE.WEB_VIDEOS_DIR === CONFIG.STORAGE.REDUNDANCY_DIR) {
195 logger.warn('Redundancy directory should be different than the videos folder.') 221 logger.warn('Redundancy directory should be different than the videos folder.')
196 } 222 }
197} 223}
198 224
199function checkTranscodingConfig () { 225function checkTranscodingConfig () {
200 if (CONFIG.TRANSCODING.ENABLED) { 226 if (CONFIG.TRANSCODING.ENABLED) {
201 if (CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false && CONFIG.TRANSCODING.HLS.ENABLED === false) { 227 if (CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED === false && CONFIG.TRANSCODING.HLS.ENABLED === false) {
202 throw new Error('You need to enable at least WebTorrent transcoding or HLS transcoding.') 228 throw new Error('You need to enable at least Web Video transcoding or HLS transcoding.')
203 } 229 }
204 230
205 if (CONFIG.TRANSCODING.CONCURRENCY <= 0) { 231 if (CONFIG.TRANSCODING.CONCURRENCY <= 0) {
@@ -264,7 +290,7 @@ function checkLiveConfig () {
264function checkObjectStorageConfig () { 290function checkObjectStorageConfig () {
265 if (CONFIG.OBJECT_STORAGE.ENABLED === true) { 291 if (CONFIG.OBJECT_STORAGE.ENABLED === true) {
266 292
267 if (!CONFIG.OBJECT_STORAGE.VIDEOS.BUCKET_NAME) { 293 if (!CONFIG.OBJECT_STORAGE.WEB_VIDEOS.BUCKET_NAME) {
268 throw new Error('videos_bucket should be set when object storage support is enabled.') 294 throw new Error('videos_bucket should be set when object storage support is enabled.')
269 } 295 }
270 296
@@ -273,10 +299,10 @@ function checkObjectStorageConfig () {
273 } 299 }
274 300
275 if ( 301 if (
276 CONFIG.OBJECT_STORAGE.VIDEOS.BUCKET_NAME === CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BUCKET_NAME && 302 CONFIG.OBJECT_STORAGE.WEB_VIDEOS.BUCKET_NAME === CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BUCKET_NAME &&
277 CONFIG.OBJECT_STORAGE.VIDEOS.PREFIX === CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.PREFIX 303 CONFIG.OBJECT_STORAGE.WEB_VIDEOS.PREFIX === CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.PREFIX
278 ) { 304 ) {
279 if (CONFIG.OBJECT_STORAGE.VIDEOS.PREFIX === '') { 305 if (CONFIG.OBJECT_STORAGE.WEB_VIDEOS.PREFIX === '') {
280 throw new Error('Object storage bucket prefixes should be set when the same bucket is used for both types of video.') 306 throw new Error('Object storage bucket prefixes should be set when the same bucket is used for both types of video.')
281 } 307 }
282 308
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts
index 0a315ea70..a872fcba3 100644
--- a/server/initializers/checker-before-init.ts
+++ b/server/initializers/checker-before-init.ts
@@ -18,7 +18,7 @@ function checkMissedConfig () {
18 'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max', 18 'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max',
19 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address', 19 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address',
20 'email.body.signature', 'email.subject.prefix', 20 'email.body.signature', 'email.subject.prefix',
21 'storage.avatars', 'storage.videos', 'storage.logs', 'storage.previews', 'storage.thumbnails', 'storage.torrents', 'storage.cache', 21 'storage.avatars', 'storage.web_videos', 'storage.logs', 'storage.previews', 'storage.thumbnails', 'storage.torrents', 'storage.cache',
22 'storage.redundancy', 'storage.tmp', 'storage.streaming_playlists', 'storage.plugins', 'storage.well_known', 22 'storage.redundancy', 'storage.tmp', 'storage.streaming_playlists', 'storage.plugins', 'storage.well_known',
23 'log.level', 'log.rotation.enabled', 'log.rotation.max_file_size', 'log.rotation.max_files', 'log.anonymize_ip', 23 'log.level', 'log.rotation.enabled', 'log.rotation.max_file_size', 'log.rotation.max_files', 'log.anonymize_ip',
24 'log.log_ping_requests', 'log.log_tracker_unknown_infohash', 'log.prettify_sql', 'log.accept_client_log', 24 'log.log_ping_requests', 'log.log_tracker_unknown_infohash', 'log.prettify_sql', 'log.accept_client_log',
@@ -29,12 +29,13 @@ function checkMissedConfig () {
29 'video_channels.max_per_user', 29 'video_channels.max_per_user',
30 'csp.enabled', 'csp.report_only', 'csp.report_uri', 30 'csp.enabled', 'csp.report_only', 'csp.report_uri',
31 'security.frameguard.enabled', 'security.powered_by_header.enabled', 31 'security.frameguard.enabled', 'security.powered_by_header.enabled',
32 'cache.previews.size', 'cache.captions.size', 'cache.torrents.size', 'admin.email', 'contact_form.enabled', 32 'cache.previews.size', 'cache.captions.size', 'cache.torrents.size', 'cache.storyboards.size',
33 'admin.email', 'contact_form.enabled',
33 'signup.enabled', 'signup.limit', 'signup.requires_approval', 'signup.requires_email_verification', 'signup.minimum_age', 34 'signup.enabled', 'signup.limit', 'signup.requires_approval', 'signup.requires_email_verification', 'signup.minimum_age',
34 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist', 35 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
35 'redundancy.videos.strategies', 'redundancy.videos.check_interval', 36 'redundancy.videos.strategies', 'redundancy.videos.check_interval',
36 'transcoding.enabled', 'transcoding.threads', 'transcoding.allow_additional_extensions', 'transcoding.hls.enabled', 37 'transcoding.enabled', 'transcoding.threads', 'transcoding.allow_additional_extensions', 'transcoding.web_videos.enabled',
37 'transcoding.profile', 'transcoding.concurrency', 38 'transcoding.hls.enabled', 'transcoding.profile', 'transcoding.concurrency',
38 'transcoding.resolutions.0p', 'transcoding.resolutions.144p', 'transcoding.resolutions.240p', 'transcoding.resolutions.360p', 39 'transcoding.resolutions.0p', 'transcoding.resolutions.144p', 'transcoding.resolutions.240p', 'transcoding.resolutions.360p',
39 'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p', 40 'transcoding.resolutions.480p', 'transcoding.resolutions.720p', 'transcoding.resolutions.1080p', 'transcoding.resolutions.1440p',
40 'transcoding.resolutions.2160p', 'transcoding.always_transcode_original_resolution', 'transcoding.remote_runners.enabled', 41 'transcoding.resolutions.2160p', 'transcoding.always_transcode_original_resolution', 'transcoding.remote_runners.enabled',
@@ -59,8 +60,8 @@ function checkMissedConfig () {
59 'object_storage.enabled', 'object_storage.endpoint', 'object_storage.region', 'object_storage.upload_acl.public', 60 'object_storage.enabled', 'object_storage.endpoint', 'object_storage.region', 'object_storage.upload_acl.public',
60 'object_storage.upload_acl.private', 'object_storage.proxy.proxify_private_files', 'object_storage.credentials.access_key_id', 61 'object_storage.upload_acl.private', 'object_storage.proxy.proxify_private_files', 'object_storage.credentials.access_key_id',
61 'object_storage.credentials.secret_access_key', 'object_storage.max_upload_part', 'object_storage.streaming_playlists.bucket_name', 62 'object_storage.credentials.secret_access_key', 'object_storage.max_upload_part', 'object_storage.streaming_playlists.bucket_name',
62 'object_storage.streaming_playlists.prefix', 'object_storage.streaming_playlists.base_url', 'object_storage.videos.bucket_name', 63 'object_storage.streaming_playlists.prefix', 'object_storage.streaming_playlists.base_url', 'object_storage.web_videos.bucket_name',
63 'object_storage.videos.prefix', 'object_storage.videos.base_url', 64 'object_storage.web_videos.prefix', 'object_storage.web_videos.base_url',
64 'theme.default', 65 'theme.default',
65 'feeds.videos.count', 'feeds.comments.count', 66 'feeds.videos.count', 'feeds.comments.count',
66 'geo_ip.enabled', 'geo_ip.country.database_url', 67 'geo_ip.enabled', 'geo_ip.country.database_url',
diff --git a/server/initializers/config.ts b/server/initializers/config.ts
index 51ac5d0ce..37cd852f1 100644
--- a/server/initializers/config.ts
+++ b/server/initializers/config.ts
@@ -106,12 +106,13 @@ const CONFIG = {
106 TMP_DIR: buildPath(config.get<string>('storage.tmp')), 106 TMP_DIR: buildPath(config.get<string>('storage.tmp')),
107 TMP_PERSISTENT_DIR: buildPath(config.get<string>('storage.tmp_persistent')), 107 TMP_PERSISTENT_DIR: buildPath(config.get<string>('storage.tmp_persistent')),
108 BIN_DIR: buildPath(config.get<string>('storage.bin')), 108 BIN_DIR: buildPath(config.get<string>('storage.bin')),
109 ACTOR_IMAGES: buildPath(config.get<string>('storage.avatars')), 109 ACTOR_IMAGES_DIR: buildPath(config.get<string>('storage.avatars')),
110 LOG_DIR: buildPath(config.get<string>('storage.logs')), 110 LOG_DIR: buildPath(config.get<string>('storage.logs')),
111 VIDEOS_DIR: buildPath(config.get<string>('storage.videos')), 111 WEB_VIDEOS_DIR: buildPath(config.get<string>('storage.web_videos')),
112 STREAMING_PLAYLISTS_DIR: buildPath(config.get<string>('storage.streaming_playlists')), 112 STREAMING_PLAYLISTS_DIR: buildPath(config.get<string>('storage.streaming_playlists')),
113 REDUNDANCY_DIR: buildPath(config.get<string>('storage.redundancy')), 113 REDUNDANCY_DIR: buildPath(config.get<string>('storage.redundancy')),
114 THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')), 114 THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')),
115 STORYBOARDS_DIR: buildPath(config.get<string>('storage.storyboards')),
115 PREVIEWS_DIR: buildPath(config.get<string>('storage.previews')), 116 PREVIEWS_DIR: buildPath(config.get<string>('storage.previews')),
116 CAPTIONS_DIR: buildPath(config.get<string>('storage.captions')), 117 CAPTIONS_DIR: buildPath(config.get<string>('storage.captions')),
117 TORRENTS_DIR: buildPath(config.get<string>('storage.torrents')), 118 TORRENTS_DIR: buildPath(config.get<string>('storage.torrents')),
@@ -139,10 +140,10 @@ const CONFIG = {
139 PROXY: { 140 PROXY: {
140 PROXIFY_PRIVATE_FILES: config.get<boolean>('object_storage.proxy.proxify_private_files') 141 PROXIFY_PRIVATE_FILES: config.get<boolean>('object_storage.proxy.proxify_private_files')
141 }, 142 },
142 VIDEOS: { 143 WEB_VIDEOS: {
143 BUCKET_NAME: config.get<string>('object_storage.videos.bucket_name'), 144 BUCKET_NAME: config.get<string>('object_storage.web_videos.bucket_name'),
144 PREFIX: config.get<string>('object_storage.videos.prefix'), 145 PREFIX: config.get<string>('object_storage.web_videos.prefix'),
145 BASE_URL: config.get<string>('object_storage.videos.base_url') 146 BASE_URL: config.get<string>('object_storage.web_videos.base_url')
146 }, 147 },
147 STREAMING_PLAYLISTS: { 148 STREAMING_PLAYLISTS: {
148 BUCKET_NAME: config.get<string>('object_storage.streaming_playlists.bucket_name'), 149 BUCKET_NAME: config.get<string>('object_storage.streaming_playlists.bucket_name'),
@@ -370,8 +371,8 @@ const CONFIG = {
370 HLS: { 371 HLS: {
371 get ENABLED () { return config.get<boolean>('transcoding.hls.enabled') } 372 get ENABLED () { return config.get<boolean>('transcoding.hls.enabled') }
372 }, 373 },
373 WEBTORRENT: { 374 WEB_VIDEOS: {
374 get ENABLED () { return config.get<boolean>('transcoding.webtorrent.enabled') } 375 get ENABLED () { return config.get<boolean>('transcoding.web_videos.enabled') }
375 }, 376 },
376 REMOTE_RUNNERS: { 377 REMOTE_RUNNERS: {
377 get ENABLED () { return config.get<boolean>('transcoding.remote_runners.enabled') } 378 get ENABLED () { return config.get<boolean>('transcoding.remote_runners.enabled') }
@@ -482,6 +483,9 @@ const CONFIG = {
482 }, 483 },
483 TORRENTS: { 484 TORRENTS: {
484 get SIZE () { return config.get<number>('cache.torrents.size') } 485 get SIZE () { return config.get<number>('cache.torrents.size') }
486 },
487 STORYBOARDS: {
488 get SIZE () { return config.get<number>('cache.storyboards.size') }
485 } 489 }
486 }, 490 },
487 INSTANCE: { 491 INSTANCE: {
@@ -580,16 +584,6 @@ function isEmailEnabled () {
580 return false 584 return false
581} 585}
582 586
583// ---------------------------------------------------------------------------
584
585export {
586 CONFIG,
587 registerConfigChangedHandler,
588 isEmailEnabled
589}
590
591// ---------------------------------------------------------------------------
592
593function getLocalConfigFilePath () { 587function getLocalConfigFilePath () {
594 const localConfigDir = getLocalConfigDir() 588 const localConfigDir = getLocalConfigDir()
595 589
@@ -600,6 +594,17 @@ function getLocalConfigFilePath () {
600 return join(localConfigDir, filename + '.json') 594 return join(localConfigDir, filename + '.json')
601} 595}
602 596
597// ---------------------------------------------------------------------------
598
599export {
600 CONFIG,
601 getLocalConfigFilePath,
602 registerConfigChangedHandler,
603 isEmailEnabled
604}
605
606// ---------------------------------------------------------------------------
607
603function getLocalConfigDir () { 608function getLocalConfigDir () {
604 if (process.env.PEERTUBE_LOCAL_CONFIG) return process.env.PEERTUBE_LOCAL_CONFIG 609 if (process.env.PEERTUBE_LOCAL_CONFIG) return process.env.PEERTUBE_LOCAL_CONFIG
605 610
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index a92fd22d6..03ae94d35 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -27,7 +27,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
27 27
28// --------------------------------------------------------------------------- 28// ---------------------------------------------------------------------------
29 29
30const LAST_MIGRATION_VERSION = 780 30const LAST_MIGRATION_VERSION = 790
31 31
32// --------------------------------------------------------------------------- 32// ---------------------------------------------------------------------------
33 33
@@ -76,6 +76,8 @@ const SORTABLE_COLUMNS = {
76 VIDEO_COMMENT_THREADS: [ 'createdAt', 'totalReplies' ], 76 VIDEO_COMMENT_THREADS: [ 'createdAt', 'totalReplies' ],
77 VIDEO_COMMENTS: [ 'createdAt' ], 77 VIDEO_COMMENTS: [ 'createdAt' ],
78 78
79 VIDEO_PASSWORDS: [ 'createdAt' ],
80
79 VIDEO_RATES: [ 'createdAt' ], 81 VIDEO_RATES: [ 'createdAt' ],
80 BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ], 82 BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
81 83
@@ -172,6 +174,7 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = {
172 'after-video-channel-import': 1, 174 'after-video-channel-import': 1,
173 'move-to-object-storage': 3, 175 'move-to-object-storage': 3,
174 'transcoding-job-builder': 1, 176 'transcoding-job-builder': 1,
177 'generate-video-storyboard': 1,
175 'notify': 1, 178 'notify': 1,
176 'federate-video': 1 179 'federate-video': 1
177} 180}
@@ -196,6 +199,7 @@ const JOB_CONCURRENCY: { [id in Exclude<JobType, 'video-transcoding' | 'video-im
196 'video-channel-import': 1, 199 'video-channel-import': 1,
197 'after-video-channel-import': 1, 200 'after-video-channel-import': 1,
198 'transcoding-job-builder': 1, 201 'transcoding-job-builder': 1,
202 'generate-video-storyboard': 1,
199 'notify': 5, 203 'notify': 5,
200 'federate-video': 3 204 'federate-video': 3
201} 205}
@@ -216,6 +220,7 @@ const JOB_TTL: { [id in JobType]: number } = {
216 'activitypub-refresher': 60000 * 10, // 10 minutes 220 'activitypub-refresher': 60000 * 10, // 10 minutes
217 'video-redundancy': 1000 * 3600 * 3, // 3 hours 221 'video-redundancy': 1000 * 3600 * 3, // 3 hours
218 'video-live-ending': 1000 * 60 * 10, // 10 minutes 222 'video-live-ending': 1000 * 60 * 10, // 10 minutes
223 'generate-video-storyboard': 1000 * 60 * 10, // 10 minutes
219 'manage-video-torrent': 1000 * 3600 * 3, // 3 hours 224 'manage-video-torrent': 1000 * 3600 * 3, // 3 hours
220 'move-to-object-storage': 1000 * 60 * 60 * 3, // 3 hours 225 'move-to-object-storage': 1000 * 60 * 60 * 3, // 3 hours
221 'video-channel-import': 1000 * 60 * 60 * 4, // 4 hours 226 'video-channel-import': 1000 * 60 * 60 * 4, // 4 hours
@@ -444,6 +449,9 @@ const CONSTRAINTS_FIELDS = {
444 REASON: { min: 1, max: 5000 }, // Length 449 REASON: { min: 1, max: 5000 }, // Length
445 ERROR_MESSAGE: { min: 1, max: 5000 }, // Length 450 ERROR_MESSAGE: { min: 1, max: 5000 }, // Length
446 PROGRESS: { min: 0, max: 100 } // Value 451 PROGRESS: { min: 0, max: 100 } // Value
452 },
453 VIDEO_PASSWORD: {
454 LENGTH: { min: 2, max: 100 }
447 } 455 }
448} 456}
449 457
@@ -520,7 +528,8 @@ const VIDEO_PRIVACIES: { [ id in VideoPrivacy ]: string } = {
520 [VideoPrivacy.PUBLIC]: 'Public', 528 [VideoPrivacy.PUBLIC]: 'Public',
521 [VideoPrivacy.UNLISTED]: 'Unlisted', 529 [VideoPrivacy.UNLISTED]: 'Unlisted',
522 [VideoPrivacy.PRIVATE]: 'Private', 530 [VideoPrivacy.PRIVATE]: 'Private',
523 [VideoPrivacy.INTERNAL]: 'Internal' 531 [VideoPrivacy.INTERNAL]: 'Internal',
532 [VideoPrivacy.PASSWORD_PROTECTED]: 'Password protected'
524} 533}
525 534
526const VIDEO_STATES: { [ id in VideoState ]: string } = { 535const VIDEO_STATES: { [ id in VideoState ]: string } = {
@@ -738,10 +747,16 @@ const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = {
738 747
739// Express static paths (router) 748// Express static paths (router)
740const STATIC_PATHS = { 749const STATIC_PATHS = {
750 // TODO: deprecated in v6, to remove
741 THUMBNAILS: '/static/thumbnails/', 751 THUMBNAILS: '/static/thumbnails/',
742 752
743 WEBSEED: '/static/webseed/', 753 // Need to keep this legacy path for previously generated torrents
744 PRIVATE_WEBSEED: '/static/webseed/private/', 754 LEGACY_WEB_VIDEOS: '/static/webseed/',
755 WEB_VIDEOS: '/static/web-videos/',
756
757 // Need to keep this legacy path for previously generated torrents
758 LEGACY_PRIVATE_WEB_VIDEOS: '/static/webseed/private/',
759 PRIVATE_WEB_VIDEOS: '/static/web-videos/private/',
745 760
746 REDUNDANCY: '/static/redundancy/', 761 REDUNDANCY: '/static/redundancy/',
747 762
@@ -756,14 +771,18 @@ const STATIC_DOWNLOAD_PATHS = {
756 HLS_VIDEOS: '/download/streaming-playlists/hls/videos/' 771 HLS_VIDEOS: '/download/streaming-playlists/hls/videos/'
757} 772}
758const LAZY_STATIC_PATHS = { 773const LAZY_STATIC_PATHS = {
774 THUMBNAILS: '/lazy-static/thumbnails/',
759 BANNERS: '/lazy-static/banners/', 775 BANNERS: '/lazy-static/banners/',
760 AVATARS: '/lazy-static/avatars/', 776 AVATARS: '/lazy-static/avatars/',
761 PREVIEWS: '/lazy-static/previews/', 777 PREVIEWS: '/lazy-static/previews/',
762 VIDEO_CAPTIONS: '/lazy-static/video-captions/', 778 VIDEO_CAPTIONS: '/lazy-static/video-captions/',
763 TORRENTS: '/lazy-static/torrents/' 779 TORRENTS: '/lazy-static/torrents/',
780 STORYBOARDS: '/lazy-static/storyboards/'
764} 781}
765const OBJECT_STORAGE_PROXY_PATHS = { 782const OBJECT_STORAGE_PROXY_PATHS = {
766 PRIVATE_WEBSEED: '/object-storage-proxy/webseed/private/', 783 // Need to keep this legacy path for previously generated torrents
784 LEGACY_PRIVATE_WEB_VIDEOS: '/object-storage-proxy/webseed/private/',
785 PRIVATE_WEB_VIDEOS: '/object-storage-proxy/web-videos/private/',
767 786
768 STREAMING_PLAYLISTS: { 787 STREAMING_PLAYLISTS: {
769 PRIVATE_HLS: '/object-storage-proxy/streaming-playlists/hls/private/' 788 PRIVATE_HLS: '/object-storage-proxy/streaming-playlists/hls/private/'
@@ -807,6 +826,14 @@ const ACTOR_IMAGES_SIZE: { [key in ActorImageType]: { width: number, height: num
807 ] 826 ]
808} 827}
809 828
829const STORYBOARD = {
830 SPRITE_SIZE: {
831 width: 192,
832 height: 108
833 },
834 SPRITES_MAX_EDGE_COUNT: 10
835}
836
810const EMBED_SIZE = { 837const EMBED_SIZE = {
811 width: 560, 838 width: 560,
812 height: 315 839 height: 315
@@ -818,6 +845,10 @@ const FILES_CACHE = {
818 DIRECTORY: join(CONFIG.STORAGE.CACHE_DIR, 'previews'), 845 DIRECTORY: join(CONFIG.STORAGE.CACHE_DIR, 'previews'),
819 MAX_AGE: 1000 * 3600 * 3 // 3 hours 846 MAX_AGE: 1000 * 3600 * 3 // 3 hours
820 }, 847 },
848 STORYBOARDS: {
849 DIRECTORY: join(CONFIG.STORAGE.CACHE_DIR, 'storyboards'),
850 MAX_AGE: 1000 * 3600 * 24 // 24 hours
851 },
821 VIDEO_CAPTIONS: { 852 VIDEO_CAPTIONS: {
822 DIRECTORY: join(CONFIG.STORAGE.CACHE_DIR, 'video-captions'), 853 DIRECTORY: join(CONFIG.STORAGE.CACHE_DIR, 'video-captions'),
823 MAX_AGE: 1000 * 3600 * 3 // 3 hours 854 MAX_AGE: 1000 * 3600 * 3 // 3 hours
@@ -832,8 +863,8 @@ const LRU_CACHE = {
832 USER_TOKENS: { 863 USER_TOKENS: {
833 MAX_SIZE: 1000 864 MAX_SIZE: 1000
834 }, 865 },
835 ACTOR_IMAGE_STATIC: { 866 FILENAME_TO_PATH_PERMANENT_FILE_CACHE: {
836 MAX_SIZE: 500 867 MAX_SIZE: 1000
837 }, 868 },
838 STATIC_VIDEO_FILES_RIGHTS_CHECK: { 869 STATIC_VIDEO_FILES_RIGHTS_CHECK: {
839 MAX_SIZE: 5000, 870 MAX_SIZE: 5000,
@@ -857,8 +888,8 @@ const DIRECTORIES = {
857 }, 888 },
858 889
859 VIDEOS: { 890 VIDEOS: {
860 PUBLIC: CONFIG.STORAGE.VIDEOS_DIR, 891 PUBLIC: CONFIG.STORAGE.WEB_VIDEOS_DIR,
861 PRIVATE: join(CONFIG.STORAGE.VIDEOS_DIR, 'private') 892 PRIVATE: join(CONFIG.STORAGE.WEB_VIDEOS_DIR, 'private')
862 }, 893 },
863 894
864 HLS_REDUNDANCY: join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls') 895 HLS_REDUNDANCY: join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls')
@@ -1084,6 +1115,7 @@ export {
1084 RESUMABLE_UPLOAD_SESSION_LIFETIME, 1115 RESUMABLE_UPLOAD_SESSION_LIFETIME,
1085 RUNNER_JOB_STATES, 1116 RUNNER_JOB_STATES,
1086 P2P_MEDIA_LOADER_PEER_VERSION, 1117 P2P_MEDIA_LOADER_PEER_VERSION,
1118 STORYBOARD,
1087 ACTOR_IMAGES_SIZE, 1119 ACTOR_IMAGES_SIZE,
1088 ACCEPT_HEADERS, 1120 ACCEPT_HEADERS,
1089 BCRYPT_SALT_SIZE, 1121 BCRYPT_SALT_SIZE,
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index 14dd8c379..bc120e398 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -10,6 +10,7 @@ import { UserModel } from '@server/models/user/user'
10import { UserNotificationModel } from '@server/models/user/user-notification' 10import { UserNotificationModel } from '@server/models/user/user-notification'
11import { UserRegistrationModel } from '@server/models/user/user-registration' 11import { UserRegistrationModel } from '@server/models/user/user-registration'
12import { UserVideoHistoryModel } from '@server/models/user/user-video-history' 12import { UserVideoHistoryModel } from '@server/models/user/user-video-history'
13import { StoryboardModel } from '@server/models/video/storyboard'
13import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync' 14import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
14import { VideoJobInfoModel } from '@server/models/video/video-job-info' 15import { VideoJobInfoModel } from '@server/models/video/video-job-info'
15import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting' 16import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting'
@@ -56,6 +57,7 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla
56import { VideoTagModel } from '../models/video/video-tag' 57import { VideoTagModel } from '../models/video/video-tag'
57import { VideoViewModel } from '../models/view/video-view' 58import { VideoViewModel } from '../models/view/video-view'
58import { CONFIG } from './config' 59import { CONFIG } from './config'
60import { VideoPasswordModel } from '@server/models/video/video-password'
59 61
60require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 62require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
61 63
@@ -163,9 +165,11 @@ async function initDatabaseModels (silent: boolean) {
163 VideoJobInfoModel, 165 VideoJobInfoModel,
164 VideoChannelSyncModel, 166 VideoChannelSyncModel,
165 UserRegistrationModel, 167 UserRegistrationModel,
168 VideoPasswordModel,
166 RunnerRegistrationTokenModel, 169 RunnerRegistrationTokenModel,
167 RunnerModel, 170 RunnerModel,
168 RunnerJobModel 171 RunnerJobModel,
172 StoryboardModel
169 ]) 173 ])
170 174
171 // Check extensions exist in the database 175 // Check extensions exist in the database
diff --git a/server/initializers/migrations/0785-video-password-protection.ts b/server/initializers/migrations/0785-video-password-protection.ts
new file mode 100644
index 000000000..1d85f4489
--- /dev/null
+++ b/server/initializers/migrations/0785-video-password-protection.ts
@@ -0,0 +1,31 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7}): Promise<void> {
8 {
9 const query = `
10 CREATE TABLE IF NOT EXISTS "videoPassword" (
11 "id" SERIAL,
12 "password" VARCHAR(255) NOT NULL,
13 "videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
14 "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
15 "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
16 PRIMARY KEY ("id")
17 );
18 `
19
20 await utils.sequelize.query(query, { transaction : utils.transaction })
21 }
22}
23
24function down (options) {
25 throw new Error('Not implemented.')
26}
27
28export {
29 up,
30 down
31}
diff --git a/server/initializers/migrations/0790-thumbnail-disk.ts b/server/initializers/migrations/0790-thumbnail-disk.ts
new file mode 100644
index 000000000..0824c042e
--- /dev/null
+++ b/server/initializers/migrations/0790-thumbnail-disk.ts
@@ -0,0 +1,47 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7}): Promise<void> {
8 const { transaction } = utils
9
10 {
11 const data = {
12 type: Sequelize.BOOLEAN,
13 allowNull: true,
14 defaultValue: true
15 }
16
17 await utils.queryInterface.addColumn('thumbnail', 'onDisk', data, { transaction })
18 }
19
20 {
21 // Remote previews are not on the disk
22 await utils.sequelize.query(
23 'UPDATE "thumbnail" SET "onDisk" = FALSE ' +
24 'WHERE "type" = 2 AND "videoId" NOT IN (SELECT "id" FROM "video" WHERE "remote" IS FALSE)',
25 { transaction }
26 )
27 }
28
29 {
30 const data = {
31 type: Sequelize.BOOLEAN,
32 allowNull: false,
33 defaultValue: null
34 }
35
36 await utils.queryInterface.changeColumn('thumbnail', 'onDisk', data, { transaction })
37 }
38}
39
40function down (options) {
41 throw new Error('Not implemented.')
42}
43
44export {
45 up,
46 down
47}
diff --git a/server/lib/activitypub/activity.ts b/server/lib/activitypub/activity.ts
index 1f6ec221e..0fed3e8fd 100644
--- a/server/lib/activitypub/activity.ts
+++ b/server/lib/activitypub/activity.ts
@@ -1,4 +1,5 @@
1import { ActivityType } from '@shared/models' 1import { doJSONRequest } from '@server/helpers/requests'
2import { APObjectId, ActivityObject, ActivityPubActor, ActivityType } from '@shared/models'
2 3
3function getAPId (object: string | { id: string }) { 4function getAPId (object: string | { id: string }) {
4 if (typeof object === 'string') return object 5 if (typeof object === 'string') return object
@@ -32,8 +33,19 @@ function buildAvailableActivities (): ActivityType[] {
32 ] 33 ]
33} 34}
34 35
36async function fetchAPObject <T extends (ActivityObject | ActivityPubActor)> (object: APObjectId) {
37 if (typeof object === 'string') {
38 const { body } = await doJSONRequest<Exclude<T, string>>(object, { activityPub: true })
39
40 return body
41 }
42
43 return object as Exclude<T, string>
44}
45
35export { 46export {
36 getAPId, 47 getAPId,
48 fetchAPObject,
37 getActivityStreamDuration, 49 getActivityStreamDuration,
38 buildAvailableActivities, 50 buildAvailableActivities,
39 getDurationFromActivityStream 51 getDurationFromActivityStream
diff --git a/server/lib/activitypub/actors/get.ts b/server/lib/activitypub/actors/get.ts
index e73b7d707..b2be3f5fb 100644
--- a/server/lib/activitypub/actors/get.ts
+++ b/server/lib/activitypub/actors/get.ts
@@ -3,8 +3,9 @@ import { logger } from '@server/helpers/logger'
3import { JobQueue } from '@server/lib/job-queue' 3import { JobQueue } from '@server/lib/job-queue'
4import { ActorLoadByUrlType, loadActorByUrl } from '@server/lib/model-loaders' 4import { ActorLoadByUrlType, loadActorByUrl } from '@server/lib/model-loaders'
5import { MActor, MActorAccountChannelId, MActorAccountChannelIdActor, MActorAccountId, MActorFullActor } from '@server/types/models' 5import { MActor, MActorAccountChannelId, MActorAccountChannelIdActor, MActorAccountId, MActorFullActor } from '@server/types/models'
6import { ActivityPubActor } from '@shared/models' 6import { arrayify } from '@shared/core-utils'
7import { getAPId } from '../activity' 7import { ActivityPubActor, APObjectId } from '@shared/models'
8import { fetchAPObject, getAPId } from '../activity'
8import { checkUrlsSameHost } from '../url' 9import { checkUrlsSameHost } from '../url'
9import { refreshActorIfNeeded } from './refresh' 10import { refreshActorIfNeeded } from './refresh'
10import { APActorCreator, fetchRemoteActor } from './shared' 11import { APActorCreator, fetchRemoteActor } from './shared'
@@ -40,7 +41,7 @@ async function getOrCreateAPActor (
40 const { actorObject } = await fetchRemoteActor(actorUrl) 41 const { actorObject } = await fetchRemoteActor(actorUrl)
41 if (actorObject === undefined) throw new Error('Cannot fetch remote actor ' + actorUrl) 42 if (actorObject === undefined) throw new Error('Cannot fetch remote actor ' + actorUrl)
42 43
43 // actorUrl is just an alias/rediraction, so process object id instead 44 // actorUrl is just an alias/redirection, so process object id instead
44 if (actorObject.id !== actorUrl) return getOrCreateAPActor(actorObject, 'all', recurseIfNeeded, updateCollections) 45 if (actorObject.id !== actorUrl) return getOrCreateAPActor(actorObject, 'all', recurseIfNeeded, updateCollections)
45 46
46 // Create the attributed to actor 47 // Create the attributed to actor
@@ -68,29 +69,48 @@ async function getOrCreateAPActor (
68 return actorRefreshed 69 return actorRefreshed
69} 70}
70 71
71function getOrCreateAPOwner (actorObject: ActivityPubActor, actorUrl: string) { 72async function getOrCreateAPOwner (actorObject: ActivityPubActor, actorUrl: string) {
72 const accountAttributedTo = actorObject.attributedTo.find(a => a.type === 'Person') 73 const accountAttributedTo = await findOwner(actorUrl, actorObject.attributedTo, 'Person')
73 if (!accountAttributedTo) throw new Error('Cannot find account attributed to video channel ' + actorUrl) 74 if (!accountAttributedTo) {
74 75 throw new Error(`Cannot find account attributed to video channel ${actorUrl}`)
75 if (checkUrlsSameHost(accountAttributedTo.id, actorUrl) !== true) {
76 throw new Error(`Account attributed to ${accountAttributedTo.id} does not have the same host than actor url ${actorUrl}`)
77 } 76 }
78 77
79 try { 78 try {
80 // Don't recurse another time 79 // Don't recurse another time
81 const recurseIfNeeded = false 80 const recurseIfNeeded = false
82 return getOrCreateAPActor(accountAttributedTo.id, 'all', recurseIfNeeded) 81 return getOrCreateAPActor(accountAttributedTo, 'all', recurseIfNeeded)
83 } catch (err) { 82 } catch (err) {
84 logger.error('Cannot get or create account attributed to video channel ' + actorUrl) 83 logger.error('Cannot get or create account attributed to video channel ' + actorUrl)
85 throw new Error(err) 84 throw new Error(err)
86 } 85 }
87} 86}
88 87
88async function findOwner (rootUrl: string, attributedTo: APObjectId[] | APObjectId, type: 'Person' | 'Group') {
89 for (const actorToCheck of arrayify(attributedTo)) {
90 const actorObject = await fetchAPObject<ActivityPubActor>(getAPId(actorToCheck))
91
92 if (!actorObject) {
93 logger.warn('Unknown attributed to actor %s for owner %s', actorToCheck, rootUrl)
94 continue
95 }
96
97 if (checkUrlsSameHost(actorObject.id, rootUrl) !== true) {
98 logger.warn(`Account attributed to ${actorObject.id} does not have the same host than owner actor url ${rootUrl}`)
99 continue
100 }
101
102 if (actorObject.type === type) return actorObject
103 }
104
105 return undefined
106}
107
89// --------------------------------------------------------------------------- 108// ---------------------------------------------------------------------------
90 109
91export { 110export {
92 getOrCreateAPOwner, 111 getOrCreateAPOwner,
93 getOrCreateAPActor 112 getOrCreateAPActor,
113 findOwner
94} 114}
95 115
96// --------------------------------------------------------------------------- 116// ---------------------------------------------------------------------------
diff --git a/server/lib/activitypub/actors/refresh.ts b/server/lib/activitypub/actors/refresh.ts
index 6d8428d66..d15cb5e90 100644
--- a/server/lib/activitypub/actors/refresh.ts
+++ b/server/lib/activitypub/actors/refresh.ts
@@ -1,5 +1,5 @@
1import { logger, loggerTagsFactory } from '@server/helpers/logger' 1import { logger, loggerTagsFactory } from '@server/helpers/logger'
2import { PromiseCache } from '@server/helpers/promise-cache' 2import { CachePromiseFactory } from '@server/helpers/promise-cache'
3import { PeerTubeRequestError } from '@server/helpers/requests' 3import { PeerTubeRequestError } from '@server/helpers/requests'
4import { ActorLoadByUrlType } from '@server/lib/model-loaders' 4import { ActorLoadByUrlType } from '@server/lib/model-loaders'
5import { ActorModel } from '@server/models/actor/actor' 5import { ActorModel } from '@server/models/actor/actor'
@@ -16,7 +16,7 @@ type RefreshOptions <T> = {
16 fetchedType: ActorLoadByUrlType 16 fetchedType: ActorLoadByUrlType
17} 17}
18 18
19const promiseCache = new PromiseCache(doRefresh, (options: RefreshOptions<MActorFull | MActorAccountChannelId>) => options.actor.url) 19const promiseCache = new CachePromiseFactory(doRefresh, (options: RefreshOptions<MActorFull | MActorAccountChannelId>) => options.actor.url)
20 20
21function refreshActorIfNeeded <T extends MActorFull | MActorAccountChannelId> (options: RefreshOptions<T>): RefreshResult <T> { 21function refreshActorIfNeeded <T extends MActorFull | MActorAccountChannelId> (options: RefreshOptions<T>): RefreshResult <T> {
22 const actorArg = options.actor 22 const actorArg = options.actor
diff --git a/server/lib/activitypub/context.ts b/server/lib/activitypub/context.ts
index a3ca52a31..750276a11 100644
--- a/server/lib/activitypub/context.ts
+++ b/server/lib/activitypub/context.ts
@@ -46,6 +46,19 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
46 46
47 Infohash: 'pt:Infohash', 47 Infohash: 'pt:Infohash',
48 48
49 tileWidth: {
50 '@type': 'sc:Number',
51 '@id': 'pt:tileWidth'
52 },
53 tileHeight: {
54 '@type': 'sc:Number',
55 '@id': 'pt:tileHeight'
56 },
57 tileDuration: {
58 '@type': 'sc:Number',
59 '@id': 'pt:tileDuration'
60 },
61
49 originallyPublishedAt: 'sc:datePublished', 62 originallyPublishedAt: 'sc:datePublished',
50 views: { 63 views: {
51 '@type': 'sc:Number', 64 '@type': 'sc:Number',
diff --git a/server/lib/activitypub/playlists/create-update.ts b/server/lib/activitypub/playlists/create-update.ts
index 9339e8ea4..b24299f29 100644
--- a/server/lib/activitypub/playlists/create-update.ts
+++ b/server/lib/activitypub/playlists/create-update.ts
@@ -4,7 +4,7 @@ import { retryTransactionWrapper } from '@server/helpers/database-utils'
4import { logger, loggerTagsFactory } from '@server/helpers/logger' 4import { logger, loggerTagsFactory } from '@server/helpers/logger'
5import { CRAWL_REQUEST_CONCURRENCY } from '@server/initializers/constants' 5import { CRAWL_REQUEST_CONCURRENCY } from '@server/initializers/constants'
6import { sequelizeTypescript } from '@server/initializers/database' 6import { sequelizeTypescript } from '@server/initializers/database'
7import { updatePlaylistMiniatureFromUrl } from '@server/lib/thumbnail' 7import { updateRemotePlaylistMiniatureFromUrl } from '@server/lib/thumbnail'
8import { VideoPlaylistModel } from '@server/models/video/video-playlist' 8import { VideoPlaylistModel } from '@server/models/video/video-playlist'
9import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element' 9import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element'
10import { FilteredModelAttributes } from '@server/types' 10import { FilteredModelAttributes } from '@server/types'
@@ -77,7 +77,7 @@ async function setVideoChannel (playlistObject: PlaylistObject, playlistAttribut
77 throw new Error('Not attributed to for playlist object ' + getAPId(playlistObject)) 77 throw new Error('Not attributed to for playlist object ' + getAPId(playlistObject))
78 } 78 }
79 79
80 const actor = await getOrCreateAPActor(playlistObject.attributedTo[0], 'all') 80 const actor = await getOrCreateAPActor(getAPId(playlistObject.attributedTo[0]), 'all')
81 81
82 if (!actor.VideoChannel) { 82 if (!actor.VideoChannel) {
83 logger.warn('Playlist "attributedTo" %s is not a video channel.', playlistObject.id, { playlistObject, ...lTags(playlistObject.id) }) 83 logger.warn('Playlist "attributedTo" %s is not a video channel.', playlistObject.id, { playlistObject, ...lTags(playlistObject.id) })
@@ -104,7 +104,7 @@ async function updatePlaylistThumbnail (playlistObject: PlaylistObject, playlist
104 let thumbnailModel: MThumbnail 104 let thumbnailModel: MThumbnail
105 105
106 try { 106 try {
107 thumbnailModel = await updatePlaylistMiniatureFromUrl({ downloadUrl: playlistObject.icon.url, playlist }) 107 thumbnailModel = await updateRemotePlaylistMiniatureFromUrl({ downloadUrl: playlistObject.icon.url, playlist })
108 await playlist.setAndSaveThumbnail(thumbnailModel, undefined) 108 await playlist.setAndSaveThumbnail(thumbnailModel, undefined)
109 } catch (err) { 109 } catch (err) {
110 logger.warn('Cannot set thumbnail of %s.', playlistObject.id, { err, ...lTags(playlistObject.id, playlist.uuid, playlist.url) }) 110 logger.warn('Cannot set thumbnail of %s.', playlistObject.id, { err, ...lTags(playlistObject.id, playlist.uuid, playlist.url) })
diff --git a/server/lib/activitypub/playlists/get.ts b/server/lib/activitypub/playlists/get.ts
index bfaf52cc9..c34554d69 100644
--- a/server/lib/activitypub/playlists/get.ts
+++ b/server/lib/activitypub/playlists/get.ts
@@ -1,12 +1,12 @@
1import { VideoPlaylistModel } from '@server/models/video/video-playlist' 1import { VideoPlaylistModel } from '@server/models/video/video-playlist'
2import { MVideoPlaylistFullSummary } from '@server/types/models' 2import { MVideoPlaylistFullSummary } from '@server/types/models'
3import { APObject } from '@shared/models' 3import { APObjectId } from '@shared/models'
4import { getAPId } from '../activity' 4import { getAPId } from '../activity'
5import { createOrUpdateVideoPlaylist } from './create-update' 5import { createOrUpdateVideoPlaylist } from './create-update'
6import { scheduleRefreshIfNeeded } from './refresh' 6import { scheduleRefreshIfNeeded } from './refresh'
7import { fetchRemoteVideoPlaylist } from './shared' 7import { fetchRemoteVideoPlaylist } from './shared'
8 8
9async function getOrCreateAPVideoPlaylist (playlistObjectArg: APObject): Promise<MVideoPlaylistFullSummary> { 9async function getOrCreateAPVideoPlaylist (playlistObjectArg: APObjectId): Promise<MVideoPlaylistFullSummary> {
10 const playlistUrl = getAPId(playlistObjectArg) 10 const playlistUrl = getAPId(playlistObjectArg)
11 11
12 const playlistFromDatabase = await VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(playlistUrl) 12 const playlistFromDatabase = await VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(playlistUrl)
diff --git a/server/lib/activitypub/process/process-create.ts b/server/lib/activitypub/process/process-create.ts
index 1e6e8956c..e89d1ab45 100644
--- a/server/lib/activitypub/process/process-create.ts
+++ b/server/lib/activitypub/process/process-create.ts
@@ -1,13 +1,24 @@
1import { isBlockedByServerOrAccount } from '@server/lib/blocklist' 1import { isBlockedByServerOrAccount } from '@server/lib/blocklist'
2import { isRedundancyAccepted } from '@server/lib/redundancy' 2import { isRedundancyAccepted } from '@server/lib/redundancy'
3import { VideoModel } from '@server/models/video/video' 3import { VideoModel } from '@server/models/video/video'
4import { ActivityCreate, CacheFileObject, PlaylistObject, VideoCommentObject, VideoObject, WatchActionObject } from '@shared/models' 4import {
5 AbuseObject,
6 ActivityCreate,
7 ActivityCreateObject,
8 ActivityObject,
9 CacheFileObject,
10 PlaylistObject,
11 VideoCommentObject,
12 VideoObject,
13 WatchActionObject
14} from '@shared/models'
5import { retryTransactionWrapper } from '../../../helpers/database-utils' 15import { retryTransactionWrapper } from '../../../helpers/database-utils'
6import { logger } from '../../../helpers/logger' 16import { logger } from '../../../helpers/logger'
7import { sequelizeTypescript } from '../../../initializers/database' 17import { sequelizeTypescript } from '../../../initializers/database'
8import { APProcessorOptions } from '../../../types/activitypub-processor.model' 18import { APProcessorOptions } from '../../../types/activitypub-processor.model'
9import { MActorSignature, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../../types/models' 19import { MActorSignature, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../../types/models'
10import { Notifier } from '../../notifier' 20import { Notifier } from '../../notifier'
21import { fetchAPObject } from '../activity'
11import { createOrUpdateCacheFile } from '../cache-file' 22import { createOrUpdateCacheFile } from '../cache-file'
12import { createOrUpdateLocalVideoViewer } from '../local-video-viewer' 23import { createOrUpdateLocalVideoViewer } from '../local-video-viewer'
13import { createOrUpdateVideoPlaylist } from '../playlists' 24import { createOrUpdateVideoPlaylist } from '../playlists'
@@ -15,35 +26,35 @@ import { forwardVideoRelatedActivity } from '../send/shared/send-utils'
15import { resolveThread } from '../video-comments' 26import { resolveThread } from '../video-comments'
16import { getOrCreateAPVideo } from '../videos' 27import { getOrCreateAPVideo } from '../videos'
17 28
18async function processCreateActivity (options: APProcessorOptions<ActivityCreate>) { 29async function processCreateActivity (options: APProcessorOptions<ActivityCreate<ActivityCreateObject>>) {
19 const { activity, byActor } = options 30 const { activity, byActor } = options
20 31
21 // Only notify if it is not from a fetcher job 32 // Only notify if it is not from a fetcher job
22 const notify = options.fromFetch !== true 33 const notify = options.fromFetch !== true
23 const activityObject = activity.object 34 const activityObject = await fetchAPObject<Exclude<ActivityObject, AbuseObject>>(activity.object)
24 const activityType = activityObject.type 35 const activityType = activityObject.type
25 36
26 if (activityType === 'Video') { 37 if (activityType === 'Video') {
27 return processCreateVideo(activity, notify) 38 return processCreateVideo(activityObject, notify)
28 } 39 }
29 40
30 if (activityType === 'Note') { 41 if (activityType === 'Note') {
31 // Comments will be fetched from videos 42 // Comments will be fetched from videos
32 if (options.fromFetch) return 43 if (options.fromFetch) return
33 44
34 return retryTransactionWrapper(processCreateVideoComment, activity, byActor, notify) 45 return retryTransactionWrapper(processCreateVideoComment, activity, activityObject, byActor, notify)
35 } 46 }
36 47
37 if (activityType === 'WatchAction') { 48 if (activityType === 'WatchAction') {
38 return retryTransactionWrapper(processCreateWatchAction, activity) 49 return retryTransactionWrapper(processCreateWatchAction, activityObject)
39 } 50 }
40 51
41 if (activityType === 'CacheFile') { 52 if (activityType === 'CacheFile') {
42 return retryTransactionWrapper(processCreateCacheFile, activity, byActor) 53 return retryTransactionWrapper(processCreateCacheFile, activity, activityObject, byActor)
43 } 54 }
44 55
45 if (activityType === 'Playlist') { 56 if (activityType === 'Playlist') {
46 return retryTransactionWrapper(processCreatePlaylist, activity, byActor) 57 return retryTransactionWrapper(processCreatePlaylist, activity, activityObject, byActor)
47 } 58 }
48 59
49 logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id }) 60 logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id })
@@ -58,9 +69,7 @@ export {
58 69
59// --------------------------------------------------------------------------- 70// ---------------------------------------------------------------------------
60 71
61async function processCreateVideo (activity: ActivityCreate, notify: boolean) { 72async function processCreateVideo (videoToCreateData: VideoObject, notify: boolean) {
62 const videoToCreateData = activity.object as VideoObject
63
64 const syncParam = { rates: false, shares: false, comments: false, thumbnail: true, refreshVideo: false } 73 const syncParam = { rates: false, shares: false, comments: false, thumbnail: true, refreshVideo: false }
65 const { video, created } = await getOrCreateAPVideo({ videoObject: videoToCreateData, syncParam }) 74 const { video, created } = await getOrCreateAPVideo({ videoObject: videoToCreateData, syncParam })
66 75
@@ -69,11 +78,13 @@ async function processCreateVideo (activity: ActivityCreate, notify: boolean) {
69 return video 78 return video
70} 79}
71 80
72async function processCreateCacheFile (activity: ActivityCreate, byActor: MActorSignature) { 81async function processCreateCacheFile (
82 activity: ActivityCreate<CacheFileObject | string>,
83 cacheFile: CacheFileObject,
84 byActor: MActorSignature
85) {
73 if (await isRedundancyAccepted(activity, byActor) !== true) return 86 if (await isRedundancyAccepted(activity, byActor) !== true) return
74 87
75 const cacheFile = activity.object as CacheFileObject
76
77 const { video } = await getOrCreateAPVideo({ videoObject: cacheFile.object }) 88 const { video } = await getOrCreateAPVideo({ videoObject: cacheFile.object })
78 89
79 await sequelizeTypescript.transaction(async t => { 90 await sequelizeTypescript.transaction(async t => {
@@ -87,9 +98,7 @@ async function processCreateCacheFile (activity: ActivityCreate, byActor: MActor
87 } 98 }
88} 99}
89 100
90async function processCreateWatchAction (activity: ActivityCreate) { 101async function processCreateWatchAction (watchAction: WatchActionObject) {
91 const watchAction = activity.object as WatchActionObject
92
93 if (watchAction.actionStatus !== 'CompletedActionStatus') return 102 if (watchAction.actionStatus !== 'CompletedActionStatus') return
94 103
95 const video = await VideoModel.loadByUrl(watchAction.object) 104 const video = await VideoModel.loadByUrl(watchAction.object)
@@ -100,8 +109,12 @@ async function processCreateWatchAction (activity: ActivityCreate) {
100 }) 109 })
101} 110}
102 111
103async function processCreateVideoComment (activity: ActivityCreate, byActor: MActorSignature, notify: boolean) { 112async function processCreateVideoComment (
104 const commentObject = activity.object as VideoCommentObject 113 activity: ActivityCreate<VideoCommentObject | string>,
114 commentObject: VideoCommentObject,
115 byActor: MActorSignature,
116 notify: boolean
117) {
105 const byAccount = byActor.Account 118 const byAccount = byActor.Account
106 119
107 if (!byAccount) throw new Error('Cannot create video comment with the non account actor ' + byActor.url) 120 if (!byAccount) throw new Error('Cannot create video comment with the non account actor ' + byActor.url)
@@ -144,8 +157,11 @@ async function processCreateVideoComment (activity: ActivityCreate, byActor: MAc
144 if (created && notify) Notifier.Instance.notifyOnNewComment(comment) 157 if (created && notify) Notifier.Instance.notifyOnNewComment(comment)
145} 158}
146 159
147async function processCreatePlaylist (activity: ActivityCreate, byActor: MActorSignature) { 160async function processCreatePlaylist (
148 const playlistObject = activity.object as PlaylistObject 161 activity: ActivityCreate<PlaylistObject | string>,
162 playlistObject: PlaylistObject,
163 byActor: MActorSignature
164) {
149 const byAccount = byActor.Account 165 const byAccount = byActor.Account
150 166
151 if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url) 167 if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url)
diff --git a/server/lib/activitypub/process/process-dislike.ts b/server/lib/activitypub/process/process-dislike.ts
index 44e349b22..4e270f917 100644
--- a/server/lib/activitypub/process/process-dislike.ts
+++ b/server/lib/activitypub/process/process-dislike.ts
@@ -1,5 +1,5 @@
1import { VideoModel } from '@server/models/video/video' 1import { VideoModel } from '@server/models/video/video'
2import { ActivityCreate, ActivityDislike, DislikeObject } from '@shared/models' 2import { ActivityDislike } from '@shared/models'
3import { retryTransactionWrapper } from '../../../helpers/database-utils' 3import { retryTransactionWrapper } from '../../../helpers/database-utils'
4import { sequelizeTypescript } from '../../../initializers/database' 4import { sequelizeTypescript } from '../../../initializers/database'
5import { AccountVideoRateModel } from '../../../models/account/account-video-rate' 5import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
@@ -7,7 +7,7 @@ import { APProcessorOptions } from '../../../types/activitypub-processor.model'
7import { MActorSignature } from '../../../types/models' 7import { MActorSignature } from '../../../types/models'
8import { federateVideoIfNeeded, getOrCreateAPVideo } from '../videos' 8import { federateVideoIfNeeded, getOrCreateAPVideo } from '../videos'
9 9
10async function processDislikeActivity (options: APProcessorOptions<ActivityCreate | ActivityDislike>) { 10async function processDislikeActivity (options: APProcessorOptions<ActivityDislike>) {
11 const { activity, byActor } = options 11 const { activity, byActor } = options
12 return retryTransactionWrapper(processDislike, activity, byActor) 12 return retryTransactionWrapper(processDislike, activity, byActor)
13} 13}
@@ -20,11 +20,8 @@ export {
20 20
21// --------------------------------------------------------------------------- 21// ---------------------------------------------------------------------------
22 22
23async function processDislike (activity: ActivityCreate | ActivityDislike, byActor: MActorSignature) { 23async function processDislike (activity: ActivityDislike, byActor: MActorSignature) {
24 const dislikeObject = activity.type === 'Dislike' 24 const dislikeObject = activity.object
25 ? activity.object
26 : (activity.object as DislikeObject).object
27
28 const byAccount = byActor.Account 25 const byAccount = byActor.Account
29 26
30 if (!byAccount) throw new Error('Cannot create dislike with the non account actor ' + byActor.url) 27 if (!byAccount) throw new Error('Cannot create dislike with the non account actor ' + byActor.url)
diff --git a/server/lib/activitypub/process/process-flag.ts b/server/lib/activitypub/process/process-flag.ts
index 10f58ef27..bea285670 100644
--- a/server/lib/activitypub/process/process-flag.ts
+++ b/server/lib/activitypub/process/process-flag.ts
@@ -3,7 +3,7 @@ import { AccountModel } from '@server/models/account/account'
3import { VideoModel } from '@server/models/video/video' 3import { VideoModel } from '@server/models/video/video'
4import { VideoCommentModel } from '@server/models/video/video-comment' 4import { VideoCommentModel } from '@server/models/video/video-comment'
5import { abusePredefinedReasonsMap } from '@shared/core-utils/abuse' 5import { abusePredefinedReasonsMap } from '@shared/core-utils/abuse'
6import { AbuseObject, AbuseState, ActivityCreate, ActivityFlag } from '@shared/models' 6import { AbuseState, ActivityFlag } from '@shared/models'
7import { retryTransactionWrapper } from '../../../helpers/database-utils' 7import { retryTransactionWrapper } from '../../../helpers/database-utils'
8import { logger } from '../../../helpers/logger' 8import { logger } from '../../../helpers/logger'
9import { sequelizeTypescript } from '../../../initializers/database' 9import { sequelizeTypescript } from '../../../initializers/database'
@@ -11,7 +11,7 @@ import { getAPId } from '../../../lib/activitypub/activity'
11import { APProcessorOptions } from '../../../types/activitypub-processor.model' 11import { APProcessorOptions } from '../../../types/activitypub-processor.model'
12import { MAccountDefault, MActorSignature, MCommentOwnerVideo } from '../../../types/models' 12import { MAccountDefault, MActorSignature, MCommentOwnerVideo } from '../../../types/models'
13 13
14async function processFlagActivity (options: APProcessorOptions<ActivityCreate | ActivityFlag>) { 14async function processFlagActivity (options: APProcessorOptions<ActivityFlag>) {
15 const { activity, byActor } = options 15 const { activity, byActor } = options
16 16
17 return retryTransactionWrapper(processCreateAbuse, activity, byActor) 17 return retryTransactionWrapper(processCreateAbuse, activity, byActor)
@@ -25,9 +25,7 @@ export {
25 25
26// --------------------------------------------------------------------------- 26// ---------------------------------------------------------------------------
27 27
28async function processCreateAbuse (activity: ActivityCreate | ActivityFlag, byActor: MActorSignature) { 28async function processCreateAbuse (flag: ActivityFlag, byActor: MActorSignature) {
29 const flag = activity.type === 'Flag' ? activity : (activity.object as AbuseObject)
30
31 const account = byActor.Account 29 const account = byActor.Account
32 if (!account) throw new Error('Cannot create abuse with the non account actor ' + byActor.url) 30 if (!account) throw new Error('Cannot create abuse with the non account actor ' + byActor.url)
33 31
diff --git a/server/lib/activitypub/process/process-undo.ts b/server/lib/activitypub/process/process-undo.ts
index 99423a72b..25f68724d 100644
--- a/server/lib/activitypub/process/process-undo.ts
+++ b/server/lib/activitypub/process/process-undo.ts
@@ -1,6 +1,14 @@
1import { VideoModel } from '@server/models/video/video' 1import { VideoModel } from '@server/models/video/video'
2import { ActivityAnnounce, ActivityFollow, ActivityLike, ActivityUndo, CacheFileObject } from '../../../../shared/models/activitypub' 2import {
3import { DislikeObject } from '../../../../shared/models/activitypub/objects' 3 ActivityAnnounce,
4 ActivityCreate,
5 ActivityDislike,
6 ActivityFollow,
7 ActivityLike,
8 ActivityUndo,
9 ActivityUndoObject,
10 CacheFileObject
11} from '../../../../shared/models/activitypub'
4import { retryTransactionWrapper } from '../../../helpers/database-utils' 12import { retryTransactionWrapper } from '../../../helpers/database-utils'
5import { logger } from '../../../helpers/logger' 13import { logger } from '../../../helpers/logger'
6import { sequelizeTypescript } from '../../../initializers/database' 14import { sequelizeTypescript } from '../../../initializers/database'
@@ -11,10 +19,11 @@ import { VideoRedundancyModel } from '../../../models/redundancy/video-redundanc
11import { VideoShareModel } from '../../../models/video/video-share' 19import { VideoShareModel } from '../../../models/video/video-share'
12import { APProcessorOptions } from '../../../types/activitypub-processor.model' 20import { APProcessorOptions } from '../../../types/activitypub-processor.model'
13import { MActorSignature } from '../../../types/models' 21import { MActorSignature } from '../../../types/models'
22import { fetchAPObject } from '../activity'
14import { forwardVideoRelatedActivity } from '../send/shared/send-utils' 23import { forwardVideoRelatedActivity } from '../send/shared/send-utils'
15import { federateVideoIfNeeded, getOrCreateAPVideo } from '../videos' 24import { federateVideoIfNeeded, getOrCreateAPVideo } from '../videos'
16 25
17async function processUndoActivity (options: APProcessorOptions<ActivityUndo>) { 26async function processUndoActivity (options: APProcessorOptions<ActivityUndo<ActivityUndoObject>>) {
18 const { activity, byActor } = options 27 const { activity, byActor } = options
19 const activityToUndo = activity.object 28 const activityToUndo = activity.object
20 29
@@ -23,8 +32,10 @@ async function processUndoActivity (options: APProcessorOptions<ActivityUndo>) {
23 } 32 }
24 33
25 if (activityToUndo.type === 'Create') { 34 if (activityToUndo.type === 'Create') {
26 if (activityToUndo.object.type === 'CacheFile') { 35 const objectToUndo = await fetchAPObject<CacheFileObject>(activityToUndo.object)
27 return retryTransactionWrapper(processUndoCacheFile, byActor, activity) 36
37 if (objectToUndo.type === 'CacheFile') {
38 return retryTransactionWrapper(processUndoCacheFile, byActor, activity, objectToUndo)
28 } 39 }
29 } 40 }
30 41
@@ -53,8 +64,8 @@ export {
53 64
54// --------------------------------------------------------------------------- 65// ---------------------------------------------------------------------------
55 66
56async function processUndoLike (byActor: MActorSignature, activity: ActivityUndo) { 67async function processUndoLike (byActor: MActorSignature, activity: ActivityUndo<ActivityLike>) {
57 const likeActivity = activity.object as ActivityLike 68 const likeActivity = activity.object
58 69
59 const { video: onlyVideo } = await getOrCreateAPVideo({ videoObject: likeActivity.object }) 70 const { video: onlyVideo } = await getOrCreateAPVideo({ videoObject: likeActivity.object })
60 // We don't care about likes of remote videos 71 // We don't care about likes of remote videos
@@ -78,12 +89,10 @@ async function processUndoLike (byActor: MActorSignature, activity: ActivityUndo
78 }) 89 })
79} 90}
80 91
81async function processUndoDislike (byActor: MActorSignature, activity: ActivityUndo) { 92async function processUndoDislike (byActor: MActorSignature, activity: ActivityUndo<ActivityDislike>) {
82 const dislike = activity.object.type === 'Dislike' 93 const dislikeActivity = activity.object
83 ? activity.object
84 : activity.object.object as DislikeObject
85 94
86 const { video: onlyVideo } = await getOrCreateAPVideo({ videoObject: dislike.object }) 95 const { video: onlyVideo } = await getOrCreateAPVideo({ videoObject: dislikeActivity.object })
87 // We don't care about likes of remote videos 96 // We don't care about likes of remote videos
88 if (!onlyVideo.isOwned()) return 97 if (!onlyVideo.isOwned()) return
89 98
@@ -91,7 +100,7 @@ async function processUndoDislike (byActor: MActorSignature, activity: ActivityU
91 if (!byActor.Account) throw new Error('Unknown account ' + byActor.url) 100 if (!byActor.Account) throw new Error('Unknown account ' + byActor.url)
92 101
93 const video = await VideoModel.loadFull(onlyVideo.id, t) 102 const video = await VideoModel.loadFull(onlyVideo.id, t)
94 const rate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byActor.Account.id, video.id, dislike.id, t) 103 const rate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byActor.Account.id, video.id, dislikeActivity.id, t)
95 if (!rate || rate.type !== 'dislike') { 104 if (!rate || rate.type !== 'dislike') {
96 logger.warn(`Unknown dislike by account %d for video %d.`, byActor.Account.id, video.id) 105 logger.warn(`Unknown dislike by account %d for video %d.`, byActor.Account.id, video.id)
97 return 106 return
@@ -107,9 +116,11 @@ async function processUndoDislike (byActor: MActorSignature, activity: ActivityU
107 116
108// --------------------------------------------------------------------------- 117// ---------------------------------------------------------------------------
109 118
110async function processUndoCacheFile (byActor: MActorSignature, activity: ActivityUndo) { 119async function processUndoCacheFile (
111 const cacheFileObject = activity.object.object as CacheFileObject 120 byActor: MActorSignature,
112 121 activity: ActivityUndo<ActivityCreate<CacheFileObject>>,
122 cacheFileObject: CacheFileObject
123) {
113 const { video } = await getOrCreateAPVideo({ videoObject: cacheFileObject.object }) 124 const { video } = await getOrCreateAPVideo({ videoObject: cacheFileObject.object })
114 125
115 return sequelizeTypescript.transaction(async t => { 126 return sequelizeTypescript.transaction(async t => {
diff --git a/server/lib/activitypub/process/process-update.ts b/server/lib/activitypub/process/process-update.ts
index 4afdbd430..9caa74e04 100644
--- a/server/lib/activitypub/process/process-update.ts
+++ b/server/lib/activitypub/process/process-update.ts
@@ -1,5 +1,5 @@
1import { isRedundancyAccepted } from '@server/lib/redundancy' 1import { isRedundancyAccepted } from '@server/lib/redundancy'
2import { ActivityUpdate, CacheFileObject, VideoObject } from '../../../../shared/models/activitypub' 2import { ActivityUpdate, ActivityUpdateObject, CacheFileObject, VideoObject } from '../../../../shared/models/activitypub'
3import { ActivityPubActor } from '../../../../shared/models/activitypub/activitypub-actor' 3import { ActivityPubActor } from '../../../../shared/models/activitypub/activitypub-actor'
4import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object' 4import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
5import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file' 5import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file'
@@ -10,16 +10,18 @@ import { sequelizeTypescript } from '../../../initializers/database'
10import { ActorModel } from '../../../models/actor/actor' 10import { ActorModel } from '../../../models/actor/actor'
11import { APProcessorOptions } from '../../../types/activitypub-processor.model' 11import { APProcessorOptions } from '../../../types/activitypub-processor.model'
12import { MActorFull, MActorSignature } from '../../../types/models' 12import { MActorFull, MActorSignature } from '../../../types/models'
13import { fetchAPObject } from '../activity'
13import { APActorUpdater } from '../actors/updater' 14import { APActorUpdater } from '../actors/updater'
14import { createOrUpdateCacheFile } from '../cache-file' 15import { createOrUpdateCacheFile } from '../cache-file'
15import { createOrUpdateVideoPlaylist } from '../playlists' 16import { createOrUpdateVideoPlaylist } from '../playlists'
16import { forwardVideoRelatedActivity } from '../send/shared/send-utils' 17import { forwardVideoRelatedActivity } from '../send/shared/send-utils'
17import { APVideoUpdater, getOrCreateAPVideo } from '../videos' 18import { APVideoUpdater, getOrCreateAPVideo } from '../videos'
18 19
19async function processUpdateActivity (options: APProcessorOptions<ActivityUpdate>) { 20async function processUpdateActivity (options: APProcessorOptions<ActivityUpdate<ActivityUpdateObject>>) {
20 const { activity, byActor } = options 21 const { activity, byActor } = options
21 22
22 const objectType = activity.object.type 23 const object = await fetchAPObject(activity.object)
24 const objectType = object.type
23 25
24 if (objectType === 'Video') { 26 if (objectType === 'Video') {
25 return retryTransactionWrapper(processUpdateVideo, activity) 27 return retryTransactionWrapper(processUpdateVideo, activity)
@@ -28,17 +30,17 @@ async function processUpdateActivity (options: APProcessorOptions<ActivityUpdate
28 if (objectType === 'Person' || objectType === 'Application' || objectType === 'Group') { 30 if (objectType === 'Person' || objectType === 'Application' || objectType === 'Group') {
29 // We need more attributes 31 // We need more attributes
30 const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url) 32 const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
31 return retryTransactionWrapper(processUpdateActor, byActorFull, activity) 33 return retryTransactionWrapper(processUpdateActor, byActorFull, object)
32 } 34 }
33 35
34 if (objectType === 'CacheFile') { 36 if (objectType === 'CacheFile') {
35 // We need more attributes 37 // We need more attributes
36 const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url) 38 const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
37 return retryTransactionWrapper(processUpdateCacheFile, byActorFull, activity) 39 return retryTransactionWrapper(processUpdateCacheFile, byActorFull, activity, object)
38 } 40 }
39 41
40 if (objectType === 'Playlist') { 42 if (objectType === 'Playlist') {
41 return retryTransactionWrapper(processUpdatePlaylist, byActor, activity) 43 return retryTransactionWrapper(processUpdatePlaylist, byActor, activity, object)
42 } 44 }
43 45
44 return undefined 46 return undefined
@@ -52,7 +54,7 @@ export {
52 54
53// --------------------------------------------------------------------------- 55// ---------------------------------------------------------------------------
54 56
55async function processUpdateVideo (activity: ActivityUpdate) { 57async function processUpdateVideo (activity: ActivityUpdate<VideoObject | string>) {
56 const videoObject = activity.object as VideoObject 58 const videoObject = activity.object as VideoObject
57 59
58 if (sanitizeAndCheckVideoTorrentObject(videoObject) === false) { 60 if (sanitizeAndCheckVideoTorrentObject(videoObject) === false) {
@@ -72,11 +74,13 @@ async function processUpdateVideo (activity: ActivityUpdate) {
72 return updater.update(activity.to) 74 return updater.update(activity.to)
73} 75}
74 76
75async function processUpdateCacheFile (byActor: MActorSignature, activity: ActivityUpdate) { 77async function processUpdateCacheFile (
78 byActor: MActorSignature,
79 activity: ActivityUpdate<CacheFileObject | string>,
80 cacheFileObject: CacheFileObject
81) {
76 if (await isRedundancyAccepted(activity, byActor) !== true) return 82 if (await isRedundancyAccepted(activity, byActor) !== true) return
77 83
78 const cacheFileObject = activity.object as CacheFileObject
79
80 if (!isCacheFileObjectValid(cacheFileObject)) { 84 if (!isCacheFileObjectValid(cacheFileObject)) {
81 logger.debug('Cache file object sent by update is not valid.', { cacheFileObject }) 85 logger.debug('Cache file object sent by update is not valid.', { cacheFileObject })
82 return undefined 86 return undefined
@@ -96,19 +100,19 @@ async function processUpdateCacheFile (byActor: MActorSignature, activity: Activ
96 } 100 }
97} 101}
98 102
99async function processUpdateActor (actor: MActorFull, activity: ActivityUpdate) { 103async function processUpdateActor (actor: MActorFull, actorObject: ActivityPubActor) {
100 const actorObject = activity.object as ActivityPubActor
101
102 logger.debug('Updating remote account "%s".', actorObject.url) 104 logger.debug('Updating remote account "%s".', actorObject.url)
103 105
104 const updater = new APActorUpdater(actorObject, actor) 106 const updater = new APActorUpdater(actorObject, actor)
105 return updater.update() 107 return updater.update()
106} 108}
107 109
108async function processUpdatePlaylist (byActor: MActorSignature, activity: ActivityUpdate) { 110async function processUpdatePlaylist (
109 const playlistObject = activity.object as PlaylistObject 111 byActor: MActorSignature,
112 activity: ActivityUpdate<PlaylistObject | string>,
113 playlistObject: PlaylistObject
114) {
110 const byAccount = byActor.Account 115 const byAccount = byActor.Account
111
112 if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url) 116 if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url)
113 117
114 await createOrUpdateVideoPlaylist(playlistObject, activity.to) 118 await createOrUpdateVideoPlaylist(playlistObject, activity.to)
diff --git a/server/lib/activitypub/send/send-create.ts b/server/lib/activitypub/send/send-create.ts
index 0e996ab80..2cd4db14d 100644
--- a/server/lib/activitypub/send/send-create.ts
+++ b/server/lib/activitypub/send/send-create.ts
@@ -1,6 +1,14 @@
1import { Transaction } from 'sequelize' 1import { Transaction } from 'sequelize'
2import { getServerActor } from '@server/models/application/application' 2import { getServerActor } from '@server/models/application/application'
3import { ActivityAudience, ActivityCreate, ContextType, VideoPlaylistPrivacy, VideoPrivacy } from '@shared/models' 3import {
4 ActivityAudience,
5 ActivityCreate,
6 ActivityCreateObject,
7 ContextType,
8 VideoCommentObject,
9 VideoPlaylistPrivacy,
10 VideoPrivacy
11} from '@shared/models'
4import { logger, loggerTagsFactory } from '../../../helpers/logger' 12import { logger, loggerTagsFactory } from '../../../helpers/logger'
5import { VideoCommentModel } from '../../../models/video/video-comment' 13import { VideoCommentModel } from '../../../models/video/video-comment'
6import { 14import {
@@ -107,7 +115,7 @@ async function sendCreateVideoComment (comment: MCommentOwnerVideo, transaction:
107 115
108 const byActor = comment.Account.Actor 116 const byActor = comment.Account.Actor
109 const threadParentComments = await VideoCommentModel.listThreadParentComments(comment, transaction) 117 const threadParentComments = await VideoCommentModel.listThreadParentComments(comment, transaction)
110 const commentObject = comment.toActivityPubObject(threadParentComments) 118 const commentObject = comment.toActivityPubObject(threadParentComments) as VideoCommentObject
111 119
112 const actorsInvolvedInComment = await getActorsInvolvedInVideo(comment.Video, transaction) 120 const actorsInvolvedInComment = await getActorsInvolvedInVideo(comment.Video, transaction)
113 // Add the actor that commented too 121 // Add the actor that commented too
@@ -168,7 +176,12 @@ async function sendCreateVideoComment (comment: MCommentOwnerVideo, transaction:
168 }) 176 })
169} 177}
170 178
171function buildCreateActivity (url: string, byActor: MActorLight, object: any, audience?: ActivityAudience): ActivityCreate { 179function buildCreateActivity <T extends ActivityCreateObject> (
180 url: string,
181 byActor: MActorLight,
182 object: T,
183 audience?: ActivityAudience
184): ActivityCreate<T> {
172 if (!audience) audience = getAudience(byActor) 185 if (!audience) audience = getAudience(byActor)
173 186
174 return audiencify( 187 return audiencify(
@@ -176,7 +189,9 @@ function buildCreateActivity (url: string, byActor: MActorLight, object: any, au
176 type: 'Create' as 'Create', 189 type: 'Create' as 'Create',
177 id: url + '/activity', 190 id: url + '/activity',
178 actor: byActor.url, 191 actor: byActor.url,
179 object: audiencify(object, audience) 192 object: typeof object === 'string'
193 ? object
194 : audiencify(object, audience)
180 }, 195 },
181 audience 196 audience
182 ) 197 )
diff --git a/server/lib/activitypub/send/send-undo.ts b/server/lib/activitypub/send/send-undo.ts
index b8eb47ff6..b0b48c9c4 100644
--- a/server/lib/activitypub/send/send-undo.ts
+++ b/server/lib/activitypub/send/send-undo.ts
@@ -1,14 +1,5 @@
1import { Transaction } from 'sequelize' 1import { Transaction } from 'sequelize'
2import { 2import { ActivityAudience, ActivityDislike, ActivityLike, ActivityUndo, ActivityUndoObject, ContextType } from '@shared/models'
3 ActivityAnnounce,
4 ActivityAudience,
5 ActivityCreate,
6 ActivityDislike,
7 ActivityFollow,
8 ActivityLike,
9 ActivityUndo,
10 ContextType
11} from '@shared/models'
12import { logger } from '../../../helpers/logger' 3import { logger } from '../../../helpers/logger'
13import { VideoModel } from '../../../models/video/video' 4import { VideoModel } from '../../../models/video/video'
14import { 5import {
@@ -128,12 +119,12 @@ export {
128 119
129// --------------------------------------------------------------------------- 120// ---------------------------------------------------------------------------
130 121
131function undoActivityData ( 122function undoActivityData <T extends ActivityUndoObject> (
132 url: string, 123 url: string,
133 byActor: MActorAudience, 124 byActor: MActorAudience,
134 object: ActivityFollow | ActivityLike | ActivityDislike | ActivityCreate | ActivityAnnounce, 125 object: T,
135 audience?: ActivityAudience 126 audience?: ActivityAudience
136): ActivityUndo { 127): ActivityUndo<T> {
137 if (!audience) audience = getAudience(byActor) 128 if (!audience) audience = getAudience(byActor)
138 129
139 return audiencify( 130 return audiencify(
@@ -151,7 +142,7 @@ async function sendUndoVideoRelatedActivity (options: {
151 byActor: MActor 142 byActor: MActor
152 video: MVideoAccountLight 143 video: MVideoAccountLight
153 url: string 144 url: string
154 activity: ActivityFollow | ActivityCreate | ActivityAnnounce 145 activity: ActivityUndoObject
155 contextType: ContextType 146 contextType: ContextType
156 transaction: Transaction 147 transaction: Transaction
157}) { 148}) {
diff --git a/server/lib/activitypub/send/send-update.ts b/server/lib/activitypub/send/send-update.ts
index 379e2d9d8..f3fb741c6 100644
--- a/server/lib/activitypub/send/send-update.ts
+++ b/server/lib/activitypub/send/send-update.ts
@@ -1,6 +1,6 @@
1import { Transaction } from 'sequelize' 1import { Transaction } from 'sequelize'
2import { getServerActor } from '@server/models/application/application' 2import { getServerActor } from '@server/models/application/application'
3import { ActivityAudience, ActivityUpdate, VideoPlaylistPrivacy, VideoPrivacy } from '@shared/models' 3import { ActivityAudience, ActivityUpdate, ActivityUpdateObject, VideoPlaylistPrivacy, VideoPrivacy } from '@shared/models'
4import { logger } from '../../../helpers/logger' 4import { logger } from '../../../helpers/logger'
5import { AccountModel } from '../../../models/account/account' 5import { AccountModel } from '../../../models/account/account'
6import { VideoModel } from '../../../models/video/video' 6import { VideoModel } from '../../../models/video/video'
@@ -10,8 +10,7 @@ import {
10 MActor, 10 MActor,
11 MActorLight, 11 MActorLight,
12 MChannelDefault, 12 MChannelDefault,
13 MVideoAP, 13 MVideoAPLight,
14 MVideoAPWithoutCaption,
15 MVideoPlaylistFull, 14 MVideoPlaylistFull,
16 MVideoRedundancyVideo 15 MVideoRedundancyVideo
17} from '../../../types/models' 16} from '../../../types/models'
@@ -20,10 +19,10 @@ import { getUpdateActivityPubUrl } from '../url'
20import { getActorsInvolvedInVideo } from './shared' 19import { getActorsInvolvedInVideo } from './shared'
21import { broadcastToFollowers, sendVideoRelatedActivity } from './shared/send-utils' 20import { broadcastToFollowers, sendVideoRelatedActivity } from './shared/send-utils'
22 21
23async function sendUpdateVideo (videoArg: MVideoAPWithoutCaption, transaction: Transaction, overriddenByActor?: MActor) { 22async function sendUpdateVideo (videoArg: MVideoAPLight, transaction: Transaction, overriddenByActor?: MActor) {
24 const video = videoArg as MVideoAP 23 if (!videoArg.hasPrivacyForFederation()) return undefined
25 24
26 if (!video.hasPrivacyForFederation()) return undefined 25 const video = await videoArg.lightAPToFullAP(transaction)
27 26
28 logger.info('Creating job to update video %s.', video.url) 27 logger.info('Creating job to update video %s.', video.url)
29 28
@@ -31,11 +30,6 @@ async function sendUpdateVideo (videoArg: MVideoAPWithoutCaption, transaction: T
31 30
32 const url = getUpdateActivityPubUrl(video.url, video.updatedAt.toISOString()) 31 const url = getUpdateActivityPubUrl(video.url, video.updatedAt.toISOString())
33 32
34 // Needed to build the AP object
35 if (!video.VideoCaptions) {
36 video.VideoCaptions = await video.$get('VideoCaptions', { transaction })
37 }
38
39 const videoObject = await video.toActivityPubObject() 33 const videoObject = await video.toActivityPubObject()
40 const audience = getAudience(byActor, video.privacy === VideoPrivacy.PUBLIC) 34 const audience = getAudience(byActor, video.privacy === VideoPrivacy.PUBLIC)
41 35
@@ -143,7 +137,12 @@ export {
143 137
144// --------------------------------------------------------------------------- 138// ---------------------------------------------------------------------------
145 139
146function buildUpdateActivity (url: string, byActor: MActorLight, object: any, audience?: ActivityAudience): ActivityUpdate { 140function buildUpdateActivity (
141 url: string,
142 byActor: MActorLight,
143 object: ActivityUpdateObject,
144 audience?: ActivityAudience
145): ActivityUpdate<ActivityUpdateObject> {
147 if (!audience) audience = getAudience(byActor) 146 if (!audience) audience = getAudience(byActor)
148 147
149 return audiencify( 148 return audiencify(
diff --git a/server/lib/activitypub/videos/federate.ts b/server/lib/activitypub/videos/federate.ts
index bd0c54b0c..d7e251153 100644
--- a/server/lib/activitypub/videos/federate.ts
+++ b/server/lib/activitypub/videos/federate.ts
@@ -1,10 +1,9 @@
1import { Transaction } from 'sequelize/types' 1import { Transaction } from 'sequelize/types'
2import { isArray } from '@server/helpers/custom-validators/misc' 2import { MVideoAP, MVideoAPLight } from '@server/types/models'
3import { MVideoAP, MVideoAPWithoutCaption } from '@server/types/models'
4import { sendCreateVideo, sendUpdateVideo } from '../send' 3import { sendCreateVideo, sendUpdateVideo } from '../send'
5import { shareVideoByServerAndChannel } from '../share' 4import { shareVideoByServerAndChannel } from '../share'
6 5
7async function federateVideoIfNeeded (videoArg: MVideoAPWithoutCaption, isNewVideo: boolean, transaction?: Transaction) { 6async function federateVideoIfNeeded (videoArg: MVideoAPLight, isNewVideo: boolean, transaction?: Transaction) {
8 const video = videoArg as MVideoAP 7 const video = videoArg as MVideoAP
9 8
10 if ( 9 if (
@@ -13,13 +12,7 @@ async function federateVideoIfNeeded (videoArg: MVideoAPWithoutCaption, isNewVid
13 // Check the video is public/unlisted and published 12 // Check the video is public/unlisted and published
14 video.hasPrivacyForFederation() && video.hasStateForFederation() 13 video.hasPrivacyForFederation() && video.hasStateForFederation()
15 ) { 14 ) {
16 // Fetch more attributes that we will need to serialize in AP object 15 const video = await videoArg.lightAPToFullAP(transaction)
17 if (isArray(video.VideoCaptions) === false) {
18 video.VideoCaptions = await video.$get('VideoCaptions', {
19 attributes: [ 'filename', 'language' ],
20 transaction
21 })
22 }
23 16
24 if (isNewVideo) { 17 if (isNewVideo) {
25 // Now we'll add the video's meta data to our followers 18 // Now we'll add the video's meta data to our followers
diff --git a/server/lib/activitypub/videos/get.ts b/server/lib/activitypub/videos/get.ts
index 14ba55034..92387c5d4 100644
--- a/server/lib/activitypub/videos/get.ts
+++ b/server/lib/activitypub/videos/get.ts
@@ -3,7 +3,7 @@ import { logger } from '@server/helpers/logger'
3import { JobQueue } from '@server/lib/job-queue' 3import { JobQueue } from '@server/lib/job-queue'
4import { loadVideoByUrl, VideoLoadByUrlType } from '@server/lib/model-loaders' 4import { loadVideoByUrl, VideoLoadByUrlType } from '@server/lib/model-loaders'
5import { MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from '@server/types/models' 5import { MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from '@server/types/models'
6import { APObject } from '@shared/models' 6import { APObjectId } from '@shared/models'
7import { getAPId } from '../activity' 7import { getAPId } from '../activity'
8import { refreshVideoIfNeeded } from './refresh' 8import { refreshVideoIfNeeded } from './refresh'
9import { APVideoCreator, fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared' 9import { APVideoCreator, fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared'
@@ -15,21 +15,21 @@ type GetVideoResult <T> = Promise<{
15}> 15}>
16 16
17type GetVideoParamAll = { 17type GetVideoParamAll = {
18 videoObject: APObject 18 videoObject: APObjectId
19 syncParam?: SyncParam 19 syncParam?: SyncParam
20 fetchType?: 'all' 20 fetchType?: 'all'
21 allowRefresh?: boolean 21 allowRefresh?: boolean
22} 22}
23 23
24type GetVideoParamImmutable = { 24type GetVideoParamImmutable = {
25 videoObject: APObject 25 videoObject: APObjectId
26 syncParam?: SyncParam 26 syncParam?: SyncParam
27 fetchType: 'only-immutable-attributes' 27 fetchType: 'only-immutable-attributes'
28 allowRefresh: false 28 allowRefresh: false
29} 29}
30 30
31type GetVideoParamOther = { 31type GetVideoParamOther = {
32 videoObject: APObject 32 videoObject: APObjectId
33 syncParam?: SyncParam 33 syncParam?: SyncParam
34 fetchType?: 'all' | 'only-video' 34 fetchType?: 'all' | 'only-video'
35 allowRefresh?: boolean 35 allowRefresh?: boolean
diff --git a/server/lib/activitypub/videos/shared/abstract-builder.ts b/server/lib/activitypub/videos/shared/abstract-builder.ts
index c0b92c93d..98c2f58eb 100644
--- a/server/lib/activitypub/videos/shared/abstract-builder.ts
+++ b/server/lib/activitypub/videos/shared/abstract-builder.ts
@@ -1,8 +1,9 @@
1import { CreationAttributes, Transaction } from 'sequelize/types' 1import { CreationAttributes, Transaction } from 'sequelize/types'
2import { deleteAllModels, filterNonExistingModels } from '@server/helpers/database-utils' 2import { deleteAllModels, filterNonExistingModels } from '@server/helpers/database-utils'
3import { logger, LoggerTagsFn } from '@server/helpers/logger' 3import { logger, LoggerTagsFn } from '@server/helpers/logger'
4import { updatePlaceholderThumbnail, updateVideoMiniatureFromUrl } from '@server/lib/thumbnail' 4import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail'
5import { setVideoTags } from '@server/lib/video' 5import { setVideoTags } from '@server/lib/video'
6import { StoryboardModel } from '@server/models/video/storyboard'
6import { VideoCaptionModel } from '@server/models/video/video-caption' 7import { VideoCaptionModel } from '@server/models/video/video-caption'
7import { VideoFileModel } from '@server/models/video/video-file' 8import { VideoFileModel } from '@server/models/video/video-file'
8import { VideoLiveModel } from '@server/models/video/video-live' 9import { VideoLiveModel } from '@server/models/video/video-live'
@@ -10,20 +11,19 @@ import { VideoStreamingPlaylistModel } from '@server/models/video/video-streamin
10import { 11import {
11 MStreamingPlaylistFiles, 12 MStreamingPlaylistFiles,
12 MStreamingPlaylistFilesVideo, 13 MStreamingPlaylistFilesVideo,
13 MThumbnail,
14 MVideoCaption, 14 MVideoCaption,
15 MVideoFile, 15 MVideoFile,
16 MVideoFullLight, 16 MVideoFullLight,
17 MVideoThumbnail 17 MVideoThumbnail
18} from '@server/types/models' 18} from '@server/types/models'
19import { ActivityTagObject, ThumbnailType, VideoObject, VideoStreamingPlaylistType } from '@shared/models' 19import { ActivityTagObject, ThumbnailType, VideoObject, VideoStreamingPlaylistType } from '@shared/models'
20import { getOrCreateAPActor } from '../../actors' 20import { findOwner, getOrCreateAPActor } from '../../actors'
21import { checkUrlsSameHost } from '../../url'
22import { 21import {
23 getCaptionAttributesFromObject, 22 getCaptionAttributesFromObject,
24 getFileAttributesFromUrl, 23 getFileAttributesFromUrl,
25 getLiveAttributesFromObject, 24 getLiveAttributesFromObject,
26 getPreviewFromIcons, 25 getPreviewFromIcons,
26 getStoryboardAttributeFromObject,
27 getStreamingPlaylistAttributesFromObject, 27 getStreamingPlaylistAttributesFromObject,
28 getTagsFromObject, 28 getTagsFromObject,
29 getThumbnailFromIcons 29 getThumbnailFromIcons
@@ -35,38 +35,40 @@ export abstract class APVideoAbstractBuilder {
35 protected abstract lTags: LoggerTagsFn 35 protected abstract lTags: LoggerTagsFn
36 36
37 protected async getOrCreateVideoChannelFromVideoObject () { 37 protected async getOrCreateVideoChannelFromVideoObject () {
38 const channel = this.videoObject.attributedTo.find(a => a.type === 'Group') 38 const channel = await findOwner(this.videoObject.id, this.videoObject.attributedTo, 'Group')
39 if (!channel) throw new Error('Cannot find associated video channel to video ' + this.videoObject.url) 39 if (!channel) throw new Error('Cannot find associated video channel to video ' + this.videoObject.url)
40 40
41 if (checkUrlsSameHost(channel.id, this.videoObject.id) !== true) {
42 throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${this.videoObject.id}`)
43 }
44
45 return getOrCreateAPActor(channel.id, 'all') 41 return getOrCreateAPActor(channel.id, 'all')
46 } 42 }
47 43
48 protected tryToGenerateThumbnail (video: MVideoThumbnail): Promise<MThumbnail> { 44 protected async setThumbnail (video: MVideoThumbnail, t?: Transaction) {
49 return updateVideoMiniatureFromUrl({ 45 const miniatureIcon = getThumbnailFromIcons(this.videoObject)
50 downloadUrl: getThumbnailFromIcons(this.videoObject).url, 46 if (!miniatureIcon) {
51 video, 47 logger.warn('Cannot find thumbnail in video object', { object: this.videoObject })
52 type: ThumbnailType.MINIATURE
53 }).catch(err => {
54 logger.warn('Cannot generate thumbnail of %s.', this.videoObject.id, { err, ...this.lTags() })
55
56 return undefined 48 return undefined
49 }
50
51 const miniatureModel = updateRemoteVideoThumbnail({
52 fileUrl: miniatureIcon.url,
53 video,
54 type: ThumbnailType.MINIATURE,
55 size: miniatureIcon,
56 onDisk: false // Lazy download remote thumbnails
57 }) 57 })
58
59 await video.addAndSaveThumbnail(miniatureModel, t)
58 } 60 }
59 61
60 protected async setPreview (video: MVideoFullLight, t?: Transaction) { 62 protected async setPreview (video: MVideoFullLight, t?: Transaction) {
61 // Don't fetch the preview that could be big, create a placeholder instead
62 const previewIcon = getPreviewFromIcons(this.videoObject) 63 const previewIcon = getPreviewFromIcons(this.videoObject)
63 if (!previewIcon) return 64 if (!previewIcon) return
64 65
65 const previewModel = updatePlaceholderThumbnail({ 66 const previewModel = updateRemoteVideoThumbnail({
66 fileUrl: previewIcon.url, 67 fileUrl: previewIcon.url,
67 video, 68 video,
68 type: ThumbnailType.PREVIEW, 69 type: ThumbnailType.PREVIEW,
69 size: previewIcon 70 size: previewIcon,
71 onDisk: false // Lazy download remote previews
70 }) 72 })
71 73
72 await video.addAndSaveThumbnail(previewModel, t) 74 await video.addAndSaveThumbnail(previewModel, t)
@@ -107,6 +109,16 @@ export abstract class APVideoAbstractBuilder {
107 } 109 }
108 } 110 }
109 111
112 protected async insertOrReplaceStoryboard (video: MVideoFullLight, t: Transaction) {
113 const existingStoryboard = await StoryboardModel.loadByVideo(video.id, t)
114 if (existingStoryboard) await existingStoryboard.destroy({ transaction: t })
115
116 const storyboardAttributes = getStoryboardAttributeFromObject(video, this.videoObject)
117 if (!storyboardAttributes) return
118
119 return StoryboardModel.create(storyboardAttributes, { transaction: t })
120 }
121
110 protected async insertOrReplaceLive (video: MVideoFullLight, transaction: Transaction) { 122 protected async insertOrReplaceLive (video: MVideoFullLight, transaction: Transaction) {
111 const attributes = getLiveAttributesFromObject(video, this.videoObject) 123 const attributes = getLiveAttributesFromObject(video, this.videoObject)
112 const [ videoLive ] = await VideoLiveModel.upsert(attributes, { transaction, returning: true }) 124 const [ videoLive ] = await VideoLiveModel.upsert(attributes, { transaction, returning: true })
@@ -114,7 +126,7 @@ export abstract class APVideoAbstractBuilder {
114 video.VideoLive = videoLive 126 video.VideoLive = videoLive
115 } 127 }
116 128
117 protected async setWebTorrentFiles (video: MVideoFullLight, t: Transaction) { 129 protected async setWebVideoFiles (video: MVideoFullLight, t: Transaction) {
118 const videoFileAttributes = getFileAttributesFromUrl(video, this.videoObject.url) 130 const videoFileAttributes = getFileAttributesFromUrl(video, this.videoObject.url)
119 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a)) 131 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
120 132
diff --git a/server/lib/activitypub/videos/shared/creator.ts b/server/lib/activitypub/videos/shared/creator.ts
index 77321d8a5..bc139e4fa 100644
--- a/server/lib/activitypub/videos/shared/creator.ts
+++ b/server/lib/activitypub/videos/shared/creator.ts
@@ -4,7 +4,7 @@ import { sequelizeTypescript } from '@server/initializers/database'
4import { Hooks } from '@server/lib/plugins/hooks' 4import { Hooks } from '@server/lib/plugins/hooks'
5import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist' 5import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist'
6import { VideoModel } from '@server/models/video/video' 6import { VideoModel } from '@server/models/video/video'
7import { MThumbnail, MVideoFullLight, MVideoThumbnail } from '@server/types/models' 7import { MVideoFullLight, MVideoThumbnail } from '@server/types/models'
8import { VideoObject } from '@shared/models' 8import { VideoObject } from '@shared/models'
9import { APVideoAbstractBuilder } from './abstract-builder' 9import { APVideoAbstractBuilder } from './abstract-builder'
10import { getVideoAttributesFromObject } from './object-to-model-attributes' 10import { getVideoAttributesFromObject } from './object-to-model-attributes'
@@ -27,64 +27,38 @@ export class APVideoCreator extends APVideoAbstractBuilder {
27 const videoData = getVideoAttributesFromObject(channel, this.videoObject, this.videoObject.to) 27 const videoData = getVideoAttributesFromObject(channel, this.videoObject, this.videoObject.to)
28 const video = VideoModel.build({ ...videoData, likes: 0, dislikes: 0 }) as MVideoThumbnail 28 const video = VideoModel.build({ ...videoData, likes: 0, dislikes: 0 }) as MVideoThumbnail
29 29
30 const promiseThumbnail = this.tryToGenerateThumbnail(video)
31
32 let thumbnailModel: MThumbnail
33 if (waitThumbnail === true) {
34 thumbnailModel = await promiseThumbnail
35 }
36
37 const { autoBlacklisted, videoCreated } = await sequelizeTypescript.transaction(async t => { 30 const { autoBlacklisted, videoCreated } = await sequelizeTypescript.transaction(async t => {
38 try { 31 const videoCreated = await video.save({ transaction: t }) as MVideoFullLight
39 const videoCreated = await video.save({ transaction: t }) as MVideoFullLight 32 videoCreated.VideoChannel = channel
40 videoCreated.VideoChannel = channel 33
41 34 await this.setThumbnail(videoCreated, t)
42 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t) 35 await this.setPreview(videoCreated, t)
43 36 await this.setWebVideoFiles(videoCreated, t)
44 await this.setPreview(videoCreated, t) 37 await this.setStreamingPlaylists(videoCreated, t)
45 await this.setWebTorrentFiles(videoCreated, t) 38 await this.setTags(videoCreated, t)
46 await this.setStreamingPlaylists(videoCreated, t) 39 await this.setTrackers(videoCreated, t)
47 await this.setTags(videoCreated, t) 40 await this.insertOrReplaceCaptions(videoCreated, t)
48 await this.setTrackers(videoCreated, t) 41 await this.insertOrReplaceLive(videoCreated, t)
49 await this.insertOrReplaceCaptions(videoCreated, t) 42 await this.insertOrReplaceStoryboard(videoCreated, t)
50 await this.insertOrReplaceLive(videoCreated, t) 43
51 44 // We added a video in this channel, set it as updated
52 // We added a video in this channel, set it as updated 45 await channel.setAsUpdated(t)
53 await channel.setAsUpdated(t) 46
54 47 const autoBlacklisted = await autoBlacklistVideoIfNeeded({
55 const autoBlacklisted = await autoBlacklistVideoIfNeeded({ 48 video: videoCreated,
56 video: videoCreated, 49 user: undefined,
57 user: undefined, 50 isRemote: true,
58 isRemote: true, 51 isNew: true,
59 isNew: true, 52 transaction: t
60 transaction: t 53 })
61 })
62
63 logger.info('Remote video with uuid %s inserted.', this.videoObject.uuid, this.lTags())
64 54
65 Hooks.runAction('action:activity-pub.remote-video.created', { video: videoCreated, videoAPObject: this.videoObject }) 55 logger.info('Remote video with uuid %s inserted.', this.videoObject.uuid, this.lTags())
66 56
67 return { autoBlacklisted, videoCreated } 57 Hooks.runAction('action:activity-pub.remote-video.created', { video: videoCreated, videoAPObject: this.videoObject })
68 } catch (err) {
69 // FIXME: Use rollback hook when https://github.com/sequelize/sequelize/pull/13038 is released
70 if (thumbnailModel) await thumbnailModel.removeThumbnail()
71 58
72 throw err 59 return { autoBlacklisted, videoCreated }
73 }
74 }) 60 })
75 61
76 if (waitThumbnail === false) {
77 // Error is already caught above
78 // eslint-disable-next-line @typescript-eslint/no-floating-promises
79 promiseThumbnail.then(thumbnailModel => {
80 if (!thumbnailModel) return
81
82 thumbnailModel = videoCreated.id
83
84 return thumbnailModel.save()
85 })
86 }
87
88 return { autoBlacklisted, videoCreated } 62 return { autoBlacklisted, videoCreated }
89 } 63 }
90} 64}
diff --git a/server/lib/activitypub/videos/shared/object-to-model-attributes.ts b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
index 8fd0a816c..a9e0bed97 100644
--- a/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
+++ b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
@@ -1,6 +1,6 @@
1import { maxBy, minBy } from 'lodash' 1import { maxBy, minBy } from 'lodash'
2import { decode as magnetUriDecode } from 'magnet-uri' 2import { decode as magnetUriDecode } from 'magnet-uri'
3import { basename } from 'path' 3import { basename, extname } from 'path'
4import { isAPVideoFileUrlMetadataObject } from '@server/helpers/custom-validators/activitypub/videos' 4import { isAPVideoFileUrlMetadataObject } from '@server/helpers/custom-validators/activitypub/videos'
5import { isVideoFileInfoHashValid } from '@server/helpers/custom-validators/videos' 5import { isVideoFileInfoHashValid } from '@server/helpers/custom-validators/videos'
6import { logger } from '@server/helpers/logger' 6import { logger } from '@server/helpers/logger'
@@ -25,6 +25,9 @@ import {
25 VideoStreamingPlaylistType 25 VideoStreamingPlaylistType
26} from '@shared/models' 26} from '@shared/models'
27import { getDurationFromActivityStream } from '../../activity' 27import { getDurationFromActivityStream } from '../../activity'
28import { isArray } from '@server/helpers/custom-validators/misc'
29import { generateImageFilename } from '@server/helpers/image-utils'
30import { arrayify } from '@shared/core-utils'
28 31
29function getThumbnailFromIcons (videoObject: VideoObject) { 32function getThumbnailFromIcons (videoObject: VideoObject) {
30 let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth) 33 let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth)
@@ -166,6 +169,26 @@ function getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObje
166 })) 169 }))
167} 170}
168 171
172function getStoryboardAttributeFromObject (video: MVideoId, videoObject: VideoObject) {
173 if (!isArray(videoObject.preview)) return undefined
174
175 const storyboard = videoObject.preview.find(p => p.rel.includes('storyboard'))
176 if (!storyboard) return undefined
177
178 const url = arrayify(storyboard.url).find(u => u.mediaType === 'image/jpeg')
179
180 return {
181 filename: generateImageFilename(extname(url.href)),
182 totalHeight: url.height,
183 totalWidth: url.width,
184 spriteHeight: url.tileHeight,
185 spriteWidth: url.tileWidth,
186 spriteDuration: getDurationFromActivityStream(url.tileDuration),
187 fileUrl: url.href,
188 videoId: video.id
189 }
190}
191
169function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) { 192function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) {
170 const privacy = to.includes(ACTIVITY_PUB.PUBLIC) 193 const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
171 ? VideoPrivacy.PUBLIC 194 ? VideoPrivacy.PUBLIC
@@ -228,6 +251,7 @@ export {
228 251
229 getLiveAttributesFromObject, 252 getLiveAttributesFromObject,
230 getCaptionAttributesFromObject, 253 getCaptionAttributesFromObject,
254 getStoryboardAttributeFromObject,
231 255
232 getVideoAttributesFromObject 256 getVideoAttributesFromObject
233} 257}
diff --git a/server/lib/activitypub/videos/updater.ts b/server/lib/activitypub/videos/updater.ts
index 6ddd2301b..522d7b043 100644
--- a/server/lib/activitypub/videos/updater.ts
+++ b/server/lib/activitypub/videos/updater.ts
@@ -41,7 +41,7 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
41 try { 41 try {
42 const channelActor = await this.getOrCreateVideoChannelFromVideoObject() 42 const channelActor = await this.getOrCreateVideoChannelFromVideoObject()
43 43
44 const thumbnailModel = await this.tryToGenerateThumbnail(this.video) 44 const thumbnailModel = await this.setThumbnail(this.video)
45 45
46 this.checkChannelUpdateOrThrow(channelActor) 46 this.checkChannelUpdateOrThrow(channelActor)
47 47
@@ -50,15 +50,21 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
50 if (thumbnailModel) await videoUpdated.addAndSaveThumbnail(thumbnailModel) 50 if (thumbnailModel) await videoUpdated.addAndSaveThumbnail(thumbnailModel)
51 51
52 await runInReadCommittedTransaction(async t => { 52 await runInReadCommittedTransaction(async t => {
53 await this.setWebTorrentFiles(videoUpdated, t) 53 await this.setWebVideoFiles(videoUpdated, t)
54 await this.setStreamingPlaylists(videoUpdated, t) 54 await this.setStreamingPlaylists(videoUpdated, t)
55 }) 55 })
56 56
57 await Promise.all([ 57 await Promise.all([
58 runInReadCommittedTransaction(t => this.setTags(videoUpdated, t)), 58 runInReadCommittedTransaction(t => this.setTags(videoUpdated, t)),
59 runInReadCommittedTransaction(t => this.setTrackers(videoUpdated, t)), 59 runInReadCommittedTransaction(t => this.setTrackers(videoUpdated, t)),
60 this.setOrDeleteLive(videoUpdated), 60 runInReadCommittedTransaction(t => this.setStoryboard(videoUpdated, t)),
61 this.setPreview(videoUpdated) 61 runInReadCommittedTransaction(t => {
62 return Promise.all([
63 this.setPreview(videoUpdated, t),
64 this.setThumbnail(videoUpdated, t)
65 ])
66 }),
67 this.setOrDeleteLive(videoUpdated)
62 ]) 68 ])
63 69
64 await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t)) 70 await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t))
@@ -138,6 +144,10 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
138 await this.insertOrReplaceCaptions(videoUpdated, t) 144 await this.insertOrReplaceCaptions(videoUpdated, t)
139 } 145 }
140 146
147 private async setStoryboard (videoUpdated: MVideoFullLight, t: Transaction) {
148 await this.insertOrReplaceStoryboard(videoUpdated, t)
149 }
150
141 private async setOrDeleteLive (videoUpdated: MVideoFullLight, transaction?: Transaction) { 151 private async setOrDeleteLive (videoUpdated: MVideoFullLight, transaction?: Transaction) {
142 if (!this.video.isLive) return 152 if (!this.video.isLive) return
143 153
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts
index 18b16bee1..be6df1792 100644
--- a/server/lib/client-html.ts
+++ b/server/lib/client-html.ts
@@ -32,6 +32,7 @@ import { getActivityStreamDuration } from './activitypub/activity'
32import { getBiggestActorImage } from './actor-image' 32import { getBiggestActorImage } from './actor-image'
33import { Hooks } from './plugins/hooks' 33import { Hooks } from './plugins/hooks'
34import { ServerConfigManager } from './server-config-manager' 34import { ServerConfigManager } from './server-config-manager'
35import { isVideoInPrivateDirectory } from './video-privacy'
35 36
36type Tags = { 37type Tags = {
37 ogType: string 38 ogType: string
@@ -106,7 +107,7 @@ class ClientHtml {
106 ]) 107 ])
107 108
108 // Let Angular application handle errors 109 // Let Angular application handle errors
109 if (!video || video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL || video.VideoBlacklist) { 110 if (!video || isVideoInPrivateDirectory(video.privacy) || video.VideoBlacklist) {
110 res.status(HttpStatusCode.NOT_FOUND_404) 111 res.status(HttpStatusCode.NOT_FOUND_404)
111 return html 112 return html
112 } 113 }
diff --git a/server/lib/files-cache/avatar-permanent-file-cache.ts b/server/lib/files-cache/avatar-permanent-file-cache.ts
new file mode 100644
index 000000000..0c508b063
--- /dev/null
+++ b/server/lib/files-cache/avatar-permanent-file-cache.ts
@@ -0,0 +1,27 @@
1import { CONFIG } from '@server/initializers/config'
2import { ACTOR_IMAGES_SIZE } from '@server/initializers/constants'
3import { ActorImageModel } from '@server/models/actor/actor-image'
4import { MActorImage } from '@server/types/models'
5import { AbstractPermanentFileCache } from './shared'
6
7export class AvatarPermanentFileCache extends AbstractPermanentFileCache<MActorImage> {
8
9 constructor () {
10 super(CONFIG.STORAGE.ACTOR_IMAGES_DIR)
11 }
12
13 protected loadModel (filename: string) {
14 return ActorImageModel.loadByName(filename)
15 }
16
17 protected getImageSize (image: MActorImage): { width: number, height: number } {
18 if (image.width && image.height) {
19 return {
20 height: image.height,
21 width: image.width
22 }
23 }
24
25 return ACTOR_IMAGES_SIZE[image.type][0]
26 }
27}
diff --git a/server/lib/files-cache/index.ts b/server/lib/files-cache/index.ts
index e5853f7d6..5630a9b80 100644
--- a/server/lib/files-cache/index.ts
+++ b/server/lib/files-cache/index.ts
@@ -1,3 +1,6 @@
1export * from './videos-preview-cache' 1export * from './avatar-permanent-file-cache'
2export * from './videos-caption-cache' 2export * from './video-miniature-permanent-file-cache'
3export * from './videos-torrent-cache' 3export * from './video-captions-simple-file-cache'
4export * from './video-previews-simple-file-cache'
5export * from './video-storyboards-simple-file-cache'
6export * from './video-torrents-simple-file-cache'
diff --git a/server/lib/files-cache/shared/abstract-permanent-file-cache.ts b/server/lib/files-cache/shared/abstract-permanent-file-cache.ts
new file mode 100644
index 000000000..f990e9872
--- /dev/null
+++ b/server/lib/files-cache/shared/abstract-permanent-file-cache.ts
@@ -0,0 +1,132 @@
1import express from 'express'
2import { LRUCache } from 'lru-cache'
3import { Model } from 'sequelize'
4import { logger } from '@server/helpers/logger'
5import { CachePromise } from '@server/helpers/promise-cache'
6import { LRU_CACHE, STATIC_MAX_AGE } from '@server/initializers/constants'
7import { downloadImageFromWorker } from '@server/lib/worker/parent-process'
8import { HttpStatusCode } from '@shared/models'
9
10type ImageModel = {
11 fileUrl: string
12 filename: string
13 onDisk: boolean
14
15 isOwned (): boolean
16 getPath (): string
17
18 save (): Promise<Model>
19}
20
21export abstract class AbstractPermanentFileCache <M extends ImageModel> {
22 // Unsafe because it can return paths that do not exist anymore
23 private readonly filenameToPathUnsafeCache = new LRUCache<string, string>({
24 max: LRU_CACHE.FILENAME_TO_PATH_PERMANENT_FILE_CACHE.MAX_SIZE
25 })
26
27 protected abstract getImageSize (image: M): { width: number, height: number }
28 protected abstract loadModel (filename: string): Promise<M>
29
30 constructor (private readonly directory: string) {
31
32 }
33
34 async lazyServe (options: {
35 filename: string
36 res: express.Response
37 next: express.NextFunction
38 }) {
39 const { filename, res, next } = options
40
41 if (this.filenameToPathUnsafeCache.has(filename)) {
42 return res.sendFile(this.filenameToPathUnsafeCache.get(filename), { maxAge: STATIC_MAX_AGE.SERVER })
43 }
44
45 const image = await this.lazyLoadIfNeeded(filename)
46 if (!image) return res.status(HttpStatusCode.NOT_FOUND_404).end()
47
48 const path = image.getPath()
49 this.filenameToPathUnsafeCache.set(filename, path)
50
51 return res.sendFile(path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER }, (err: any) => {
52 if (!err) return
53
54 this.onServeError({ err, image, next, filename })
55 })
56 }
57
58 @CachePromise({
59 keyBuilder: filename => filename
60 })
61 private async lazyLoadIfNeeded (filename: string) {
62 const image = await this.loadModel(filename)
63 if (!image) return undefined
64
65 if (image.onDisk === false) {
66 if (!image.fileUrl) return undefined
67
68 try {
69 await this.downloadRemoteFile(image)
70 } catch (err) {
71 logger.warn('Cannot process remote image %s.', image.fileUrl, { err })
72
73 return undefined
74 }
75 }
76
77 return image
78 }
79
80 async downloadRemoteFile (image: M) {
81 logger.info('Download remote image %s lazily.', image.fileUrl)
82
83 const destination = await this.downloadImage({
84 filename: image.filename,
85 fileUrl: image.fileUrl,
86 size: this.getImageSize(image)
87 })
88
89 image.onDisk = true
90 image.save()
91 .catch(err => logger.error('Cannot save new image disk state.', { err }))
92
93 return destination
94 }
95
96 private onServeError (options: {
97 err: any
98 image: M
99 filename: string
100 next: express.NextFunction
101 }) {
102 const { err, image, filename, next } = options
103
104 // It seems this actor image is not on the disk anymore
105 if (err.status === HttpStatusCode.NOT_FOUND_404 && !image.isOwned()) {
106 logger.error('Cannot lazy serve image %s.', filename, { err })
107
108 this.filenameToPathUnsafeCache.delete(filename)
109
110 image.onDisk = false
111 image.save()
112 .catch(err => logger.error('Cannot save new image disk state.', { err }))
113 }
114
115 return next(err)
116 }
117
118 private downloadImage (options: {
119 fileUrl: string
120 filename: string
121 size: { width: number, height: number }
122 }) {
123 const downloaderOptions = {
124 url: options.fileUrl,
125 destDir: this.directory,
126 destName: options.filename,
127 size: options.size
128 }
129
130 return downloadImageFromWorker(downloaderOptions)
131 }
132}
diff --git a/server/lib/files-cache/abstract-video-static-file-cache.ts b/server/lib/files-cache/shared/abstract-simple-file-cache.ts
index a7ac88525..6fab322cd 100644
--- a/server/lib/files-cache/abstract-video-static-file-cache.ts
+++ b/server/lib/files-cache/shared/abstract-simple-file-cache.ts
@@ -1,10 +1,10 @@
1import { remove } from 'fs-extra' 1import { remove } from 'fs-extra'
2import { logger } from '../../helpers/logger' 2import { logger } from '../../../helpers/logger'
3import memoizee from 'memoizee' 3import memoizee from 'memoizee'
4 4
5type GetFilePathResult = { isOwned: boolean, path: string, downloadName?: string } | undefined 5type GetFilePathResult = { isOwned: boolean, path: string, downloadName?: string } | undefined
6 6
7export abstract class AbstractVideoStaticFileCache <T> { 7export abstract class AbstractSimpleFileCache <T> {
8 8
9 getFilePath: (params: T) => Promise<GetFilePathResult> 9 getFilePath: (params: T) => Promise<GetFilePathResult>
10 10
diff --git a/server/lib/files-cache/shared/index.ts b/server/lib/files-cache/shared/index.ts
new file mode 100644
index 000000000..61c4aacc7
--- /dev/null
+++ b/server/lib/files-cache/shared/index.ts
@@ -0,0 +1,2 @@
1export * from './abstract-permanent-file-cache'
2export * from './abstract-simple-file-cache'
diff --git a/server/lib/files-cache/videos-caption-cache.ts b/server/lib/files-cache/video-captions-simple-file-cache.ts
index d21acf4ef..cbeeff732 100644
--- a/server/lib/files-cache/videos-caption-cache.ts
+++ b/server/lib/files-cache/video-captions-simple-file-cache.ts
@@ -5,11 +5,11 @@ import { CONFIG } from '../../initializers/config'
5import { FILES_CACHE } from '../../initializers/constants' 5import { FILES_CACHE } from '../../initializers/constants'
6import { VideoModel } from '../../models/video/video' 6import { VideoModel } from '../../models/video/video'
7import { VideoCaptionModel } from '../../models/video/video-caption' 7import { VideoCaptionModel } from '../../models/video/video-caption'
8import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache' 8import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache'
9 9
10class VideosCaptionCache extends AbstractVideoStaticFileCache <string> { 10class VideoCaptionsSimpleFileCache extends AbstractSimpleFileCache <string> {
11 11
12 private static instance: VideosCaptionCache 12 private static instance: VideoCaptionsSimpleFileCache
13 13
14 private constructor () { 14 private constructor () {
15 super() 15 super()
@@ -23,7 +23,9 @@ class VideosCaptionCache extends AbstractVideoStaticFileCache <string> {
23 const videoCaption = await VideoCaptionModel.loadWithVideoByFilename(filename) 23 const videoCaption = await VideoCaptionModel.loadWithVideoByFilename(filename)
24 if (!videoCaption) return undefined 24 if (!videoCaption) return undefined
25 25
26 if (videoCaption.isOwned()) return { isOwned: true, path: join(CONFIG.STORAGE.CAPTIONS_DIR, videoCaption.filename) } 26 if (videoCaption.isOwned()) {
27 return { isOwned: true, path: join(CONFIG.STORAGE.CAPTIONS_DIR, videoCaption.filename) }
28 }
27 29
28 return this.loadRemoteFile(filename) 30 return this.loadRemoteFile(filename)
29 } 31 }
@@ -55,5 +57,5 @@ class VideosCaptionCache extends AbstractVideoStaticFileCache <string> {
55} 57}
56 58
57export { 59export {
58 VideosCaptionCache 60 VideoCaptionsSimpleFileCache
59} 61}
diff --git a/server/lib/files-cache/video-miniature-permanent-file-cache.ts b/server/lib/files-cache/video-miniature-permanent-file-cache.ts
new file mode 100644
index 000000000..35d9466f7
--- /dev/null
+++ b/server/lib/files-cache/video-miniature-permanent-file-cache.ts
@@ -0,0 +1,28 @@
1import { CONFIG } from '@server/initializers/config'
2import { THUMBNAILS_SIZE } from '@server/initializers/constants'
3import { ThumbnailModel } from '@server/models/video/thumbnail'
4import { MThumbnail } from '@server/types/models'
5import { ThumbnailType } from '@shared/models'
6import { AbstractPermanentFileCache } from './shared'
7
8export class VideoMiniaturePermanentFileCache extends AbstractPermanentFileCache<MThumbnail> {
9
10 constructor () {
11 super(CONFIG.STORAGE.THUMBNAILS_DIR)
12 }
13
14 protected loadModel (filename: string) {
15 return ThumbnailModel.loadByFilename(filename, ThumbnailType.MINIATURE)
16 }
17
18 protected getImageSize (image: MThumbnail): { width: number, height: number } {
19 if (image.width && image.height) {
20 return {
21 height: image.height,
22 width: image.width
23 }
24 }
25
26 return THUMBNAILS_SIZE
27 }
28}
diff --git a/server/lib/files-cache/videos-preview-cache.ts b/server/lib/files-cache/video-previews-simple-file-cache.ts
index d19c3f4f4..a05e80e16 100644
--- a/server/lib/files-cache/videos-preview-cache.ts
+++ b/server/lib/files-cache/video-previews-simple-file-cache.ts
@@ -1,15 +1,15 @@
1import { join } from 'path' 1import { join } from 'path'
2import { FILES_CACHE } from '../../initializers/constants' 2import { FILES_CACHE } from '../../initializers/constants'
3import { VideoModel } from '../../models/video/video' 3import { VideoModel } from '../../models/video/video'
4import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache' 4import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache'
5import { doRequestAndSaveToFile } from '@server/helpers/requests' 5import { doRequestAndSaveToFile } from '@server/helpers/requests'
6import { ThumbnailModel } from '@server/models/video/thumbnail' 6import { ThumbnailModel } from '@server/models/video/thumbnail'
7import { ThumbnailType } from '@shared/models' 7import { ThumbnailType } from '@shared/models'
8import { logger } from '@server/helpers/logger' 8import { logger } from '@server/helpers/logger'
9 9
10class VideosPreviewCache extends AbstractVideoStaticFileCache <string> { 10class VideoPreviewsSimpleFileCache extends AbstractSimpleFileCache <string> {
11 11
12 private static instance: VideosPreviewCache 12 private static instance: VideoPreviewsSimpleFileCache
13 13
14 private constructor () { 14 private constructor () {
15 super() 15 super()
@@ -54,5 +54,5 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
54} 54}
55 55
56export { 56export {
57 VideosPreviewCache 57 VideoPreviewsSimpleFileCache
58} 58}
diff --git a/server/lib/files-cache/video-storyboards-simple-file-cache.ts b/server/lib/files-cache/video-storyboards-simple-file-cache.ts
new file mode 100644
index 000000000..4cd96e70c
--- /dev/null
+++ b/server/lib/files-cache/video-storyboards-simple-file-cache.ts
@@ -0,0 +1,53 @@
1import { join } from 'path'
2import { logger } from '@server/helpers/logger'
3import { doRequestAndSaveToFile } from '@server/helpers/requests'
4import { StoryboardModel } from '@server/models/video/storyboard'
5import { FILES_CACHE } from '../../initializers/constants'
6import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache'
7
8class VideoStoryboardsSimpleFileCache extends AbstractSimpleFileCache <string> {
9
10 private static instance: VideoStoryboardsSimpleFileCache
11
12 private constructor () {
13 super()
14 }
15
16 static get Instance () {
17 return this.instance || (this.instance = new this())
18 }
19
20 async getFilePathImpl (filename: string) {
21 const storyboard = await StoryboardModel.loadWithVideoByFilename(filename)
22 if (!storyboard) return undefined
23
24 if (storyboard.Video.isOwned()) return { isOwned: true, path: storyboard.getPath() }
25
26 return this.loadRemoteFile(storyboard.filename)
27 }
28
29 // Key is the storyboard filename
30 protected async loadRemoteFile (key: string) {
31 const storyboard = await StoryboardModel.loadWithVideoByFilename(key)
32 if (!storyboard) return undefined
33
34 const destPath = join(FILES_CACHE.STORYBOARDS.DIRECTORY, storyboard.filename)
35 const remoteUrl = storyboard.getOriginFileUrl(storyboard.Video)
36
37 try {
38 await doRequestAndSaveToFile(remoteUrl, destPath)
39
40 logger.debug('Fetched remote storyboard %s to %s.', remoteUrl, destPath)
41
42 return { isOwned: false, path: destPath }
43 } catch (err) {
44 logger.info('Cannot fetch remote storyboard file %s.', remoteUrl, { err })
45
46 return undefined
47 }
48 }
49}
50
51export {
52 VideoStoryboardsSimpleFileCache
53}
diff --git a/server/lib/files-cache/videos-torrent-cache.ts b/server/lib/files-cache/video-torrents-simple-file-cache.ts
index a6bf98dd4..8bcd0b9bf 100644
--- a/server/lib/files-cache/videos-torrent-cache.ts
+++ b/server/lib/files-cache/video-torrents-simple-file-cache.ts
@@ -6,11 +6,11 @@ import { MVideo, MVideoFile } from '@server/types/models'
6import { CONFIG } from '../../initializers/config' 6import { CONFIG } from '../../initializers/config'
7import { FILES_CACHE } from '../../initializers/constants' 7import { FILES_CACHE } from '../../initializers/constants'
8import { VideoModel } from '../../models/video/video' 8import { VideoModel } from '../../models/video/video'
9import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache' 9import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache'
10 10
11class VideosTorrentCache extends AbstractVideoStaticFileCache <string> { 11class VideoTorrentsSimpleFileCache extends AbstractSimpleFileCache <string> {
12 12
13 private static instance: VideosTorrentCache 13 private static instance: VideoTorrentsSimpleFileCache
14 14
15 private constructor () { 15 private constructor () {
16 super() 16 super()
@@ -66,5 +66,5 @@ class VideosTorrentCache extends AbstractVideoStaticFileCache <string> {
66} 66}
67 67
68export { 68export {
69 VideosTorrentCache 69 VideoTorrentsSimpleFileCache
70} 70}
diff --git a/server/lib/hls.ts b/server/lib/hls.ts
index fc1d7e1b0..19044d7c2 100644
--- a/server/lib/hls.ts
+++ b/server/lib/hls.ts
@@ -8,7 +8,7 @@ import { sha256 } from '@shared/extra-utils'
8import { getVideoStreamDimensionsInfo } from '@shared/ffmpeg' 8import { getVideoStreamDimensionsInfo } from '@shared/ffmpeg'
9import { VideoStorage } from '@shared/models' 9import { VideoStorage } from '@shared/models'
10import { getAudioStreamCodec, getVideoStreamCodec } from '../helpers/ffmpeg' 10import { getAudioStreamCodec, getVideoStreamCodec } from '../helpers/ffmpeg'
11import { logger } from '../helpers/logger' 11import { logger, loggerTagsFactory } from '../helpers/logger'
12import { doRequest, doRequestAndSaveToFile } from '../helpers/requests' 12import { doRequest, doRequestAndSaveToFile } from '../helpers/requests'
13import { generateRandomString } from '../helpers/utils' 13import { generateRandomString } from '../helpers/utils'
14import { CONFIG } from '../initializers/config' 14import { CONFIG } from '../initializers/config'
@@ -20,6 +20,8 @@ import { storeHLSFileFromFilename } from './object-storage'
20import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getHlsResolutionPlaylistFilename } from './paths' 20import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getHlsResolutionPlaylistFilename } from './paths'
21import { VideoPathManager } from './video-path-manager' 21import { VideoPathManager } from './video-path-manager'
22 22
23const lTags = loggerTagsFactory('hls')
24
23async function updateStreamingPlaylistsInfohashesIfNeeded () { 25async function updateStreamingPlaylistsInfohashesIfNeeded () {
24 const playlistsToUpdate = await VideoStreamingPlaylistModel.listByIncorrectPeerVersion() 26 const playlistsToUpdate = await VideoStreamingPlaylistModel.listByIncorrectPeerVersion()
25 27
@@ -48,7 +50,7 @@ async function updatePlaylistAfterFileChange (video: MVideo, playlist: MStreamin
48 50
49 video.setHLSPlaylist(playlistWithFiles) 51 video.setHLSPlaylist(playlistWithFiles)
50 } catch (err) { 52 } catch (err) {
51 logger.info('Cannot update playlist after file change. Maybe due to concurrent transcoding', { err }) 53 logger.warn('Cannot update playlist after file change. Maybe due to concurrent transcoding', { err })
52 } 54 }
53} 55}
54 56
@@ -95,6 +97,8 @@ function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingPlaylist
95 const masterPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.playlistFilename) 97 const masterPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.playlistFilename)
96 await writeFile(masterPlaylistPath, masterPlaylists.join('\n') + '\n') 98 await writeFile(masterPlaylistPath, masterPlaylists.join('\n') + '\n')
97 99
100 logger.info('Updating %s master playlist file of video %s', masterPlaylistPath, video.uuid, lTags(video.uuid))
101
98 if (playlist.storage === VideoStorage.OBJECT_STORAGE) { 102 if (playlist.storage === VideoStorage.OBJECT_STORAGE) {
99 playlist.playlistUrl = await storeHLSFileFromFilename(playlist, playlist.playlistFilename) 103 playlist.playlistUrl = await storeHLSFileFromFilename(playlist, playlist.playlistFilename)
100 await remove(masterPlaylistPath) 104 await remove(masterPlaylistPath)
diff --git a/server/lib/job-queue/handlers/generate-storyboard.ts b/server/lib/job-queue/handlers/generate-storyboard.ts
new file mode 100644
index 000000000..ec07c568c
--- /dev/null
+++ b/server/lib/job-queue/handlers/generate-storyboard.ts
@@ -0,0 +1,149 @@
1import { Job } from 'bullmq'
2import { join } from 'path'
3import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg'
4import { generateImageFilename, getImageSize } from '@server/helpers/image-utils'
5import { logger, loggerTagsFactory } from '@server/helpers/logger'
6import { CONFIG } from '@server/initializers/config'
7import { STORYBOARD } from '@server/initializers/constants'
8import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
9import { VideoPathManager } from '@server/lib/video-path-manager'
10import { StoryboardModel } from '@server/models/video/storyboard'
11import { VideoModel } from '@server/models/video/video'
12import { MVideo } from '@server/types/models'
13import { FFmpegImage, isAudioFile } from '@shared/ffmpeg'
14import { GenerateStoryboardPayload } from '@shared/models'
15
16const lTagsBase = loggerTagsFactory('storyboard')
17
18async function processGenerateStoryboard (job: Job): Promise<void> {
19 const payload = job.data as GenerateStoryboardPayload
20 const lTags = lTagsBase(payload.videoUUID)
21
22 logger.info('Processing generate storyboard of %s in job %s.', payload.videoUUID, job.id, lTags)
23
24 const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(payload.videoUUID)
25
26 try {
27 const video = await VideoModel.loadFull(payload.videoUUID)
28 if (!video) {
29 logger.info('Video %s does not exist anymore, skipping storyboard generation.', payload.videoUUID, lTags)
30 return
31 }
32
33 const inputFile = video.getMaxQualityFile()
34
35 await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async videoPath => {
36 const isAudio = await isAudioFile(videoPath)
37
38 if (isAudio) {
39 logger.info('Do not generate a storyboard of %s since the video does not have a video stream', payload.videoUUID, lTags)
40 return
41 }
42
43 const ffmpeg = new FFmpegImage(getFFmpegCommandWrapperOptions('thumbnail'))
44
45 const filename = generateImageFilename()
46 const destination = join(CONFIG.STORAGE.STORYBOARDS_DIR, filename)
47
48 const totalSprites = buildTotalSprites(video)
49 if (totalSprites === 0) {
50 logger.info('Do not generate a storyboard of %s because the video is not long enough', payload.videoUUID, lTags)
51 return
52 }
53
54 const spriteDuration = Math.round(video.duration / totalSprites)
55
56 const spritesCount = findGridSize({
57 toFind: totalSprites,
58 maxEdgeCount: STORYBOARD.SPRITES_MAX_EDGE_COUNT
59 })
60
61 logger.debug(
62 'Generating storyboard from video of %s to %s', video.uuid, destination,
63 { ...lTags, spritesCount, spriteDuration, videoDuration: video.duration }
64 )
65
66 await ffmpeg.generateStoryboardFromVideo({
67 destination,
68 path: videoPath,
69 sprites: {
70 size: STORYBOARD.SPRITE_SIZE,
71 count: spritesCount,
72 duration: spriteDuration
73 }
74 })
75
76 const imageSize = await getImageSize(destination)
77
78 const existing = await StoryboardModel.loadByVideo(video.id)
79 if (existing) await existing.destroy()
80
81 await StoryboardModel.create({
82 filename,
83 totalHeight: imageSize.height,
84 totalWidth: imageSize.width,
85 spriteHeight: STORYBOARD.SPRITE_SIZE.height,
86 spriteWidth: STORYBOARD.SPRITE_SIZE.width,
87 spriteDuration,
88 videoId: video.id
89 })
90
91 logger.info('Storyboard generation %s ended for video %s.', destination, video.uuid, lTags)
92 })
93
94 if (payload.federate) {
95 await federateVideoIfNeeded(video, false)
96 }
97 } finally {
98 inputFileMutexReleaser()
99 }
100}
101
102// ---------------------------------------------------------------------------
103
104export {
105 processGenerateStoryboard
106}
107
108function buildTotalSprites (video: MVideo) {
109 const maxSprites = STORYBOARD.SPRITE_SIZE.height * STORYBOARD.SPRITE_SIZE.width
110 const totalSprites = Math.min(Math.ceil(video.duration), maxSprites)
111
112 // We can generate a single line
113 if (totalSprites <= STORYBOARD.SPRITES_MAX_EDGE_COUNT) return totalSprites
114
115 return findGridFit(totalSprites, STORYBOARD.SPRITES_MAX_EDGE_COUNT)
116}
117
118function findGridSize (options: {
119 toFind: number
120 maxEdgeCount: number
121}) {
122 const { toFind, maxEdgeCount } = options
123
124 for (let i = 1; i <= maxEdgeCount; i++) {
125 for (let j = i; j <= maxEdgeCount; j++) {
126 if (toFind === i * j) return { width: j, height: i }
127 }
128 }
129
130 throw new Error(`Could not find grid size (to find: ${toFind}, max edge count: ${maxEdgeCount}`)
131}
132
133function findGridFit (value: number, maxMultiplier: number) {
134 for (let i = value; i--; i > 0) {
135 if (!isPrimeWithin(i, maxMultiplier)) return i
136 }
137
138 throw new Error('Could not find prime number below ' + value)
139}
140
141function isPrimeWithin (value: number, maxMultiplier: number) {
142 if (value < 2) return false
143
144 for (let i = 2, end = Math.min(Math.sqrt(value), maxMultiplier); i <= end; i++) {
145 if (value % i === 0 && value / i <= maxMultiplier) return false
146 }
147
148 return true
149}
diff --git a/server/lib/job-queue/handlers/move-to-object-storage.ts b/server/lib/job-queue/handlers/move-to-object-storage.ts
index 26752ff37..9a99b6722 100644
--- a/server/lib/job-queue/handlers/move-to-object-storage.ts
+++ b/server/lib/job-queue/handlers/move-to-object-storage.ts
@@ -4,7 +4,7 @@ import { join } from 'path'
4import { logger, loggerTagsFactory } from '@server/helpers/logger' 4import { logger, loggerTagsFactory } from '@server/helpers/logger'
5import { updateTorrentMetadata } from '@server/helpers/webtorrent' 5import { updateTorrentMetadata } from '@server/helpers/webtorrent'
6import { P2P_MEDIA_LOADER_PEER_VERSION } from '@server/initializers/constants' 6import { P2P_MEDIA_LOADER_PEER_VERSION } from '@server/initializers/constants'
7import { storeHLSFileFromFilename, storeWebTorrentFile } from '@server/lib/object-storage' 7import { storeHLSFileFromFilename, storeWebVideoFile } from '@server/lib/object-storage'
8import { getHLSDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths' 8import { getHLSDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths'
9import { VideoPathManager } from '@server/lib/video-path-manager' 9import { VideoPathManager } from '@server/lib/video-path-manager'
10import { moveToFailedMoveToObjectStorageState, moveToNextState } from '@server/lib/video-state' 10import { moveToFailedMoveToObjectStorageState, moveToNextState } from '@server/lib/video-state'
@@ -33,9 +33,9 @@ export async function processMoveToObjectStorage (job: Job) {
33 33
34 try { 34 try {
35 if (video.VideoFiles) { 35 if (video.VideoFiles) {
36 logger.debug('Moving %d webtorrent files for video %s.', video.VideoFiles.length, video.uuid, lTags) 36 logger.debug('Moving %d web video files for video %s.', video.VideoFiles.length, video.uuid, lTags)
37 37
38 await moveWebTorrentFiles(video) 38 await moveWebVideoFiles(video)
39 } 39 }
40 40
41 if (video.VideoStreamingPlaylists) { 41 if (video.VideoStreamingPlaylists) {
@@ -75,11 +75,11 @@ export async function onMoveToObjectStorageFailure (job: Job, err: any) {
75 75
76// --------------------------------------------------------------------------- 76// ---------------------------------------------------------------------------
77 77
78async function moveWebTorrentFiles (video: MVideoWithAllFiles) { 78async function moveWebVideoFiles (video: MVideoWithAllFiles) {
79 for (const file of video.VideoFiles) { 79 for (const file of video.VideoFiles) {
80 if (file.storage !== VideoStorage.FILE_SYSTEM) continue 80 if (file.storage !== VideoStorage.FILE_SYSTEM) continue
81 81
82 const fileUrl = await storeWebTorrentFile(video, file) 82 const fileUrl = await storeWebVideoFile(video, file)
83 83
84 const oldPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, file) 84 const oldPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, file)
85 await onFileMoved({ videoOrPlaylist: video, file, fileUrl, oldPath }) 85 await onFileMoved({ videoOrPlaylist: video, file, fileUrl, oldPath })
diff --git a/server/lib/job-queue/handlers/video-file-import.ts b/server/lib/job-queue/handlers/video-file-import.ts
index 9a4550e4d..d221e8968 100644
--- a/server/lib/job-queue/handlers/video-file-import.ts
+++ b/server/lib/job-queue/handlers/video-file-import.ts
@@ -3,7 +3,7 @@ import { copy, stat } from 'fs-extra'
3import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' 3import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
4import { CONFIG } from '@server/initializers/config' 4import { CONFIG } from '@server/initializers/config'
5import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' 5import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
6import { generateWebTorrentVideoFilename } from '@server/lib/paths' 6import { generateWebVideoFilename } from '@server/lib/paths'
7import { buildMoveToObjectStorageJob } from '@server/lib/video' 7import { buildMoveToObjectStorageJob } from '@server/lib/video'
8import { VideoPathManager } from '@server/lib/video-path-manager' 8import { VideoPathManager } from '@server/lib/video-path-manager'
9import { VideoModel } from '@server/models/video/video' 9import { VideoModel } from '@server/models/video/video'
@@ -56,7 +56,7 @@ async function updateVideoFile (video: MVideoFullLight, inputFilePath: string) {
56 56
57 if (currentVideoFile) { 57 if (currentVideoFile) {
58 // Remove old file and old torrent 58 // Remove old file and old torrent
59 await video.removeWebTorrentFile(currentVideoFile) 59 await video.removeWebVideoFile(currentVideoFile)
60 // Remove the old video file from the array 60 // Remove the old video file from the array
61 video.VideoFiles = video.VideoFiles.filter(f => f !== currentVideoFile) 61 video.VideoFiles = video.VideoFiles.filter(f => f !== currentVideoFile)
62 62
@@ -66,7 +66,7 @@ async function updateVideoFile (video: MVideoFullLight, inputFilePath: string) {
66 const newVideoFile = new VideoFileModel({ 66 const newVideoFile = new VideoFileModel({
67 resolution, 67 resolution,
68 extname: fileExt, 68 extname: fileExt,
69 filename: generateWebTorrentVideoFilename(resolution, fileExt), 69 filename: generateWebVideoFilename(resolution, fileExt),
70 storage: VideoStorage.FILE_SYSTEM, 70 storage: VideoStorage.FILE_SYSTEM,
71 size, 71 size,
72 fps, 72 fps,
diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts
index cdd362f6e..e5cd258d6 100644
--- a/server/lib/job-queue/handlers/video-import.ts
+++ b/server/lib/job-queue/handlers/video-import.ts
@@ -4,7 +4,7 @@ import { retryTransactionWrapper } from '@server/helpers/database-utils'
4import { YoutubeDLWrapper } from '@server/helpers/youtube-dl' 4import { YoutubeDLWrapper } from '@server/helpers/youtube-dl'
5import { CONFIG } from '@server/initializers/config' 5import { CONFIG } from '@server/initializers/config'
6import { isPostImportVideoAccepted } from '@server/lib/moderation' 6import { isPostImportVideoAccepted } from '@server/lib/moderation'
7import { generateWebTorrentVideoFilename } from '@server/lib/paths' 7import { generateWebVideoFilename } from '@server/lib/paths'
8import { Hooks } from '@server/lib/plugins/hooks' 8import { Hooks } from '@server/lib/plugins/hooks'
9import { ServerConfigManager } from '@server/lib/server-config-manager' 9import { ServerConfigManager } from '@server/lib/server-config-manager'
10import { createOptimizeOrMergeAudioJobs } from '@server/lib/transcoding/create-transcoding-job' 10import { createOptimizeOrMergeAudioJobs } from '@server/lib/transcoding/create-transcoding-job'
@@ -39,7 +39,7 @@ import { VideoFileModel } from '../../../models/video/video-file'
39import { VideoImportModel } from '../../../models/video/video-import' 39import { VideoImportModel } from '../../../models/video/video-import'
40import { federateVideoIfNeeded } from '../../activitypub/videos' 40import { federateVideoIfNeeded } from '../../activitypub/videos'
41import { Notifier } from '../../notifier' 41import { Notifier } from '../../notifier'
42import { generateVideoMiniature } from '../../thumbnail' 42import { generateLocalVideoMiniature } from '../../thumbnail'
43import { JobQueue } from '../job-queue' 43import { JobQueue } from '../job-queue'
44 44
45async function processVideoImport (job: Job): Promise<VideoImportPreventExceptionResult> { 45async function processVideoImport (job: Job): Promise<VideoImportPreventExceptionResult> {
@@ -148,7 +148,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
148 extname: fileExt, 148 extname: fileExt,
149 resolution, 149 resolution,
150 size: stats.size, 150 size: stats.size,
151 filename: generateWebTorrentVideoFilename(resolution, fileExt), 151 filename: generateWebVideoFilename(resolution, fileExt),
152 fps, 152 fps,
153 videoId: videoImport.videoId 153 videoId: videoImport.videoId
154 } 154 }
@@ -274,7 +274,7 @@ async function generateMiniature (videoImportWithFiles: MVideoImportDefaultFiles
274 } 274 }
275 } 275 }
276 276
277 const miniatureModel = await generateVideoMiniature({ 277 const miniatureModel = await generateLocalVideoMiniature({
278 video: videoImportWithFiles.Video, 278 video: videoImportWithFiles.Video,
279 videoFile, 279 videoFile,
280 type: thumbnailType 280 type: thumbnailType
@@ -306,6 +306,15 @@ async function afterImportSuccess (options: {
306 Notifier.Instance.notifyOnNewVideoIfNeeded(video) 306 Notifier.Instance.notifyOnNewVideoIfNeeded(video)
307 } 307 }
308 308
309 // Generate the storyboard in the job queue, and don't forget to federate an update after
310 await JobQueue.Instance.createJob({
311 type: 'generate-video-storyboard' as 'generate-video-storyboard',
312 payload: {
313 videoUUID: video.uuid,
314 federate: true
315 }
316 })
317
309 if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) { 318 if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
310 await JobQueue.Instance.createJob( 319 await JobQueue.Instance.createJob(
311 await buildMoveToObjectStorageJob({ video, previousVideoState: VideoState.TO_IMPORT }) 320 await buildMoveToObjectStorageJob({ video, previousVideoState: VideoState.TO_IMPORT })
diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts
index 49feb53f2..ae886de35 100644
--- a/server/lib/job-queue/handlers/video-live-ending.ts
+++ b/server/lib/job-queue/handlers/video-live-ending.ts
@@ -1,11 +1,13 @@
1import { Job } from 'bullmq' 1import { Job } from 'bullmq'
2import { readdir, remove } from 'fs-extra' 2import { readdir, remove } from 'fs-extra'
3import { join } from 'path' 3import { join } from 'path'
4import { peertubeTruncate } from '@server/helpers/core-utils'
5import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
4import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' 6import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
5import { federateVideoIfNeeded } from '@server/lib/activitypub/videos' 7import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
6import { cleanupAndDestroyPermanentLive, cleanupTMPLiveFiles, cleanupUnsavedNormalLive } from '@server/lib/live' 8import { cleanupAndDestroyPermanentLive, cleanupTMPLiveFiles, cleanupUnsavedNormalLive } from '@server/lib/live'
7import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveReplayBaseDirectory } from '@server/lib/paths' 9import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveReplayBaseDirectory } from '@server/lib/paths'
8import { generateVideoMiniature } from '@server/lib/thumbnail' 10import { generateLocalVideoMiniature } from '@server/lib/thumbnail'
9import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/hls-transcoding' 11import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/hls-transcoding'
10import { VideoPathManager } from '@server/lib/video-path-manager' 12import { VideoPathManager } from '@server/lib/video-path-manager'
11import { moveToNextState } from '@server/lib/video-state' 13import { moveToNextState } from '@server/lib/video-state'
@@ -20,8 +22,7 @@ import { MVideo, MVideoLive, MVideoLiveSession, MVideoWithAllFiles } from '@serv
20import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@shared/ffmpeg' 22import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@shared/ffmpeg'
21import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models' 23import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models'
22import { logger, loggerTagsFactory } from '../../../helpers/logger' 24import { logger, loggerTagsFactory } from '../../../helpers/logger'
23import { peertubeTruncate } from '@server/helpers/core-utils' 25import { JobQueue } from '../job-queue'
24import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
25 26
26const lTags = loggerTagsFactory('live', 'job') 27const lTags = loggerTagsFactory('live', 'job')
27 28
@@ -142,11 +143,13 @@ async function saveReplayToExternalVideo (options: {
142 await remove(replayDirectory) 143 await remove(replayDirectory)
143 144
144 for (const type of [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ]) { 145 for (const type of [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ]) {
145 const image = await generateVideoMiniature({ video: replayVideo, videoFile: replayVideo.getMaxQualityFile(), type }) 146 const image = await generateLocalVideoMiniature({ video: replayVideo, videoFile: replayVideo.getMaxQualityFile(), type })
146 await replayVideo.addAndSaveThumbnail(image) 147 await replayVideo.addAndSaveThumbnail(image)
147 } 148 }
148 149
149 await moveToNextState({ video: replayVideo, isNewVideo: true }) 150 await moveToNextState({ video: replayVideo, isNewVideo: true })
151
152 await createStoryboardJob(replayVideo)
150} 153}
151 154
152async function replaceLiveByReplay (options: { 155async function replaceLiveByReplay (options: {
@@ -186,6 +189,7 @@ async function replaceLiveByReplay (options: {
186 189
187 await assignReplayFilesToVideo({ video: videoWithFiles, replayDirectory }) 190 await assignReplayFilesToVideo({ video: videoWithFiles, replayDirectory })
188 191
192 // FIXME: should not happen in this function
189 if (permanentLive) { // Remove session replay 193 if (permanentLive) { // Remove session replay
190 await remove(replayDirectory) 194 await remove(replayDirectory)
191 } else { // We won't stream again in this live, we can delete the base replay directory 195 } else { // We won't stream again in this live, we can delete the base replay directory
@@ -194,7 +198,7 @@ async function replaceLiveByReplay (options: {
194 198
195 // Regenerate the thumbnail & preview? 199 // Regenerate the thumbnail & preview?
196 if (videoWithFiles.getMiniature().automaticallyGenerated === true) { 200 if (videoWithFiles.getMiniature().automaticallyGenerated === true) {
197 const miniature = await generateVideoMiniature({ 201 const miniature = await generateLocalVideoMiniature({
198 video: videoWithFiles, 202 video: videoWithFiles,
199 videoFile: videoWithFiles.getMaxQualityFile(), 203 videoFile: videoWithFiles.getMaxQualityFile(),
200 type: ThumbnailType.MINIATURE 204 type: ThumbnailType.MINIATURE
@@ -203,7 +207,7 @@ async function replaceLiveByReplay (options: {
203 } 207 }
204 208
205 if (videoWithFiles.getPreview().automaticallyGenerated === true) { 209 if (videoWithFiles.getPreview().automaticallyGenerated === true) {
206 const preview = await generateVideoMiniature({ 210 const preview = await generateLocalVideoMiniature({
207 video: videoWithFiles, 211 video: videoWithFiles,
208 videoFile: videoWithFiles.getMaxQualityFile(), 212 videoFile: videoWithFiles.getMaxQualityFile(),
209 type: ThumbnailType.PREVIEW 213 type: ThumbnailType.PREVIEW
@@ -213,6 +217,8 @@ async function replaceLiveByReplay (options: {
213 217
214 // We consider this is a new video 218 // We consider this is a new video
215 await moveToNextState({ video: videoWithFiles, isNewVideo: true }) 219 await moveToNextState({ video: videoWithFiles, isNewVideo: true })
220
221 await createStoryboardJob(videoWithFiles)
216} 222}
217 223
218async function assignReplayFilesToVideo (options: { 224async function assignReplayFilesToVideo (options: {
@@ -277,3 +283,13 @@ async function cleanupLiveAndFederate (options: {
277 logger.warn('Cannot federate live after cleanup', { videoId: video.id, err }) 283 logger.warn('Cannot federate live after cleanup', { videoId: video.id, err })
278 } 284 }
279} 285}
286
287function createStoryboardJob (video: MVideo) {
288 return JobQueue.Instance.createJob({
289 type: 'generate-video-storyboard' as 'generate-video-storyboard',
290 payload: {
291 videoUUID: video.uuid,
292 federate: true
293 }
294 })
295}
diff --git a/server/lib/job-queue/handlers/video-transcoding.ts b/server/lib/job-queue/handlers/video-transcoding.ts
index f8758f170..1c8f4fd9f 100644
--- a/server/lib/job-queue/handlers/video-transcoding.ts
+++ b/server/lib/job-queue/handlers/video-transcoding.ts
@@ -1,8 +1,8 @@
1import { Job } from 'bullmq' 1import { Job } from 'bullmq'
2import { onTranscodingEnded } from '@server/lib/transcoding/ended-transcoding' 2import { onTranscodingEnded } from '@server/lib/transcoding/ended-transcoding'
3import { generateHlsPlaylistResolution } from '@server/lib/transcoding/hls-transcoding' 3import { generateHlsPlaylistResolution } from '@server/lib/transcoding/hls-transcoding'
4import { mergeAudioVideofile, optimizeOriginalVideofile, transcodeNewWebTorrentResolution } from '@server/lib/transcoding/web-transcoding' 4import { mergeAudioVideofile, optimizeOriginalVideofile, transcodeNewWebVideoResolution } from '@server/lib/transcoding/web-transcoding'
5import { removeAllWebTorrentFiles } from '@server/lib/video-file' 5import { removeAllWebVideoFiles } from '@server/lib/video-file'
6import { VideoPathManager } from '@server/lib/video-path-manager' 6import { VideoPathManager } from '@server/lib/video-path-manager'
7import { moveToFailedTranscodingState } from '@server/lib/video-state' 7import { moveToFailedTranscodingState } from '@server/lib/video-state'
8import { UserModel } from '@server/models/user/user' 8import { UserModel } from '@server/models/user/user'
@@ -11,7 +11,7 @@ import { MUser, MUserId, MVideoFullLight } from '@server/types/models'
11import { 11import {
12 HLSTranscodingPayload, 12 HLSTranscodingPayload,
13 MergeAudioTranscodingPayload, 13 MergeAudioTranscodingPayload,
14 NewWebTorrentResolutionTranscodingPayload, 14 NewWebVideoResolutionTranscodingPayload,
15 OptimizeTranscodingPayload, 15 OptimizeTranscodingPayload,
16 VideoTranscodingPayload 16 VideoTranscodingPayload
17} from '@shared/models' 17} from '@shared/models'
@@ -22,9 +22,9 @@ type HandlerFunction = (job: Job, payload: VideoTranscodingPayload, video: MVide
22 22
23const handlers: { [ id in VideoTranscodingPayload['type'] ]: HandlerFunction } = { 23const handlers: { [ id in VideoTranscodingPayload['type'] ]: HandlerFunction } = {
24 'new-resolution-to-hls': handleHLSJob, 24 'new-resolution-to-hls': handleHLSJob,
25 'new-resolution-to-webtorrent': handleNewWebTorrentResolutionJob, 25 'new-resolution-to-web-video': handleNewWebVideoResolutionJob,
26 'merge-audio-to-webtorrent': handleWebTorrentMergeAudioJob, 26 'merge-audio-to-web-video': handleWebVideoMergeAudioJob,
27 'optimize-to-webtorrent': handleWebTorrentOptimizeJob 27 'optimize-to-web-video': handleWebVideoOptimizeJob
28} 28}
29 29
30const lTags = loggerTagsFactory('transcoding') 30const lTags = loggerTagsFactory('transcoding')
@@ -74,7 +74,7 @@ export {
74// Job handlers 74// Job handlers
75// --------------------------------------------------------------------------- 75// ---------------------------------------------------------------------------
76 76
77async function handleWebTorrentMergeAudioJob (job: Job, payload: MergeAudioTranscodingPayload, video: MVideoFullLight, user: MUserId) { 77async function handleWebVideoMergeAudioJob (job: Job, payload: MergeAudioTranscodingPayload, video: MVideoFullLight, user: MUserId) {
78 logger.info('Handling merge audio transcoding job for %s.', video.uuid, lTags(video.uuid), { payload }) 78 logger.info('Handling merge audio transcoding job for %s.', video.uuid, lTags(video.uuid), { payload })
79 79
80 await mergeAudioVideofile({ video, resolution: payload.resolution, fps: payload.fps, job }) 80 await mergeAudioVideofile({ video, resolution: payload.resolution, fps: payload.fps, job })
@@ -84,7 +84,7 @@ async function handleWebTorrentMergeAudioJob (job: Job, payload: MergeAudioTrans
84 await onTranscodingEnded({ isNewVideo: payload.isNewVideo, moveVideoToNextState: !payload.hasChildren, video }) 84 await onTranscodingEnded({ isNewVideo: payload.isNewVideo, moveVideoToNextState: !payload.hasChildren, video })
85} 85}
86 86
87async function handleWebTorrentOptimizeJob (job: Job, payload: OptimizeTranscodingPayload, video: MVideoFullLight, user: MUserId) { 87async function handleWebVideoOptimizeJob (job: Job, payload: OptimizeTranscodingPayload, video: MVideoFullLight, user: MUserId) {
88 logger.info('Handling optimize transcoding job for %s.', video.uuid, lTags(video.uuid), { payload }) 88 logger.info('Handling optimize transcoding job for %s.', video.uuid, lTags(video.uuid), { payload })
89 89
90 await optimizeOriginalVideofile({ video, inputVideoFile: video.getMaxQualityFile(), quickTranscode: payload.quickTranscode, job }) 90 await optimizeOriginalVideofile({ video, inputVideoFile: video.getMaxQualityFile(), quickTranscode: payload.quickTranscode, job })
@@ -96,12 +96,12 @@ async function handleWebTorrentOptimizeJob (job: Job, payload: OptimizeTranscodi
96 96
97// --------------------------------------------------------------------------- 97// ---------------------------------------------------------------------------
98 98
99async function handleNewWebTorrentResolutionJob (job: Job, payload: NewWebTorrentResolutionTranscodingPayload, video: MVideoFullLight) { 99async function handleNewWebVideoResolutionJob (job: Job, payload: NewWebVideoResolutionTranscodingPayload, video: MVideoFullLight) {
100 logger.info('Handling WebTorrent transcoding job for %s.', video.uuid, lTags(video.uuid), { payload }) 100 logger.info('Handling Web Video transcoding job for %s.', video.uuid, lTags(video.uuid), { payload })
101 101
102 await transcodeNewWebTorrentResolution({ video, resolution: payload.resolution, fps: payload.fps, job }) 102 await transcodeNewWebVideoResolution({ video, resolution: payload.resolution, fps: payload.fps, job })
103 103
104 logger.info('WebTorrent transcoding job for %s ended.', video.uuid, lTags(video.uuid), { payload }) 104 logger.info('Web Video transcoding job for %s ended.', video.uuid, lTags(video.uuid), { payload })
105 105
106 await onTranscodingEnded({ isNewVideo: payload.isNewVideo, moveVideoToNextState: true, video }) 106 await onTranscodingEnded({ isNewVideo: payload.isNewVideo, moveVideoToNextState: true, video })
107} 107}
@@ -118,7 +118,7 @@ async function handleHLSJob (job: Job, payload: HLSTranscodingPayload, videoArg:
118 video = await VideoModel.loadFull(videoArg.uuid) 118 video = await VideoModel.loadFull(videoArg.uuid)
119 119
120 const videoFileInput = payload.copyCodecs 120 const videoFileInput = payload.copyCodecs
121 ? video.getWebTorrentFile(payload.resolution) 121 ? video.getWebVideoFile(payload.resolution)
122 : video.getMaxQualityFile() 122 : video.getMaxQualityFile()
123 123
124 const videoOrStreamingPlaylist = videoFileInput.getVideoOrStreamingPlaylist() 124 const videoOrStreamingPlaylist = videoFileInput.getVideoOrStreamingPlaylist()
@@ -140,10 +140,10 @@ async function handleHLSJob (job: Job, payload: HLSTranscodingPayload, videoArg:
140 140
141 logger.info('HLS transcoding job for %s ended.', video.uuid, lTags(video.uuid), { payload }) 141 logger.info('HLS transcoding job for %s ended.', video.uuid, lTags(video.uuid), { payload })
142 142
143 if (payload.deleteWebTorrentFiles === true) { 143 if (payload.deleteWebVideoFiles === true) {
144 logger.info('Removing WebTorrent files of %s now we have a HLS version of it.', video.uuid, lTags(video.uuid)) 144 logger.info('Removing Web Video files of %s now we have a HLS version of it.', video.uuid, lTags(video.uuid))
145 145
146 await removeAllWebTorrentFiles(video) 146 await removeAllWebVideoFiles(video)
147 } 147 }
148 148
149 await onTranscodingEnded({ isNewVideo: payload.isNewVideo, moveVideoToNextState: true, video }) 149 await onTranscodingEnded({ isNewVideo: payload.isNewVideo, moveVideoToNextState: true, video })
diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts
index 03f6fbea7..177bca285 100644
--- a/server/lib/job-queue/job-queue.ts
+++ b/server/lib/job-queue/job-queue.ts
@@ -25,6 +25,7 @@ import {
25 DeleteResumableUploadMetaFilePayload, 25 DeleteResumableUploadMetaFilePayload,
26 EmailPayload, 26 EmailPayload,
27 FederateVideoPayload, 27 FederateVideoPayload,
28 GenerateStoryboardPayload,
28 JobState, 29 JobState,
29 JobType, 30 JobType,
30 ManageVideoTorrentPayload, 31 ManageVideoTorrentPayload,
@@ -65,6 +66,7 @@ import { processVideoLiveEnding } from './handlers/video-live-ending'
65import { processVideoStudioEdition } from './handlers/video-studio-edition' 66import { processVideoStudioEdition } from './handlers/video-studio-edition'
66import { processVideoTranscoding } from './handlers/video-transcoding' 67import { processVideoTranscoding } from './handlers/video-transcoding'
67import { processVideosViewsStats } from './handlers/video-views-stats' 68import { processVideosViewsStats } from './handlers/video-views-stats'
69import { processGenerateStoryboard } from './handlers/generate-storyboard'
68 70
69export type CreateJobArgument = 71export type CreateJobArgument =
70 { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } | 72 { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
@@ -91,7 +93,8 @@ export type CreateJobArgument =
91 { type: 'after-video-channel-import', payload: AfterVideoChannelImportPayload } | 93 { type: 'after-video-channel-import', payload: AfterVideoChannelImportPayload } |
92 { type: 'notify', payload: NotifyPayload } | 94 { type: 'notify', payload: NotifyPayload } |
93 { type: 'move-to-object-storage', payload: MoveObjectStoragePayload } | 95 { type: 'move-to-object-storage', payload: MoveObjectStoragePayload } |
94 { type: 'federate-video', payload: FederateVideoPayload } 96 { type: 'federate-video', payload: FederateVideoPayload } |
97 { type: 'generate-video-storyboard', payload: GenerateStoryboardPayload }
95 98
96export type CreateJobOptions = { 99export type CreateJobOptions = {
97 delay?: number 100 delay?: number
@@ -122,7 +125,8 @@ const handlers: { [id in JobType]: (job: Job) => Promise<any> } = {
122 'video-redundancy': processVideoRedundancy, 125 'video-redundancy': processVideoRedundancy,
123 'video-studio-edition': processVideoStudioEdition, 126 'video-studio-edition': processVideoStudioEdition,
124 'video-transcoding': processVideoTranscoding, 127 'video-transcoding': processVideoTranscoding,
125 'videos-views-stats': processVideosViewsStats 128 'videos-views-stats': processVideosViewsStats,
129 'generate-video-storyboard': processGenerateStoryboard
126} 130}
127 131
128const errorHandlers: { [id in JobType]?: (job: Job, err: any) => Promise<any> } = { 132const errorHandlers: { [id in JobType]?: (job: Job, err: any) => Promise<any> } = {
@@ -141,10 +145,11 @@ const jobTypes: JobType[] = [
141 'after-video-channel-import', 145 'after-video-channel-import',
142 'email', 146 'email',
143 'federate-video', 147 'federate-video',
144 'transcoding-job-builder', 148 'generate-video-storyboard',
145 'manage-video-torrent', 149 'manage-video-torrent',
146 'move-to-object-storage', 150 'move-to-object-storage',
147 'notify', 151 'notify',
152 'transcoding-job-builder',
148 'video-channel-import', 153 'video-channel-import',
149 'video-file-import', 154 'video-file-import',
150 'video-import', 155 'video-import',
diff --git a/server/lib/local-actor.ts b/server/lib/local-actor.ts
index 16dc265a3..611e6d0af 100644
--- a/server/lib/local-actor.ts
+++ b/server/lib/local-actor.ts
@@ -1,5 +1,4 @@
1import { remove } from 'fs-extra' 1import { remove } from 'fs-extra'
2import { LRUCache } from 'lru-cache'
3import { join } from 'path' 2import { join } from 'path'
4import { Transaction } from 'sequelize/types' 3import { Transaction } from 'sequelize/types'
5import { ActorModel } from '@server/models/actor/actor' 4import { ActorModel } from '@server/models/actor/actor'
@@ -8,14 +7,14 @@ import { buildUUID } from '@shared/extra-utils'
8import { ActivityPubActorType, ActorImageType } from '@shared/models' 7import { ActivityPubActorType, ActorImageType } from '@shared/models'
9import { retryTransactionWrapper } from '../helpers/database-utils' 8import { retryTransactionWrapper } from '../helpers/database-utils'
10import { CONFIG } from '../initializers/config' 9import { CONFIG } from '../initializers/config'
11import { ACTOR_IMAGES_SIZE, LRU_CACHE, WEBSERVER } from '../initializers/constants' 10import { ACTOR_IMAGES_SIZE, WEBSERVER } from '../initializers/constants'
12import { sequelizeTypescript } from '../initializers/database' 11import { sequelizeTypescript } from '../initializers/database'
13import { MAccountDefault, MActor, MChannelDefault } from '../types/models' 12import { MAccountDefault, MActor, MChannelDefault } from '../types/models'
14import { deleteActorImages, updateActorImages } from './activitypub/actors' 13import { deleteActorImages, updateActorImages } from './activitypub/actors'
15import { sendUpdateActor } from './activitypub/send' 14import { sendUpdateActor } from './activitypub/send'
16import { downloadImageFromWorker, processImageFromWorker } from './worker/parent-process' 15import { processImageFromWorker } from './worker/parent-process'
17 16
18function buildActorInstance (type: ActivityPubActorType, url: string, preferredUsername: string) { 17export function buildActorInstance (type: ActivityPubActorType, url: string, preferredUsername: string) {
19 return new ActorModel({ 18 return new ActorModel({
20 type, 19 type,
21 url, 20 url,
@@ -32,7 +31,7 @@ function buildActorInstance (type: ActivityPubActorType, url: string, preferredU
32 }) as MActor 31 }) as MActor
33} 32}
34 33
35async function updateLocalActorImageFiles ( 34export async function updateLocalActorImageFiles (
36 accountOrChannel: MAccountDefault | MChannelDefault, 35 accountOrChannel: MAccountDefault | MChannelDefault,
37 imagePhysicalFile: Express.Multer.File, 36 imagePhysicalFile: Express.Multer.File,
38 type: ActorImageType 37 type: ActorImageType
@@ -41,7 +40,7 @@ async function updateLocalActorImageFiles (
41 const extension = getLowercaseExtension(imagePhysicalFile.filename) 40 const extension = getLowercaseExtension(imagePhysicalFile.filename)
42 41
43 const imageName = buildUUID() + extension 42 const imageName = buildUUID() + extension
44 const destination = join(CONFIG.STORAGE.ACTOR_IMAGES, imageName) 43 const destination = join(CONFIG.STORAGE.ACTOR_IMAGES_DIR, imageName)
45 await processImageFromWorker({ path: imagePhysicalFile.path, destination, newSize: imageSize, keepOriginal: true }) 44 await processImageFromWorker({ path: imagePhysicalFile.path, destination, newSize: imageSize, keepOriginal: true })
46 45
47 return { 46 return {
@@ -73,7 +72,7 @@ async function updateLocalActorImageFiles (
73 })) 72 }))
74} 73}
75 74
76async function deleteLocalActorImageFile (accountOrChannel: MAccountDefault | MChannelDefault, type: ActorImageType) { 75export async function deleteLocalActorImageFile (accountOrChannel: MAccountDefault | MChannelDefault, type: ActorImageType) {
77 return retryTransactionWrapper(() => { 76 return retryTransactionWrapper(() => {
78 return sequelizeTypescript.transaction(async t => { 77 return sequelizeTypescript.transaction(async t => {
79 const updatedActor = await deleteActorImages(accountOrChannel.Actor, type, t) 78 const updatedActor = await deleteActorImages(accountOrChannel.Actor, type, t)
@@ -88,7 +87,7 @@ async function deleteLocalActorImageFile (accountOrChannel: MAccountDefault | MC
88 87
89// --------------------------------------------------------------------------- 88// ---------------------------------------------------------------------------
90 89
91async function findAvailableLocalActorName (baseActorName: string, transaction?: Transaction) { 90export async function findAvailableLocalActorName (baseActorName: string, transaction?: Transaction) {
92 let actor = await ActorModel.loadLocalByName(baseActorName, transaction) 91 let actor = await ActorModel.loadLocalByName(baseActorName, transaction)
93 if (!actor) return baseActorName 92 if (!actor) return baseActorName
94 93
@@ -101,34 +100,3 @@ async function findAvailableLocalActorName (baseActorName: string, transaction?:
101 100
102 throw new Error('Cannot find available actor local name (too much iterations).') 101 throw new Error('Cannot find available actor local name (too much iterations).')
103} 102}
104
105// ---------------------------------------------------------------------------
106
107function downloadActorImageFromWorker (options: {
108 fileUrl: string
109 filename: string
110 type: ActorImageType
111 size: typeof ACTOR_IMAGES_SIZE[ActorImageType][0]
112}) {
113 const downloaderOptions = {
114 url: options.fileUrl,
115 destDir: CONFIG.STORAGE.ACTOR_IMAGES,
116 destName: options.filename,
117 size: options.size
118 }
119
120 return downloadImageFromWorker(downloaderOptions)
121}
122
123// Unsafe so could returns paths that does not exist anymore
124const actorImagePathUnsafeCache = new LRUCache<string, string>({ max: LRU_CACHE.ACTOR_IMAGE_STATIC.MAX_SIZE })
125
126export {
127 actorImagePathUnsafeCache,
128 updateLocalActorImageFiles,
129 findAvailableLocalActorName,
130 downloadActorImageFromWorker,
131 deleteLocalActorImageFile,
132 downloadImageFromWorker,
133 buildActorInstance
134}
diff --git a/server/lib/object-storage/index.ts b/server/lib/object-storage/index.ts
index 6525f8dfb..3ad6cab63 100644
--- a/server/lib/object-storage/index.ts
+++ b/server/lib/object-storage/index.ts
@@ -1,4 +1,5 @@
1export * from './keys' 1export * from './keys'
2export * from './proxy' 2export * from './proxy'
3export * from './pre-signed-urls'
3export * from './urls' 4export * from './urls'
4export * from './videos' 5export * from './videos'
diff --git a/server/lib/object-storage/keys.ts b/server/lib/object-storage/keys.ts
index 4f17073f4..6d2098298 100644
--- a/server/lib/object-storage/keys.ts
+++ b/server/lib/object-storage/keys.ts
@@ -9,12 +9,12 @@ function generateHLSObjectBaseStorageKey (playlist: MStreamingPlaylistVideo) {
9 return join(playlist.getStringType(), playlist.Video.uuid) 9 return join(playlist.getStringType(), playlist.Video.uuid)
10} 10}
11 11
12function generateWebTorrentObjectStorageKey (filename: string) { 12function generateWebVideoObjectStorageKey (filename: string) {
13 return filename 13 return filename
14} 14}
15 15
16export { 16export {
17 generateHLSObjectStorageKey, 17 generateHLSObjectStorageKey,
18 generateHLSObjectBaseStorageKey, 18 generateHLSObjectBaseStorageKey,
19 generateWebTorrentObjectStorageKey 19 generateWebVideoObjectStorageKey
20} 20}
diff --git a/server/lib/object-storage/pre-signed-urls.ts b/server/lib/object-storage/pre-signed-urls.ts
new file mode 100644
index 000000000..caf149bb8
--- /dev/null
+++ b/server/lib/object-storage/pre-signed-urls.ts
@@ -0,0 +1,46 @@
1import { GetObjectCommand } from '@aws-sdk/client-s3'
2import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
3import { CONFIG } from '@server/initializers/config'
4import { MStreamingPlaylistVideo, MVideoFile } from '@server/types/models'
5import { generateHLSObjectStorageKey, generateWebVideoObjectStorageKey } from './keys'
6import { buildKey, getClient } from './shared'
7import { getHLSPublicFileUrl, getWebVideoPublicFileUrl } from './urls'
8
9export async function generateWebVideoPresignedUrl (options: {
10 file: MVideoFile
11 downloadFilename: string
12}) {
13 const { file, downloadFilename } = options
14
15 const key = generateWebVideoObjectStorageKey(file.filename)
16
17 const command = new GetObjectCommand({
18 Bucket: CONFIG.OBJECT_STORAGE.WEB_VIDEOS.BUCKET_NAME,
19 Key: buildKey(key, CONFIG.OBJECT_STORAGE.WEB_VIDEOS),
20 ResponseContentDisposition: `attachment; filename=${downloadFilename}`
21 })
22
23 const url = await getSignedUrl(getClient(), command, { expiresIn: 3600 * 24 })
24
25 return getWebVideoPublicFileUrl(url)
26}
27
28export async function generateHLSFilePresignedUrl (options: {
29 streamingPlaylist: MStreamingPlaylistVideo
30 file: MVideoFile
31 downloadFilename: string
32}) {
33 const { streamingPlaylist, file, downloadFilename } = options
34
35 const key = generateHLSObjectStorageKey(streamingPlaylist, file.filename)
36
37 const command = new GetObjectCommand({
38 Bucket: CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BUCKET_NAME,
39 Key: buildKey(key, CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS),
40 ResponseContentDisposition: `attachment; filename=${downloadFilename}`
41 })
42
43 const url = await getSignedUrl(getClient(), command, { expiresIn: 3600 * 24 })
44
45 return getHLSPublicFileUrl(url)
46}
diff --git a/server/lib/object-storage/proxy.ts b/server/lib/object-storage/proxy.ts
index c782a8a25..c09a0d1b0 100644
--- a/server/lib/object-storage/proxy.ts
+++ b/server/lib/object-storage/proxy.ts
@@ -7,19 +7,19 @@ import { StreamReplacer } from '@server/helpers/stream-replacer'
7import { MStreamingPlaylist, MVideo } from '@server/types/models' 7import { MStreamingPlaylist, MVideo } from '@server/types/models'
8import { HttpStatusCode } from '@shared/models' 8import { HttpStatusCode } from '@shared/models'
9import { injectQueryToPlaylistUrls } from '../hls' 9import { injectQueryToPlaylistUrls } from '../hls'
10import { getHLSFileReadStream, getWebTorrentFileReadStream } from './videos' 10import { getHLSFileReadStream, getWebVideoFileReadStream } from './videos'
11 11
12export async function proxifyWebTorrentFile (options: { 12export async function proxifyWebVideoFile (options: {
13 req: express.Request 13 req: express.Request
14 res: express.Response 14 res: express.Response
15 filename: string 15 filename: string
16}) { 16}) {
17 const { req, res, filename } = options 17 const { req, res, filename } = options
18 18
19 logger.debug('Proxifying WebTorrent file %s from object storage.', filename) 19 logger.debug('Proxifying Web Video file %s from object storage.', filename)
20 20
21 try { 21 try {
22 const { response: s3Response, stream } = await getWebTorrentFileReadStream({ 22 const { response: s3Response, stream } = await getWebVideoFileReadStream({
23 filename, 23 filename,
24 rangeHeader: req.header('range') 24 rangeHeader: req.header('range')
25 }) 25 })
diff --git a/server/lib/object-storage/urls.ts b/server/lib/object-storage/urls.ts
index b8ef94559..40619cd5a 100644
--- a/server/lib/object-storage/urls.ts
+++ b/server/lib/object-storage/urls.ts
@@ -9,8 +9,8 @@ function getInternalUrl (config: BucketInfo, keyWithoutPrefix: string) {
9 9
10// --------------------------------------------------------------------------- 10// ---------------------------------------------------------------------------
11 11
12function getWebTorrentPublicFileUrl (fileUrl: string) { 12function getWebVideoPublicFileUrl (fileUrl: string) {
13 const baseUrl = CONFIG.OBJECT_STORAGE.VIDEOS.BASE_URL 13 const baseUrl = CONFIG.OBJECT_STORAGE.WEB_VIDEOS.BASE_URL
14 if (!baseUrl) return fileUrl 14 if (!baseUrl) return fileUrl
15 15
16 return replaceByBaseUrl(fileUrl, baseUrl) 16 return replaceByBaseUrl(fileUrl, baseUrl)
@@ -29,8 +29,8 @@ function getHLSPrivateFileUrl (video: MVideoUUID, filename: string) {
29 return WEBSERVER.URL + OBJECT_STORAGE_PROXY_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + video.uuid + `/${filename}` 29 return WEBSERVER.URL + OBJECT_STORAGE_PROXY_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + video.uuid + `/${filename}`
30} 30}
31 31
32function getWebTorrentPrivateFileUrl (filename: string) { 32function getWebVideoPrivateFileUrl (filename: string) {
33 return WEBSERVER.URL + OBJECT_STORAGE_PROXY_PATHS.PRIVATE_WEBSEED + filename 33 return WEBSERVER.URL + OBJECT_STORAGE_PROXY_PATHS.PRIVATE_WEB_VIDEOS + filename
34} 34}
35 35
36// --------------------------------------------------------------------------- 36// ---------------------------------------------------------------------------
@@ -38,11 +38,11 @@ function getWebTorrentPrivateFileUrl (filename: string) {
38export { 38export {
39 getInternalUrl, 39 getInternalUrl,
40 40
41 getWebTorrentPublicFileUrl, 41 getWebVideoPublicFileUrl,
42 getHLSPublicFileUrl, 42 getHLSPublicFileUrl,
43 43
44 getHLSPrivateFileUrl, 44 getHLSPrivateFileUrl,
45 getWebTorrentPrivateFileUrl, 45 getWebVideoPrivateFileUrl,
46 46
47 replaceByBaseUrl 47 replaceByBaseUrl
48} 48}
diff --git a/server/lib/object-storage/videos.ts b/server/lib/object-storage/videos.ts
index 9152c5352..891e9ff76 100644
--- a/server/lib/object-storage/videos.ts
+++ b/server/lib/object-storage/videos.ts
@@ -4,7 +4,7 @@ import { CONFIG } from '@server/initializers/config'
4import { MStreamingPlaylistVideo, MVideo, MVideoFile } from '@server/types/models' 4import { MStreamingPlaylistVideo, MVideo, MVideoFile } from '@server/types/models'
5import { getHLSDirectory } from '../paths' 5import { getHLSDirectory } from '../paths'
6import { VideoPathManager } from '../video-path-manager' 6import { VideoPathManager } from '../video-path-manager'
7import { generateHLSObjectBaseStorageKey, generateHLSObjectStorageKey, generateWebTorrentObjectStorageKey } from './keys' 7import { generateHLSObjectBaseStorageKey, generateHLSObjectStorageKey, generateWebVideoObjectStorageKey } from './keys'
8import { 8import {
9 createObjectReadStream, 9 createObjectReadStream,
10 listKeysOfPrefix, 10 listKeysOfPrefix,
@@ -55,21 +55,21 @@ function storeHLSFileFromContent (playlist: MStreamingPlaylistVideo, path: strin
55 55
56// --------------------------------------------------------------------------- 56// ---------------------------------------------------------------------------
57 57
58function storeWebTorrentFile (video: MVideo, file: MVideoFile) { 58function storeWebVideoFile (video: MVideo, file: MVideoFile) {
59 return storeObject({ 59 return storeObject({
60 inputPath: VideoPathManager.Instance.getFSVideoFileOutputPath(video, file), 60 inputPath: VideoPathManager.Instance.getFSVideoFileOutputPath(video, file),
61 objectStorageKey: generateWebTorrentObjectStorageKey(file.filename), 61 objectStorageKey: generateWebVideoObjectStorageKey(file.filename),
62 bucketInfo: CONFIG.OBJECT_STORAGE.VIDEOS, 62 bucketInfo: CONFIG.OBJECT_STORAGE.WEB_VIDEOS,
63 isPrivate: video.hasPrivateStaticPath() 63 isPrivate: video.hasPrivateStaticPath()
64 }) 64 })
65} 65}
66 66
67// --------------------------------------------------------------------------- 67// ---------------------------------------------------------------------------
68 68
69async function updateWebTorrentFileACL (video: MVideo, file: MVideoFile) { 69async function updateWebVideoFileACL (video: MVideo, file: MVideoFile) {
70 await updateObjectACL({ 70 await updateObjectACL({
71 objectStorageKey: generateWebTorrentObjectStorageKey(file.filename), 71 objectStorageKey: generateWebVideoObjectStorageKey(file.filename),
72 bucketInfo: CONFIG.OBJECT_STORAGE.VIDEOS, 72 bucketInfo: CONFIG.OBJECT_STORAGE.WEB_VIDEOS,
73 isPrivate: video.hasPrivateStaticPath() 73 isPrivate: video.hasPrivateStaticPath()
74 }) 74 })
75} 75}
@@ -102,8 +102,8 @@ function removeHLSFileObjectStorageByFullKey (key: string) {
102 102
103// --------------------------------------------------------------------------- 103// ---------------------------------------------------------------------------
104 104
105function removeWebTorrentObjectStorage (videoFile: MVideoFile) { 105function removeWebVideoObjectStorage (videoFile: MVideoFile) {
106 return removeObject(generateWebTorrentObjectStorageKey(videoFile.filename), CONFIG.OBJECT_STORAGE.VIDEOS) 106 return removeObject(generateWebVideoObjectStorageKey(videoFile.filename), CONFIG.OBJECT_STORAGE.WEB_VIDEOS)
107} 107}
108 108
109// --------------------------------------------------------------------------- 109// ---------------------------------------------------------------------------
@@ -122,15 +122,15 @@ async function makeHLSFileAvailable (playlist: MStreamingPlaylistVideo, filename
122 return destination 122 return destination
123} 123}
124 124
125async function makeWebTorrentFileAvailable (filename: string, destination: string) { 125async function makeWebVideoFileAvailable (filename: string, destination: string) {
126 const key = generateWebTorrentObjectStorageKey(filename) 126 const key = generateWebVideoObjectStorageKey(filename)
127 127
128 logger.info('Fetching WebTorrent file %s from object storage to %s.', key, destination, lTags()) 128 logger.info('Fetching Web Video file %s from object storage to %s.', key, destination, lTags())
129 129
130 await makeAvailable({ 130 await makeAvailable({
131 key, 131 key,
132 destination, 132 destination,
133 bucketInfo: CONFIG.OBJECT_STORAGE.VIDEOS 133 bucketInfo: CONFIG.OBJECT_STORAGE.WEB_VIDEOS
134 }) 134 })
135 135
136 return destination 136 return destination
@@ -138,17 +138,17 @@ async function makeWebTorrentFileAvailable (filename: string, destination: strin
138 138
139// --------------------------------------------------------------------------- 139// ---------------------------------------------------------------------------
140 140
141function getWebTorrentFileReadStream (options: { 141function getWebVideoFileReadStream (options: {
142 filename: string 142 filename: string
143 rangeHeader: string 143 rangeHeader: string
144}) { 144}) {
145 const { filename, rangeHeader } = options 145 const { filename, rangeHeader } = options
146 146
147 const key = generateWebTorrentObjectStorageKey(filename) 147 const key = generateWebVideoObjectStorageKey(filename)
148 148
149 return createObjectReadStream({ 149 return createObjectReadStream({
150 key, 150 key,
151 bucketInfo: CONFIG.OBJECT_STORAGE.VIDEOS, 151 bucketInfo: CONFIG.OBJECT_STORAGE.WEB_VIDEOS,
152 rangeHeader 152 rangeHeader
153 }) 153 })
154} 154}
@@ -174,12 +174,12 @@ function getHLSFileReadStream (options: {
174export { 174export {
175 listHLSFileKeysOf, 175 listHLSFileKeysOf,
176 176
177 storeWebTorrentFile, 177 storeWebVideoFile,
178 storeHLSFileFromFilename, 178 storeHLSFileFromFilename,
179 storeHLSFileFromPath, 179 storeHLSFileFromPath,
180 storeHLSFileFromContent, 180 storeHLSFileFromContent,
181 181
182 updateWebTorrentFileACL, 182 updateWebVideoFileACL,
183 updateHLSFilesACL, 183 updateHLSFilesACL,
184 184
185 removeHLSObjectStorage, 185 removeHLSObjectStorage,
@@ -187,11 +187,11 @@ export {
187 removeHLSFileObjectStorageByPath, 187 removeHLSFileObjectStorageByPath,
188 removeHLSFileObjectStorageByFullKey, 188 removeHLSFileObjectStorageByFullKey,
189 189
190 removeWebTorrentObjectStorage, 190 removeWebVideoObjectStorage,
191 191
192 makeWebTorrentFileAvailable, 192 makeWebVideoFileAvailable,
193 makeHLSFileAvailable, 193 makeHLSFileAvailable,
194 194
195 getWebTorrentFileReadStream, 195 getWebVideoFileReadStream,
196 getHLSFileReadStream 196 getHLSFileReadStream
197} 197}
diff --git a/server/lib/paths.ts b/server/lib/paths.ts
index 470970f55..db1cdede2 100644
--- a/server/lib/paths.ts
+++ b/server/lib/paths.ts
@@ -8,7 +8,7 @@ import { isVideoInPrivateDirectory } from './video-privacy'
8 8
9// ################## Video file name ################## 9// ################## Video file name ##################
10 10
11function generateWebTorrentVideoFilename (resolution: number, extname: string) { 11function generateWebVideoFilename (resolution: number, extname: string) {
12 return buildUUID() + '-' + resolution + extname 12 return buildUUID() + '-' + resolution + extname
13} 13}
14 14
@@ -76,7 +76,7 @@ function getFSTorrentFilePath (videoFile: MVideoFile) {
76 76
77export { 77export {
78 generateHLSVideoFilename, 78 generateHLSVideoFilename,
79 generateWebTorrentVideoFilename, 79 generateWebVideoFilename,
80 80
81 generateTorrentFileName, 81 generateTorrentFileName,
82 getFSTorrentFilePath, 82 getFSTorrentFilePath,
diff --git a/server/lib/plugins/plugin-helpers-builder.ts b/server/lib/plugins/plugin-helpers-builder.ts
index d235f52c0..b4e3eece4 100644
--- a/server/lib/plugins/plugin-helpers-builder.ts
+++ b/server/lib/plugins/plugin-helpers-builder.ts
@@ -104,7 +104,7 @@ function buildVideosHelpers () {
104 const video = await VideoModel.loadFull(id) 104 const video = await VideoModel.loadFull(id)
105 if (!video) return undefined 105 if (!video) return undefined
106 106
107 const webtorrentVideoFiles = (video.VideoFiles || []).map(f => ({ 107 const webVideoFiles = (video.VideoFiles || []).map(f => ({
108 path: f.storage === VideoStorage.FILE_SYSTEM 108 path: f.storage === VideoStorage.FILE_SYSTEM
109 ? VideoPathManager.Instance.getFSVideoFileOutputPath(video, f) 109 ? VideoPathManager.Instance.getFSVideoFileOutputPath(video, f)
110 : null, 110 : null,
@@ -138,8 +138,12 @@ function buildVideosHelpers () {
138 })) 138 }))
139 139
140 return { 140 return {
141 webtorrent: { 141 webtorrent: { // TODO: remove in v7
142 videoFiles: webtorrentVideoFiles 142 videoFiles: webVideoFiles
143 },
144
145 webVideo: {
146 videoFiles: webVideoFiles
143 }, 147 },
144 148
145 hls: { 149 hls: {
diff --git a/server/lib/redis.ts b/server/lib/redis.ts
index 8430b2227..48d9986b5 100644
--- a/server/lib/redis.ts
+++ b/server/lib/redis.ts
@@ -325,8 +325,8 @@ class Redis {
325 const value = await this.getValue('resumable-upload-' + uploadId) 325 const value = await this.getValue('resumable-upload-' + uploadId)
326 326
327 return value 327 return value
328 ? JSON.parse(value) 328 ? JSON.parse(value) as { video: { id: number, shortUUID: string, uuid: string } }
329 : '' 329 : undefined
330 } 330 }
331 331
332 deleteUploadSession (uploadId: string) { 332 deleteUploadSession (uploadId: string) {
diff --git a/server/lib/runners/job-handlers/shared/vod-helpers.ts b/server/lib/runners/job-handlers/shared/vod-helpers.ts
index 93ae89ff8..1a2ad02ca 100644
--- a/server/lib/runners/job-handlers/shared/vod-helpers.ts
+++ b/server/lib/runners/job-handlers/shared/vod-helpers.ts
@@ -2,7 +2,7 @@ import { move } from 'fs-extra'
2import { dirname, join } from 'path' 2import { dirname, join } from 'path'
3import { logger, LoggerTagsFn } from '@server/helpers/logger' 3import { logger, LoggerTagsFn } from '@server/helpers/logger'
4import { onTranscodingEnded } from '@server/lib/transcoding/ended-transcoding' 4import { onTranscodingEnded } from '@server/lib/transcoding/ended-transcoding'
5import { onWebTorrentVideoFileTranscoding } from '@server/lib/transcoding/web-transcoding' 5import { onWebVideoFileTranscoding } from '@server/lib/transcoding/web-transcoding'
6import { buildNewFile } from '@server/lib/video-file' 6import { buildNewFile } from '@server/lib/video-file'
7import { VideoModel } from '@server/models/video/video' 7import { VideoModel } from '@server/models/video/video'
8import { MVideoFullLight } from '@server/types/models' 8import { MVideoFullLight } from '@server/types/models'
@@ -22,7 +22,7 @@ export async function onVODWebVideoOrAudioMergeTranscodingJob (options: {
22 const newVideoFilePath = join(dirname(videoFilePath), videoFile.filename) 22 const newVideoFilePath = join(dirname(videoFilePath), videoFile.filename)
23 await move(videoFilePath, newVideoFilePath) 23 await move(videoFilePath, newVideoFilePath)
24 24
25 await onWebTorrentVideoFileTranscoding({ 25 await onWebVideoFileTranscoding({
26 video, 26 video,
27 videoFile, 27 videoFile,
28 videoOutputPath: newVideoFilePath 28 videoOutputPath: newVideoFilePath
diff --git a/server/lib/runners/job-handlers/vod-audio-merge-transcoding-job-handler.ts b/server/lib/runners/job-handlers/vod-audio-merge-transcoding-job-handler.ts
index 5f247d792..905007db9 100644
--- a/server/lib/runners/job-handlers/vod-audio-merge-transcoding-job-handler.ts
+++ b/server/lib/runners/job-handlers/vod-audio-merge-transcoding-job-handler.ts
@@ -83,7 +83,7 @@ export class VODAudioMergeTranscodingJobHandler extends AbstractVODTranscodingJo
83 83
84 // We can remove the old audio file 84 // We can remove the old audio file
85 const oldAudioFile = video.VideoFiles[0] 85 const oldAudioFile = video.VideoFiles[0]
86 await video.removeWebTorrentFile(oldAudioFile) 86 await video.removeWebVideoFile(oldAudioFile)
87 await oldAudioFile.destroy() 87 await oldAudioFile.destroy()
88 video.VideoFiles = [] 88 video.VideoFiles = []
89 89
diff --git a/server/lib/runners/job-handlers/vod-hls-transcoding-job-handler.ts b/server/lib/runners/job-handlers/vod-hls-transcoding-job-handler.ts
index cc94bcbda..02845952c 100644
--- a/server/lib/runners/job-handlers/vod-hls-transcoding-job-handler.ts
+++ b/server/lib/runners/job-handlers/vod-hls-transcoding-job-handler.ts
@@ -5,7 +5,7 @@ import { renameVideoFileInPlaylist } from '@server/lib/hls'
5import { getHlsResolutionPlaylistFilename } from '@server/lib/paths' 5import { getHlsResolutionPlaylistFilename } from '@server/lib/paths'
6import { onTranscodingEnded } from '@server/lib/transcoding/ended-transcoding' 6import { onTranscodingEnded } from '@server/lib/transcoding/ended-transcoding'
7import { onHLSVideoFileTranscoding } from '@server/lib/transcoding/hls-transcoding' 7import { onHLSVideoFileTranscoding } from '@server/lib/transcoding/hls-transcoding'
8import { buildNewFile, removeAllWebTorrentFiles } from '@server/lib/video-file' 8import { buildNewFile, removeAllWebVideoFiles } from '@server/lib/video-file'
9import { VideoJobInfoModel } from '@server/models/video/video-job-info' 9import { VideoJobInfoModel } from '@server/models/video/video-job-info'
10import { MVideo } from '@server/types/models' 10import { MVideo } from '@server/types/models'
11import { MRunnerJob } from '@server/types/models/runners' 11import { MRunnerJob } from '@server/types/models/runners'
@@ -106,7 +106,7 @@ export class VODHLSTranscodingJobHandler extends AbstractVODTranscodingJobHandle
106 if (privatePayload.deleteWebVideoFiles === true) { 106 if (privatePayload.deleteWebVideoFiles === true) {
107 logger.info('Removing web video files of %s now we have a HLS version of it.', video.uuid, this.lTags(video.uuid)) 107 logger.info('Removing web video files of %s now we have a HLS version of it.', video.uuid, this.lTags(video.uuid))
108 108
109 await removeAllWebTorrentFiles(video) 109 await removeAllWebVideoFiles(video)
110 } 110 }
111 111
112 logger.info('Runner VOD HLS job %s for %s ended.', runnerJob.uuid, video.uuid, this.lTags(runnerJob.uuid, video.uuid)) 112 logger.info('Runner VOD HLS job %s for %s ended.', runnerJob.uuid, video.uuid, this.lTags(runnerJob.uuid, video.uuid))
diff --git a/server/lib/schedulers/videos-redundancy-scheduler.ts b/server/lib/schedulers/videos-redundancy-scheduler.ts
index dc450c338..24d340a73 100644
--- a/server/lib/schedulers/videos-redundancy-scheduler.ts
+++ b/server/lib/schedulers/videos-redundancy-scheduler.ts
@@ -23,7 +23,7 @@ import { getLocalVideoCacheFileActivityPubUrl, getLocalVideoCacheStreamingPlayli
23import { getOrCreateAPVideo } from '../activitypub/videos' 23import { getOrCreateAPVideo } from '../activitypub/videos'
24import { downloadPlaylistSegments } from '../hls' 24import { downloadPlaylistSegments } from '../hls'
25import { removeVideoRedundancy } from '../redundancy' 25import { removeVideoRedundancy } from '../redundancy'
26import { generateHLSRedundancyUrl, generateWebTorrentRedundancyUrl } from '../video-urls' 26import { generateHLSRedundancyUrl, generateWebVideoRedundancyUrl } from '../video-urls'
27import { AbstractScheduler } from './abstract-scheduler' 27import { AbstractScheduler } from './abstract-scheduler'
28 28
29const lTags = loggerTagsFactory('redundancy') 29const lTags = loggerTagsFactory('redundancy')
@@ -244,7 +244,7 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
244 const createdModel: MVideoRedundancyFileVideo = await VideoRedundancyModel.create({ 244 const createdModel: MVideoRedundancyFileVideo = await VideoRedundancyModel.create({
245 expiresOn, 245 expiresOn,
246 url: getLocalVideoCacheFileActivityPubUrl(file), 246 url: getLocalVideoCacheFileActivityPubUrl(file),
247 fileUrl: generateWebTorrentRedundancyUrl(file), 247 fileUrl: generateWebVideoRedundancyUrl(file),
248 strategy, 248 strategy,
249 videoFileId: file.id, 249 videoFileId: file.id,
250 actorId: serverActor.id 250 actorId: serverActor.id
diff --git a/server/lib/server-config-manager.ts b/server/lib/server-config-manager.ts
index 924adb337..5ce89b16d 100644
--- a/server/lib/server-config-manager.ts
+++ b/server/lib/server-config-manager.ts
@@ -132,8 +132,8 @@ class ServerConfigManager {
132 hls: { 132 hls: {
133 enabled: CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.HLS.ENABLED 133 enabled: CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.HLS.ENABLED
134 }, 134 },
135 webtorrent: { 135 web_videos: {
136 enabled: CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.WEBTORRENT.ENABLED 136 enabled: CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED
137 }, 137 },
138 enabledResolutions: this.getEnabledResolutions('vod'), 138 enabledResolutions: this.getEnabledResolutions('vod'),
139 profile: CONFIG.TRANSCODING.PROFILE, 139 profile: CONFIG.TRANSCODING.PROFILE,
diff --git a/server/lib/thumbnail.ts b/server/lib/thumbnail.ts
index 02b867a91..d95442795 100644
--- a/server/lib/thumbnail.ts
+++ b/server/lib/thumbnail.ts
@@ -7,13 +7,12 @@ import { ThumbnailModel } from '../models/video/thumbnail'
7import { MVideoFile, MVideoThumbnail, MVideoUUID } from '../types/models' 7import { MVideoFile, MVideoThumbnail, MVideoUUID } from '../types/models'
8import { MThumbnail } from '../types/models/video/thumbnail' 8import { MThumbnail } from '../types/models/video/thumbnail'
9import { MVideoPlaylistThumbnail } from '../types/models/video/video-playlist' 9import { MVideoPlaylistThumbnail } from '../types/models/video/video-playlist'
10import { downloadImageFromWorker } from './local-actor'
11import { VideoPathManager } from './video-path-manager' 10import { VideoPathManager } from './video-path-manager'
12import { processImageFromWorker } from './worker/parent-process' 11import { downloadImageFromWorker, processImageFromWorker } from './worker/parent-process'
13 12
14type ImageSize = { height?: number, width?: number } 13type ImageSize = { height?: number, width?: number }
15 14
16function updatePlaylistMiniatureFromExisting (options: { 15function updateLocalPlaylistMiniatureFromExisting (options: {
17 inputPath: string 16 inputPath: string
18 playlist: MVideoPlaylistThumbnail 17 playlist: MVideoPlaylistThumbnail
19 automaticallyGenerated: boolean 18 automaticallyGenerated: boolean
@@ -35,11 +34,12 @@ function updatePlaylistMiniatureFromExisting (options: {
35 width, 34 width,
36 type, 35 type,
37 automaticallyGenerated, 36 automaticallyGenerated,
37 onDisk: true,
38 existingThumbnail 38 existingThumbnail
39 }) 39 })
40} 40}
41 41
42function updatePlaylistMiniatureFromUrl (options: { 42function updateRemotePlaylistMiniatureFromUrl (options: {
43 downloadUrl: string 43 downloadUrl: string
44 playlist: MVideoPlaylistThumbnail 44 playlist: MVideoPlaylistThumbnail
45 size?: ImageSize 45 size?: ImageSize
@@ -57,42 +57,10 @@ function updatePlaylistMiniatureFromUrl (options: {
57 return downloadImageFromWorker({ url: downloadUrl, destDir: basePath, destName: filename, size: { width, height } }) 57 return downloadImageFromWorker({ url: downloadUrl, destDir: basePath, destName: filename, size: { width, height } })
58 } 58 }
59 59
60 return updateThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl }) 60 return updateThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl, onDisk: true })
61} 61}
62 62
63function updateVideoMiniatureFromUrl (options: { 63function updateLocalVideoMiniatureFromExisting (options: {
64 downloadUrl: string
65 video: MVideoThumbnail
66 type: ThumbnailType
67 size?: ImageSize
68}) {
69 const { downloadUrl, video, type, size } = options
70 const { filename: updatedFilename, basePath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size)
71
72 // Only save the file URL if it is a remote video
73 const fileUrl = video.isOwned()
74 ? null
75 : downloadUrl
76
77 const thumbnailUrlChanged = hasThumbnailUrlChanged(existingThumbnail, downloadUrl, video)
78
79 // Do not change the thumbnail filename if the file did not change
80 const filename = thumbnailUrlChanged
81 ? updatedFilename
82 : existingThumbnail.filename
83
84 const thumbnailCreator = () => {
85 if (thumbnailUrlChanged) {
86 return downloadImageFromWorker({ url: downloadUrl, destDir: basePath, destName: filename, size: { width, height } })
87 }
88
89 return Promise.resolve()
90 }
91
92 return updateThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl })
93}
94
95function updateVideoMiniatureFromExisting (options: {
96 inputPath: string 64 inputPath: string
97 video: MVideoThumbnail 65 video: MVideoThumbnail
98 type: ThumbnailType 66 type: ThumbnailType
@@ -115,11 +83,12 @@ function updateVideoMiniatureFromExisting (options: {
115 width, 83 width,
116 type, 84 type,
117 automaticallyGenerated, 85 automaticallyGenerated,
118 existingThumbnail 86 existingThumbnail,
87 onDisk: true
119 }) 88 })
120} 89}
121 90
122function generateVideoMiniature (options: { 91function generateLocalVideoMiniature (options: {
123 video: MVideoThumbnail 92 video: MVideoThumbnail
124 videoFile: MVideoFile 93 videoFile: MVideoFile
125 type: ThumbnailType 94 type: ThumbnailType
@@ -150,34 +119,68 @@ function generateVideoMiniature (options: {
150 width, 119 width,
151 type, 120 type,
152 automaticallyGenerated: true, 121 automaticallyGenerated: true,
122 onDisk: true,
153 existingThumbnail 123 existingThumbnail
154 }) 124 })
155 }) 125 })
156} 126}
157 127
158function updatePlaceholderThumbnail (options: { 128// ---------------------------------------------------------------------------
159 fileUrl: string 129
130function updateLocalVideoMiniatureFromUrl (options: {
131 downloadUrl: string
160 video: MVideoThumbnail 132 video: MVideoThumbnail
161 type: ThumbnailType 133 type: ThumbnailType
162 size: ImageSize 134 size?: ImageSize
163}) { 135}) {
164 const { fileUrl, video, type, size } = options 136 const { downloadUrl, video, type, size } = options
165 const { filename: updatedFilename, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size) 137 const { filename: updatedFilename, basePath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size)
166 138
167 const thumbnailUrlChanged = hasThumbnailUrlChanged(existingThumbnail, fileUrl, video) 139 // Only save the file URL if it is a remote video
140 const fileUrl = video.isOwned()
141 ? null
142 : downloadUrl
168 143
169 const thumbnail = existingThumbnail || new ThumbnailModel() 144 const thumbnailUrlChanged = hasThumbnailUrlChanged(existingThumbnail, downloadUrl, video)
170 145
171 // Do not change the thumbnail filename if the file did not change 146 // Do not change the thumbnail filename if the file did not change
172 const filename = thumbnailUrlChanged 147 const filename = thumbnailUrlChanged
173 ? updatedFilename 148 ? updatedFilename
174 : existingThumbnail.filename 149 : existingThumbnail.filename
175 150
176 thumbnail.filename = filename 151 const thumbnailCreator = () => {
152 if (thumbnailUrlChanged) {
153 return downloadImageFromWorker({ url: downloadUrl, destDir: basePath, destName: filename, size: { width, height } })
154 }
155
156 return Promise.resolve()
157 }
158
159 return updateThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl, onDisk: true })
160}
161
162function updateRemoteVideoThumbnail (options: {
163 fileUrl: string
164 video: MVideoThumbnail
165 type: ThumbnailType
166 size: ImageSize
167 onDisk: boolean
168}) {
169 const { fileUrl, video, type, size, onDisk } = options
170 const { filename: generatedFilename, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size)
171
172 const thumbnail = existingThumbnail || new ThumbnailModel()
173
174 // Do not change the thumbnail filename if the file did not change
175 if (hasThumbnailUrlChanged(existingThumbnail, fileUrl, video)) {
176 thumbnail.filename = generatedFilename
177 }
178
177 thumbnail.height = height 179 thumbnail.height = height
178 thumbnail.width = width 180 thumbnail.width = width
179 thumbnail.type = type 181 thumbnail.type = type
180 thumbnail.fileUrl = fileUrl 182 thumbnail.fileUrl = fileUrl
183 thumbnail.onDisk = onDisk
181 184
182 return thumbnail 185 return thumbnail
183} 186}
@@ -185,14 +188,18 @@ function updatePlaceholderThumbnail (options: {
185// --------------------------------------------------------------------------- 188// ---------------------------------------------------------------------------
186 189
187export { 190export {
188 generateVideoMiniature, 191 generateLocalVideoMiniature,
189 updateVideoMiniatureFromUrl, 192 updateLocalVideoMiniatureFromUrl,
190 updateVideoMiniatureFromExisting, 193 updateLocalVideoMiniatureFromExisting,
191 updatePlaceholderThumbnail, 194 updateRemoteVideoThumbnail,
192 updatePlaylistMiniatureFromUrl, 195 updateRemotePlaylistMiniatureFromUrl,
193 updatePlaylistMiniatureFromExisting 196 updateLocalPlaylistMiniatureFromExisting
194} 197}
195 198
199// ---------------------------------------------------------------------------
200// Private
201// ---------------------------------------------------------------------------
202
196function hasThumbnailUrlChanged (existingThumbnail: MThumbnail, downloadUrl: string, video: MVideoUUID) { 203function hasThumbnailUrlChanged (existingThumbnail: MThumbnail, downloadUrl: string, video: MVideoUUID) {
197 const existingUrl = existingThumbnail 204 const existingUrl = existingThumbnail
198 ? existingThumbnail.fileUrl 205 ? existingThumbnail.fileUrl
@@ -258,6 +265,7 @@ async function updateThumbnailFromFunction (parameters: {
258 height: number 265 height: number
259 width: number 266 width: number
260 type: ThumbnailType 267 type: ThumbnailType
268 onDisk: boolean
261 automaticallyGenerated?: boolean 269 automaticallyGenerated?: boolean
262 fileUrl?: string 270 fileUrl?: string
263 existingThumbnail?: MThumbnail 271 existingThumbnail?: MThumbnail
@@ -269,6 +277,7 @@ async function updateThumbnailFromFunction (parameters: {
269 height, 277 height,
270 type, 278 type,
271 existingThumbnail, 279 existingThumbnail,
280 onDisk,
272 automaticallyGenerated = null, 281 automaticallyGenerated = null,
273 fileUrl = null 282 fileUrl = null
274 } = parameters 283 } = parameters
@@ -285,6 +294,7 @@ async function updateThumbnailFromFunction (parameters: {
285 thumbnail.type = type 294 thumbnail.type = type
286 thumbnail.fileUrl = fileUrl 295 thumbnail.fileUrl = fileUrl
287 thumbnail.automaticallyGenerated = automaticallyGenerated 296 thumbnail.automaticallyGenerated = automaticallyGenerated
297 thumbnail.onDisk = onDisk
288 298
289 if (oldFilename) thumbnail.previousThumbnailFilename = oldFilename 299 if (oldFilename) thumbnail.previousThumbnailFilename = oldFilename
290 300
diff --git a/server/lib/transcoding/create-transcoding-job.ts b/server/lib/transcoding/create-transcoding-job.ts
index abe32684d..d78e68b87 100644
--- a/server/lib/transcoding/create-transcoding-job.ts
+++ b/server/lib/transcoding/create-transcoding-job.ts
@@ -15,7 +15,7 @@ export function createOptimizeOrMergeAudioJobs (options: {
15// --------------------------------------------------------------------------- 15// ---------------------------------------------------------------------------
16 16
17export function createTranscodingJobs (options: { 17export function createTranscodingJobs (options: {
18 transcodingType: 'hls' | 'webtorrent' 18 transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7
19 video: MVideoFullLight 19 video: MVideoFullLight
20 resolutions: number[] 20 resolutions: number[]
21 isNewVideo: boolean 21 isNewVideo: boolean
diff --git a/server/lib/transcoding/shared/job-builders/abstract-job-builder.ts b/server/lib/transcoding/shared/job-builders/abstract-job-builder.ts
index 80dc05bfb..15fc814ae 100644
--- a/server/lib/transcoding/shared/job-builders/abstract-job-builder.ts
+++ b/server/lib/transcoding/shared/job-builders/abstract-job-builder.ts
@@ -12,7 +12,7 @@ export abstract class AbstractJobBuilder {
12 }): Promise<any> 12 }): Promise<any>
13 13
14 abstract createTranscodingJobs (options: { 14 abstract createTranscodingJobs (options: {
15 transcodingType: 'hls' | 'webtorrent' 15 transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7
16 video: MVideoFullLight 16 video: MVideoFullLight
17 resolutions: number[] 17 resolutions: number[]
18 isNewVideo: boolean 18 isNewVideo: boolean
diff --git a/server/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts b/server/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts
index 4f802e2a6..0505c2b2f 100644
--- a/server/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts
+++ b/server/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts
@@ -12,7 +12,7 @@ import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAud
12import { 12import {
13 HLSTranscodingPayload, 13 HLSTranscodingPayload,
14 MergeAudioTranscodingPayload, 14 MergeAudioTranscodingPayload,
15 NewWebTorrentResolutionTranscodingPayload, 15 NewWebVideoResolutionTranscodingPayload,
16 OptimizeTranscodingPayload, 16 OptimizeTranscodingPayload,
17 VideoTranscodingPayload 17 VideoTranscodingPayload
18} from '@shared/models' 18} from '@shared/models'
@@ -33,7 +33,7 @@ export class TranscodingJobQueueBuilder extends AbstractJobBuilder {
33 const { video, videoFile, isNewVideo, user, videoFileAlreadyLocked } = options 33 const { video, videoFile, isNewVideo, user, videoFileAlreadyLocked } = options
34 34
35 let mergeOrOptimizePayload: MergeAudioTranscodingPayload | OptimizeTranscodingPayload 35 let mergeOrOptimizePayload: MergeAudioTranscodingPayload | OptimizeTranscodingPayload
36 let nextTranscodingSequentialJobPayloads: (NewWebTorrentResolutionTranscodingPayload | HLSTranscodingPayload)[][] = [] 36 let nextTranscodingSequentialJobPayloads: (NewWebVideoResolutionTranscodingPayload | HLSTranscodingPayload)[][] = []
37 37
38 const mutexReleaser = videoFileAlreadyLocked 38 const mutexReleaser = videoFileAlreadyLocked
39 ? () => {} 39 ? () => {}
@@ -60,7 +60,7 @@ export class TranscodingJobQueueBuilder extends AbstractJobBuilder {
60 if (CONFIG.TRANSCODING.HLS.ENABLED === true) { 60 if (CONFIG.TRANSCODING.HLS.ENABLED === true) {
61 nextTranscodingSequentialJobPayloads.push([ 61 nextTranscodingSequentialJobPayloads.push([
62 this.buildHLSJobPayload({ 62 this.buildHLSJobPayload({
63 deleteWebTorrentFiles: CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false, 63 deleteWebVideoFiles: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED === false,
64 64
65 // We had some issues with a web video quick transcoded while producing a HLS version of it 65 // We had some issues with a web video quick transcoded while producing a HLS version of it
66 copyCodecs: !quickTranscode, 66 copyCodecs: !quickTranscode,
@@ -116,7 +116,7 @@ export class TranscodingJobQueueBuilder extends AbstractJobBuilder {
116 // --------------------------------------------------------------------------- 116 // ---------------------------------------------------------------------------
117 117
118 async createTranscodingJobs (options: { 118 async createTranscodingJobs (options: {
119 transcodingType: 'hls' | 'webtorrent' 119 transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7
120 video: MVideoFullLight 120 video: MVideoFullLight
121 resolutions: number[] 121 resolutions: number[]
122 isNewVideo: boolean 122 isNewVideo: boolean
@@ -138,8 +138,8 @@ export class TranscodingJobQueueBuilder extends AbstractJobBuilder {
138 return this.buildHLSJobPayload({ videoUUID: video.uuid, resolution, fps, isNewVideo }) 138 return this.buildHLSJobPayload({ videoUUID: video.uuid, resolution, fps, isNewVideo })
139 } 139 }
140 140
141 if (transcodingType === 'webtorrent') { 141 if (transcodingType === 'webtorrent' || transcodingType === 'web-video') {
142 return this.buildWebTorrentJobPayload({ videoUUID: video.uuid, resolution, fps, isNewVideo }) 142 return this.buildWebVideoJobPayload({ videoUUID: video.uuid, resolution, fps, isNewVideo })
143 } 143 }
144 144
145 throw new Error('Unknown transcoding type') 145 throw new Error('Unknown transcoding type')
@@ -149,7 +149,7 @@ export class TranscodingJobQueueBuilder extends AbstractJobBuilder {
149 149
150 const parent = transcodingType === 'hls' 150 const parent = transcodingType === 'hls'
151 ? this.buildHLSJobPayload({ videoUUID: video.uuid, resolution: maxResolution, fps, isNewVideo }) 151 ? this.buildHLSJobPayload({ videoUUID: video.uuid, resolution: maxResolution, fps, isNewVideo })
152 : this.buildWebTorrentJobPayload({ videoUUID: video.uuid, resolution: maxResolution, fps, isNewVideo }) 152 : this.buildWebVideoJobPayload({ videoUUID: video.uuid, resolution: maxResolution, fps, isNewVideo })
153 153
154 // Process the last resolution after the other ones to prevent concurrency issue 154 // Process the last resolution after the other ones to prevent concurrency issue
155 // Because low resolutions use the biggest one as ffmpeg input 155 // Because low resolutions use the biggest one as ffmpeg input
@@ -160,8 +160,8 @@ export class TranscodingJobQueueBuilder extends AbstractJobBuilder {
160 160
161 private async createTranscodingJobsWithChildren (options: { 161 private async createTranscodingJobsWithChildren (options: {
162 videoUUID: string 162 videoUUID: string
163 parent: (HLSTranscodingPayload | NewWebTorrentResolutionTranscodingPayload) 163 parent: (HLSTranscodingPayload | NewWebVideoResolutionTranscodingPayload)
164 children: (HLSTranscodingPayload | NewWebTorrentResolutionTranscodingPayload)[] 164 children: (HLSTranscodingPayload | NewWebVideoResolutionTranscodingPayload)[]
165 user: MUserId | null 165 user: MUserId | null
166 }) { 166 }) {
167 const { videoUUID, parent, children, user } = options 167 const { videoUUID, parent, children, user } = options
@@ -203,14 +203,14 @@ export class TranscodingJobQueueBuilder extends AbstractJobBuilder {
203 options 203 options
204 ) 204 )
205 205
206 const sequentialPayloads: (NewWebTorrentResolutionTranscodingPayload | HLSTranscodingPayload)[][] = [] 206 const sequentialPayloads: (NewWebVideoResolutionTranscodingPayload | HLSTranscodingPayload)[][] = []
207 207
208 for (const resolution of resolutionsEnabled) { 208 for (const resolution of resolutionsEnabled) {
209 const fps = computeOutputFPS({ inputFPS: inputVideoFPS, resolution }) 209 const fps = computeOutputFPS({ inputFPS: inputVideoFPS, resolution })
210 210
211 if (CONFIG.TRANSCODING.WEBTORRENT.ENABLED) { 211 if (CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED) {
212 const payloads: (NewWebTorrentResolutionTranscodingPayload | HLSTranscodingPayload)[] = [ 212 const payloads: (NewWebVideoResolutionTranscodingPayload | HLSTranscodingPayload)[] = [
213 this.buildWebTorrentJobPayload({ 213 this.buildWebVideoJobPayload({
214 videoUUID: video.uuid, 214 videoUUID: video.uuid,
215 resolution, 215 resolution,
216 fps, 216 fps,
@@ -253,10 +253,10 @@ export class TranscodingJobQueueBuilder extends AbstractJobBuilder {
253 resolution: number 253 resolution: number
254 fps: number 254 fps: number
255 isNewVideo: boolean 255 isNewVideo: boolean
256 deleteWebTorrentFiles?: boolean // default false 256 deleteWebVideoFiles?: boolean // default false
257 copyCodecs?: boolean // default false 257 copyCodecs?: boolean // default false
258 }): HLSTranscodingPayload { 258 }): HLSTranscodingPayload {
259 const { videoUUID, resolution, fps, isNewVideo, deleteWebTorrentFiles = false, copyCodecs = false } = options 259 const { videoUUID, resolution, fps, isNewVideo, deleteWebVideoFiles = false, copyCodecs = false } = options
260 260
261 return { 261 return {
262 type: 'new-resolution-to-hls', 262 type: 'new-resolution-to-hls',
@@ -265,20 +265,20 @@ export class TranscodingJobQueueBuilder extends AbstractJobBuilder {
265 fps, 265 fps,
266 copyCodecs, 266 copyCodecs,
267 isNewVideo, 267 isNewVideo,
268 deleteWebTorrentFiles 268 deleteWebVideoFiles
269 } 269 }
270 } 270 }
271 271
272 private buildWebTorrentJobPayload (options: { 272 private buildWebVideoJobPayload (options: {
273 videoUUID: string 273 videoUUID: string
274 resolution: number 274 resolution: number
275 fps: number 275 fps: number
276 isNewVideo: boolean 276 isNewVideo: boolean
277 }): NewWebTorrentResolutionTranscodingPayload { 277 }): NewWebVideoResolutionTranscodingPayload {
278 const { videoUUID, resolution, fps, isNewVideo } = options 278 const { videoUUID, resolution, fps, isNewVideo } = options
279 279
280 return { 280 return {
281 type: 'new-resolution-to-webtorrent', 281 type: 'new-resolution-to-web-video',
282 videoUUID, 282 videoUUID,
283 isNewVideo, 283 isNewVideo,
284 resolution, 284 resolution,
@@ -294,7 +294,7 @@ export class TranscodingJobQueueBuilder extends AbstractJobBuilder {
294 const { videoUUID, isNewVideo, hasChildren } = options 294 const { videoUUID, isNewVideo, hasChildren } = options
295 295
296 return { 296 return {
297 type: 'merge-audio-to-webtorrent', 297 type: 'merge-audio-to-web-video',
298 resolution: DEFAULT_AUDIO_RESOLUTION, 298 resolution: DEFAULT_AUDIO_RESOLUTION,
299 fps: VIDEO_TRANSCODING_FPS.AUDIO_MERGE, 299 fps: VIDEO_TRANSCODING_FPS.AUDIO_MERGE,
300 videoUUID, 300 videoUUID,
@@ -312,7 +312,7 @@ export class TranscodingJobQueueBuilder extends AbstractJobBuilder {
312 const { videoUUID, quickTranscode, isNewVideo, hasChildren } = options 312 const { videoUUID, quickTranscode, isNewVideo, hasChildren } = options
313 313
314 return { 314 return {
315 type: 'optimize-to-webtorrent', 315 type: 'optimize-to-web-video',
316 videoUUID, 316 videoUUID,
317 isNewVideo, 317 isNewVideo,
318 hasChildren, 318 hasChildren,
diff --git a/server/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts b/server/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts
index ba2a46f44..f0671bd7a 100644
--- a/server/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts
+++ b/server/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts
@@ -62,7 +62,7 @@ export class TranscodingRunnerJobBuilder extends AbstractJobBuilder {
62 if (CONFIG.TRANSCODING.HLS.ENABLED === true) { 62 if (CONFIG.TRANSCODING.HLS.ENABLED === true) {
63 await new VODHLSTranscodingJobHandler().create({ 63 await new VODHLSTranscodingJobHandler().create({
64 video, 64 video,
65 deleteWebVideoFiles: CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false, 65 deleteWebVideoFiles: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED === false,
66 resolution: maxResolution, 66 resolution: maxResolution,
67 fps, 67 fps,
68 isNewVideo, 68 isNewVideo,
@@ -89,7 +89,7 @@ export class TranscodingRunnerJobBuilder extends AbstractJobBuilder {
89 // --------------------------------------------------------------------------- 89 // ---------------------------------------------------------------------------
90 90
91 async createTranscodingJobs (options: { 91 async createTranscodingJobs (options: {
92 transcodingType: 'hls' | 'webtorrent' 92 transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7
93 video: MVideoFullLight 93 video: MVideoFullLight
94 resolutions: number[] 94 resolutions: number[]
95 isNewVideo: boolean 95 isNewVideo: boolean
@@ -130,7 +130,7 @@ export class TranscodingRunnerJobBuilder extends AbstractJobBuilder {
130 continue 130 continue
131 } 131 }
132 132
133 if (transcodingType === 'webtorrent') { 133 if (transcodingType === 'webtorrent' || transcodingType === 'web-video') {
134 await new VODWebVideoTranscodingJobHandler().create({ 134 await new VODWebVideoTranscodingJobHandler().create({
135 video, 135 video,
136 resolution, 136 resolution,
@@ -169,7 +169,7 @@ export class TranscodingRunnerJobBuilder extends AbstractJobBuilder {
169 for (const resolution of resolutionsEnabled) { 169 for (const resolution of resolutionsEnabled) {
170 const fps = computeOutputFPS({ inputFPS: inputVideoFPS, resolution }) 170 const fps = computeOutputFPS({ inputFPS: inputVideoFPS, resolution })
171 171
172 if (CONFIG.TRANSCODING.WEBTORRENT.ENABLED) { 172 if (CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED) {
173 await new VODWebVideoTranscodingJobHandler().create({ 173 await new VODWebVideoTranscodingJobHandler().create({
174 video, 174 video,
175 resolution, 175 resolution,
diff --git a/server/lib/transcoding/web-transcoding.ts b/server/lib/transcoding/web-transcoding.ts
index 7cc8f20bc..f92d457a0 100644
--- a/server/lib/transcoding/web-transcoding.ts
+++ b/server/lib/transcoding/web-transcoding.ts
@@ -9,7 +9,8 @@ import { ffprobePromise, getVideoStreamDuration, getVideoStreamFPS, TranscodeVOD
9import { VideoResolution, VideoStorage } from '@shared/models' 9import { VideoResolution, VideoStorage } from '@shared/models'
10import { CONFIG } from '../../initializers/config' 10import { CONFIG } from '../../initializers/config'
11import { VideoFileModel } from '../../models/video/video-file' 11import { VideoFileModel } from '../../models/video/video-file'
12import { generateWebTorrentVideoFilename } from '../paths' 12import { JobQueue } from '../job-queue'
13import { generateWebVideoFilename } from '../paths'
13import { buildFileMetadata } from '../video-file' 14import { buildFileMetadata } from '../video-file'
14import { VideoPathManager } from '../video-path-manager' 15import { VideoPathManager } from '../video-path-manager'
15import { buildFFmpegVOD } from './shared' 16import { buildFFmpegVOD } from './shared'
@@ -62,10 +63,10 @@ export async function optimizeOriginalVideofile (options: {
62 // Important to do this before getVideoFilename() to take in account the new filename 63 // Important to do this before getVideoFilename() to take in account the new filename
63 inputVideoFile.resolution = resolution 64 inputVideoFile.resolution = resolution
64 inputVideoFile.extname = newExtname 65 inputVideoFile.extname = newExtname
65 inputVideoFile.filename = generateWebTorrentVideoFilename(resolution, newExtname) 66 inputVideoFile.filename = generateWebVideoFilename(resolution, newExtname)
66 inputVideoFile.storage = VideoStorage.FILE_SYSTEM 67 inputVideoFile.storage = VideoStorage.FILE_SYSTEM
67 68
68 const { videoFile } = await onWebTorrentVideoFileTranscoding({ 69 const { videoFile } = await onWebVideoFileTranscoding({
69 video, 70 video,
70 videoFile: inputVideoFile, 71 videoFile: inputVideoFile,
71 videoOutputPath 72 videoOutputPath
@@ -82,8 +83,8 @@ export async function optimizeOriginalVideofile (options: {
82 } 83 }
83} 84}
84 85
85// Transcode the original video file to a lower resolution compatible with WebTorrent 86// Transcode the original video file to a lower resolution compatible with web browsers
86export async function transcodeNewWebTorrentResolution (options: { 87export async function transcodeNewWebVideoResolution (options: {
87 video: MVideoFullLight 88 video: MVideoFullLight
88 resolution: VideoResolution 89 resolution: VideoResolution
89 fps: number 90 fps: number
@@ -104,7 +105,7 @@ export async function transcodeNewWebTorrentResolution (options: {
104 const newVideoFile = new VideoFileModel({ 105 const newVideoFile = new VideoFileModel({
105 resolution, 106 resolution,
106 extname: newExtname, 107 extname: newExtname,
107 filename: generateWebTorrentVideoFilename(resolution, newExtname), 108 filename: generateWebVideoFilename(resolution, newExtname),
108 size: 0, 109 size: 0,
109 videoId: video.id 110 videoId: video.id
110 }) 111 })
@@ -125,7 +126,7 @@ export async function transcodeNewWebTorrentResolution (options: {
125 126
126 await buildFFmpegVOD(job).transcode(transcodeOptions) 127 await buildFFmpegVOD(job).transcode(transcodeOptions)
127 128
128 return onWebTorrentVideoFileTranscoding({ video, videoFile: newVideoFile, videoOutputPath }) 129 return onWebVideoFileTranscoding({ video, videoFile: newVideoFile, videoOutputPath })
129 }) 130 })
130 131
131 return result 132 return result
@@ -188,17 +189,18 @@ export async function mergeAudioVideofile (options: {
188 // Important to do this before getVideoFilename() to take in account the new file extension 189 // Important to do this before getVideoFilename() to take in account the new file extension
189 inputVideoFile.extname = newExtname 190 inputVideoFile.extname = newExtname
190 inputVideoFile.resolution = resolution 191 inputVideoFile.resolution = resolution
191 inputVideoFile.filename = generateWebTorrentVideoFilename(inputVideoFile.resolution, newExtname) 192 inputVideoFile.filename = generateWebVideoFilename(inputVideoFile.resolution, newExtname)
192 193
193 // ffmpeg generated a new video file, so update the video duration 194 // ffmpeg generated a new video file, so update the video duration
194 // See https://trac.ffmpeg.org/ticket/5456 195 // See https://trac.ffmpeg.org/ticket/5456
195 video.duration = await getVideoStreamDuration(videoOutputPath) 196 video.duration = await getVideoStreamDuration(videoOutputPath)
196 await video.save() 197 await video.save()
197 198
198 return onWebTorrentVideoFileTranscoding({ 199 return onWebVideoFileTranscoding({
199 video, 200 video,
200 videoFile: inputVideoFile, 201 videoFile: inputVideoFile,
201 videoOutputPath 202 videoOutputPath,
203 wasAudioFile: true
202 }) 204 })
203 }) 205 })
204 206
@@ -208,12 +210,13 @@ export async function mergeAudioVideofile (options: {
208 } 210 }
209} 211}
210 212
211export async function onWebTorrentVideoFileTranscoding (options: { 213export async function onWebVideoFileTranscoding (options: {
212 video: MVideoFullLight 214 video: MVideoFullLight
213 videoFile: MVideoFile 215 videoFile: MVideoFile
214 videoOutputPath: string 216 videoOutputPath: string
217 wasAudioFile?: boolean // default false
215}) { 218}) {
216 const { video, videoFile, videoOutputPath } = options 219 const { video, videoFile, videoOutputPath, wasAudioFile } = options
217 220
218 const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid) 221 const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
219 222
@@ -236,12 +239,23 @@ export async function onWebTorrentVideoFileTranscoding (options: {
236 239
237 await createTorrentAndSetInfoHash(video, videoFile) 240 await createTorrentAndSetInfoHash(video, videoFile)
238 241
239 const oldFile = await VideoFileModel.loadWebTorrentFile({ videoId: video.id, fps: videoFile.fps, resolution: videoFile.resolution }) 242 const oldFile = await VideoFileModel.loadWebVideoFile({ videoId: video.id, fps: videoFile.fps, resolution: videoFile.resolution })
240 if (oldFile) await video.removeWebTorrentFile(oldFile) 243 if (oldFile) await video.removeWebVideoFile(oldFile)
241 244
242 await VideoFileModel.customUpsert(videoFile, 'video', undefined) 245 await VideoFileModel.customUpsert(videoFile, 'video', undefined)
243 video.VideoFiles = await video.$get('VideoFiles') 246 video.VideoFiles = await video.$get('VideoFiles')
244 247
248 if (wasAudioFile) {
249 await JobQueue.Instance.createJob({
250 type: 'generate-video-storyboard' as 'generate-video-storyboard',
251 payload: {
252 videoUUID: video.uuid,
253 // No need to federate, we process these jobs sequentially
254 federate: false
255 }
256 })
257 }
258
245 return { video, videoFile } 259 return { video, videoFile }
246 } finally { 260 } finally {
247 mutexReleaser() 261 mutexReleaser()
diff --git a/server/lib/video-file.ts b/server/lib/video-file.ts
index 88d48c945..46af67ccd 100644
--- a/server/lib/video-file.ts
+++ b/server/lib/video-file.ts
@@ -7,7 +7,7 @@ import { getFileSize } from '@shared/extra-utils'
7import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, isAudioFile } from '@shared/ffmpeg' 7import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, isAudioFile } from '@shared/ffmpeg'
8import { VideoFileMetadata, VideoResolution } from '@shared/models' 8import { VideoFileMetadata, VideoResolution } from '@shared/models'
9import { lTags } from './object-storage/shared' 9import { lTags } from './object-storage/shared'
10import { generateHLSVideoFilename, generateWebTorrentVideoFilename } from './paths' 10import { generateHLSVideoFilename, generateWebVideoFilename } from './paths'
11import { VideoPathManager } from './video-path-manager' 11import { VideoPathManager } from './video-path-manager'
12 12
13async function buildNewFile (options: { 13async function buildNewFile (options: {
@@ -33,7 +33,7 @@ async function buildNewFile (options: {
33 } 33 }
34 34
35 videoFile.filename = mode === 'web-video' 35 videoFile.filename = mode === 'web-video'
36 ? generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname) 36 ? generateWebVideoFilename(videoFile.resolution, videoFile.extname)
37 : generateHLSVideoFilename(videoFile.resolution) 37 : generateHLSVideoFilename(videoFile.resolution)
38 38
39 return videoFile 39 return videoFile
@@ -85,12 +85,12 @@ async function removeHLSFile (video: MVideoWithAllFiles, fileToDeleteId: number)
85 85
86// --------------------------------------------------------------------------- 86// ---------------------------------------------------------------------------
87 87
88async function removeAllWebTorrentFiles (video: MVideoWithAllFiles) { 88async function removeAllWebVideoFiles (video: MVideoWithAllFiles) {
89 const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid) 89 const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
90 90
91 try { 91 try {
92 for (const file of video.VideoFiles) { 92 for (const file of video.VideoFiles) {
93 await video.removeWebTorrentFile(file) 93 await video.removeWebVideoFile(file)
94 await file.destroy() 94 await file.destroy()
95 } 95 }
96 96
@@ -102,17 +102,17 @@ async function removeAllWebTorrentFiles (video: MVideoWithAllFiles) {
102 return video 102 return video
103} 103}
104 104
105async function removeWebTorrentFile (video: MVideoWithAllFiles, fileToDeleteId: number) { 105async function removeWebVideoFile (video: MVideoWithAllFiles, fileToDeleteId: number) {
106 const files = video.VideoFiles 106 const files = video.VideoFiles
107 107
108 if (files.length === 1) { 108 if (files.length === 1) {
109 return removeAllWebTorrentFiles(video) 109 return removeAllWebVideoFiles(video)
110 } 110 }
111 111
112 const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid) 112 const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
113 try { 113 try {
114 const toDelete = files.find(f => f.id === fileToDeleteId) 114 const toDelete = files.find(f => f.id === fileToDeleteId)
115 await video.removeWebTorrentFile(toDelete) 115 await video.removeWebVideoFile(toDelete)
116 await toDelete.destroy() 116 await toDelete.destroy()
117 117
118 video.VideoFiles = files.filter(f => f.id !== toDelete.id) 118 video.VideoFiles = files.filter(f => f.id !== toDelete.id)
@@ -138,8 +138,8 @@ export {
138 138
139 removeHLSPlaylist, 139 removeHLSPlaylist,
140 removeHLSFile, 140 removeHLSFile,
141 removeAllWebTorrentFiles, 141 removeAllWebVideoFiles,
142 removeWebTorrentFile, 142 removeWebVideoFile,
143 143
144 buildFileMetadata 144 buildFileMetadata
145} 145}
diff --git a/server/lib/video-path-manager.ts b/server/lib/video-path-manager.ts
index 9953cae5d..133544bb2 100644
--- a/server/lib/video-path-manager.ts
+++ b/server/lib/video-path-manager.ts
@@ -8,7 +8,7 @@ import { DIRECTORIES } from '@server/initializers/constants'
8import { MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '@server/types/models' 8import { MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '@server/types/models'
9import { buildUUID } from '@shared/extra-utils' 9import { buildUUID } from '@shared/extra-utils'
10import { VideoStorage } from '@shared/models' 10import { VideoStorage } from '@shared/models'
11import { makeHLSFileAvailable, makeWebTorrentFileAvailable } from './object-storage' 11import { makeHLSFileAvailable, makeWebVideoFileAvailable } from './object-storage'
12import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from './paths' 12import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from './paths'
13import { isVideoInPrivateDirectory } from './video-privacy' 13import { isVideoInPrivateDirectory } from './video-privacy'
14 14
@@ -78,7 +78,7 @@ class VideoPathManager {
78 } 78 }
79 79
80 return this.makeAvailableFactory( 80 return this.makeAvailableFactory(
81 () => makeWebTorrentFileAvailable(videoFile.filename, destination), 81 () => makeWebVideoFileAvailable(videoFile.filename, destination),
82 true, 82 true,
83 cb 83 cb
84 ) 84 )
diff --git a/server/lib/video-pre-import.ts b/server/lib/video-pre-import.ts
index df67dc953..381f1f535 100644
--- a/server/lib/video-pre-import.ts
+++ b/server/lib/video-pre-import.ts
@@ -29,7 +29,8 @@ import {
29} from '@server/types/models' 29} from '@server/types/models'
30import { ThumbnailType, VideoImportCreate, VideoImportPayload, VideoImportState, VideoPrivacy, VideoState } from '@shared/models' 30import { ThumbnailType, VideoImportCreate, VideoImportPayload, VideoImportState, VideoPrivacy, VideoState } from '@shared/models'
31import { getLocalVideoActivityPubUrl } from './activitypub/url' 31import { getLocalVideoActivityPubUrl } from './activitypub/url'
32import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from './thumbnail' 32import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail'
33import { VideoPasswordModel } from '@server/models/video/video-password'
33 34
34class YoutubeDlImportError extends Error { 35class YoutubeDlImportError extends Error {
35 code: YoutubeDlImportError.CODE 36 code: YoutubeDlImportError.CODE
@@ -64,8 +65,9 @@ async function insertFromImportIntoDB (parameters: {
64 tags: string[] 65 tags: string[]
65 videoImportAttributes: FilteredModelAttributes<VideoImportModel> 66 videoImportAttributes: FilteredModelAttributes<VideoImportModel>
66 user: MUser 67 user: MUser
68 videoPasswords?: string[]
67}): Promise<MVideoImportFormattable> { 69}): Promise<MVideoImportFormattable> {
68 const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user } = parameters 70 const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user, videoPasswords } = parameters
69 71
70 const videoImport = await sequelizeTypescript.transaction(async t => { 72 const videoImport = await sequelizeTypescript.transaction(async t => {
71 const sequelizeOptions = { transaction: t } 73 const sequelizeOptions = { transaction: t }
@@ -77,6 +79,10 @@ async function insertFromImportIntoDB (parameters: {
77 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t) 79 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
78 if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t) 80 if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t)
79 81
82 if (videoCreated.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
83 await VideoPasswordModel.addPasswords(videoPasswords, video.id, t)
84 }
85
80 await autoBlacklistVideoIfNeeded({ 86 await autoBlacklistVideoIfNeeded({
81 video: videoCreated, 87 video: videoCreated,
82 user, 88 user,
@@ -208,7 +214,8 @@ async function buildYoutubeDLImport (options: {
208 state: VideoImportState.PENDING, 214 state: VideoImportState.PENDING,
209 userId: user.id, 215 userId: user.id,
210 videoChannelSyncId: channelSync?.id 216 videoChannelSyncId: channelSync?.id
211 } 217 },
218 videoPasswords: importDataOverride.videoPasswords
212 }) 219 })
213 220
214 // Get video subtitles 221 // Get video subtitles
@@ -249,19 +256,22 @@ async function forgeThumbnail ({ inputPath, video, downloadUrl, type }: {
249 type: ThumbnailType 256 type: ThumbnailType
250}): Promise<MThumbnail> { 257}): Promise<MThumbnail> {
251 if (inputPath) { 258 if (inputPath) {
252 return updateVideoMiniatureFromExisting({ 259 return updateLocalVideoMiniatureFromExisting({
253 inputPath, 260 inputPath,
254 video, 261 video,
255 type, 262 type,
256 automaticallyGenerated: false 263 automaticallyGenerated: false
257 }) 264 })
258 } else if (downloadUrl) { 265 }
266
267 if (downloadUrl) {
259 try { 268 try {
260 return await updateVideoMiniatureFromUrl({ downloadUrl, video, type }) 269 return await updateLocalVideoMiniatureFromUrl({ downloadUrl, video, type })
261 } catch (err) { 270 } catch (err) {
262 logger.warn('Cannot process thumbnail %s from youtube-dl.', downloadUrl, { err }) 271 logger.warn('Cannot process thumbnail %s from youtube-dl.', downloadUrl, { err })
263 } 272 }
264 } 273 }
274
265 return null 275 return null
266} 276}
267 277
diff --git a/server/lib/video-privacy.ts b/server/lib/video-privacy.ts
index 41f9d62b3..5dd4d9781 100644
--- a/server/lib/video-privacy.ts
+++ b/server/lib/video-privacy.ts
@@ -4,7 +4,13 @@ import { logger } from '@server/helpers/logger'
4import { DIRECTORIES } from '@server/initializers/constants' 4import { DIRECTORIES } from '@server/initializers/constants'
5import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' 5import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
6import { VideoPrivacy, VideoStorage } from '@shared/models' 6import { VideoPrivacy, VideoStorage } from '@shared/models'
7import { updateHLSFilesACL, updateWebTorrentFileACL } from './object-storage' 7import { updateHLSFilesACL, updateWebVideoFileACL } from './object-storage'
8
9const validPrivacySet = new Set([
10 VideoPrivacy.PRIVATE,
11 VideoPrivacy.INTERNAL,
12 VideoPrivacy.PASSWORD_PROTECTED
13])
8 14
9function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) { 15function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) {
10 if (video.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) { 16 if (video.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
@@ -14,8 +20,8 @@ function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) {
14 video.privacy = newPrivacy 20 video.privacy = newPrivacy
15} 21}
16 22
17function isVideoInPrivateDirectory (privacy: VideoPrivacy) { 23function isVideoInPrivateDirectory (privacy) {
18 return privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.INTERNAL 24 return validPrivacySet.has(privacy)
19} 25}
20 26
21function isVideoInPublicDirectory (privacy: VideoPrivacy) { 27function isVideoInPublicDirectory (privacy: VideoPrivacy) {
@@ -61,9 +67,9 @@ async function moveFiles (options: {
61 67
62 for (const file of video.VideoFiles) { 68 for (const file of video.VideoFiles) {
63 if (file.storage === VideoStorage.FILE_SYSTEM) { 69 if (file.storage === VideoStorage.FILE_SYSTEM) {
64 await moveWebTorrentFileOnFS(type, video, file) 70 await moveWebVideoFileOnFS(type, video, file)
65 } else { 71 } else {
66 await updateWebTorrentFileACL(video, file) 72 await updateWebVideoFileACL(video, file)
67 } 73 }
68 } 74 }
69 75
@@ -78,22 +84,22 @@ async function moveFiles (options: {
78 } 84 }
79} 85}
80 86
81async function moveWebTorrentFileOnFS (type: MoveType, video: MVideo, file: MVideoFile) { 87async function moveWebVideoFileOnFS (type: MoveType, video: MVideo, file: MVideoFile) {
82 const directories = getWebTorrentDirectories(type) 88 const directories = getWebVideoDirectories(type)
83 89
84 const source = join(directories.old, file.filename) 90 const source = join(directories.old, file.filename)
85 const destination = join(directories.new, file.filename) 91 const destination = join(directories.new, file.filename)
86 92
87 try { 93 try {
88 logger.info('Moving WebTorrent files of %s after privacy change (%s -> %s).', video.uuid, source, destination) 94 logger.info('Moving web video files of %s after privacy change (%s -> %s).', video.uuid, source, destination)
89 95
90 await move(source, destination) 96 await move(source, destination)
91 } catch (err) { 97 } catch (err) {
92 logger.error('Cannot move webtorrent file %s to %s after privacy change', source, destination, { err }) 98 logger.error('Cannot move web video file %s to %s after privacy change', source, destination, { err })
93 } 99 }
94} 100}
95 101
96function getWebTorrentDirectories (moveType: MoveType) { 102function getWebVideoDirectories (moveType: MoveType) {
97 if (moveType === 'private-to-public') { 103 if (moveType === 'private-to-public') {
98 return { old: DIRECTORIES.VIDEOS.PRIVATE, new: DIRECTORIES.VIDEOS.PUBLIC } 104 return { old: DIRECTORIES.VIDEOS.PRIVATE, new: DIRECTORIES.VIDEOS.PUBLIC }
99 } 105 }
diff --git a/server/lib/video-studio.ts b/server/lib/video-studio.ts
index 0d3db8f60..f549a7084 100644
--- a/server/lib/video-studio.ts
+++ b/server/lib/video-studio.ts
@@ -12,7 +12,7 @@ import { JobQueue } from './job-queue'
12import { VideoStudioTranscodingJobHandler } from './runners' 12import { VideoStudioTranscodingJobHandler } from './runners'
13import { createOptimizeOrMergeAudioJobs } from './transcoding/create-transcoding-job' 13import { createOptimizeOrMergeAudioJobs } from './transcoding/create-transcoding-job'
14import { getTranscodingJobPriority } from './transcoding/transcoding-priority' 14import { getTranscodingJobPriority } from './transcoding/transcoding-priority'
15import { buildNewFile, removeHLSPlaylist, removeWebTorrentFile } from './video-file' 15import { buildNewFile, removeHLSPlaylist, removeWebVideoFile } from './video-file'
16import { VideoPathManager } from './video-path-manager' 16import { VideoPathManager } from './video-path-manager'
17 17
18const lTags = loggerTagsFactory('video-studio') 18const lTags = loggerTagsFactory('video-studio')
@@ -119,12 +119,12 @@ export async function onVideoStudioEnded (options: {
119// Private 119// Private
120// --------------------------------------------------------------------------- 120// ---------------------------------------------------------------------------
121 121
122async function removeAllFiles (video: MVideoWithAllFiles, webTorrentFileException: MVideoFile) { 122async function removeAllFiles (video: MVideoWithAllFiles, webVideoFileException: MVideoFile) {
123 await removeHLSPlaylist(video) 123 await removeHLSPlaylist(video)
124 124
125 for (const file of video.VideoFiles) { 125 for (const file of video.VideoFiles) {
126 if (file.id === webTorrentFileException.id) continue 126 if (file.id === webVideoFileException.id) continue
127 127
128 await removeWebTorrentFile(video, file.id) 128 await removeWebVideoFile(video, file.id)
129 } 129 }
130} 130}
diff --git a/server/lib/video-tokens-manager.ts b/server/lib/video-tokens-manager.ts
index 660533528..e28e55cf7 100644
--- a/server/lib/video-tokens-manager.ts
+++ b/server/lib/video-tokens-manager.ts
@@ -12,26 +12,34 @@ class VideoTokensManager {
12 12
13 private static instance: VideoTokensManager 13 private static instance: VideoTokensManager
14 14
15 private readonly lruCache = new LRUCache<string, { videoUUID: string, user: MUserAccountUrl }>({ 15 private readonly lruCache = new LRUCache<string, { videoUUID: string, user?: MUserAccountUrl }>({
16 max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE, 16 max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE,
17 ttl: LRU_CACHE.VIDEO_TOKENS.TTL 17 ttl: LRU_CACHE.VIDEO_TOKENS.TTL
18 }) 18 })
19 19
20 private constructor () {} 20 private constructor () {}
21 21
22 create (options: { 22 createForAuthUser (options: {
23 user: MUserAccountUrl 23 user: MUserAccountUrl
24 videoUUID: string 24 videoUUID: string
25 }) { 25 }) {
26 const token = buildUUID() 26 const { token, expires } = this.generateVideoToken()
27
28 const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL)
29 27
30 this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ])) 28 this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ]))
31 29
32 return { token, expires } 30 return { token, expires }
33 } 31 }
34 32
33 createForPasswordProtectedVideo (options: {
34 videoUUID: string
35 }) {
36 const { token, expires } = this.generateVideoToken()
37
38 this.lruCache.set(token, pick(options, [ 'videoUUID' ]))
39
40 return { token, expires }
41 }
42
35 hasToken (options: { 43 hasToken (options: {
36 token: string 44 token: string
37 videoUUID: string 45 videoUUID: string
@@ -54,6 +62,13 @@ class VideoTokensManager {
54 static get Instance () { 62 static get Instance () {
55 return this.instance || (this.instance = new this()) 63 return this.instance || (this.instance = new this())
56 } 64 }
65
66 private generateVideoToken () {
67 const token = buildUUID()
68 const expires = new Date(new Date().getTime() + LRU_CACHE.VIDEO_TOKENS.TTL)
69
70 return { token, expires }
71 }
57} 72}
58 73
59// --------------------------------------------------------------------------- 74// ---------------------------------------------------------------------------
diff --git a/server/lib/video-urls.ts b/server/lib/video-urls.ts
index 64c2c9bf9..0597488ad 100644
--- a/server/lib/video-urls.ts
+++ b/server/lib/video-urls.ts
@@ -9,7 +9,7 @@ function generateHLSRedundancyUrl (video: MVideo, playlist: MStreamingPlaylist)
9 return WEBSERVER.URL + STATIC_PATHS.REDUNDANCY + playlist.getStringType() + '/' + video.uuid 9 return WEBSERVER.URL + STATIC_PATHS.REDUNDANCY + playlist.getStringType() + '/' + video.uuid
10} 10}
11 11
12function generateWebTorrentRedundancyUrl (file: MVideoFile) { 12function generateWebVideoRedundancyUrl (file: MVideoFile) {
13 return WEBSERVER.URL + STATIC_PATHS.REDUNDANCY + file.filename 13 return WEBSERVER.URL + STATIC_PATHS.REDUNDANCY + file.filename
14} 14}
15 15
@@ -26,6 +26,6 @@ function getLocalVideoFileMetadataUrl (video: MVideoUUID, videoFile: MVideoFile)
26export { 26export {
27 getLocalVideoFileMetadataUrl, 27 getLocalVideoFileMetadataUrl,
28 28
29 generateWebTorrentRedundancyUrl, 29 generateWebVideoRedundancyUrl,
30 generateHLSRedundancyUrl 30 generateHLSRedundancyUrl
31} 31}
diff --git a/server/lib/video.ts b/server/lib/video.ts
index 588dc553f..362c861a5 100644
--- a/server/lib/video.ts
+++ b/server/lib/video.ts
@@ -10,7 +10,7 @@ import { FilteredModelAttributes } from '@server/types'
10import { MThumbnail, MVideoFullLight, MVideoTag, MVideoThumbnail, MVideoUUID } from '@server/types/models' 10import { MThumbnail, MVideoFullLight, MVideoTag, MVideoThumbnail, MVideoUUID } from '@server/types/models'
11import { ManageVideoTorrentPayload, ThumbnailType, VideoCreate, VideoPrivacy, VideoState } from '@shared/models' 11import { ManageVideoTorrentPayload, ThumbnailType, VideoCreate, VideoPrivacy, VideoState } from '@shared/models'
12import { CreateJobArgument, JobQueue } from './job-queue/job-queue' 12import { CreateJobArgument, JobQueue } from './job-queue/job-queue'
13import { updateVideoMiniatureFromExisting } from './thumbnail' 13import { updateLocalVideoMiniatureFromExisting } from './thumbnail'
14import { moveFilesIfPrivacyChanged } from './video-privacy' 14import { moveFilesIfPrivacyChanged } from './video-privacy'
15 15
16function buildLocalVideoFromReq (videoInfo: VideoCreate, channelId: number): FilteredModelAttributes<VideoModel> { 16function buildLocalVideoFromReq (videoInfo: VideoCreate, channelId: number): FilteredModelAttributes<VideoModel> {
@@ -55,7 +55,7 @@ async function buildVideoThumbnailsFromReq (options: {
55 const fields = files?.[p.fieldName] 55 const fields = files?.[p.fieldName]
56 56
57 if (fields) { 57 if (fields) {
58 return updateVideoMiniatureFromExisting({ 58 return updateLocalVideoMiniatureFromExisting({
59 inputPath: fields[0].path, 59 inputPath: fields[0].path,
60 video, 60 video,
61 type: p.type, 61 type: p.type,
diff --git a/server/lib/worker/workers/image-downloader.ts b/server/lib/worker/workers/image-downloader.ts
index 4b32f723e..209594589 100644
--- a/server/lib/worker/workers/image-downloader.ts
+++ b/server/lib/worker/workers/image-downloader.ts
@@ -24,6 +24,8 @@ async function downloadImage (options: {
24 24
25 throw err 25 throw err
26 } 26 }
27
28 return destPath
27} 29}
28 30
29module.exports = downloadImage 31module.exports = downloadImage
diff --git a/server/middlewares/auth.ts b/server/middlewares/auth.ts
index 0eefa2a8e..39a7b2998 100644
--- a/server/middlewares/auth.ts
+++ b/server/middlewares/auth.ts
@@ -5,6 +5,7 @@ import { RunnerModel } from '@server/models/runner/runner'
5import { HttpStatusCode } from '../../shared/models/http/http-error-codes' 5import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
6import { logger } from '../helpers/logger' 6import { logger } from '../helpers/logger'
7import { handleOAuthAuthenticate } from '../lib/auth/oauth' 7import { handleOAuthAuthenticate } from '../lib/auth/oauth'
8import { ServerErrorCode } from '@shared/models'
8 9
9function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) { 10function authenticate (req: express.Request, res: express.Response, next: express.NextFunction) {
10 handleOAuthAuthenticate(req, res) 11 handleOAuthAuthenticate(req, res)
@@ -48,15 +49,23 @@ function authenticateSocket (socket: Socket, next: (err?: any) => void) {
48 .catch(err => logger.error('Cannot get access token.', { err })) 49 .catch(err => logger.error('Cannot get access token.', { err }))
49} 50}
50 51
51function authenticatePromise (req: express.Request, res: express.Response) { 52function authenticatePromise (options: {
53 req: express.Request
54 res: express.Response
55 errorMessage?: string
56 errorStatus?: HttpStatusCode
57 errorType?: ServerErrorCode
58}) {
59 const { req, res, errorMessage = 'Not authenticated', errorStatus = HttpStatusCode.UNAUTHORIZED_401, errorType } = options
52 return new Promise<void>(resolve => { 60 return new Promise<void>(resolve => {
53 // Already authenticated? (or tried to) 61 // Already authenticated? (or tried to)
54 if (res.locals.oauth?.token.User) return resolve() 62 if (res.locals.oauth?.token.User) return resolve()
55 63
56 if (res.locals.authenticated === false) { 64 if (res.locals.authenticated === false) {
57 return res.fail({ 65 return res.fail({
58 status: HttpStatusCode.UNAUTHORIZED_401, 66 status: errorStatus,
59 message: 'Not authenticated' 67 type: errorType,
68 message: errorMessage
60 }) 69 })
61 } 70 }
62 71
diff --git a/server/middlewares/validators/config.ts b/server/middlewares/validators/config.ts
index a0074cb24..a6dbba524 100644
--- a/server/middlewares/validators/config.ts
+++ b/server/middlewares/validators/config.ts
@@ -25,6 +25,7 @@ const customConfigUpdateValidator = [
25 body('cache.previews.size').isInt(), 25 body('cache.previews.size').isInt(),
26 body('cache.captions.size').isInt(), 26 body('cache.captions.size').isInt(),
27 body('cache.torrents.size').isInt(), 27 body('cache.torrents.size').isInt(),
28 body('cache.storyboards.size').isInt(),
28 29
29 body('signup.enabled').isBoolean(), 30 body('signup.enabled').isBoolean(),
30 body('signup.limit').isInt(), 31 body('signup.limit').isInt(),
@@ -58,7 +59,7 @@ const customConfigUpdateValidator = [
58 59
59 body('transcoding.alwaysTranscodeOriginalResolution').isBoolean(), 60 body('transcoding.alwaysTranscodeOriginalResolution').isBoolean(),
60 61
61 body('transcoding.webtorrent.enabled').isBoolean(), 62 body('transcoding.webVideos.enabled').isBoolean(),
62 body('transcoding.hls.enabled').isBoolean(), 63 body('transcoding.hls.enabled').isBoolean(),
63 64
64 body('videoStudio.enabled').isBoolean(), 65 body('videoStudio.enabled').isBoolean(),
@@ -152,8 +153,8 @@ function checkInvalidConfigIfEmailDisabled (customConfig: CustomConfig, res: exp
152function checkInvalidTranscodingConfig (customConfig: CustomConfig, res: express.Response) { 153function checkInvalidTranscodingConfig (customConfig: CustomConfig, res: express.Response) {
153 if (customConfig.transcoding.enabled === false) return true 154 if (customConfig.transcoding.enabled === false) return true
154 155
155 if (customConfig.transcoding.webtorrent.enabled === false && customConfig.transcoding.hls.enabled === false) { 156 if (customConfig.transcoding.webVideos.enabled === false && customConfig.transcoding.hls.enabled === false) {
156 res.fail({ message: 'You need to enable at least webtorrent transcoding or hls transcoding' }) 157 res.fail({ message: 'You need to enable at least web_videos transcoding or hls transcoding' })
157 return false 158 return false
158 } 159 }
159 160
diff --git a/server/middlewares/validators/shared/index.ts b/server/middlewares/validators/shared/index.ts
index de98cd442..e5cff2dda 100644
--- a/server/middlewares/validators/shared/index.ts
+++ b/server/middlewares/validators/shared/index.ts
@@ -10,4 +10,5 @@ export * from './video-comments'
10export * from './video-imports' 10export * from './video-imports'
11export * from './video-ownerships' 11export * from './video-ownerships'
12export * from './video-playlists' 12export * from './video-playlists'
13export * from './video-passwords'
13export * from './videos' 14export * from './videos'
diff --git a/server/middlewares/validators/shared/video-passwords.ts b/server/middlewares/validators/shared/video-passwords.ts
new file mode 100644
index 000000000..efcc95dc4
--- /dev/null
+++ b/server/middlewares/validators/shared/video-passwords.ts
@@ -0,0 +1,80 @@
1import express from 'express'
2import { HttpStatusCode, UserRight, VideoPrivacy } from '@shared/models'
3import { forceNumber } from '@shared/core-utils'
4import { VideoPasswordModel } from '@server/models/video/video-password'
5import { header } from 'express-validator'
6import { getVideoWithAttributes } from '@server/helpers/video'
7
8function isValidVideoPasswordHeader () {
9 return header('x-peertube-video-password')
10 .optional()
11 .isString()
12}
13
14function checkVideoIsPasswordProtected (res: express.Response) {
15 const video = getVideoWithAttributes(res)
16 if (video.privacy !== VideoPrivacy.PASSWORD_PROTECTED) {
17 res.fail({
18 status: HttpStatusCode.BAD_REQUEST_400,
19 message: 'Video is not password protected'
20 })
21 return false
22 }
23
24 return true
25}
26
27async function doesVideoPasswordExist (idArg: number | string, res: express.Response) {
28 const video = getVideoWithAttributes(res)
29 const id = forceNumber(idArg)
30 const videoPassword = await VideoPasswordModel.loadByIdAndVideo({ id, videoId: video.id })
31
32 if (!videoPassword) {
33 res.fail({
34 status: HttpStatusCode.NOT_FOUND_404,
35 message: 'Video password not found'
36 })
37 return false
38 }
39
40 res.locals.videoPassword = videoPassword
41
42 return true
43}
44
45async function isVideoPasswordDeletable (res: express.Response) {
46 const user = res.locals.oauth.token.User
47 const userAccount = user.Account
48 const video = res.locals.videoAll
49
50 // Check if the user who did the request is able to delete the video passwords
51 if (
52 user.hasRight(UserRight.UPDATE_ANY_VIDEO) === false && // Not a moderator
53 video.VideoChannel.accountId !== userAccount.id // Not the video owner
54 ) {
55 res.fail({
56 status: HttpStatusCode.FORBIDDEN_403,
57 message: 'Cannot remove passwords of another user\'s video'
58 })
59 return false
60 }
61
62 const passwordCount = await VideoPasswordModel.countByVideoId(video.id)
63
64 if (passwordCount <= 1) {
65 res.fail({
66 status: HttpStatusCode.BAD_REQUEST_400,
67 message: 'Cannot delete the last password of the protected video'
68 })
69 return false
70 }
71
72 return true
73}
74
75export {
76 isValidVideoPasswordHeader,
77 checkVideoIsPasswordProtected as isVideoPasswordProtected,
78 doesVideoPasswordExist,
79 isVideoPasswordDeletable
80}
diff --git a/server/middlewares/validators/shared/videos.ts b/server/middlewares/validators/shared/videos.ts
index 0033a32ff..9a7497007 100644
--- a/server/middlewares/validators/shared/videos.ts
+++ b/server/middlewares/validators/shared/videos.ts
@@ -20,6 +20,8 @@ import {
20 MVideoWithRights 20 MVideoWithRights
21} from '@server/types/models' 21} from '@server/types/models'
22import { HttpStatusCode, ServerErrorCode, UserRight, VideoPrivacy } from '@shared/models' 22import { HttpStatusCode, ServerErrorCode, UserRight, VideoPrivacy } from '@shared/models'
23import { VideoPasswordModel } from '@server/models/video/video-password'
24import { exists } from '@server/helpers/custom-validators/misc'
23 25
24async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') { 26async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') {
25 const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined 27 const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
@@ -111,8 +113,12 @@ async function checkCanSeeVideo (options: {
111}) { 113}) {
112 const { req, res, video, paramId } = options 114 const { req, res, video, paramId } = options
113 115
114 if (video.requiresAuth({ urlParamId: paramId, checkBlacklist: true })) { 116 if (video.requiresUserAuth({ urlParamId: paramId, checkBlacklist: true })) {
115 return checkCanSeeAuthVideo(req, res, video) 117 return checkCanSeeUserAuthVideo({ req, res, video })
118 }
119
120 if (video.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
121 return checkCanSeePasswordProtectedVideo({ req, res, video })
116 } 122 }
117 123
118 if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC) { 124 if (video.privacy === VideoPrivacy.UNLISTED || video.privacy === VideoPrivacy.PUBLIC) {
@@ -122,7 +128,13 @@ async function checkCanSeeVideo (options: {
122 throw new Error('Unknown video privacy when checking video right ' + video.url) 128 throw new Error('Unknown video privacy when checking video right ' + video.url)
123} 129}
124 130
125async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoId | MVideoWithRights) { 131async function checkCanSeeUserAuthVideo (options: {
132 req: Request
133 res: Response
134 video: MVideoId | MVideoWithRights
135}) {
136 const { req, res, video } = options
137
126 const fail = () => { 138 const fail = () => {
127 res.fail({ 139 res.fail({
128 status: HttpStatusCode.FORBIDDEN_403, 140 status: HttpStatusCode.FORBIDDEN_403,
@@ -132,14 +144,12 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI
132 return false 144 return false
133 } 145 }
134 146
135 await authenticatePromise(req, res) 147 await authenticatePromise({ req, res })
136 148
137 const user = res.locals.oauth?.token.User 149 const user = res.locals.oauth?.token.User
138 if (!user) return fail() 150 if (!user) return fail()
139 151
140 const videoWithRights = (video as MVideoWithRights).VideoChannel?.Account?.userId 152 const videoWithRights = await getVideoWithRights(video as MVideoWithRights)
141 ? video as MVideoWithRights
142 : await VideoModel.loadFull(video.id)
143 153
144 const privacy = videoWithRights.privacy 154 const privacy = videoWithRights.privacy
145 155
@@ -148,16 +158,14 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI
148 return true 158 return true
149 } 159 }
150 160
151 const isOwnedByUser = videoWithRights.VideoChannel.Account.userId === user.id
152
153 if (videoWithRights.isBlacklisted()) { 161 if (videoWithRights.isBlacklisted()) {
154 if (isOwnedByUser || user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) return true 162 if (canUserAccessVideo(user, videoWithRights, UserRight.MANAGE_VIDEO_BLACKLIST)) return true
155 163
156 return fail() 164 return fail()
157 } 165 }
158 166
159 if (privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.UNLISTED) { 167 if (privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.UNLISTED) {
160 if (isOwnedByUser || user.hasRight(UserRight.SEE_ALL_VIDEOS)) return true 168 if (canUserAccessVideo(user, videoWithRights, UserRight.SEE_ALL_VIDEOS)) return true
161 169
162 return fail() 170 return fail()
163 } 171 }
@@ -166,6 +174,59 @@ async function checkCanSeeAuthVideo (req: Request, res: Response, video: MVideoI
166 return fail() 174 return fail()
167} 175}
168 176
177async function checkCanSeePasswordProtectedVideo (options: {
178 req: Request
179 res: Response
180 video: MVideo
181}) {
182 const { req, res, video } = options
183
184 const videoWithRights = await getVideoWithRights(video as MVideoWithRights)
185
186 const videoPassword = req.header('x-peertube-video-password')
187
188 if (!exists(videoPassword)) {
189 const errorMessage = 'Please provide a password to access this password protected video'
190 const errorType = ServerErrorCode.VIDEO_REQUIRES_PASSWORD
191
192 if (req.header('authorization')) {
193 await authenticatePromise({ req, res, errorMessage, errorStatus: HttpStatusCode.FORBIDDEN_403, errorType })
194 const user = res.locals.oauth?.token.User
195
196 if (canUserAccessVideo(user, videoWithRights, UserRight.SEE_ALL_VIDEOS)) return true
197 }
198
199 res.fail({
200 status: HttpStatusCode.FORBIDDEN_403,
201 type: errorType,
202 message: errorMessage
203 })
204 return false
205 }
206
207 if (await VideoPasswordModel.isACorrectPassword({ videoId: video.id, password: videoPassword })) return true
208
209 res.fail({
210 status: HttpStatusCode.FORBIDDEN_403,
211 type: ServerErrorCode.INCORRECT_VIDEO_PASSWORD,
212 message: 'Incorrect video password. Access to the video is denied.'
213 })
214
215 return false
216}
217
218function canUserAccessVideo (user: MUser, video: MVideoWithRights | MVideoAccountLight, right: UserRight) {
219 const isOwnedByUser = video.VideoChannel.Account.userId === user.id
220
221 return isOwnedByUser || user.hasRight(right)
222}
223
224async function getVideoWithRights (video: MVideoWithRights): Promise<MVideoWithRights> {
225 return video.VideoChannel?.Account?.userId
226 ? video
227 : VideoModel.loadFull(video.id)
228}
229
169// --------------------------------------------------------------------------- 230// ---------------------------------------------------------------------------
170 231
171async function checkCanAccessVideoStaticFiles (options: { 232async function checkCanAccessVideoStaticFiles (options: {
@@ -176,7 +237,7 @@ async function checkCanAccessVideoStaticFiles (options: {
176}) { 237}) {
177 const { video, req, res } = options 238 const { video, req, res } = options
178 239
179 if (res.locals.oauth?.token.User) { 240 if (res.locals.oauth?.token.User || exists(req.header('x-peertube-video-password'))) {
180 return checkCanSeeVideo(options) 241 return checkCanSeeVideo(options)
181 } 242 }
182 243
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts
index 959f663ac..07d6cba82 100644
--- a/server/middlewares/validators/sort.ts
+++ b/server/middlewares/validators/sort.ts
@@ -28,6 +28,7 @@ export const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS)
28export const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS) 28export const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
29export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES) 29export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
30export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS) 30export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS)
31export const videoPasswordsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PASSWORDS)
31 32
32export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS) 33export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
33export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS) 34export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
diff --git a/server/middlewares/validators/static.ts b/server/middlewares/validators/static.ts
index 9c2d890ba..86cc0a8d7 100644
--- a/server/middlewares/validators/static.ts
+++ b/server/middlewares/validators/static.ts
@@ -9,7 +9,7 @@ import { VideoModel } from '@server/models/video/video'
9import { VideoFileModel } from '@server/models/video/video-file' 9import { VideoFileModel } from '@server/models/video/video-file'
10import { MStreamingPlaylist, MVideoFile, MVideoThumbnail } from '@server/types/models' 10import { MStreamingPlaylist, MVideoFile, MVideoThumbnail } from '@server/types/models'
11import { HttpStatusCode } from '@shared/models' 11import { HttpStatusCode } from '@shared/models'
12import { areValidationErrors, checkCanAccessVideoStaticFiles } from './shared' 12import { areValidationErrors, checkCanAccessVideoStaticFiles, isValidVideoPasswordHeader } from './shared'
13 13
14type LRUValue = { 14type LRUValue = {
15 allowed: boolean 15 allowed: boolean
@@ -22,9 +22,11 @@ const staticFileTokenBypass = new LRUCache<string, LRUValue>({
22 ttl: LRU_CACHE.STATIC_VIDEO_FILES_RIGHTS_CHECK.TTL 22 ttl: LRU_CACHE.STATIC_VIDEO_FILES_RIGHTS_CHECK.TTL
23}) 23})
24 24
25const ensureCanAccessVideoPrivateWebTorrentFiles = [ 25const ensureCanAccessVideoPrivateWebVideoFiles = [
26 query('videoFileToken').optional().custom(exists), 26 query('videoFileToken').optional().custom(exists),
27 27
28 isValidVideoPasswordHeader(),
29
28 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 30 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
29 if (areValidationErrors(req, res)) return 31 if (areValidationErrors(req, res)) return
30 32
@@ -46,7 +48,7 @@ const ensureCanAccessVideoPrivateWebTorrentFiles = [
46 return res.sendStatus(HttpStatusCode.FORBIDDEN_403) 48 return res.sendStatus(HttpStatusCode.FORBIDDEN_403)
47 } 49 }
48 50
49 const result = await isWebTorrentAllowed(req, res) 51 const result = await isWebVideoAllowed(req, res)
50 52
51 staticFileTokenBypass.set(cacheKey, result) 53 staticFileTokenBypass.set(cacheKey, result)
52 54
@@ -73,6 +75,8 @@ const ensureCanAccessPrivateVideoHLSFiles = [
73 .optional() 75 .optional()
74 .customSanitizer(isSafePeerTubeFilenameWithoutExtension), 76 .customSanitizer(isSafePeerTubeFilenameWithoutExtension),
75 77
78 isValidVideoPasswordHeader(),
79
76 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 80 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
77 if (areValidationErrors(req, res)) return 81 if (areValidationErrors(req, res)) return
78 82
@@ -118,13 +122,13 @@ const ensureCanAccessPrivateVideoHLSFiles = [
118] 122]
119 123
120export { 124export {
121 ensureCanAccessVideoPrivateWebTorrentFiles, 125 ensureCanAccessVideoPrivateWebVideoFiles,
122 ensureCanAccessPrivateVideoHLSFiles 126 ensureCanAccessPrivateVideoHLSFiles
123} 127}
124 128
125// --------------------------------------------------------------------------- 129// ---------------------------------------------------------------------------
126 130
127async function isWebTorrentAllowed (req: express.Request, res: express.Response) { 131async function isWebVideoAllowed (req: express.Request, res: express.Response) {
128 const filename = basename(req.path) 132 const filename = basename(req.path)
129 133
130 const file = await VideoFileModel.loadWithVideoByFilename(filename) 134 const file = await VideoFileModel.loadWithVideoByFilename(filename)
@@ -167,11 +171,11 @@ async function isHLSAllowed (req: express.Request, res: express.Response, videoU
167} 171}
168 172
169function extractTokenOrDie (req: express.Request, res: express.Response) { 173function extractTokenOrDie (req: express.Request, res: express.Response) {
170 const token = res.locals.oauth?.token.accessToken || req.query.videoFileToken 174 const token = req.header('x-peertube-video-password') || req.query.videoFileToken || res.locals.oauth?.token.accessToken
171 175
172 if (!token) { 176 if (!token) {
173 return res.fail({ 177 return res.fail({
174 message: 'Bearer token is missing in headers or video file token is missing in URL query parameters', 178 message: 'Video password header, video file token query parameter and bearer token are all missing', //
175 status: HttpStatusCode.FORBIDDEN_403 179 status: HttpStatusCode.FORBIDDEN_403
176 }) 180 })
177 } 181 }
diff --git a/server/middlewares/validators/videos/index.ts b/server/middlewares/validators/videos/index.ts
index d225dfe45..0c824c314 100644
--- a/server/middlewares/validators/videos/index.ts
+++ b/server/middlewares/validators/videos/index.ts
@@ -12,6 +12,8 @@ export * from './video-shares'
12export * from './video-source' 12export * from './video-source'
13export * from './video-stats' 13export * from './video-stats'
14export * from './video-studio' 14export * from './video-studio'
15export * from './video-token'
15export * from './video-transcoding' 16export * from './video-transcoding'
16export * from './videos' 17export * from './videos'
17export * from './video-channel-sync' 18export * from './video-channel-sync'
19export * from './video-passwords'
diff --git a/server/middlewares/validators/videos/video-captions.ts b/server/middlewares/validators/videos/video-captions.ts
index 72b2febc3..077a58d2e 100644
--- a/server/middlewares/validators/videos/video-captions.ts
+++ b/server/middlewares/validators/videos/video-captions.ts
@@ -10,7 +10,8 @@ import {
10 checkUserCanManageVideo, 10 checkUserCanManageVideo,
11 doesVideoCaptionExist, 11 doesVideoCaptionExist,
12 doesVideoExist, 12 doesVideoExist,
13 isValidVideoIdParam 13 isValidVideoIdParam,
14 isValidVideoPasswordHeader
14} from '../shared' 15} from '../shared'
15 16
16const addVideoCaptionValidator = [ 17const addVideoCaptionValidator = [
@@ -62,6 +63,8 @@ const deleteVideoCaptionValidator = [
62const listVideoCaptionsValidator = [ 63const listVideoCaptionsValidator = [
63 isValidVideoIdParam('videoId'), 64 isValidVideoIdParam('videoId'),
64 65
66 isValidVideoPasswordHeader(),
67
65 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 68 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
66 if (areValidationErrors(req, res)) return 69 if (areValidationErrors(req, res)) return
67 if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return 70 if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
diff --git a/server/middlewares/validators/videos/video-comments.ts b/server/middlewares/validators/videos/video-comments.ts
index 133feb7bd..70689b02e 100644
--- a/server/middlewares/validators/videos/video-comments.ts
+++ b/server/middlewares/validators/videos/video-comments.ts
@@ -14,7 +14,8 @@ import {
14 doesVideoCommentExist, 14 doesVideoCommentExist,
15 doesVideoCommentThreadExist, 15 doesVideoCommentThreadExist,
16 doesVideoExist, 16 doesVideoExist,
17 isValidVideoIdParam 17 isValidVideoIdParam,
18 isValidVideoPasswordHeader
18} from '../shared' 19} from '../shared'
19 20
20const listVideoCommentsValidator = [ 21const listVideoCommentsValidator = [
@@ -51,6 +52,7 @@ const listVideoCommentsValidator = [
51 52
52const listVideoCommentThreadsValidator = [ 53const listVideoCommentThreadsValidator = [
53 isValidVideoIdParam('videoId'), 54 isValidVideoIdParam('videoId'),
55 isValidVideoPasswordHeader(),
54 56
55 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 57 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
56 if (areValidationErrors(req, res)) return 58 if (areValidationErrors(req, res)) return
@@ -67,6 +69,7 @@ const listVideoThreadCommentsValidator = [
67 69
68 param('threadId') 70 param('threadId')
69 .custom(isIdValid), 71 .custom(isIdValid),
72 isValidVideoPasswordHeader(),
70 73
71 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 74 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
72 if (areValidationErrors(req, res)) return 75 if (areValidationErrors(req, res)) return
@@ -84,6 +87,7 @@ const addVideoCommentThreadValidator = [
84 87
85 body('text') 88 body('text')
86 .custom(isValidVideoCommentText), 89 .custom(isValidVideoCommentText),
90 isValidVideoPasswordHeader(),
87 91
88 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 92 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
89 if (areValidationErrors(req, res)) return 93 if (areValidationErrors(req, res)) return
@@ -102,6 +106,7 @@ const addVideoCommentReplyValidator = [
102 isValidVideoIdParam('videoId'), 106 isValidVideoIdParam('videoId'),
103 107
104 param('commentId').custom(isIdValid), 108 param('commentId').custom(isIdValid),
109 isValidVideoPasswordHeader(),
105 110
106 body('text').custom(isValidVideoCommentText), 111 body('text').custom(isValidVideoCommentText),
107 112
diff --git a/server/middlewares/validators/videos/video-files.ts b/server/middlewares/validators/videos/video-files.ts
index 92c5b9483..6c0ecda42 100644
--- a/server/middlewares/validators/videos/video-files.ts
+++ b/server/middlewares/validators/videos/video-files.ts
@@ -5,7 +5,7 @@ import { MVideo } from '@server/types/models'
5import { HttpStatusCode } from '@shared/models' 5import { HttpStatusCode } from '@shared/models'
6import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared' 6import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared'
7 7
8const videoFilesDeleteWebTorrentValidator = [ 8const videoFilesDeleteWebVideoValidator = [
9 isValidVideoIdParam('id'), 9 isValidVideoIdParam('id'),
10 10
11 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 11 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
@@ -16,17 +16,17 @@ const videoFilesDeleteWebTorrentValidator = [
16 16
17 if (!checkLocalVideo(video, res)) return 17 if (!checkLocalVideo(video, res)) return
18 18
19 if (!video.hasWebTorrentFiles()) { 19 if (!video.hasWebVideoFiles()) {
20 return res.fail({ 20 return res.fail({
21 status: HttpStatusCode.BAD_REQUEST_400, 21 status: HttpStatusCode.BAD_REQUEST_400,
22 message: 'This video does not have WebTorrent files' 22 message: 'This video does not have Web Video files'
23 }) 23 })
24 } 24 }
25 25
26 if (!video.getHLSPlaylist()) { 26 if (!video.getHLSPlaylist()) {
27 return res.fail({ 27 return res.fail({
28 status: HttpStatusCode.BAD_REQUEST_400, 28 status: HttpStatusCode.BAD_REQUEST_400,
29 message: 'Cannot delete WebTorrent files since this video does not have HLS playlist' 29 message: 'Cannot delete Web Video files since this video does not have HLS playlist'
30 }) 30 })
31 } 31 }
32 32
@@ -34,7 +34,7 @@ const videoFilesDeleteWebTorrentValidator = [
34 } 34 }
35] 35]
36 36
37const videoFilesDeleteWebTorrentFileValidator = [ 37const videoFilesDeleteWebVideoFileValidator = [
38 isValidVideoIdParam('id'), 38 isValidVideoIdParam('id'),
39 39
40 param('videoFileId') 40 param('videoFileId')
@@ -52,14 +52,14 @@ const videoFilesDeleteWebTorrentFileValidator = [
52 if (!files.find(f => f.id === +req.params.videoFileId)) { 52 if (!files.find(f => f.id === +req.params.videoFileId)) {
53 return res.fail({ 53 return res.fail({
54 status: HttpStatusCode.NOT_FOUND_404, 54 status: HttpStatusCode.NOT_FOUND_404,
55 message: 'This video does not have this WebTorrent file id' 55 message: 'This video does not have this Web Video file id'
56 }) 56 })
57 } 57 }
58 58
59 if (files.length === 1 && !video.getHLSPlaylist()) { 59 if (files.length === 1 && !video.getHLSPlaylist()) {
60 return res.fail({ 60 return res.fail({
61 status: HttpStatusCode.BAD_REQUEST_400, 61 status: HttpStatusCode.BAD_REQUEST_400,
62 message: 'Cannot delete WebTorrent files since this video does not have HLS playlist' 62 message: 'Cannot delete Web Video files since this video does not have HLS playlist'
63 }) 63 })
64 } 64 }
65 65
@@ -87,10 +87,10 @@ const videoFilesDeleteHLSValidator = [
87 }) 87 })
88 } 88 }
89 89
90 if (!video.hasWebTorrentFiles()) { 90 if (!video.hasWebVideoFiles()) {
91 return res.fail({ 91 return res.fail({
92 status: HttpStatusCode.BAD_REQUEST_400, 92 status: HttpStatusCode.BAD_REQUEST_400,
93 message: 'Cannot delete HLS playlist since this video does not have WebTorrent files' 93 message: 'Cannot delete HLS playlist since this video does not have Web Video files'
94 }) 94 })
95 } 95 }
96 96
@@ -128,10 +128,10 @@ const videoFilesDeleteHLSFileValidator = [
128 } 128 }
129 129
130 // Last file to delete 130 // Last file to delete
131 if (hlsFiles.length === 1 && !video.hasWebTorrentFiles()) { 131 if (hlsFiles.length === 1 && !video.hasWebVideoFiles()) {
132 return res.fail({ 132 return res.fail({
133 status: HttpStatusCode.BAD_REQUEST_400, 133 status: HttpStatusCode.BAD_REQUEST_400,
134 message: 'Cannot delete last HLS playlist file since this video does not have WebTorrent files' 134 message: 'Cannot delete last HLS playlist file since this video does not have Web Video files'
135 }) 135 })
136 } 136 }
137 137
@@ -140,8 +140,8 @@ const videoFilesDeleteHLSFileValidator = [
140] 140]
141 141
142export { 142export {
143 videoFilesDeleteWebTorrentValidator, 143 videoFilesDeleteWebVideoValidator,
144 videoFilesDeleteWebTorrentFileValidator, 144 videoFilesDeleteWebVideoFileValidator,
145 145
146 videoFilesDeleteHLSValidator, 146 videoFilesDeleteHLSValidator,
147 videoFilesDeleteHLSFileValidator 147 videoFilesDeleteHLSFileValidator
diff --git a/server/middlewares/validators/videos/video-imports.ts b/server/middlewares/validators/videos/video-imports.ts
index 72442aeb6..a1cb65b70 100644
--- a/server/middlewares/validators/videos/video-imports.ts
+++ b/server/middlewares/validators/videos/video-imports.ts
@@ -9,7 +9,11 @@ import { HttpStatusCode, UserRight, VideoImportState } from '@shared/models'
9import { VideoImportCreate } from '@shared/models/videos/import/video-import-create.model' 9import { VideoImportCreate } from '@shared/models/videos/import/video-import-create.model'
10import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc' 10import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
11import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports' 11import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports'
12import { isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos' 12import {
13 isValidPasswordProtectedPrivacy,
14 isVideoMagnetUriValid,
15 isVideoNameValid
16} from '../../../helpers/custom-validators/videos'
13import { cleanUpReqFiles } from '../../../helpers/express-utils' 17import { cleanUpReqFiles } from '../../../helpers/express-utils'
14import { logger } from '../../../helpers/logger' 18import { logger } from '../../../helpers/logger'
15import { CONFIG } from '../../../initializers/config' 19import { CONFIG } from '../../../initializers/config'
@@ -38,6 +42,10 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
38 .custom(isVideoNameValid).withMessage( 42 .custom(isVideoNameValid).withMessage(
39 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long` 43 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
40 ), 44 ),
45 body('videoPasswords')
46 .optional()
47 .isArray()
48 .withMessage('Video passwords should be an array.'),
41 49
42 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 50 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
43 const user = res.locals.oauth.token.User 51 const user = res.locals.oauth.token.User
@@ -45,6 +53,8 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
45 53
46 if (areValidationErrors(req, res)) return cleanUpReqFiles(req) 54 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
47 55
56 if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
57
48 if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true && req.body.targetUrl) { 58 if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true && req.body.targetUrl) {
49 cleanUpReqFiles(req) 59 cleanUpReqFiles(req)
50 60
diff --git a/server/middlewares/validators/videos/video-live.ts b/server/middlewares/validators/videos/video-live.ts
index 2aff831a8..ec69a3011 100644
--- a/server/middlewares/validators/videos/video-live.ts
+++ b/server/middlewares/validators/videos/video-live.ts
@@ -17,7 +17,7 @@ import {
17 VideoState 17 VideoState
18} from '@shared/models' 18} from '@shared/models'
19import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc' 19import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc'
20import { isVideoNameValid, isVideoPrivacyValid } from '../../../helpers/custom-validators/videos' 20import { isValidPasswordProtectedPrivacy, isVideoNameValid, isVideoReplayPrivacyValid } from '../../../helpers/custom-validators/videos'
21import { cleanUpReqFiles } from '../../../helpers/express-utils' 21import { cleanUpReqFiles } from '../../../helpers/express-utils'
22import { logger } from '../../../helpers/logger' 22import { logger } from '../../../helpers/logger'
23import { CONFIG } from '../../../initializers/config' 23import { CONFIG } from '../../../initializers/config'
@@ -69,7 +69,7 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
69 body('replaySettings.privacy') 69 body('replaySettings.privacy')
70 .optional() 70 .optional()
71 .customSanitizer(toIntOrNull) 71 .customSanitizer(toIntOrNull)
72 .custom(isVideoPrivacyValid), 72 .custom(isVideoReplayPrivacyValid),
73 73
74 body('permanentLive') 74 body('permanentLive')
75 .optional() 75 .optional()
@@ -81,9 +81,16 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
81 .customSanitizer(toIntOrNull) 81 .customSanitizer(toIntOrNull)
82 .custom(isLiveLatencyModeValid), 82 .custom(isLiveLatencyModeValid),
83 83
84 body('videoPasswords')
85 .optional()
86 .isArray()
87 .withMessage('Video passwords should be an array.'),
88
84 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 89 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
85 if (areValidationErrors(req, res)) return cleanUpReqFiles(req) 90 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
86 91
92 if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
93
87 if (CONFIG.LIVE.ENABLED !== true) { 94 if (CONFIG.LIVE.ENABLED !== true) {
88 cleanUpReqFiles(req) 95 cleanUpReqFiles(req)
89 96
@@ -170,7 +177,7 @@ const videoLiveUpdateValidator = [
170 body('replaySettings.privacy') 177 body('replaySettings.privacy')
171 .optional() 178 .optional()
172 .customSanitizer(toIntOrNull) 179 .customSanitizer(toIntOrNull)
173 .custom(isVideoPrivacyValid), 180 .custom(isVideoReplayPrivacyValid),
174 181
175 body('latencyMode') 182 body('latencyMode')
176 .optional() 183 .optional()
diff --git a/server/middlewares/validators/videos/video-passwords.ts b/server/middlewares/validators/videos/video-passwords.ts
new file mode 100644
index 000000000..200e496f6
--- /dev/null
+++ b/server/middlewares/validators/videos/video-passwords.ts
@@ -0,0 +1,77 @@
1import express from 'express'
2import {
3 areValidationErrors,
4 doesVideoExist,
5 isVideoPasswordProtected,
6 isValidVideoIdParam,
7 doesVideoPasswordExist,
8 isVideoPasswordDeletable,
9 checkUserCanManageVideo
10} from '../shared'
11import { body, param } from 'express-validator'
12import { isIdValid } from '@server/helpers/custom-validators/misc'
13import { isValidPasswordProtectedPrivacy } from '@server/helpers/custom-validators/videos'
14import { UserRight } from '@shared/models'
15
16const listVideoPasswordValidator = [
17 isValidVideoIdParam('videoId'),
18
19 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
20 if (areValidationErrors(req, res)) return
21
22 if (!await doesVideoExist(req.params.videoId, res)) return
23 if (!isVideoPasswordProtected(res)) return
24
25 // Check if the user who did the request is able to access video password list
26 const user = res.locals.oauth.token.User
27 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.SEE_ALL_VIDEOS, res)) return
28
29 return next()
30 }
31]
32
33const updateVideoPasswordListValidator = [
34 body('passwords')
35 .optional()
36 .isArray()
37 .withMessage('Video passwords should be an array.'),
38
39 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
40 if (areValidationErrors(req, res)) return
41
42 if (!await doesVideoExist(req.params.videoId, res)) return
43 if (!isValidPasswordProtectedPrivacy(req, res)) return
44
45 // Check if the user who did the request is able to update video passwords
46 const user = res.locals.oauth.token.User
47 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return
48
49 return next()
50 }
51]
52
53const removeVideoPasswordValidator = [
54 isValidVideoIdParam('videoId'),
55
56 param('passwordId')
57 .custom(isIdValid),
58
59 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
60 if (areValidationErrors(req, res)) return
61
62 if (!await doesVideoExist(req.params.videoId, res)) return
63 if (!isVideoPasswordProtected(res)) return
64 if (!await doesVideoPasswordExist(req.params.passwordId, res)) return
65 if (!await isVideoPasswordDeletable(res)) return
66
67 return next()
68 }
69]
70
71// ---------------------------------------------------------------------------
72
73export {
74 listVideoPasswordValidator,
75 updateVideoPasswordListValidator,
76 removeVideoPasswordValidator
77}
diff --git a/server/middlewares/validators/videos/video-playlists.ts b/server/middlewares/validators/videos/video-playlists.ts
index c631a16f8..95a5ba63a 100644
--- a/server/middlewares/validators/videos/video-playlists.ts
+++ b/server/middlewares/validators/videos/video-playlists.ts
@@ -153,7 +153,7 @@ const videoPlaylistsGetValidator = (fetchType: VideoPlaylistFetchType) => {
153 } 153 }
154 154
155 if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) { 155 if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
156 await authenticatePromise(req, res) 156 await authenticatePromise({ req, res })
157 157
158 const user = res.locals.oauth ? res.locals.oauth.token.User : null 158 const user = res.locals.oauth ? res.locals.oauth.token.User : null
159 159
diff --git a/server/middlewares/validators/videos/video-rates.ts b/server/middlewares/validators/videos/video-rates.ts
index 275634d5b..c837b047b 100644
--- a/server/middlewares/validators/videos/video-rates.ts
+++ b/server/middlewares/validators/videos/video-rates.ts
@@ -7,13 +7,14 @@ import { isIdValid } from '../../../helpers/custom-validators/misc'
7import { isRatingValid } from '../../../helpers/custom-validators/video-rates' 7import { isRatingValid } from '../../../helpers/custom-validators/video-rates'
8import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos' 8import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos'
9import { AccountVideoRateModel } from '../../../models/account/account-video-rate' 9import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
10import { areValidationErrors, checkCanSeeVideo, doesVideoExist, isValidVideoIdParam } from '../shared' 10import { areValidationErrors, checkCanSeeVideo, doesVideoExist, isValidVideoIdParam, isValidVideoPasswordHeader } from '../shared'
11 11
12const videoUpdateRateValidator = [ 12const videoUpdateRateValidator = [
13 isValidVideoIdParam('id'), 13 isValidVideoIdParam('id'),
14 14
15 body('rating') 15 body('rating')
16 .custom(isVideoRatingTypeValid), 16 .custom(isVideoRatingTypeValid),
17 isValidVideoPasswordHeader(),
17 18
18 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 19 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
19 if (areValidationErrors(req, res)) return 20 if (areValidationErrors(req, res)) return
diff --git a/server/middlewares/validators/videos/video-token.ts b/server/middlewares/validators/videos/video-token.ts
new file mode 100644
index 000000000..d4253e21d
--- /dev/null
+++ b/server/middlewares/validators/videos/video-token.ts
@@ -0,0 +1,24 @@
1import express from 'express'
2import { VideoPrivacy } from '../../../../shared/models/videos'
3import { HttpStatusCode } from '@shared/models'
4import { exists } from '@server/helpers/custom-validators/misc'
5
6const videoFileTokenValidator = [
7 (req: express.Request, res: express.Response, next: express.NextFunction) => {
8 const video = res.locals.onlyVideo
9 if (video.privacy !== VideoPrivacy.PASSWORD_PROTECTED && !exists(res.locals.oauth.token.User)) {
10 return res.fail({
11 status: HttpStatusCode.UNAUTHORIZED_401,
12 message: 'Not authenticated'
13 })
14 }
15
16 return next()
17 }
18]
19
20// ---------------------------------------------------------------------------
21
22export {
23 videoFileTokenValidator
24}
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts
index 794e1d4f1..b39d13a23 100644
--- a/server/middlewares/validators/videos/videos.ts
+++ b/server/middlewares/validators/videos/videos.ts
@@ -2,6 +2,7 @@ import express from 'express'
2import { body, header, param, query, ValidationChain } from 'express-validator' 2import { body, header, param, query, ValidationChain } from 'express-validator'
3import { isTestInstance } from '@server/helpers/core-utils' 3import { isTestInstance } from '@server/helpers/core-utils'
4import { getResumableUploadPath } from '@server/helpers/upload' 4import { getResumableUploadPath } from '@server/helpers/upload'
5import { uploadx } from '@server/lib/uploadx'
5import { Redis } from '@server/lib/redis' 6import { Redis } from '@server/lib/redis'
6import { getServerActor } from '@server/models/application/application' 7import { getServerActor } from '@server/models/application/application'
7import { ExpressPromiseHandler } from '@server/types/express-handler' 8import { ExpressPromiseHandler } from '@server/types/express-handler'
@@ -23,6 +24,7 @@ import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../
23import { 24import {
24 areVideoTagsValid, 25 areVideoTagsValid,
25 isScheduleVideoUpdatePrivacyValid, 26 isScheduleVideoUpdatePrivacyValid,
27 isValidPasswordProtectedPrivacy,
26 isVideoCategoryValid, 28 isVideoCategoryValid,
27 isVideoDescriptionValid, 29 isVideoDescriptionValid,
28 isVideoFileMimeTypeValid, 30 isVideoFileMimeTypeValid,
@@ -39,7 +41,6 @@ import {
39} from '../../../helpers/custom-validators/videos' 41} from '../../../helpers/custom-validators/videos'
40import { cleanUpReqFiles } from '../../../helpers/express-utils' 42import { cleanUpReqFiles } from '../../../helpers/express-utils'
41import { logger } from '../../../helpers/logger' 43import { logger } from '../../../helpers/logger'
42import { deleteFileAndCatch } from '../../../helpers/utils'
43import { getVideoWithAttributes } from '../../../helpers/video' 44import { getVideoWithAttributes } from '../../../helpers/video'
44import { CONFIG } from '../../../initializers/config' 45import { CONFIG } from '../../../initializers/config'
45import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants' 46import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants'
@@ -55,7 +56,8 @@ import {
55 doesVideoChannelOfAccountExist, 56 doesVideoChannelOfAccountExist,
56 doesVideoExist, 57 doesVideoExist,
57 doesVideoFileOfVideoExist, 58 doesVideoFileOfVideoExist,
58 isValidVideoIdParam 59 isValidVideoIdParam,
60 isValidVideoPasswordHeader
59} from '../shared' 61} from '../shared'
60 62
61const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ 63const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
@@ -70,6 +72,10 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
70 body('channelId') 72 body('channelId')
71 .customSanitizer(toIntOrNull) 73 .customSanitizer(toIntOrNull)
72 .custom(isIdValid), 74 .custom(isIdValid),
75 body('videoPasswords')
76 .optional()
77 .isArray()
78 .withMessage('Video passwords should be an array.'),
73 79
74 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 80 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
75 if (areValidationErrors(req, res)) return cleanUpReqFiles(req) 81 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
@@ -81,6 +87,8 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
81 return cleanUpReqFiles(req) 87 return cleanUpReqFiles(req)
82 } 88 }
83 89
90 if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
91
84 try { 92 try {
85 if (!videoFile.duration) await addDurationToVideo(videoFile) 93 if (!videoFile.duration) await addDurationToVideo(videoFile)
86 } catch (err) { 94 } catch (err) {
@@ -107,7 +115,7 @@ const videosAddResumableValidator = [
107 const user = res.locals.oauth.token.User 115 const user = res.locals.oauth.token.User
108 const body: express.CustomUploadXFile<express.UploadXFileMetadata> = req.body 116 const body: express.CustomUploadXFile<express.UploadXFileMetadata> = req.body
109 const file = { ...body, duration: undefined, path: getResumableUploadPath(body.name), filename: body.metadata.filename } 117 const file = { ...body, duration: undefined, path: getResumableUploadPath(body.name), filename: body.metadata.filename }
110 const cleanup = () => deleteFileAndCatch(file.path) 118 const cleanup = () => uploadx.storage.delete(file).catch(err => logger.error('Cannot delete the file %s', file.name, { err }))
111 119
112 const uploadId = req.query.upload_id 120 const uploadId = req.query.upload_id
113 const sessionExists = await Redis.Instance.doesUploadSessionExist(uploadId) 121 const sessionExists = await Redis.Instance.doesUploadSessionExist(uploadId)
@@ -124,11 +132,15 @@ const videosAddResumableValidator = [
124 }) 132 })
125 } 133 }
126 134
127 if (isTestInstance()) { 135 const videoStillExists = await VideoModel.load(sessionResponse.video.id)
128 res.setHeader('x-resumable-upload-cached', 'true') 136
129 } 137 if (videoStillExists) {
138 if (isTestInstance()) {
139 res.setHeader('x-resumable-upload-cached', 'true')
140 }
130 141
131 return res.json(sessionResponse) 142 return res.json(sessionResponse)
143 }
132 } 144 }
133 145
134 await Redis.Instance.setUploadSession(uploadId) 146 await Redis.Instance.setUploadSession(uploadId)
@@ -174,6 +186,10 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
174 body('channelId') 186 body('channelId')
175 .customSanitizer(toIntOrNull) 187 .customSanitizer(toIntOrNull)
176 .custom(isIdValid), 188 .custom(isIdValid),
189 body('videoPasswords')
190 .optional()
191 .isArray()
192 .withMessage('Video passwords should be an array.'),
177 193
178 header('x-upload-content-length') 194 header('x-upload-content-length')
179 .isNumeric() 195 .isNumeric()
@@ -205,10 +221,14 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
205 const files = { videofile: [ videoFileMetadata ] } 221 const files = { videofile: [ videoFileMetadata ] }
206 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup() 222 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
207 223
208 // multer required unsetting the Content-Type, now we can set it for node-uploadx 224 if (!isValidPasswordProtectedPrivacy(req, res)) return cleanup()
225
226 // Multer required unsetting the Content-Type, now we can set it for node-uploadx
209 req.headers['content-type'] = 'application/json; charset=utf-8' 227 req.headers['content-type'] = 'application/json; charset=utf-8'
210 // place previewfile in metadata so that uploadx saves it in .META 228
229 // Place thumbnail/previewfile in metadata so that uploadx saves it in .META
211 if (req.files?.['previewfile']) req.body.previewfile = req.files['previewfile'] 230 if (req.files?.['previewfile']) req.body.previewfile = req.files['previewfile']
231 if (req.files?.['thumbnailfile']) req.body.thumbnailfile = req.files['thumbnailfile']
212 232
213 return next() 233 return next()
214 } 234 }
@@ -227,12 +247,18 @@ const videosUpdateValidator = getCommonVideoEditAttributes().concat([
227 .optional() 247 .optional()
228 .customSanitizer(toIntOrNull) 248 .customSanitizer(toIntOrNull)
229 .custom(isIdValid), 249 .custom(isIdValid),
250 body('videoPasswords')
251 .optional()
252 .isArray()
253 .withMessage('Video passwords should be an array.'),
230 254
231 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 255 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
232 if (areValidationErrors(req, res)) return cleanUpReqFiles(req) 256 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
233 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req) 257 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
234 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req) 258 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
235 259
260 if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
261
236 const video = getVideoWithAttributes(res) 262 const video = getVideoWithAttributes(res)
237 if (video.isLive && video.privacy !== req.body.privacy && video.state !== VideoState.WAITING_FOR_LIVE) { 263 if (video.isLive && video.privacy !== req.body.privacy && video.state !== VideoState.WAITING_FOR_LIVE) {
238 return res.fail({ message: 'Cannot update privacy of a live that has already started' }) 264 return res.fail({ message: 'Cannot update privacy of a live that has already started' })
@@ -281,6 +307,8 @@ const videosCustomGetValidator = (fetchType: 'for-api' | 'all' | 'only-video' |
281 return [ 307 return [
282 isValidVideoIdParam('id'), 308 isValidVideoIdParam('id'),
283 309
310 isValidVideoPasswordHeader(),
311
284 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 312 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
285 if (areValidationErrors(req, res)) return 313 if (areValidationErrors(req, res)) return
286 if (!await doesVideoExist(req.params.id, res, fetchType)) return 314 if (!await doesVideoExist(req.params.id, res, fetchType)) return
@@ -478,10 +506,14 @@ const commonVideosFiltersValidator = [
478 .optional() 506 .optional()
479 .customSanitizer(toBooleanOrNull) 507 .customSanitizer(toBooleanOrNull)
480 .custom(isBooleanValid).withMessage('Should have a valid hasHLSFiles boolean'), 508 .custom(isBooleanValid).withMessage('Should have a valid hasHLSFiles boolean'),
481 query('hasWebtorrentFiles') 509 query('hasWebtorrentFiles') // TODO: remove in v7
482 .optional() 510 .optional()
483 .customSanitizer(toBooleanOrNull) 511 .customSanitizer(toBooleanOrNull)
484 .custom(isBooleanValid).withMessage('Should have a valid hasWebtorrentFiles boolean'), 512 .custom(isBooleanValid).withMessage('Should have a valid hasWebtorrentFiles boolean'),
513 query('hasWebVideoFiles')
514 .optional()
515 .customSanitizer(toBooleanOrNull)
516 .custom(isBooleanValid).withMessage('Should have a valid hasWebVideoFiles boolean'),
485 query('skipCount') 517 query('skipCount')
486 .optional() 518 .optional()
487 .customSanitizer(toBooleanOrNull) 519 .customSanitizer(toBooleanOrNull)
diff --git a/server/models/actor/actor-image.ts b/server/models/actor/actor-image.ts
index 9c34a0101..51085a16d 100644
--- a/server/models/actor/actor-image.ts
+++ b/server/models/actor/actor-image.ts
@@ -157,11 +157,11 @@ export class ActorImageModel extends Model<Partial<AttributesOnly<ActorImageMode
157 } 157 }
158 158
159 getPath () { 159 getPath () {
160 return join(CONFIG.STORAGE.ACTOR_IMAGES, this.filename) 160 return join(CONFIG.STORAGE.ACTOR_IMAGES_DIR, this.filename)
161 } 161 }
162 162
163 removeImage () { 163 removeImage () {
164 const imagePath = join(CONFIG.STORAGE.ACTOR_IMAGES, this.filename) 164 const imagePath = join(CONFIG.STORAGE.ACTOR_IMAGES_DIR, this.filename)
165 return remove(imagePath) 165 return remove(imagePath)
166 } 166 }
167 167
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts
index c2a72b71f..cebf47dfd 100644
--- a/server/models/redundancy/video-redundancy.ts
+++ b/server/models/redundancy/video-redundancy.ts
@@ -162,7 +162,7 @@ export class VideoRedundancyModel extends Model<Partial<AttributesOnly<VideoRedu
162 const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}` 162 const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}`
163 logger.info('Removing duplicated video file %s.', logIdentifier) 163 logger.info('Removing duplicated video file %s.', logIdentifier)
164 164
165 videoFile.Video.removeWebTorrentFile(videoFile, true) 165 videoFile.Video.removeWebVideoFile(videoFile, true)
166 .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err })) 166 .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err }))
167 } 167 }
168 168
diff --git a/server/models/user/user.ts b/server/models/user/user.ts
index 4f6a8fce4..ff6328d48 100644
--- a/server/models/user/user.ts
+++ b/server/models/user/user.ts
@@ -786,7 +786,7 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
786 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' + 786 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
787 `WHERE "account"."userId" = ${options.whereUserId} ${andWhere}` 787 `WHERE "account"."userId" = ${options.whereUserId} ${andWhere}`
788 788
789 const webtorrentFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' + 789 const webVideoFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' +
790 'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" AND "video"."isLive" IS FALSE ' + 790 'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" AND "video"."isLive" IS FALSE ' +
791 videoChannelJoin 791 videoChannelJoin
792 792
@@ -797,7 +797,7 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
797 797
798 return 'SELECT COALESCE(SUM("size"), 0) AS "total" ' + 798 return 'SELECT COALESCE(SUM("size"), 0) AS "total" ' +
799 'FROM (' + 799 'FROM (' +
800 `SELECT MAX("t1"."size") AS "size" FROM (${webtorrentFiles} UNION ${hlsFiles}) t1 ` + 800 `SELECT MAX("t1"."size") AS "size" FROM (${webVideoFiles} UNION ${hlsFiles}) t1 ` +
801 'GROUP BY "t1"."videoId"' + 801 'GROUP BY "t1"."videoId"' +
802 ') t2' 802 ') t2'
803 } 803 }
@@ -890,8 +890,6 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
890 890
891 nsfwPolicy: this.nsfwPolicy, 891 nsfwPolicy: this.nsfwPolicy,
892 892
893 // FIXME: deprecated in 4.1
894 webTorrentEnabled: this.p2pEnabled,
895 p2pEnabled: this.p2pEnabled, 893 p2pEnabled: this.p2pEnabled,
896 894
897 videosHistoryEnabled: this.videosHistoryEnabled, 895 videosHistoryEnabled: this.videosHistoryEnabled,
diff --git a/server/models/video/formatter/index.ts b/server/models/video/formatter/index.ts
new file mode 100644
index 000000000..77b406559
--- /dev/null
+++ b/server/models/video/formatter/index.ts
@@ -0,0 +1,2 @@
1export * from './video-activity-pub-format'
2export * from './video-api-format'
diff --git a/server/models/video/formatter/shared/index.ts b/server/models/video/formatter/shared/index.ts
new file mode 100644
index 000000000..d558fa7d6
--- /dev/null
+++ b/server/models/video/formatter/shared/index.ts
@@ -0,0 +1 @@
export * from './video-format-utils'
diff --git a/server/models/video/formatter/shared/video-format-utils.ts b/server/models/video/formatter/shared/video-format-utils.ts
new file mode 100644
index 000000000..df3bbdf1c
--- /dev/null
+++ b/server/models/video/formatter/shared/video-format-utils.ts
@@ -0,0 +1,7 @@
1import { MVideoFile } from '@server/types/models'
2
3export function sortByResolutionDesc (fileA: MVideoFile, fileB: MVideoFile) {
4 if (fileA.resolution < fileB.resolution) return 1
5 if (fileA.resolution === fileB.resolution) return 0
6 return -1
7}
diff --git a/server/models/video/formatter/video-activity-pub-format.ts b/server/models/video/formatter/video-activity-pub-format.ts
new file mode 100644
index 000000000..c0d3d5f3e
--- /dev/null
+++ b/server/models/video/formatter/video-activity-pub-format.ts
@@ -0,0 +1,295 @@
1
2import { isArray } from 'lodash'
3import { generateMagnetUri } from '@server/helpers/webtorrent'
4import { getActivityStreamDuration } from '@server/lib/activitypub/activity'
5import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls'
6import {
7 ActivityIconObject,
8 ActivityPlaylistUrlObject,
9 ActivityPubStoryboard,
10 ActivityTagObject,
11 ActivityTrackerUrlObject,
12 ActivityUrlObject,
13 VideoObject
14} from '@shared/models'
15import { MIMETYPES, WEBSERVER } from '../../../initializers/constants'
16import {
17 getLocalVideoCommentsActivityPubUrl,
18 getLocalVideoDislikesActivityPubUrl,
19 getLocalVideoLikesActivityPubUrl,
20 getLocalVideoSharesActivityPubUrl
21} from '../../../lib/activitypub/url'
22import { MStreamingPlaylistFiles, MUserId, MVideo, MVideoAP, MVideoFile } from '../../../types/models'
23import { VideoCaptionModel } from '../video-caption'
24import { sortByResolutionDesc } from './shared'
25import { getCategoryLabel, getLanguageLabel, getLicenceLabel } from './video-api-format'
26
27export function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
28 const language = video.language
29 ? { identifier: video.language, name: getLanguageLabel(video.language) }
30 : undefined
31
32 const category = video.category
33 ? { identifier: video.category + '', name: getCategoryLabel(video.category) }
34 : undefined
35
36 const licence = video.licence
37 ? { identifier: video.licence + '', name: getLicenceLabel(video.licence) }
38 : undefined
39
40 const url: ActivityUrlObject[] = [
41 // HTML url should be the first element in the array so Mastodon correctly displays the embed
42 {
43 type: 'Link',
44 mediaType: 'text/html',
45 href: WEBSERVER.URL + '/videos/watch/' + video.uuid
46 } as ActivityUrlObject,
47
48 ...buildVideoFileUrls({ video, files: video.VideoFiles }),
49
50 ...buildStreamingPlaylistUrls(video),
51
52 ...buildTrackerUrls(video)
53 ]
54
55 return {
56 type: 'Video' as 'Video',
57 id: video.url,
58 name: video.name,
59 duration: getActivityStreamDuration(video.duration),
60 uuid: video.uuid,
61 category,
62 licence,
63 language,
64 views: video.views,
65 sensitive: video.nsfw,
66 waitTranscoding: video.waitTranscoding,
67
68 state: video.state,
69 commentsEnabled: video.commentsEnabled,
70 downloadEnabled: video.downloadEnabled,
71 published: video.publishedAt.toISOString(),
72
73 originallyPublishedAt: video.originallyPublishedAt
74 ? video.originallyPublishedAt.toISOString()
75 : null,
76
77 updated: video.updatedAt.toISOString(),
78
79 tag: buildTags(video),
80
81 mediaType: 'text/markdown',
82 content: video.description,
83 support: video.support,
84
85 subtitleLanguage: buildSubtitleLanguage(video),
86
87 icon: buildIcon(video),
88
89 preview: buildPreviewAPAttribute(video),
90
91 url,
92
93 likes: getLocalVideoLikesActivityPubUrl(video),
94 dislikes: getLocalVideoDislikesActivityPubUrl(video),
95 shares: getLocalVideoSharesActivityPubUrl(video),
96 comments: getLocalVideoCommentsActivityPubUrl(video),
97
98 attributedTo: [
99 {
100 type: 'Person',
101 id: video.VideoChannel.Account.Actor.url
102 },
103 {
104 type: 'Group',
105 id: video.VideoChannel.Actor.url
106 }
107 ],
108
109 ...buildLiveAPAttributes(video)
110 }
111}
112
113// ---------------------------------------------------------------------------
114// Private
115// ---------------------------------------------------------------------------
116
117function buildLiveAPAttributes (video: MVideoAP) {
118 if (!video.isLive) {
119 return {
120 isLiveBroadcast: false,
121 liveSaveReplay: null,
122 permanentLive: null,
123 latencyMode: null
124 }
125 }
126
127 return {
128 isLiveBroadcast: true,
129 liveSaveReplay: video.VideoLive.saveReplay,
130 permanentLive: video.VideoLive.permanentLive,
131 latencyMode: video.VideoLive.latencyMode
132 }
133}
134
135function buildPreviewAPAttribute (video: MVideoAP): ActivityPubStoryboard[] {
136 if (!video.Storyboard) return undefined
137
138 const storyboard = video.Storyboard
139
140 return [
141 {
142 type: 'Image',
143 rel: [ 'storyboard' ],
144 url: [
145 {
146 mediaType: 'image/jpeg',
147
148 href: storyboard.getOriginFileUrl(video),
149
150 width: storyboard.totalWidth,
151 height: storyboard.totalHeight,
152
153 tileWidth: storyboard.spriteWidth,
154 tileHeight: storyboard.spriteHeight,
155 tileDuration: getActivityStreamDuration(storyboard.spriteDuration)
156 }
157 ]
158 }
159 ]
160}
161
162function buildVideoFileUrls (options: {
163 video: MVideo
164 files: MVideoFile[]
165 user?: MUserId
166}): ActivityUrlObject[] {
167 const { video, files } = options
168
169 if (!isArray(files)) return []
170
171 const urls: ActivityUrlObject[] = []
172
173 const trackerUrls = video.getTrackerUrls()
174 const sortedFiles = files
175 .filter(f => !f.isLive())
176 .sort(sortByResolutionDesc)
177
178 for (const file of sortedFiles) {
179 urls.push({
180 type: 'Link',
181 mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] as any,
182 href: file.getFileUrl(video),
183 height: file.resolution,
184 size: file.size,
185 fps: file.fps
186 })
187
188 urls.push({
189 type: 'Link',
190 rel: [ 'metadata', MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] ],
191 mediaType: 'application/json' as 'application/json',
192 href: getLocalVideoFileMetadataUrl(video, file),
193 height: file.resolution,
194 fps: file.fps
195 })
196
197 if (file.hasTorrent()) {
198 urls.push({
199 type: 'Link',
200 mediaType: 'application/x-bittorrent' as 'application/x-bittorrent',
201 href: file.getTorrentUrl(),
202 height: file.resolution
203 })
204
205 urls.push({
206 type: 'Link',
207 mediaType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
208 href: generateMagnetUri(video, file, trackerUrls),
209 height: file.resolution
210 })
211 }
212 }
213
214 return urls
215}
216
217// ---------------------------------------------------------------------------
218
219function buildStreamingPlaylistUrls (video: MVideoAP): ActivityPlaylistUrlObject[] {
220 if (!isArray(video.VideoStreamingPlaylists)) return []
221
222 return video.VideoStreamingPlaylists
223 .map(playlist => ({
224 type: 'Link',
225 mediaType: 'application/x-mpegURL' as 'application/x-mpegURL',
226 href: playlist.getMasterPlaylistUrl(video),
227 tag: buildStreamingPlaylistTags(video, playlist)
228 }))
229}
230
231function buildStreamingPlaylistTags (video: MVideoAP, playlist: MStreamingPlaylistFiles) {
232 return [
233 ...playlist.p2pMediaLoaderInfohashes.map(i => ({ type: 'Infohash' as 'Infohash', name: i })),
234
235 {
236 type: 'Link',
237 name: 'sha256',
238 mediaType: 'application/json' as 'application/json',
239 href: playlist.getSha256SegmentsUrl(video)
240 },
241
242 ...buildVideoFileUrls({ video, files: playlist.VideoFiles })
243 ] as ActivityTagObject[]
244}
245
246// ---------------------------------------------------------------------------
247
248function buildTrackerUrls (video: MVideoAP): ActivityTrackerUrlObject[] {
249 return video.getTrackerUrls()
250 .map(trackerUrl => {
251 const rel2 = trackerUrl.startsWith('http')
252 ? 'http'
253 : 'websocket'
254
255 return {
256 type: 'Link',
257 name: `tracker-${rel2}`,
258 rel: [ 'tracker', rel2 ],
259 href: trackerUrl
260 }
261 })
262}
263
264// ---------------------------------------------------------------------------
265
266function buildTags (video: MVideoAP) {
267 if (!isArray(video.Tags)) return []
268
269 return video.Tags.map(t => ({
270 type: 'Hashtag' as 'Hashtag',
271 name: t.name
272 }))
273}
274
275function buildIcon (video: MVideoAP): ActivityIconObject[] {
276 return [ video.getMiniature(), video.getPreview() ]
277 .map(i => ({
278 type: 'Image',
279 url: i.getOriginFileUrl(video),
280 mediaType: 'image/jpeg',
281 width: i.width,
282 height: i.height
283 }))
284}
285
286function buildSubtitleLanguage (video: MVideoAP) {
287 if (!isArray(video.VideoCaptions)) return []
288
289 return video.VideoCaptions
290 .map(caption => ({
291 identifier: caption.language,
292 name: VideoCaptionModel.getLanguageLabel(caption.language),
293 url: caption.getFileUrl(video)
294 }))
295}
diff --git a/server/models/video/formatter/video-api-format.ts b/server/models/video/formatter/video-api-format.ts
new file mode 100644
index 000000000..1af51d132
--- /dev/null
+++ b/server/models/video/formatter/video-api-format.ts
@@ -0,0 +1,304 @@
1import { generateMagnetUri } from '@server/helpers/webtorrent'
2import { tracer } from '@server/lib/opentelemetry/tracing'
3import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls'
4import { VideoViewsManager } from '@server/lib/views/video-views-manager'
5import { uuidToShort } from '@shared/extra-utils'
6import {
7 Video,
8 VideoAdditionalAttributes,
9 VideoDetails,
10 VideoFile,
11 VideoInclude,
12 VideosCommonQueryAfterSanitize,
13 VideoStreamingPlaylist
14} from '@shared/models'
15import { isArray } from '../../../helpers/custom-validators/misc'
16import { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES, VIDEO_STATES } from '../../../initializers/constants'
17import { MServer, MStreamingPlaylistRedundanciesOpt, MVideoFormattable, MVideoFormattableDetails } from '../../../types/models'
18import { MVideoFileRedundanciesOpt } from '../../../types/models/video/video-file'
19import { sortByResolutionDesc } from './shared'
20
21export type VideoFormattingJSONOptions = {
22 completeDescription?: boolean
23
24 additionalAttributes?: {
25 state?: boolean
26 waitTranscoding?: boolean
27 scheduledUpdate?: boolean
28 blacklistInfo?: boolean
29 files?: boolean
30 blockedOwner?: boolean
31 }
32}
33
34export function guessAdditionalAttributesFromQuery (query: VideosCommonQueryAfterSanitize): VideoFormattingJSONOptions {
35 if (!query?.include) return {}
36
37 return {
38 additionalAttributes: {
39 state: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
40 waitTranscoding: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
41 scheduledUpdate: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
42 blacklistInfo: !!(query.include & VideoInclude.BLACKLISTED),
43 files: !!(query.include & VideoInclude.FILES),
44 blockedOwner: !!(query.include & VideoInclude.BLOCKED_OWNER)
45 }
46 }
47}
48
49// ---------------------------------------------------------------------------
50
51export function videoModelToFormattedJSON (video: MVideoFormattable, options: VideoFormattingJSONOptions = {}): Video {
52 const span = tracer.startSpan('peertube.VideoModel.toFormattedJSON')
53
54 const userHistory = isArray(video.UserVideoHistories)
55 ? video.UserVideoHistories[0]
56 : undefined
57
58 const videoObject: Video = {
59 id: video.id,
60 uuid: video.uuid,
61 shortUUID: uuidToShort(video.uuid),
62
63 url: video.url,
64
65 name: video.name,
66 category: {
67 id: video.category,
68 label: getCategoryLabel(video.category)
69 },
70 licence: {
71 id: video.licence,
72 label: getLicenceLabel(video.licence)
73 },
74 language: {
75 id: video.language,
76 label: getLanguageLabel(video.language)
77 },
78 privacy: {
79 id: video.privacy,
80 label: getPrivacyLabel(video.privacy)
81 },
82 nsfw: video.nsfw,
83
84 truncatedDescription: video.getTruncatedDescription(),
85 description: options && options.completeDescription === true
86 ? video.description
87 : video.getTruncatedDescription(),
88
89 isLocal: video.isOwned(),
90 duration: video.duration,
91
92 views: video.views,
93 viewers: VideoViewsManager.Instance.getViewers(video),
94
95 likes: video.likes,
96 dislikes: video.dislikes,
97 thumbnailPath: video.getMiniatureStaticPath(),
98 previewPath: video.getPreviewStaticPath(),
99 embedPath: video.getEmbedStaticPath(),
100 createdAt: video.createdAt,
101 updatedAt: video.updatedAt,
102 publishedAt: video.publishedAt,
103 originallyPublishedAt: video.originallyPublishedAt,
104
105 isLive: video.isLive,
106
107 account: video.VideoChannel.Account.toFormattedSummaryJSON(),
108 channel: video.VideoChannel.toFormattedSummaryJSON(),
109
110 userHistory: userHistory
111 ? { currentTime: userHistory.currentTime }
112 : undefined,
113
114 // Can be added by external plugins
115 pluginData: (video as any).pluginData,
116
117 ...buildAdditionalAttributes(video, options)
118 }
119
120 span.end()
121
122 return videoObject
123}
124
125export function videoModelToFormattedDetailsJSON (video: MVideoFormattableDetails): VideoDetails {
126 const span = tracer.startSpan('peertube.VideoModel.toFormattedDetailsJSON')
127
128 const videoJSON = video.toFormattedJSON({
129 completeDescription: true,
130 additionalAttributes: {
131 scheduledUpdate: true,
132 blacklistInfo: true,
133 files: true
134 }
135 }) as Video & Required<Pick<Video, 'files' | 'streamingPlaylists' | 'scheduledUpdate' | 'blacklisted' | 'blacklistedReason'>>
136
137 const tags = video.Tags
138 ? video.Tags.map(t => t.name)
139 : []
140
141 const detailsJSON = {
142 ...videoJSON,
143
144 support: video.support,
145 descriptionPath: video.getDescriptionAPIPath(),
146 channel: video.VideoChannel.toFormattedJSON(),
147 account: video.VideoChannel.Account.toFormattedJSON(),
148 tags,
149 commentsEnabled: video.commentsEnabled,
150 downloadEnabled: video.downloadEnabled,
151 waitTranscoding: video.waitTranscoding,
152 state: {
153 id: video.state,
154 label: getStateLabel(video.state)
155 },
156
157 trackerUrls: video.getTrackerUrls()
158 }
159
160 span.end()
161
162 return detailsJSON
163}
164
165export function streamingPlaylistsModelToFormattedJSON (
166 video: MVideoFormattable,
167 playlists: MStreamingPlaylistRedundanciesOpt[]
168): VideoStreamingPlaylist[] {
169 if (isArray(playlists) === false) return []
170
171 return playlists
172 .map(playlist => ({
173 id: playlist.id,
174 type: playlist.type,
175
176 playlistUrl: playlist.getMasterPlaylistUrl(video),
177 segmentsSha256Url: playlist.getSha256SegmentsUrl(video),
178
179 redundancies: isArray(playlist.RedundancyVideos)
180 ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl }))
181 : [],
182
183 files: videoFilesModelToFormattedJSON(video, playlist.VideoFiles)
184 }))
185}
186
187export function videoFilesModelToFormattedJSON (
188 video: MVideoFormattable,
189 videoFiles: MVideoFileRedundanciesOpt[],
190 options: {
191 includeMagnet?: boolean // default true
192 } = {}
193): VideoFile[] {
194 const { includeMagnet = true } = options
195
196 if (isArray(videoFiles) === false) return []
197
198 const trackerUrls = includeMagnet
199 ? video.getTrackerUrls()
200 : []
201
202 return videoFiles
203 .filter(f => !f.isLive())
204 .sort(sortByResolutionDesc)
205 .map(videoFile => {
206 return {
207 id: videoFile.id,
208
209 resolution: {
210 id: videoFile.resolution,
211 label: videoFile.resolution === 0
212 ? 'Audio'
213 : `${videoFile.resolution}p`
214 },
215
216 magnetUri: includeMagnet && videoFile.hasTorrent()
217 ? generateMagnetUri(video, videoFile, trackerUrls)
218 : undefined,
219
220 size: videoFile.size,
221 fps: videoFile.fps,
222
223 torrentUrl: videoFile.getTorrentUrl(),
224 torrentDownloadUrl: videoFile.getTorrentDownloadUrl(),
225
226 fileUrl: videoFile.getFileUrl(video),
227 fileDownloadUrl: videoFile.getFileDownloadUrl(video),
228
229 metadataUrl: videoFile.metadataUrl ?? getLocalVideoFileMetadataUrl(video, videoFile)
230 }
231 })
232}
233
234// ---------------------------------------------------------------------------
235
236export function getCategoryLabel (id: number) {
237 return VIDEO_CATEGORIES[id] || 'Unknown'
238}
239
240export function getLicenceLabel (id: number) {
241 return VIDEO_LICENCES[id] || 'Unknown'
242}
243
244export function getLanguageLabel (id: string) {
245 return VIDEO_LANGUAGES[id] || 'Unknown'
246}
247
248export function getPrivacyLabel (id: number) {
249 return VIDEO_PRIVACIES[id] || 'Unknown'
250}
251
252export function getStateLabel (id: number) {
253 return VIDEO_STATES[id] || 'Unknown'
254}
255
256// ---------------------------------------------------------------------------
257// Private
258// ---------------------------------------------------------------------------
259
260function buildAdditionalAttributes (video: MVideoFormattable, options: VideoFormattingJSONOptions) {
261 const add = options.additionalAttributes
262
263 const result: Partial<VideoAdditionalAttributes> = {}
264
265 if (add?.state === true) {
266 result.state = {
267 id: video.state,
268 label: getStateLabel(video.state)
269 }
270 }
271
272 if (add?.waitTranscoding === true) {
273 result.waitTranscoding = video.waitTranscoding
274 }
275
276 if (add?.scheduledUpdate === true && video.ScheduleVideoUpdate) {
277 result.scheduledUpdate = {
278 updateAt: video.ScheduleVideoUpdate.updateAt,
279 privacy: video.ScheduleVideoUpdate.privacy || undefined
280 }
281 }
282
283 if (add?.blacklistInfo === true) {
284 result.blacklisted = !!video.VideoBlacklist
285 result.blacklistedReason =
286 video.VideoBlacklist
287 ? video.VideoBlacklist.reason
288 : null
289 }
290
291 if (add?.blockedOwner === true) {
292 result.blockedOwner = video.VideoChannel.Account.isBlocked()
293
294 const server = video.VideoChannel.Account.Actor.Server as MServer
295 result.blockedServer = !!(server?.isBlocked())
296 }
297
298 if (add?.files === true) {
299 result.streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists)
300 result.files = videoFilesModelToFormattedJSON(video, video.VideoFiles)
301 }
302
303 return result
304}
diff --git a/server/models/video/formatter/video-format-utils.ts b/server/models/video/formatter/video-format-utils.ts
deleted file mode 100644
index f2001e432..000000000
--- a/server/models/video/formatter/video-format-utils.ts
+++ /dev/null
@@ -1,543 +0,0 @@
1import { generateMagnetUri } from '@server/helpers/webtorrent'
2import { getActivityStreamDuration } from '@server/lib/activitypub/activity'
3import { tracer } from '@server/lib/opentelemetry/tracing'
4import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls'
5import { VideoViewsManager } from '@server/lib/views/video-views-manager'
6import { uuidToShort } from '@shared/extra-utils'
7import {
8 ActivityTagObject,
9 ActivityUrlObject,
10 Video,
11 VideoDetails,
12 VideoFile,
13 VideoInclude,
14 VideoObject,
15 VideosCommonQueryAfterSanitize,
16 VideoStreamingPlaylist
17} from '@shared/models'
18import { isArray } from '../../../helpers/custom-validators/misc'
19import {
20 MIMETYPES,
21 VIDEO_CATEGORIES,
22 VIDEO_LANGUAGES,
23 VIDEO_LICENCES,
24 VIDEO_PRIVACIES,
25 VIDEO_STATES,
26 WEBSERVER
27} from '../../../initializers/constants'
28import {
29 getLocalVideoCommentsActivityPubUrl,
30 getLocalVideoDislikesActivityPubUrl,
31 getLocalVideoLikesActivityPubUrl,
32 getLocalVideoSharesActivityPubUrl
33} from '../../../lib/activitypub/url'
34import {
35 MServer,
36 MStreamingPlaylistRedundanciesOpt,
37 MUserId,
38 MVideo,
39 MVideoAP,
40 MVideoFile,
41 MVideoFormattable,
42 MVideoFormattableDetails
43} from '../../../types/models'
44import { MVideoFileRedundanciesOpt } from '../../../types/models/video/video-file'
45import { VideoCaptionModel } from '../video-caption'
46
47export type VideoFormattingJSONOptions = {
48 completeDescription?: boolean
49
50 additionalAttributes?: {
51 state?: boolean
52 waitTranscoding?: boolean
53 scheduledUpdate?: boolean
54 blacklistInfo?: boolean
55 files?: boolean
56 blockedOwner?: boolean
57 }
58}
59
60function guessAdditionalAttributesFromQuery (query: VideosCommonQueryAfterSanitize): VideoFormattingJSONOptions {
61 if (!query?.include) return {}
62
63 return {
64 additionalAttributes: {
65 state: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
66 waitTranscoding: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
67 scheduledUpdate: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
68 blacklistInfo: !!(query.include & VideoInclude.BLACKLISTED),
69 files: !!(query.include & VideoInclude.FILES),
70 blockedOwner: !!(query.include & VideoInclude.BLOCKED_OWNER)
71 }
72 }
73}
74
75function videoModelToFormattedJSON (video: MVideoFormattable, options: VideoFormattingJSONOptions = {}): Video {
76 const span = tracer.startSpan('peertube.VideoModel.toFormattedJSON')
77
78 const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined
79
80 const videoObject: Video = {
81 id: video.id,
82 uuid: video.uuid,
83 shortUUID: uuidToShort(video.uuid),
84
85 url: video.url,
86
87 name: video.name,
88 category: {
89 id: video.category,
90 label: getCategoryLabel(video.category)
91 },
92 licence: {
93 id: video.licence,
94 label: getLicenceLabel(video.licence)
95 },
96 language: {
97 id: video.language,
98 label: getLanguageLabel(video.language)
99 },
100 privacy: {
101 id: video.privacy,
102 label: getPrivacyLabel(video.privacy)
103 },
104 nsfw: video.nsfw,
105
106 truncatedDescription: video.getTruncatedDescription(),
107 description: options && options.completeDescription === true
108 ? video.description
109 : video.getTruncatedDescription(),
110
111 isLocal: video.isOwned(),
112 duration: video.duration,
113
114 views: video.views,
115 viewers: VideoViewsManager.Instance.getViewers(video),
116
117 likes: video.likes,
118 dislikes: video.dislikes,
119 thumbnailPath: video.getMiniatureStaticPath(),
120 previewPath: video.getPreviewStaticPath(),
121 embedPath: video.getEmbedStaticPath(),
122 createdAt: video.createdAt,
123 updatedAt: video.updatedAt,
124 publishedAt: video.publishedAt,
125 originallyPublishedAt: video.originallyPublishedAt,
126
127 isLive: video.isLive,
128
129 account: video.VideoChannel.Account.toFormattedSummaryJSON(),
130 channel: video.VideoChannel.toFormattedSummaryJSON(),
131
132 userHistory: userHistory
133 ? { currentTime: userHistory.currentTime }
134 : undefined,
135
136 // Can be added by external plugins
137 pluginData: (video as any).pluginData
138 }
139
140 const add = options.additionalAttributes
141 if (add?.state === true) {
142 videoObject.state = {
143 id: video.state,
144 label: getStateLabel(video.state)
145 }
146 }
147
148 if (add?.waitTranscoding === true) {
149 videoObject.waitTranscoding = video.waitTranscoding
150 }
151
152 if (add?.scheduledUpdate === true && video.ScheduleVideoUpdate) {
153 videoObject.scheduledUpdate = {
154 updateAt: video.ScheduleVideoUpdate.updateAt,
155 privacy: video.ScheduleVideoUpdate.privacy || undefined
156 }
157 }
158
159 if (add?.blacklistInfo === true) {
160 videoObject.blacklisted = !!video.VideoBlacklist
161 videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null
162 }
163
164 if (add?.blockedOwner === true) {
165 videoObject.blockedOwner = video.VideoChannel.Account.isBlocked()
166
167 const server = video.VideoChannel.Account.Actor.Server as MServer
168 videoObject.blockedServer = !!(server?.isBlocked())
169 }
170
171 if (add?.files === true) {
172 videoObject.streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists)
173 videoObject.files = videoFilesModelToFormattedJSON(video, video.VideoFiles)
174 }
175
176 span.end()
177
178 return videoObject
179}
180
181function videoModelToFormattedDetailsJSON (video: MVideoFormattableDetails): VideoDetails {
182 const span = tracer.startSpan('peertube.VideoModel.toFormattedDetailsJSON')
183
184 const videoJSON = video.toFormattedJSON({
185 completeDescription: true,
186 additionalAttributes: {
187 scheduledUpdate: true,
188 blacklistInfo: true,
189 files: true
190 }
191 }) as Video & Required<Pick<Video, 'files' | 'streamingPlaylists'>>
192
193 const tags = video.Tags ? video.Tags.map(t => t.name) : []
194
195 const detailsJSON = {
196 support: video.support,
197 descriptionPath: video.getDescriptionAPIPath(),
198 channel: video.VideoChannel.toFormattedJSON(),
199 account: video.VideoChannel.Account.toFormattedJSON(),
200 tags,
201 commentsEnabled: video.commentsEnabled,
202 downloadEnabled: video.downloadEnabled,
203 waitTranscoding: video.waitTranscoding,
204 state: {
205 id: video.state,
206 label: getStateLabel(video.state)
207 },
208
209 trackerUrls: video.getTrackerUrls()
210 }
211
212 span.end()
213
214 return Object.assign(videoJSON, detailsJSON)
215}
216
217function streamingPlaylistsModelToFormattedJSON (
218 video: MVideoFormattable,
219 playlists: MStreamingPlaylistRedundanciesOpt[]
220): VideoStreamingPlaylist[] {
221 if (isArray(playlists) === false) return []
222
223 return playlists
224 .map(playlist => {
225 const redundancies = isArray(playlist.RedundancyVideos)
226 ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl }))
227 : []
228
229 const files = videoFilesModelToFormattedJSON(video, playlist.VideoFiles)
230
231 return {
232 id: playlist.id,
233 type: playlist.type,
234 playlistUrl: playlist.getMasterPlaylistUrl(video),
235 segmentsSha256Url: playlist.getSha256SegmentsUrl(video),
236 redundancies,
237 files
238 }
239 })
240}
241
242function sortByResolutionDesc (fileA: MVideoFile, fileB: MVideoFile) {
243 if (fileA.resolution < fileB.resolution) return 1
244 if (fileA.resolution === fileB.resolution) return 0
245 return -1
246}
247
248function videoFilesModelToFormattedJSON (
249 video: MVideoFormattable,
250 videoFiles: MVideoFileRedundanciesOpt[],
251 options: {
252 includeMagnet?: boolean // default true
253 } = {}
254): VideoFile[] {
255 const { includeMagnet = true } = options
256
257 const trackerUrls = includeMagnet
258 ? video.getTrackerUrls()
259 : []
260
261 return (videoFiles || [])
262 .filter(f => !f.isLive())
263 .sort(sortByResolutionDesc)
264 .map(videoFile => {
265 return {
266 id: videoFile.id,
267
268 resolution: {
269 id: videoFile.resolution,
270 label: videoFile.resolution === 0 ? 'Audio' : `${videoFile.resolution}p`
271 },
272
273 magnetUri: includeMagnet && videoFile.hasTorrent()
274 ? generateMagnetUri(video, videoFile, trackerUrls)
275 : undefined,
276
277 size: videoFile.size,
278 fps: videoFile.fps,
279
280 torrentUrl: videoFile.getTorrentUrl(),
281 torrentDownloadUrl: videoFile.getTorrentDownloadUrl(),
282
283 fileUrl: videoFile.getFileUrl(video),
284 fileDownloadUrl: videoFile.getFileDownloadUrl(video),
285
286 metadataUrl: videoFile.metadataUrl ?? getLocalVideoFileMetadataUrl(video, videoFile)
287 } as VideoFile
288 })
289}
290
291function addVideoFilesInAPAcc (options: {
292 acc: ActivityUrlObject[] | ActivityTagObject[]
293 video: MVideo
294 files: MVideoFile[]
295 user?: MUserId
296}) {
297 const { acc, video, files } = options
298
299 const trackerUrls = video.getTrackerUrls()
300
301 const sortedFiles = (files || [])
302 .filter(f => !f.isLive())
303 .sort(sortByResolutionDesc)
304
305 for (const file of sortedFiles) {
306 acc.push({
307 type: 'Link',
308 mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] as any,
309 href: file.getFileUrl(video),
310 height: file.resolution,
311 size: file.size,
312 fps: file.fps
313 })
314
315 acc.push({
316 type: 'Link',
317 rel: [ 'metadata', MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] ],
318 mediaType: 'application/json' as 'application/json',
319 href: getLocalVideoFileMetadataUrl(video, file),
320 height: file.resolution,
321 fps: file.fps
322 })
323
324 if (file.hasTorrent()) {
325 acc.push({
326 type: 'Link',
327 mediaType: 'application/x-bittorrent' as 'application/x-bittorrent',
328 href: file.getTorrentUrl(),
329 height: file.resolution
330 })
331
332 acc.push({
333 type: 'Link',
334 mediaType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
335 href: generateMagnetUri(video, file, trackerUrls),
336 height: file.resolution
337 })
338 }
339 }
340}
341
342function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
343 if (!video.Tags) video.Tags = []
344
345 const tag = video.Tags.map(t => ({
346 type: 'Hashtag' as 'Hashtag',
347 name: t.name
348 }))
349
350 let language
351 if (video.language) {
352 language = {
353 identifier: video.language,
354 name: getLanguageLabel(video.language)
355 }
356 }
357
358 let category
359 if (video.category) {
360 category = {
361 identifier: video.category + '',
362 name: getCategoryLabel(video.category)
363 }
364 }
365
366 let licence
367 if (video.licence) {
368 licence = {
369 identifier: video.licence + '',
370 name: getLicenceLabel(video.licence)
371 }
372 }
373
374 const url: ActivityUrlObject[] = [
375 // HTML url should be the first element in the array so Mastodon correctly displays the embed
376 {
377 type: 'Link',
378 mediaType: 'text/html',
379 href: WEBSERVER.URL + '/videos/watch/' + video.uuid
380 }
381 ]
382
383 addVideoFilesInAPAcc({ acc: url, video, files: video.VideoFiles || [] })
384
385 for (const playlist of (video.VideoStreamingPlaylists || [])) {
386 const tag = playlist.p2pMediaLoaderInfohashes
387 .map(i => ({ type: 'Infohash' as 'Infohash', name: i })) as ActivityTagObject[]
388 tag.push({
389 type: 'Link',
390 name: 'sha256',
391 mediaType: 'application/json' as 'application/json',
392 href: playlist.getSha256SegmentsUrl(video)
393 })
394
395 addVideoFilesInAPAcc({ acc: tag, video, files: playlist.VideoFiles || [] })
396
397 url.push({
398 type: 'Link',
399 mediaType: 'application/x-mpegURL' as 'application/x-mpegURL',
400 href: playlist.getMasterPlaylistUrl(video),
401 tag
402 })
403 }
404
405 for (const trackerUrl of video.getTrackerUrls()) {
406 const rel2 = trackerUrl.startsWith('http')
407 ? 'http'
408 : 'websocket'
409
410 url.push({
411 type: 'Link',
412 name: `tracker-${rel2}`,
413 rel: [ 'tracker', rel2 ],
414 href: trackerUrl
415 })
416 }
417
418 const subtitleLanguage = []
419 for (const caption of video.VideoCaptions) {
420 subtitleLanguage.push({
421 identifier: caption.language,
422 name: VideoCaptionModel.getLanguageLabel(caption.language),
423 url: caption.getFileUrl(video)
424 })
425 }
426
427 const icons = [ video.getMiniature(), video.getPreview() ]
428
429 return {
430 type: 'Video' as 'Video',
431 id: video.url,
432 name: video.name,
433 duration: getActivityStreamDuration(video.duration),
434 uuid: video.uuid,
435 tag,
436 category,
437 licence,
438 language,
439 views: video.views,
440 sensitive: video.nsfw,
441 waitTranscoding: video.waitTranscoding,
442
443 state: video.state,
444 commentsEnabled: video.commentsEnabled,
445 downloadEnabled: video.downloadEnabled,
446 published: video.publishedAt.toISOString(),
447
448 originallyPublishedAt: video.originallyPublishedAt
449 ? video.originallyPublishedAt.toISOString()
450 : null,
451
452 updated: video.updatedAt.toISOString(),
453
454 mediaType: 'text/markdown',
455 content: video.description,
456 support: video.support,
457
458 subtitleLanguage,
459
460 icon: icons.map(i => ({
461 type: 'Image',
462 url: i.getOriginFileUrl(video),
463 mediaType: 'image/jpeg',
464 width: i.width,
465 height: i.height
466 })),
467
468 url,
469
470 likes: getLocalVideoLikesActivityPubUrl(video),
471 dislikes: getLocalVideoDislikesActivityPubUrl(video),
472 shares: getLocalVideoSharesActivityPubUrl(video),
473 comments: getLocalVideoCommentsActivityPubUrl(video),
474
475 attributedTo: [
476 {
477 type: 'Person',
478 id: video.VideoChannel.Account.Actor.url
479 },
480 {
481 type: 'Group',
482 id: video.VideoChannel.Actor.url
483 }
484 ],
485
486 ...buildLiveAPAttributes(video)
487 }
488}
489
490function getCategoryLabel (id: number) {
491 return VIDEO_CATEGORIES[id] || 'Unknown'
492}
493
494function getLicenceLabel (id: number) {
495 return VIDEO_LICENCES[id] || 'Unknown'
496}
497
498function getLanguageLabel (id: string) {
499 return VIDEO_LANGUAGES[id] || 'Unknown'
500}
501
502function getPrivacyLabel (id: number) {
503 return VIDEO_PRIVACIES[id] || 'Unknown'
504}
505
506function getStateLabel (id: number) {
507 return VIDEO_STATES[id] || 'Unknown'
508}
509
510export {
511 videoModelToFormattedJSON,
512 videoModelToFormattedDetailsJSON,
513 videoFilesModelToFormattedJSON,
514 videoModelToActivityPubObject,
515
516 guessAdditionalAttributesFromQuery,
517
518 getCategoryLabel,
519 getLicenceLabel,
520 getLanguageLabel,
521 getPrivacyLabel,
522 getStateLabel
523}
524
525// ---------------------------------------------------------------------------
526
527function buildLiveAPAttributes (video: MVideoAP) {
528 if (!video.isLive) {
529 return {
530 isLiveBroadcast: false,
531 liveSaveReplay: null,
532 permanentLive: null,
533 latencyMode: null
534 }
535 }
536
537 return {
538 isLiveBroadcast: true,
539 liveSaveReplay: video.VideoLive.saveReplay,
540 permanentLive: video.VideoLive.permanentLive,
541 latencyMode: video.VideoLive.latencyMode
542 }
543}
diff --git a/server/models/video/sql/video/shared/abstract-video-query-builder.ts b/server/models/video/sql/video/shared/abstract-video-query-builder.ts
index cbd57ad8c..56a00aa0c 100644
--- a/server/models/video/sql/video/shared/abstract-video-query-builder.ts
+++ b/server/models/video/sql/video/shared/abstract-video-query-builder.ts
@@ -111,7 +111,7 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
111 } 111 }
112 } 112 }
113 113
114 protected includeWebtorrentFiles () { 114 protected includeWebVideoFiles () {
115 this.addJoin('LEFT JOIN "videoFile" AS "VideoFiles" ON "VideoFiles"."videoId" = "video"."id"') 115 this.addJoin('LEFT JOIN "videoFile" AS "VideoFiles" ON "VideoFiles"."videoId" = "video"."id"')
116 116
117 this.attributes = { 117 this.attributes = {
@@ -263,7 +263,7 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
263 } 263 }
264 } 264 }
265 265
266 protected includeWebTorrentRedundancies () { 266 protected includeWebVideoRedundancies () {
267 this.addJoin( 267 this.addJoin(
268 'LEFT OUTER JOIN "videoRedundancy" AS "VideoFiles->RedundancyVideos" ON ' + 268 'LEFT OUTER JOIN "videoRedundancy" AS "VideoFiles->RedundancyVideos" ON ' +
269 '"VideoFiles"."id" = "VideoFiles->RedundancyVideos"."videoFileId"' 269 '"VideoFiles"."id" = "VideoFiles->RedundancyVideos"."videoFileId"'
diff --git a/server/models/video/sql/video/shared/video-file-query-builder.ts b/server/models/video/sql/video/shared/video-file-query-builder.ts
index cc53a4860..196b72b43 100644
--- a/server/models/video/sql/video/shared/video-file-query-builder.ts
+++ b/server/models/video/sql/video/shared/video-file-query-builder.ts
@@ -14,7 +14,7 @@ export type FileQueryOptions = {
14 14
15/** 15/**
16 * 16 *
17 * Fetch files (webtorrent and streaming playlist) according to a video 17 * Fetch files (web videos and streaming playlist) according to a video
18 * 18 *
19 */ 19 */
20 20
@@ -25,8 +25,8 @@ export class VideoFileQueryBuilder extends AbstractVideoQueryBuilder {
25 super(sequelize, 'get') 25 super(sequelize, 'get')
26 } 26 }
27 27
28 queryWebTorrentVideos (options: FileQueryOptions) { 28 queryWebVideos (options: FileQueryOptions) {
29 this.buildWebtorrentFilesQuery(options) 29 this.buildWebVideoFilesQuery(options)
30 30
31 return this.runQuery(options) 31 return this.runQuery(options)
32 } 32 }
@@ -37,15 +37,15 @@ export class VideoFileQueryBuilder extends AbstractVideoQueryBuilder {
37 return this.runQuery(options) 37 return this.runQuery(options)
38 } 38 }
39 39
40 private buildWebtorrentFilesQuery (options: FileQueryOptions) { 40 private buildWebVideoFilesQuery (options: FileQueryOptions) {
41 this.attributes = { 41 this.attributes = {
42 '"video"."id"': '' 42 '"video"."id"': ''
43 } 43 }
44 44
45 this.includeWebtorrentFiles() 45 this.includeWebVideoFiles()
46 46
47 if (options.includeRedundancy) { 47 if (options.includeRedundancy) {
48 this.includeWebTorrentRedundancies() 48 this.includeWebVideoRedundancies()
49 } 49 }
50 50
51 this.whereId(options) 51 this.whereId(options)
diff --git a/server/models/video/sql/video/shared/video-model-builder.ts b/server/models/video/sql/video/shared/video-model-builder.ts
index 0a2beb7db..740aa842f 100644
--- a/server/models/video/sql/video/shared/video-model-builder.ts
+++ b/server/models/video/sql/video/shared/video-model-builder.ts
@@ -60,10 +60,10 @@ export class VideoModelBuilder {
60 buildVideosFromRows (options: { 60 buildVideosFromRows (options: {
61 rows: SQLRow[] 61 rows: SQLRow[]
62 include?: VideoInclude 62 include?: VideoInclude
63 rowsWebTorrentFiles?: SQLRow[] 63 rowsWebVideoFiles?: SQLRow[]
64 rowsStreamingPlaylist?: SQLRow[] 64 rowsStreamingPlaylist?: SQLRow[]
65 }) { 65 }) {
66 const { rows, rowsWebTorrentFiles, rowsStreamingPlaylist, include } = options 66 const { rows, rowsWebVideoFiles, rowsStreamingPlaylist, include } = options
67 67
68 this.reinit() 68 this.reinit()
69 69
@@ -85,8 +85,8 @@ export class VideoModelBuilder {
85 this.addActorAvatar(row, 'VideoChannel.Account.Actor', accountActor) 85 this.addActorAvatar(row, 'VideoChannel.Account.Actor', accountActor)
86 } 86 }
87 87
88 if (!rowsWebTorrentFiles) { 88 if (!rowsWebVideoFiles) {
89 this.addWebTorrentFile(row, videoModel) 89 this.addWebVideoFile(row, videoModel)
90 } 90 }
91 91
92 if (!rowsStreamingPlaylist) { 92 if (!rowsStreamingPlaylist) {
@@ -112,7 +112,7 @@ export class VideoModelBuilder {
112 } 112 }
113 } 113 }
114 114
115 this.grabSeparateWebTorrentFiles(rowsWebTorrentFiles) 115 this.grabSeparateWebVideoFiles(rowsWebVideoFiles)
116 this.grabSeparateStreamingPlaylistFiles(rowsStreamingPlaylist) 116 this.grabSeparateStreamingPlaylistFiles(rowsStreamingPlaylist)
117 117
118 return this.videos 118 return this.videos
@@ -140,15 +140,15 @@ export class VideoModelBuilder {
140 this.videos = [] 140 this.videos = []
141 } 141 }
142 142
143 private grabSeparateWebTorrentFiles (rowsWebTorrentFiles?: SQLRow[]) { 143 private grabSeparateWebVideoFiles (rowsWebVideoFiles?: SQLRow[]) {
144 if (!rowsWebTorrentFiles) return 144 if (!rowsWebVideoFiles) return
145 145
146 for (const row of rowsWebTorrentFiles) { 146 for (const row of rowsWebVideoFiles) {
147 const id = row['VideoFiles.id'] 147 const id = row['VideoFiles.id']
148 if (!id) continue 148 if (!id) continue
149 149
150 const videoModel = this.videosMemo[row.id] 150 const videoModel = this.videosMemo[row.id]
151 this.addWebTorrentFile(row, videoModel) 151 this.addWebVideoFile(row, videoModel)
152 this.addRedundancy(row, 'VideoFiles', this.videoFileMemo[id]) 152 this.addRedundancy(row, 'VideoFiles', this.videoFileMemo[id])
153 } 153 }
154 } 154 }
@@ -258,7 +258,7 @@ export class VideoModelBuilder {
258 this.thumbnailsDone.add(id) 258 this.thumbnailsDone.add(id)
259 } 259 }
260 260
261 private addWebTorrentFile (row: SQLRow, videoModel: VideoModel) { 261 private addWebVideoFile (row: SQLRow, videoModel: VideoModel) {
262 const id = row['VideoFiles.id'] 262 const id = row['VideoFiles.id']
263 if (!id || this.videoFileMemo[id]) return 263 if (!id || this.videoFileMemo[id]) return
264 264
diff --git a/server/models/video/sql/video/shared/video-table-attributes.ts b/server/models/video/sql/video/shared/video-table-attributes.ts
index 34967cd20..e0fa9d7c1 100644
--- a/server/models/video/sql/video/shared/video-table-attributes.ts
+++ b/server/models/video/sql/video/shared/video-table-attributes.ts
@@ -60,6 +60,7 @@ export class VideoTableAttributes {
60 'height', 60 'height',
61 'width', 61 'width',
62 'fileUrl', 62 'fileUrl',
63 'onDisk',
63 'automaticallyGenerated', 64 'automaticallyGenerated',
64 'videoId', 65 'videoId',
65 'videoPlaylistId', 66 'videoPlaylistId',
diff --git a/server/models/video/sql/video/video-model-get-query-builder.ts b/server/models/video/sql/video/video-model-get-query-builder.ts
index 8e90ff641..3f43d4d92 100644
--- a/server/models/video/sql/video/video-model-get-query-builder.ts
+++ b/server/models/video/sql/video/video-model-get-query-builder.ts
@@ -35,7 +35,7 @@ export type BuildVideoGetQueryOptions = {
35 35
36export class VideoModelGetQueryBuilder { 36export class VideoModelGetQueryBuilder {
37 videoQueryBuilder: VideosModelGetQuerySubBuilder 37 videoQueryBuilder: VideosModelGetQuerySubBuilder
38 webtorrentFilesQueryBuilder: VideoFileQueryBuilder 38 webVideoFilesQueryBuilder: VideoFileQueryBuilder
39 streamingPlaylistFilesQueryBuilder: VideoFileQueryBuilder 39 streamingPlaylistFilesQueryBuilder: VideoFileQueryBuilder
40 40
41 private readonly videoModelBuilder: VideoModelBuilder 41 private readonly videoModelBuilder: VideoModelBuilder
@@ -44,7 +44,7 @@ export class VideoModelGetQueryBuilder {
44 44
45 constructor (protected readonly sequelize: Sequelize) { 45 constructor (protected readonly sequelize: Sequelize) {
46 this.videoQueryBuilder = new VideosModelGetQuerySubBuilder(sequelize) 46 this.videoQueryBuilder = new VideosModelGetQuerySubBuilder(sequelize)
47 this.webtorrentFilesQueryBuilder = new VideoFileQueryBuilder(sequelize) 47 this.webVideoFilesQueryBuilder = new VideoFileQueryBuilder(sequelize)
48 this.streamingPlaylistFilesQueryBuilder = new VideoFileQueryBuilder(sequelize) 48 this.streamingPlaylistFilesQueryBuilder = new VideoFileQueryBuilder(sequelize)
49 49
50 this.videoModelBuilder = new VideoModelBuilder('get', new VideoTableAttributes('get')) 50 this.videoModelBuilder = new VideoModelBuilder('get', new VideoTableAttributes('get'))
@@ -57,11 +57,11 @@ export class VideoModelGetQueryBuilder {
57 includeRedundancy: this.shouldIncludeRedundancies(options) 57 includeRedundancy: this.shouldIncludeRedundancies(options)
58 } 58 }
59 59
60 const [ videoRows, webtorrentFilesRows, streamingPlaylistFilesRows ] = await Promise.all([ 60 const [ videoRows, webVideoFilesRows, streamingPlaylistFilesRows ] = await Promise.all([
61 this.videoQueryBuilder.queryVideos(options), 61 this.videoQueryBuilder.queryVideos(options),
62 62
63 VideoModelGetQueryBuilder.videoFilesInclude.has(options.type) 63 VideoModelGetQueryBuilder.videoFilesInclude.has(options.type)
64 ? this.webtorrentFilesQueryBuilder.queryWebTorrentVideos(fileQueryOptions) 64 ? this.webVideoFilesQueryBuilder.queryWebVideos(fileQueryOptions)
65 : Promise.resolve(undefined), 65 : Promise.resolve(undefined),
66 66
67 VideoModelGetQueryBuilder.videoFilesInclude.has(options.type) 67 VideoModelGetQueryBuilder.videoFilesInclude.has(options.type)
@@ -71,7 +71,7 @@ export class VideoModelGetQueryBuilder {
71 71
72 const videos = this.videoModelBuilder.buildVideosFromRows({ 72 const videos = this.videoModelBuilder.buildVideosFromRows({
73 rows: videoRows, 73 rows: videoRows,
74 rowsWebTorrentFiles: webtorrentFilesRows, 74 rowsWebVideoFiles: webVideoFilesRows,
75 rowsStreamingPlaylist: streamingPlaylistFilesRows 75 rowsStreamingPlaylist: streamingPlaylistFilesRows
76 }) 76 })
77 77
@@ -92,7 +92,7 @@ export class VideoModelGetQueryBuilder {
92export class VideosModelGetQuerySubBuilder extends AbstractVideoQueryBuilder { 92export class VideosModelGetQuerySubBuilder extends AbstractVideoQueryBuilder {
93 protected attributes: { [key: string]: string } 93 protected attributes: { [key: string]: string }
94 94
95 protected webtorrentFilesQuery: string 95 protected webVideoFilesQuery: string
96 protected streamingPlaylistFilesQuery: string 96 protected streamingPlaylistFilesQuery: string
97 97
98 private static readonly trackersInclude = new Set<GetType>([ 'api' ]) 98 private static readonly trackersInclude = new Set<GetType>([ 'api' ])
diff --git a/server/models/video/sql/video/videos-id-list-query-builder.ts b/server/models/video/sql/video/videos-id-list-query-builder.ts
index cba77c1d1..7f2376102 100644
--- a/server/models/video/sql/video/videos-id-list-query-builder.ts
+++ b/server/models/video/sql/video/videos-id-list-query-builder.ts
@@ -48,7 +48,9 @@ export type BuildVideosListQueryOptions = {
48 48
49 hasFiles?: boolean 49 hasFiles?: boolean
50 hasHLSFiles?: boolean 50 hasHLSFiles?: boolean
51 hasWebtorrentFiles?: boolean 51
52 hasWebVideoFiles?: boolean
53 hasWebtorrentFiles?: boolean // TODO: Remove in v7
52 54
53 accountId?: number 55 accountId?: number
54 videoChannelId?: number 56 videoChannelId?: number
@@ -175,7 +177,9 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
175 } 177 }
176 178
177 if (exists(options.hasWebtorrentFiles)) { 179 if (exists(options.hasWebtorrentFiles)) {
178 this.whereWebTorrentFileExists(options.hasWebtorrentFiles) 180 this.whereWebVideoFileExists(options.hasWebtorrentFiles)
181 } else if (exists(options.hasWebVideoFiles)) {
182 this.whereWebVideoFileExists(options.hasWebVideoFiles)
179 } 183 }
180 184
181 if (exists(options.hasHLSFiles)) { 185 if (exists(options.hasHLSFiles)) {
@@ -400,18 +404,18 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
400 } 404 }
401 405
402 private whereFileExists () { 406 private whereFileExists () {
403 this.and.push(`(${this.buildWebTorrentFileExistsQuery(true)} OR ${this.buildHLSFileExistsQuery(true)})`) 407 this.and.push(`(${this.buildWebVideoFileExistsQuery(true)} OR ${this.buildHLSFileExistsQuery(true)})`)
404 } 408 }
405 409
406 private whereWebTorrentFileExists (exists: boolean) { 410 private whereWebVideoFileExists (exists: boolean) {
407 this.and.push(this.buildWebTorrentFileExistsQuery(exists)) 411 this.and.push(this.buildWebVideoFileExistsQuery(exists))
408 } 412 }
409 413
410 private whereHLSFileExists (exists: boolean) { 414 private whereHLSFileExists (exists: boolean) {
411 this.and.push(this.buildHLSFileExistsQuery(exists)) 415 this.and.push(this.buildHLSFileExistsQuery(exists))
412 } 416 }
413 417
414 private buildWebTorrentFileExistsQuery (exists: boolean) { 418 private buildWebVideoFileExistsQuery (exists: boolean) {
415 const prefix = exists ? '' : 'NOT ' 419 const prefix = exists ? '' : 'NOT '
416 420
417 return prefix + 'EXISTS (SELECT 1 FROM "videoFile" WHERE "videoFile"."videoId" = "video"."id")' 421 return prefix + 'EXISTS (SELECT 1 FROM "videoFile" WHERE "videoFile"."videoId" = "video"."id")'
diff --git a/server/models/video/sql/video/videos-model-list-query-builder.ts b/server/models/video/sql/video/videos-model-list-query-builder.ts
index 3fdac4ed3..b73dc28cd 100644
--- a/server/models/video/sql/video/videos-model-list-query-builder.ts
+++ b/server/models/video/sql/video/videos-model-list-query-builder.ts
@@ -18,7 +18,7 @@ export class VideosModelListQueryBuilder extends AbstractVideoQueryBuilder {
18 private innerQuery: string 18 private innerQuery: string
19 private innerSort: string 19 private innerSort: string
20 20
21 webtorrentFilesQueryBuilder: VideoFileQueryBuilder 21 webVideoFilesQueryBuilder: VideoFileQueryBuilder
22 streamingPlaylistFilesQueryBuilder: VideoFileQueryBuilder 22 streamingPlaylistFilesQueryBuilder: VideoFileQueryBuilder
23 23
24 private readonly videoModelBuilder: VideoModelBuilder 24 private readonly videoModelBuilder: VideoModelBuilder
@@ -27,7 +27,7 @@ export class VideosModelListQueryBuilder extends AbstractVideoQueryBuilder {
27 super(sequelize, 'list') 27 super(sequelize, 'list')
28 28
29 this.videoModelBuilder = new VideoModelBuilder(this.mode, this.tables) 29 this.videoModelBuilder = new VideoModelBuilder(this.mode, this.tables)
30 this.webtorrentFilesQueryBuilder = new VideoFileQueryBuilder(sequelize) 30 this.webVideoFilesQueryBuilder = new VideoFileQueryBuilder(sequelize)
31 this.streamingPlaylistFilesQueryBuilder = new VideoFileQueryBuilder(sequelize) 31 this.streamingPlaylistFilesQueryBuilder = new VideoFileQueryBuilder(sequelize)
32 } 32 }
33 33
@@ -48,12 +48,12 @@ export class VideosModelListQueryBuilder extends AbstractVideoQueryBuilder {
48 includeRedundancy: false 48 includeRedundancy: false
49 } 49 }
50 50
51 const [ rowsWebTorrentFiles, rowsStreamingPlaylist ] = await Promise.all([ 51 const [ rowsWebVideoFiles, rowsStreamingPlaylist ] = await Promise.all([
52 this.webtorrentFilesQueryBuilder.queryWebTorrentVideos(fileQueryOptions), 52 this.webVideoFilesQueryBuilder.queryWebVideos(fileQueryOptions),
53 this.streamingPlaylistFilesQueryBuilder.queryStreamingPlaylistVideos(fileQueryOptions) 53 this.streamingPlaylistFilesQueryBuilder.queryStreamingPlaylistVideos(fileQueryOptions)
54 ]) 54 ])
55 55
56 return this.videoModelBuilder.buildVideosFromRows({ rows, include: options.include, rowsStreamingPlaylist, rowsWebTorrentFiles }) 56 return this.videoModelBuilder.buildVideosFromRows({ rows, include: options.include, rowsStreamingPlaylist, rowsWebVideoFiles })
57 } 57 }
58 } 58 }
59 59
diff --git a/server/models/video/storyboard.ts b/server/models/video/storyboard.ts
new file mode 100644
index 000000000..65a044c98
--- /dev/null
+++ b/server/models/video/storyboard.ts
@@ -0,0 +1,169 @@
1import { remove } from 'fs-extra'
2import { join } from 'path'
3import { AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
4import { CONFIG } from '@server/initializers/config'
5import { MStoryboard, MStoryboardVideo, MVideo } from '@server/types/models'
6import { Storyboard } from '@shared/models'
7import { AttributesOnly } from '@shared/typescript-utils'
8import { logger } from '../../helpers/logger'
9import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
10import { VideoModel } from './video'
11import { Transaction } from 'sequelize'
12
13@Table({
14 tableName: 'storyboard',
15 indexes: [
16 {
17 fields: [ 'videoId' ],
18 unique: true
19 },
20 {
21 fields: [ 'filename' ],
22 unique: true
23 }
24 ]
25})
26export class StoryboardModel extends Model<Partial<AttributesOnly<StoryboardModel>>> {
27
28 @AllowNull(false)
29 @Column
30 filename: string
31
32 @AllowNull(false)
33 @Column
34 totalHeight: number
35
36 @AllowNull(false)
37 @Column
38 totalWidth: number
39
40 @AllowNull(false)
41 @Column
42 spriteHeight: number
43
44 @AllowNull(false)
45 @Column
46 spriteWidth: number
47
48 @AllowNull(false)
49 @Column
50 spriteDuration: number
51
52 @AllowNull(true)
53 @Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max))
54 fileUrl: string
55
56 @ForeignKey(() => VideoModel)
57 @Column
58 videoId: number
59
60 @BelongsTo(() => VideoModel, {
61 foreignKey: {
62 allowNull: true
63 },
64 onDelete: 'CASCADE'
65 })
66 Video: VideoModel
67
68 @CreatedAt
69 createdAt: Date
70
71 @UpdatedAt
72 updatedAt: Date
73
74 @AfterDestroy
75 static removeInstanceFile (instance: StoryboardModel) {
76 logger.info('Removing storyboard file %s.', instance.filename)
77
78 // Don't block the transaction
79 instance.removeFile()
80 .catch(err => logger.error('Cannot remove storyboard file %s.', instance.filename, { err }))
81 }
82
83 static loadByVideo (videoId: number, transaction?: Transaction): Promise<MStoryboard> {
84 const query = {
85 where: {
86 videoId
87 },
88 transaction
89 }
90
91 return StoryboardModel.findOne(query)
92 }
93
94 static loadByFilename (filename: string): Promise<MStoryboard> {
95 const query = {
96 where: {
97 filename
98 }
99 }
100
101 return StoryboardModel.findOne(query)
102 }
103
104 static loadWithVideoByFilename (filename: string): Promise<MStoryboardVideo> {
105 const query = {
106 where: {
107 filename
108 },
109 include: [
110 {
111 model: VideoModel.unscoped(),
112 required: true
113 }
114 ]
115 }
116
117 return StoryboardModel.findOne(query)
118 }
119
120 // ---------------------------------------------------------------------------
121
122 static async listStoryboardsOf (video: MVideo): Promise<MStoryboardVideo[]> {
123 const query = {
124 where: {
125 videoId: video.id
126 }
127 }
128
129 const storyboards = await StoryboardModel.findAll<MStoryboard>(query)
130
131 return storyboards.map(s => Object.assign(s, { Video: video }))
132 }
133
134 // ---------------------------------------------------------------------------
135
136 getOriginFileUrl (video: MVideo) {
137 if (video.isOwned()) {
138 return WEBSERVER.URL + this.getLocalStaticPath()
139 }
140
141 return this.fileUrl
142 }
143
144 getLocalStaticPath () {
145 return LAZY_STATIC_PATHS.STORYBOARDS + this.filename
146 }
147
148 getPath () {
149 return join(CONFIG.STORAGE.STORYBOARDS_DIR, this.filename)
150 }
151
152 removeFile () {
153 return remove(this.getPath())
154 }
155
156 toFormattedJSON (this: MStoryboardVideo): Storyboard {
157 return {
158 storyboardPath: this.getLocalStaticPath(),
159
160 totalHeight: this.totalHeight,
161 totalWidth: this.totalWidth,
162
163 spriteWidth: this.spriteWidth,
164 spriteHeight: this.spriteHeight,
165
166 spriteDuration: this.spriteDuration
167 }
168 }
169}
diff --git a/server/models/video/thumbnail.ts b/server/models/video/thumbnail.ts
index a4ac581e5..1722acdb4 100644
--- a/server/models/video/thumbnail.ts
+++ b/server/models/video/thumbnail.ts
@@ -21,7 +21,7 @@ import { AttributesOnly } from '@shared/typescript-utils'
21import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' 21import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
22import { logger } from '../../helpers/logger' 22import { logger } from '../../helpers/logger'
23import { CONFIG } from '../../initializers/config' 23import { CONFIG } from '../../initializers/config'
24import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants' 24import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
25import { VideoModel } from './video' 25import { VideoModel } from './video'
26import { VideoPlaylistModel } from './video-playlist' 26import { VideoPlaylistModel } from './video-playlist'
27 27
@@ -69,6 +69,10 @@ export class ThumbnailModel extends Model<Partial<AttributesOnly<ThumbnailModel>
69 @Column 69 @Column
70 automaticallyGenerated: boolean 70 automaticallyGenerated: boolean
71 71
72 @AllowNull(false)
73 @Column
74 onDisk: boolean
75
72 @ForeignKey(() => VideoModel) 76 @ForeignKey(() => VideoModel)
73 @Column 77 @Column
74 videoId: number 78 videoId: number
@@ -106,7 +110,7 @@ export class ThumbnailModel extends Model<Partial<AttributesOnly<ThumbnailModel>
106 [ThumbnailType.MINIATURE]: { 110 [ThumbnailType.MINIATURE]: {
107 label: 'miniature', 111 label: 'miniature',
108 directory: CONFIG.STORAGE.THUMBNAILS_DIR, 112 directory: CONFIG.STORAGE.THUMBNAILS_DIR,
109 staticPath: STATIC_PATHS.THUMBNAILS 113 staticPath: LAZY_STATIC_PATHS.THUMBNAILS
110 }, 114 },
111 [ThumbnailType.PREVIEW]: { 115 [ThumbnailType.PREVIEW]: {
112 label: 'preview', 116 label: 'preview',
@@ -197,4 +201,8 @@ export class ThumbnailModel extends Model<Partial<AttributesOnly<ThumbnailModel>
197 201
198 this.previousThumbnailFilename = undefined 202 this.previousThumbnailFilename = undefined
199 } 203 }
204
205 isOwned () {
206 return !this.fileUrl
207 }
200} 208}
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts
index 1fb1cae82..dd4cefd65 100644
--- a/server/models/video/video-caption.ts
+++ b/server/models/video/video-caption.ts
@@ -15,7 +15,7 @@ import {
15 Table, 15 Table,
16 UpdatedAt 16 UpdatedAt
17} from 'sequelize-typescript' 17} from 'sequelize-typescript'
18import { MVideo, MVideoCaption, MVideoCaptionFormattable, MVideoCaptionVideo } from '@server/types/models' 18import { MVideo, MVideoCaption, MVideoCaptionFormattable, MVideoCaptionLanguageUrl, MVideoCaptionVideo } from '@server/types/models'
19import { buildUUID } from '@shared/extra-utils' 19import { buildUUID } from '@shared/extra-utils'
20import { AttributesOnly } from '@shared/typescript-utils' 20import { AttributesOnly } from '@shared/typescript-utils'
21import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model' 21import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model'
@@ -225,7 +225,7 @@ export class VideoCaptionModel extends Model<Partial<AttributesOnly<VideoCaption
225 } 225 }
226 } 226 }
227 227
228 getCaptionStaticPath (this: MVideoCaption) { 228 getCaptionStaticPath (this: MVideoCaptionLanguageUrl) {
229 return join(LAZY_STATIC_PATHS.VIDEO_CAPTIONS, this.filename) 229 return join(LAZY_STATIC_PATHS.VIDEO_CAPTIONS, this.filename)
230 } 230 }
231 231
@@ -233,9 +233,7 @@ export class VideoCaptionModel extends Model<Partial<AttributesOnly<VideoCaption
233 return remove(CONFIG.STORAGE.CAPTIONS_DIR + this.filename) 233 return remove(CONFIG.STORAGE.CAPTIONS_DIR + this.filename)
234 } 234 }
235 235
236 getFileUrl (video: MVideo) { 236 getFileUrl (this: MVideoCaptionLanguageUrl, video: MVideo) {
237 if (!this.Video) this.Video = video as VideoModel
238
239 if (video.isOwned()) return WEBSERVER.URL + this.getCaptionStaticPath() 237 if (video.isOwned()) return WEBSERVER.URL + this.getCaptionStaticPath()
240 238
241 return this.fileUrl 239 return this.fileUrl
diff --git a/server/models/video/video-change-ownership.ts b/server/models/video/video-change-ownership.ts
index 2db4b523a..26f072f4f 100644
--- a/server/models/video/video-change-ownership.ts
+++ b/server/models/video/video-change-ownership.ts
@@ -45,7 +45,7 @@ enum ScopeNames {
45 { 45 {
46 model: VideoModel.scope([ 46 model: VideoModel.scope([
47 VideoScopeNames.WITH_THUMBNAILS, 47 VideoScopeNames.WITH_THUMBNAILS,
48 VideoScopeNames.WITH_WEBTORRENT_FILES, 48 VideoScopeNames.WITH_WEB_VIDEO_FILES,
49 VideoScopeNames.WITH_STREAMING_PLAYLISTS, 49 VideoScopeNames.WITH_STREAMING_PLAYLISTS,
50 VideoScopeNames.WITH_ACCOUNT_DETAILS 50 VideoScopeNames.WITH_ACCOUNT_DETAILS
51 ]), 51 ]),
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
index 07bc13de1..ee34ad2ff 100644
--- a/server/models/video/video-file.ts
+++ b/server/models/video/video-file.ts
@@ -26,8 +26,8 @@ import { buildRemoteVideoBaseUrl } from '@server/lib/activitypub/url'
26import { 26import {
27 getHLSPrivateFileUrl, 27 getHLSPrivateFileUrl,
28 getHLSPublicFileUrl, 28 getHLSPublicFileUrl,
29 getWebTorrentPrivateFileUrl, 29 getWebVideoPrivateFileUrl,
30 getWebTorrentPublicFileUrl 30 getWebVideoPublicFileUrl
31} from '@server/lib/object-storage' 31} from '@server/lib/object-storage'
32import { getFSTorrentFilePath } from '@server/lib/paths' 32import { getFSTorrentFilePath } from '@server/lib/paths'
33import { isVideoInPrivateDirectory } from '@server/lib/video-privacy' 33import { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
@@ -276,15 +276,15 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
276 276
277 static async doesOwnedTorrentFileExist (filename: string) { 277 static async doesOwnedTorrentFileExist (filename: string) {
278 const query = 'SELECT 1 FROM "videoFile" ' + 278 const query = 'SELECT 1 FROM "videoFile" ' +
279 'LEFT JOIN "video" "webtorrent" ON "webtorrent"."id" = "videoFile"."videoId" AND "webtorrent"."remote" IS FALSE ' + 279 'LEFT JOIN "video" "webvideo" ON "webvideo"."id" = "videoFile"."videoId" AND "webvideo"."remote" IS FALSE ' +
280 'LEFT JOIN "videoStreamingPlaylist" ON "videoStreamingPlaylist"."id" = "videoFile"."videoStreamingPlaylistId" ' + 280 'LEFT JOIN "videoStreamingPlaylist" ON "videoStreamingPlaylist"."id" = "videoFile"."videoStreamingPlaylistId" ' +
281 'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' + 281 'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' +
282 'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webtorrent"."id" IS NOT NULL) LIMIT 1' 282 'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webvideo"."id" IS NOT NULL) LIMIT 1'
283 283
284 return doesExist(this.sequelize, query, { filename }) 284 return doesExist(this.sequelize, query, { filename })
285 } 285 }
286 286
287 static async doesOwnedWebTorrentVideoFileExist (filename: string) { 287 static async doesOwnedWebVideoFileExist (filename: string) {
288 const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' + 288 const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' +
289 `WHERE "filename" = $filename AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1` 289 `WHERE "filename" = $filename AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
290 290
@@ -378,7 +378,7 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
378 } 378 }
379 379
380 static getStats () { 380 static getStats () {
381 const webtorrentFilesQuery: FindOptions = { 381 const webVideoFilesQuery: FindOptions = {
382 include: [ 382 include: [
383 { 383 {
384 attributes: [], 384 attributes: [],
@@ -412,10 +412,10 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
412 } 412 }
413 413
414 return Promise.all([ 414 return Promise.all([
415 VideoFileModel.aggregate('size', 'SUM', webtorrentFilesQuery), 415 VideoFileModel.aggregate('size', 'SUM', webVideoFilesQuery),
416 VideoFileModel.aggregate('size', 'SUM', hlsFilesQuery) 416 VideoFileModel.aggregate('size', 'SUM', hlsFilesQuery)
417 ]).then(([ webtorrentResult, hlsResult ]) => ({ 417 ]).then(([ webVideoResult, hlsResult ]) => ({
418 totalLocalVideoFilesSize: parseAggregateResult(webtorrentResult) + parseAggregateResult(hlsResult) 418 totalLocalVideoFilesSize: parseAggregateResult(webVideoResult) + parseAggregateResult(hlsResult)
419 })) 419 }))
420 } 420 }
421 421
@@ -433,7 +433,7 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
433 433
434 const element = mode === 'streaming-playlist' 434 const element = mode === 'streaming-playlist'
435 ? await VideoFileModel.loadHLSFile({ ...baseFind, playlistId: videoFile.videoStreamingPlaylistId }) 435 ? await VideoFileModel.loadHLSFile({ ...baseFind, playlistId: videoFile.videoStreamingPlaylistId })
436 : await VideoFileModel.loadWebTorrentFile({ ...baseFind, videoId: videoFile.videoId }) 436 : await VideoFileModel.loadWebVideoFile({ ...baseFind, videoId: videoFile.videoId })
437 437
438 if (!element) return videoFile.save({ transaction }) 438 if (!element) return videoFile.save({ transaction })
439 439
@@ -444,7 +444,7 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
444 return element.save({ transaction }) 444 return element.save({ transaction })
445 } 445 }
446 446
447 static async loadWebTorrentFile (options: { 447 static async loadWebVideoFile (options: {
448 videoId: number 448 videoId: number
449 fps: number 449 fps: number
450 resolution: number 450 resolution: number
@@ -523,7 +523,7 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
523 return getHLSPrivateFileUrl(video, this.filename) 523 return getHLSPrivateFileUrl(video, this.filename)
524 } 524 }
525 525
526 return getWebTorrentPrivateFileUrl(this.filename) 526 return getWebVideoPrivateFileUrl(this.filename)
527 } 527 }
528 528
529 private getPublicObjectStorageUrl () { 529 private getPublicObjectStorageUrl () {
@@ -531,7 +531,7 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
531 return getHLSPublicFileUrl(this.fileUrl) 531 return getHLSPublicFileUrl(this.fileUrl)
532 } 532 }
533 533
534 return getWebTorrentPublicFileUrl(this.fileUrl) 534 return getWebVideoPublicFileUrl(this.fileUrl)
535 } 535 }
536 536
537 // --------------------------------------------------------------------------- 537 // ---------------------------------------------------------------------------
@@ -553,15 +553,15 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
553 getFileStaticPath (video: MVideo) { 553 getFileStaticPath (video: MVideo) {
554 if (this.isHLS()) return this.getHLSFileStaticPath(video) 554 if (this.isHLS()) return this.getHLSFileStaticPath(video)
555 555
556 return this.getWebTorrentFileStaticPath(video) 556 return this.getWebVideoFileStaticPath(video)
557 } 557 }
558 558
559 private getWebTorrentFileStaticPath (video: MVideo) { 559 private getWebVideoFileStaticPath (video: MVideo) {
560 if (isVideoInPrivateDirectory(video.privacy)) { 560 if (isVideoInPrivateDirectory(video.privacy)) {
561 return join(STATIC_PATHS.PRIVATE_WEBSEED, this.filename) 561 return join(STATIC_PATHS.PRIVATE_WEB_VIDEOS, this.filename)
562 } 562 }
563 563
564 return join(STATIC_PATHS.WEBSEED, this.filename) 564 return join(STATIC_PATHS.WEB_VIDEOS, this.filename)
565 } 565 }
566 566
567 private getHLSFileStaticPath (video: MVideo) { 567 private getHLSFileStaticPath (video: MVideo) {
diff --git a/server/models/video/video-password.ts b/server/models/video/video-password.ts
new file mode 100644
index 000000000..648366c3b
--- /dev/null
+++ b/server/models/video/video-password.ts
@@ -0,0 +1,137 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DefaultScope, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { VideoModel } from './video'
3import { AttributesOnly } from '@shared/typescript-utils'
4import { ResultList, VideoPassword } from '@shared/models'
5import { getSort, throwIfNotValid } from '../shared'
6import { FindOptions, Transaction } from 'sequelize'
7import { MVideoPassword } from '@server/types/models'
8import { isPasswordValid } from '@server/helpers/custom-validators/videos'
9import { pick } from '@shared/core-utils'
10
11@DefaultScope(() => ({
12 include: [
13 {
14 model: VideoModel.unscoped(),
15 required: true
16 }
17 ]
18}))
19@Table({
20 tableName: 'videoPassword',
21 indexes: [
22 {
23 fields: [ 'videoId', 'password' ],
24 unique: true
25 }
26 ]
27})
28export class VideoPasswordModel extends Model<Partial<AttributesOnly<VideoPasswordModel>>> {
29
30 @AllowNull(false)
31 @Is('VideoPassword', value => throwIfNotValid(value, isPasswordValid, 'videoPassword'))
32 @Column
33 password: string
34
35 @CreatedAt
36 createdAt: Date
37
38 @UpdatedAt
39 updatedAt: Date
40
41 @ForeignKey(() => VideoModel)
42 @Column
43 videoId: number
44
45 @BelongsTo(() => VideoModel, {
46 foreignKey: {
47 allowNull: false
48 },
49 onDelete: 'cascade'
50 })
51 Video: VideoModel
52
53 static async countByVideoId (videoId: number, t?: Transaction) {
54 const query: FindOptions = {
55 where: {
56 videoId
57 },
58 transaction: t
59 }
60
61 return VideoPasswordModel.count(query)
62 }
63
64 static async loadByIdAndVideo (options: { id: number, videoId: number, t?: Transaction }): Promise<MVideoPassword> {
65 const { id, videoId, t } = options
66 const query: FindOptions = {
67 where: {
68 id,
69 videoId
70 },
71 transaction: t
72 }
73
74 return VideoPasswordModel.findOne(query)
75 }
76
77 static async listPasswords (options: {
78 start: number
79 count: number
80 sort: string
81 videoId: number
82 }): Promise<ResultList<MVideoPassword>> {
83 const { start, count, sort, videoId } = options
84
85 const { count: total, rows: data } = await VideoPasswordModel.findAndCountAll({
86 where: { videoId },
87 order: getSort(sort),
88 offset: start,
89 limit: count
90 })
91
92 return { total, data }
93 }
94
95 static async addPasswords (passwords: string[], videoId: number, transaction?: Transaction): Promise<void> {
96 for (const password of passwords) {
97 await VideoPasswordModel.create({
98 password,
99 videoId
100 }, { transaction })
101 }
102 }
103
104 static async deleteAllPasswords (videoId: number, transaction?: Transaction) {
105 await VideoPasswordModel.destroy({
106 where: { videoId },
107 transaction
108 })
109 }
110
111 static async deletePassword (passwordId: number, transaction?: Transaction) {
112 await VideoPasswordModel.destroy({
113 where: { id: passwordId },
114 transaction
115 })
116 }
117
118 static async isACorrectPassword (options: {
119 videoId: number
120 password: string
121 }) {
122 const query = {
123 where: pick(options, [ 'videoId', 'password' ])
124 }
125 return VideoPasswordModel.findOne(query)
126 }
127
128 toFormattedJSON (): VideoPassword {
129 return {
130 id: this.id,
131 password: this.password,
132 videoId: this.videoId,
133 createdAt: this.createdAt,
134 updatedAt: this.updatedAt
135 }
136 }
137}
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
index b832f9768..61ae6b9fe 100644
--- a/server/models/video/video-playlist-element.ts
+++ b/server/models/video/video-playlist-element.ts
@@ -336,7 +336,10 @@ export class VideoPlaylistElementModel extends Model<Partial<AttributesOnly<Vide
336 // Internal video? 336 // Internal video?
337 if (video.privacy === VideoPrivacy.INTERNAL && accountId) return VideoPlaylistElementType.REGULAR 337 if (video.privacy === VideoPrivacy.INTERNAL && accountId) return VideoPlaylistElementType.REGULAR
338 338
339 if (video.privacy === VideoPrivacy.PRIVATE || video.privacy === VideoPrivacy.INTERNAL) return VideoPlaylistElementType.PRIVATE 339 // Private, internal and password protected videos cannot be read without appropriate access (ownership, internal)
340 if (new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]).has(video.privacy)) {
341 return VideoPlaylistElementType.PRIVATE
342 }
340 343
341 if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE 344 if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE
342 345
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
index faf4bea78..15999d409 100644
--- a/server/models/video/video-playlist.ts
+++ b/server/models/video/video-playlist.ts
@@ -32,7 +32,7 @@ import {
32import { 32import {
33 ACTIVITY_PUB, 33 ACTIVITY_PUB,
34 CONSTRAINTS_FIELDS, 34 CONSTRAINTS_FIELDS,
35 STATIC_PATHS, 35 LAZY_STATIC_PATHS,
36 THUMBNAILS_SIZE, 36 THUMBNAILS_SIZE,
37 VIDEO_PLAYLIST_PRIVACIES, 37 VIDEO_PLAYLIST_PRIVACIES,
38 VIDEO_PLAYLIST_TYPES, 38 VIDEO_PLAYLIST_TYPES,
@@ -592,13 +592,13 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
592 getThumbnailUrl () { 592 getThumbnailUrl () {
593 if (!this.hasThumbnail()) return null 593 if (!this.hasThumbnail()) return null
594 594
595 return WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.Thumbnail.filename 595 return WEBSERVER.URL + LAZY_STATIC_PATHS.THUMBNAILS + this.Thumbnail.filename
596 } 596 }
597 597
598 getThumbnailStaticPath () { 598 getThumbnailStaticPath () {
599 if (!this.hasThumbnail()) return null 599 if (!this.hasThumbnail()) return null
600 600
601 return join(STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename) 601 return join(LAZY_STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename)
602 } 602 }
603 603
604 getWatchStaticPath () { 604 getWatchStaticPath () {
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 8e3af62a4..4c6297243 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -29,7 +29,7 @@ import {
29import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video' 29import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
30import { InternalEventEmitter } from '@server/lib/internal-event-emitter' 30import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
31import { LiveManager } from '@server/lib/live/live-manager' 31import { LiveManager } from '@server/lib/live/live-manager'
32import { removeHLSFileObjectStorageByFilename, removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage' 32import { removeHLSFileObjectStorageByFilename, removeHLSObjectStorage, removeWebVideoObjectStorage } from '@server/lib/object-storage'
33import { tracer } from '@server/lib/opentelemetry/tracing' 33import { tracer } from '@server/lib/opentelemetry/tracing'
34import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths' 34import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths'
35import { Hooks } from '@server/lib/plugins/hooks' 35import { Hooks } from '@server/lib/plugins/hooks'
@@ -58,7 +58,7 @@ import {
58import { AttributesOnly } from '@shared/typescript-utils' 58import { AttributesOnly } from '@shared/typescript-utils'
59import { peertubeTruncate } from '../../helpers/core-utils' 59import { peertubeTruncate } from '../../helpers/core-utils'
60import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 60import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
61import { exists, isBooleanValid, isUUIDValid } from '../../helpers/custom-validators/misc' 61import { exists, isArray, isBooleanValid, isUUIDValid } from '../../helpers/custom-validators/misc'
62import { 62import {
63 isVideoDescriptionValid, 63 isVideoDescriptionValid,
64 isVideoDurationValid, 64 isVideoDurationValid,
@@ -75,6 +75,7 @@ import {
75 MChannel, 75 MChannel,
76 MChannelAccountDefault, 76 MChannelAccountDefault,
77 MChannelId, 77 MChannelId,
78 MStoryboard,
78 MStreamingPlaylist, 79 MStreamingPlaylist,
79 MStreamingPlaylistFilesVideo, 80 MStreamingPlaylistFilesVideo,
80 MUserAccountId, 81 MUserAccountId,
@@ -83,6 +84,8 @@ import {
83 MVideoAccountLight, 84 MVideoAccountLight,
84 MVideoAccountLightBlacklistAllFiles, 85 MVideoAccountLightBlacklistAllFiles,
85 MVideoAP, 86 MVideoAP,
87 MVideoAPLight,
88 MVideoCaptionLanguageUrl,
86 MVideoDetails, 89 MVideoDetails,
87 MVideoFileVideo, 90 MVideoFileVideo,
88 MVideoFormattable, 91 MVideoFormattable,
@@ -111,13 +114,13 @@ import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated,
111import { UserModel } from '../user/user' 114import { UserModel } from '../user/user'
112import { UserVideoHistoryModel } from '../user/user-video-history' 115import { UserVideoHistoryModel } from '../user/user-video-history'
113import { VideoViewModel } from '../view/video-view' 116import { VideoViewModel } from '../view/video-view'
117import { videoModelToActivityPubObject } from './formatter/video-activity-pub-format'
114import { 118import {
115 videoFilesModelToFormattedJSON, 119 videoFilesModelToFormattedJSON,
116 VideoFormattingJSONOptions, 120 VideoFormattingJSONOptions,
117 videoModelToActivityPubObject,
118 videoModelToFormattedDetailsJSON, 121 videoModelToFormattedDetailsJSON,
119 videoModelToFormattedJSON 122 videoModelToFormattedJSON
120} from './formatter/video-format-utils' 123} from './formatter/video-api-format'
121import { ScheduleVideoUpdateModel } from './schedule-video-update' 124import { ScheduleVideoUpdateModel } from './schedule-video-update'
122import { 125import {
123 BuildVideosListQueryOptions, 126 BuildVideosListQueryOptions,
@@ -126,6 +129,7 @@ import {
126 VideosIdListQueryBuilder, 129 VideosIdListQueryBuilder,
127 VideosModelListQueryBuilder 130 VideosModelListQueryBuilder
128} from './sql/video' 131} from './sql/video'
132import { StoryboardModel } from './storyboard'
129import { TagModel } from './tag' 133import { TagModel } from './tag'
130import { ThumbnailModel } from './thumbnail' 134import { ThumbnailModel } from './thumbnail'
131import { VideoBlacklistModel } from './video-blacklist' 135import { VideoBlacklistModel } from './video-blacklist'
@@ -136,6 +140,7 @@ import { VideoFileModel } from './video-file'
136import { VideoImportModel } from './video-import' 140import { VideoImportModel } from './video-import'
137import { VideoJobInfoModel } from './video-job-info' 141import { VideoJobInfoModel } from './video-job-info'
138import { VideoLiveModel } from './video-live' 142import { VideoLiveModel } from './video-live'
143import { VideoPasswordModel } from './video-password'
139import { VideoPlaylistElementModel } from './video-playlist-element' 144import { VideoPlaylistElementModel } from './video-playlist-element'
140import { VideoShareModel } from './video-share' 145import { VideoShareModel } from './video-share'
141import { VideoSourceModel } from './video-source' 146import { VideoSourceModel } from './video-source'
@@ -146,7 +151,7 @@ export enum ScopeNames {
146 FOR_API = 'FOR_API', 151 FOR_API = 'FOR_API',
147 WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS', 152 WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
148 WITH_TAGS = 'WITH_TAGS', 153 WITH_TAGS = 'WITH_TAGS',
149 WITH_WEBTORRENT_FILES = 'WITH_WEBTORRENT_FILES', 154 WITH_WEB_VIDEO_FILES = 'WITH_WEB_VIDEO_FILES',
150 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', 155 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
151 WITH_BLACKLISTED = 'WITH_BLACKLISTED', 156 WITH_BLACKLISTED = 'WITH_BLACKLISTED',
152 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS', 157 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
@@ -285,7 +290,7 @@ export type ForAPIOptions = {
285 } 290 }
286 ] 291 ]
287 }, 292 },
288 [ScopeNames.WITH_WEBTORRENT_FILES]: (withRedundancies = false) => { 293 [ScopeNames.WITH_WEB_VIDEO_FILES]: (withRedundancies = false) => {
289 let subInclude: any[] = [] 294 let subInclude: any[] = []
290 295
291 if (withRedundancies === true) { 296 if (withRedundancies === true) {
@@ -734,6 +739,15 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
734 }) 739 })
735 VideoCaptions: VideoCaptionModel[] 740 VideoCaptions: VideoCaptionModel[]
736 741
742 @HasMany(() => VideoPasswordModel, {
743 foreignKey: {
744 name: 'videoId',
745 allowNull: false
746 },
747 onDelete: 'cascade'
748 })
749 VideoPasswords: VideoPasswordModel[]
750
737 @HasOne(() => VideoJobInfoModel, { 751 @HasOne(() => VideoJobInfoModel, {
738 foreignKey: { 752 foreignKey: {
739 name: 'videoId', 753 name: 'videoId',
@@ -743,6 +757,16 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
743 }) 757 })
744 VideoJobInfo: VideoJobInfoModel 758 VideoJobInfo: VideoJobInfoModel
745 759
760 @HasOne(() => StoryboardModel, {
761 foreignKey: {
762 name: 'videoId',
763 allowNull: false
764 },
765 onDelete: 'cascade',
766 hooks: true
767 })
768 Storyboard: StoryboardModel
769
746 @AfterCreate 770 @AfterCreate
747 static notifyCreate (video: MVideo) { 771 static notifyCreate (video: MVideo) {
748 InternalEventEmitter.Instance.emit('video-created', { video }) 772 InternalEventEmitter.Instance.emit('video-created', { video })
@@ -789,7 +813,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
789 813
790 // Remove physical files and torrents 814 // Remove physical files and torrents
791 instance.VideoFiles.forEach(file => { 815 instance.VideoFiles.forEach(file => {
792 tasks.push(instance.removeWebTorrentFile(file)) 816 tasks.push(instance.removeWebVideoFile(file))
793 }) 817 })
794 818
795 // Remove playlists file 819 // Remove playlists file
@@ -894,6 +918,10 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
894 required: false 918 required: false
895 }, 919 },
896 { 920 {
921 model: StoryboardModel.unscoped(),
922 required: false
923 },
924 {
897 attributes: [ 'id', 'url' ], 925 attributes: [ 'id', 'url' ],
898 model: VideoShareModel.unscoped(), 926 model: VideoShareModel.unscoped(),
899 required: false, 927 required: false,
@@ -1079,7 +1107,10 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1079 include?: VideoInclude 1107 include?: VideoInclude
1080 1108
1081 hasFiles?: boolean // default false 1109 hasFiles?: boolean // default false
1082 hasWebtorrentFiles?: boolean 1110
1111 hasWebtorrentFiles?: boolean // TODO: remove in v7
1112 hasWebVideoFiles?: boolean
1113
1083 hasHLSFiles?: boolean 1114 hasHLSFiles?: boolean
1084 1115
1085 categoryOneOf?: number[] 1116 categoryOneOf?: number[]
@@ -1144,6 +1175,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1144 'historyOfUser', 1175 'historyOfUser',
1145 'hasHLSFiles', 1176 'hasHLSFiles',
1146 'hasWebtorrentFiles', 1177 'hasWebtorrentFiles',
1178 'hasWebVideoFiles',
1147 'search', 1179 'search',
1148 'excludeAlreadyWatched' 1180 'excludeAlreadyWatched'
1149 ]), 1181 ]),
@@ -1177,7 +1209,9 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1177 1209
1178 user?: MUserAccountId 1210 user?: MUserAccountId
1179 1211
1180 hasWebtorrentFiles?: boolean 1212 hasWebtorrentFiles?: boolean // TODO: remove in v7
1213 hasWebVideoFiles?: boolean
1214
1181 hasHLSFiles?: boolean 1215 hasHLSFiles?: boolean
1182 1216
1183 search?: string 1217 search?: string
@@ -1224,6 +1258,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1224 'durationMax', 1258 'durationMax',
1225 'hasHLSFiles', 1259 'hasHLSFiles',
1226 'hasWebtorrentFiles', 1260 'hasWebtorrentFiles',
1261 'hasWebVideoFiles',
1227 'uuids', 1262 'uuids',
1228 'search', 1263 'search',
1229 'displayOnlyForFollower', 1264 'displayOnlyForFollower',
@@ -1648,7 +1683,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1648 return this.getQualityFileBy(minBy) 1683 return this.getQualityFileBy(minBy)
1649 } 1684 }
1650 1685
1651 getWebTorrentFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo { 1686 getWebVideoFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
1652 if (Array.isArray(this.VideoFiles) === false) return undefined 1687 if (Array.isArray(this.VideoFiles) === false) return undefined
1653 1688
1654 const file = this.VideoFiles.find(f => f.resolution === resolution) 1689 const file = this.VideoFiles.find(f => f.resolution === resolution)
@@ -1657,7 +1692,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1657 return Object.assign(file, { Video: this }) 1692 return Object.assign(file, { Video: this })
1658 } 1693 }
1659 1694
1660 hasWebTorrentFiles () { 1695 hasWebVideoFiles () {
1661 return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0 1696 return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0
1662 } 1697 }
1663 1698
@@ -1758,6 +1793,32 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1758 ) 1793 )
1759 } 1794 }
1760 1795
1796 async lightAPToFullAP (this: MVideoAPLight, transaction: Transaction): Promise<MVideoAP> {
1797 const videoAP = this as MVideoAP
1798
1799 const getCaptions = () => {
1800 if (isArray(videoAP.VideoCaptions)) return videoAP.VideoCaptions
1801
1802 return this.$get('VideoCaptions', {
1803 attributes: [ 'filename', 'language', 'fileUrl' ],
1804 transaction
1805 }) as Promise<MVideoCaptionLanguageUrl[]>
1806 }
1807
1808 const getStoryboard = () => {
1809 if (videoAP.Storyboard) return videoAP.Storyboard
1810
1811 return this.$get('Storyboard', { transaction }) as Promise<MStoryboard>
1812 }
1813
1814 const [ captions, storyboard ] = await Promise.all([ getCaptions(), getStoryboard() ])
1815
1816 return Object.assign(this, {
1817 VideoCaptions: captions,
1818 Storyboard: storyboard
1819 })
1820 }
1821
1761 getTruncatedDescription () { 1822 getTruncatedDescription () {
1762 if (!this.description) return null 1823 if (!this.description) return null
1763 1824
@@ -1830,7 +1891,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1830 .concat(toAdd) 1891 .concat(toAdd)
1831 } 1892 }
1832 1893
1833 removeWebTorrentFile (videoFile: MVideoFile, isRedundancy = false) { 1894 removeWebVideoFile (videoFile: MVideoFile, isRedundancy = false) {
1834 const filePath = isRedundancy 1895 const filePath = isRedundancy
1835 ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile) 1896 ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile)
1836 : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile) 1897 : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile)
@@ -1839,7 +1900,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1839 if (!isRedundancy) promises.push(videoFile.removeTorrent()) 1900 if (!isRedundancy) promises.push(videoFile.removeTorrent())
1840 1901
1841 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) { 1902 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
1842 promises.push(removeWebTorrentObjectStorage(videoFile)) 1903 promises.push(removeWebVideoObjectStorage(videoFile))
1843 } 1904 }
1844 1905
1845 return Promise.all(promises) 1906 return Promise.all(promises)
@@ -1918,7 +1979,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1918 1979
1919 // --------------------------------------------------------------------------- 1980 // ---------------------------------------------------------------------------
1920 1981
1921 requiresAuth (options: { 1982 requiresUserAuth (options: {
1922 urlParamId: string 1983 urlParamId: string
1923 checkBlacklist: boolean 1984 checkBlacklist: boolean
1924 }) { 1985 }) {
@@ -1936,11 +1997,11 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1936 1997
1937 if (checkBlacklist && this.VideoBlacklist) return true 1998 if (checkBlacklist && this.VideoBlacklist) return true
1938 1999
1939 if (this.privacy !== VideoPrivacy.PUBLIC) { 2000 if (this.privacy === VideoPrivacy.PUBLIC || this.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
1940 throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`) 2001 return false
1941 } 2002 }
1942 2003
1943 return false 2004 throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`)
1944 } 2005 }
1945 2006
1946 hasPrivateStaticPath () { 2007 hasPrivateStaticPath () {
@@ -1962,7 +2023,7 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1962 } 2023 }
1963 2024
1964 getBandwidthBits (this: MVideo, videoFile: MVideoFile) { 2025 getBandwidthBits (this: MVideo, videoFile: MVideoFile) {
1965 if (!this.duration) throw new Error(`Cannot get bandwidth bits because video ${this.url} has duration of 0`) 2026 if (!this.duration) return videoFile.size
1966 2027
1967 return Math.ceil((videoFile.size * 8) / this.duration) 2028 return Math.ceil((videoFile.size * 8) / this.duration)
1968 } 2029 }
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts
index 472cad182..80b616ccf 100644
--- a/server/tests/api/check-params/config.ts
+++ b/server/tests/api/check-params/config.ts
@@ -74,6 +74,9 @@ describe('Test config API validators', function () {
74 }, 74 },
75 torrents: { 75 torrents: {
76 size: 4 76 size: 4
77 },
78 storyboards: {
79 size: 5
77 } 80 }
78 }, 81 },
79 signup: { 82 signup: {
@@ -123,7 +126,7 @@ describe('Test config API validators', function () {
123 '2160p': false 126 '2160p': false
124 }, 127 },
125 alwaysTranscodeOriginalResolution: false, 128 alwaysTranscodeOriginalResolution: false,
126 webtorrent: { 129 webVideos: {
127 enabled: true 130 enabled: true
128 }, 131 },
129 hls: { 132 hls: {
@@ -342,7 +345,7 @@ describe('Test config API validators', function () {
342 }) 345 })
343 }) 346 })
344 347
345 it('Should fail with a disabled webtorrent & hls transcoding', async function () { 348 it('Should fail with a disabled web videos & hls transcoding', async function () {
346 const newUpdateParams = { 349 const newUpdateParams = {
347 ...updateParams, 350 ...updateParams,
348 351
@@ -350,7 +353,7 @@ describe('Test config API validators', function () {
350 hls: { 353 hls: {
351 enabled: false 354 enabled: false
352 }, 355 },
353 webtorrent: { 356 web_videos: {
354 enabled: false 357 enabled: false
355 } 358 }
356 } 359 }
diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts
index 400d312d3..c2a7ccd78 100644
--- a/server/tests/api/check-params/index.ts
+++ b/server/tests/api/check-params/index.ts
@@ -34,6 +34,7 @@ import './video-comments'
34import './video-files' 34import './video-files'
35import './video-imports' 35import './video-imports'
36import './video-playlists' 36import './video-playlists'
37import './video-storyboards'
37import './video-source' 38import './video-source'
38import './video-studio' 39import './video-studio'
39import './video-token' 40import './video-token'
diff --git a/server/tests/api/check-params/live.ts b/server/tests/api/check-params/live.ts
index 2dc735c23..5021db516 100644
--- a/server/tests/api/check-params/live.ts
+++ b/server/tests/api/check-params/live.ts
@@ -143,7 +143,7 @@ describe('Test video lives API validator', function () {
143 }) 143 })
144 144
145 it('Should fail with a bad privacy for replay settings', async function () { 145 it('Should fail with a bad privacy for replay settings', async function () {
146 const fields = { ...baseCorrectParams, replaySettings: { privacy: 5 } } 146 const fields = { ...baseCorrectParams, saveReplay: true, replaySettings: { privacy: 999 } }
147 147
148 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 148 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
149 }) 149 })
@@ -194,7 +194,7 @@ describe('Test video lives API validator', function () {
194 it('Should fail with a big thumbnail file', async function () { 194 it('Should fail with a big thumbnail file', async function () {
195 const fields = baseCorrectParams 195 const fields = baseCorrectParams
196 const attaches = { 196 const attaches = {
197 thumbnailfile: buildAbsoluteFixturePath('preview-big.png') 197 thumbnailfile: buildAbsoluteFixturePath('custom-preview-big.png')
198 } 198 }
199 199
200 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) 200 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches })
@@ -212,7 +212,7 @@ describe('Test video lives API validator', function () {
212 it('Should fail with a big preview file', async function () { 212 it('Should fail with a big preview file', async function () {
213 const fields = baseCorrectParams 213 const fields = baseCorrectParams
214 const attaches = { 214 const attaches = {
215 previewfile: buildAbsoluteFixturePath('preview-big.png') 215 previewfile: buildAbsoluteFixturePath('custom-preview-big.png')
216 } 216 }
217 217
218 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) 218 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches })
@@ -472,7 +472,7 @@ describe('Test video lives API validator', function () {
472 }) 472 })
473 473
474 it('Should fail with a bad privacy for replay settings', async function () { 474 it('Should fail with a bad privacy for replay settings', async function () {
475 const fields = { saveReplay: true, replaySettings: { privacy: 5 } } 475 const fields = { saveReplay: true, replaySettings: { privacy: 999 } }
476 476
477 await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 477 await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
478 }) 478 })
diff --git a/server/tests/api/check-params/runners.ts b/server/tests/api/check-params/runners.ts
index 48821b678..4ba90802f 100644
--- a/server/tests/api/check-params/runners.ts
+++ b/server/tests/api/check-params/runners.ts
@@ -752,7 +752,7 @@ describe('Test managing runners', function () {
752 }) 752 })
753 753
754 it('Should fail with an invalid vod audio merge payload', async function () { 754 it('Should fail with an invalid vod audio merge payload', async function () {
755 const attributes = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' } 755 const attributes = { name: 'audio_with_preview', previewfile: 'custom-preview.jpg', fixture: 'sample.ogg' }
756 await server.videos.upload({ attributes, mode: 'legacy' }) 756 await server.videos.upload({ attributes, mode: 'legacy' })
757 757
758 await waitJobs([ server ]) 758 await waitJobs([ server ])
diff --git a/server/tests/api/check-params/transcoding.ts b/server/tests/api/check-params/transcoding.ts
index 9846ac182..4bebcb528 100644
--- a/server/tests/api/check-params/transcoding.ts
+++ b/server/tests/api/check-params/transcoding.ts
@@ -49,21 +49,21 @@ describe('Test transcoding API validators', function () {
49 49
50 it('Should not run transcoding of a unknown video', async function () { 50 it('Should not run transcoding of a unknown video', async function () {
51 await servers[0].videos.runTranscoding({ videoId: 404, transcodingType: 'hls', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) 51 await servers[0].videos.runTranscoding({ videoId: 404, transcodingType: 'hls', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
52 await servers[0].videos.runTranscoding({ videoId: 404, transcodingType: 'webtorrent', expectedStatus: HttpStatusCode.NOT_FOUND_404 }) 52 await servers[0].videos.runTranscoding({ videoId: 404, transcodingType: 'web-video', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
53 }) 53 })
54 54
55 it('Should not run transcoding of a remote video', async function () { 55 it('Should not run transcoding of a remote video', async function () {
56 const expectedStatus = HttpStatusCode.BAD_REQUEST_400 56 const expectedStatus = HttpStatusCode.BAD_REQUEST_400
57 57
58 await servers[0].videos.runTranscoding({ videoId: remoteId, transcodingType: 'hls', expectedStatus }) 58 await servers[0].videos.runTranscoding({ videoId: remoteId, transcodingType: 'hls', expectedStatus })
59 await servers[0].videos.runTranscoding({ videoId: remoteId, transcodingType: 'webtorrent', expectedStatus }) 59 await servers[0].videos.runTranscoding({ videoId: remoteId, transcodingType: 'web-video', expectedStatus })
60 }) 60 })
61 61
62 it('Should not run transcoding by a non admin user', async function () { 62 it('Should not run transcoding by a non admin user', async function () {
63 const expectedStatus = HttpStatusCode.FORBIDDEN_403 63 const expectedStatus = HttpStatusCode.FORBIDDEN_403
64 64
65 await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'hls', token: userToken, expectedStatus }) 65 await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'hls', token: userToken, expectedStatus })
66 await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'webtorrent', token: moderatorToken, expectedStatus }) 66 await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'web-video', token: moderatorToken, expectedStatus })
67 }) 67 })
68 68
69 it('Should not run transcoding without transcoding type', async function () { 69 it('Should not run transcoding without transcoding type', async function () {
@@ -82,7 +82,7 @@ describe('Test transcoding API validators', function () {
82 await servers[0].config.disableTranscoding() 82 await servers[0].config.disableTranscoding()
83 83
84 await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'hls', expectedStatus }) 84 await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'hls', expectedStatus })
85 await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'webtorrent', expectedStatus }) 85 await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'web-video', expectedStatus })
86 }) 86 })
87 87
88 it('Should run transcoding', async function () { 88 it('Should run transcoding', async function () {
@@ -93,15 +93,15 @@ describe('Test transcoding API validators', function () {
93 await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'hls' }) 93 await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'hls' })
94 await waitJobs(servers) 94 await waitJobs(servers)
95 95
96 await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'webtorrent' }) 96 await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'web-video' })
97 await waitJobs(servers) 97 await waitJobs(servers)
98 }) 98 })
99 99
100 it('Should not run transcoding on a video that is already being transcoded', async function () { 100 it('Should not run transcoding on a video that is already being transcoded', async function () {
101 await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'webtorrent' }) 101 await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'web-video' })
102 102
103 const expectedStatus = HttpStatusCode.CONFLICT_409 103 const expectedStatus = HttpStatusCode.CONFLICT_409
104 await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'webtorrent', expectedStatus }) 104 await servers[0].videos.runTranscoding({ videoId: validId, transcodingType: 'web-video', expectedStatus })
105 }) 105 })
106 106
107 after(async function () { 107 after(async function () {
diff --git a/server/tests/api/check-params/video-files.ts b/server/tests/api/check-params/video-files.ts
index 9dc59a1b5..4d43ab6f8 100644
--- a/server/tests/api/check-params/video-files.ts
+++ b/server/tests/api/check-params/video-files.ts
@@ -60,7 +60,7 @@ describe('Test videos files', function () {
60 }) 60 })
61 61
62 describe('Deleting files', function () { 62 describe('Deleting files', function () {
63 let webtorrentId: string 63 let webVideoId: string
64 let hlsId: string 64 let hlsId: string
65 let remoteId: string 65 let remoteId: string
66 66
@@ -68,10 +68,10 @@ describe('Test videos files', function () {
68 let validId2: string 68 let validId2: string
69 69
70 let hlsFileId: number 70 let hlsFileId: number
71 let webtorrentFileId: number 71 let webVideoFileId: number
72 72
73 let remoteHLSFileId: number 73 let remoteHLSFileId: number
74 let remoteWebtorrentFileId: number 74 let remoteWebVideoFileId: number
75 75
76 before(async function () { 76 before(async function () {
77 this.timeout(300_000) 77 this.timeout(300_000)
@@ -83,7 +83,7 @@ describe('Test videos files', function () {
83 const video = await servers[1].videos.get({ id: uuid }) 83 const video = await servers[1].videos.get({ id: uuid })
84 remoteId = video.uuid 84 remoteId = video.uuid
85 remoteHLSFileId = video.streamingPlaylists[0].files[0].id 85 remoteHLSFileId = video.streamingPlaylists[0].files[0].id
86 remoteWebtorrentFileId = video.files[0].id 86 remoteWebVideoFileId = video.files[0].id
87 } 87 }
88 88
89 { 89 {
@@ -96,7 +96,7 @@ describe('Test videos files', function () {
96 const video = await servers[0].videos.get({ id: uuid }) 96 const video = await servers[0].videos.get({ id: uuid })
97 validId1 = video.uuid 97 validId1 = video.uuid
98 hlsFileId = video.streamingPlaylists[0].files[0].id 98 hlsFileId = video.streamingPlaylists[0].files[0].id
99 webtorrentFileId = video.files[0].id 99 webVideoFileId = video.files[0].id
100 } 100 }
101 101
102 { 102 {
@@ -117,8 +117,8 @@ describe('Test videos files', function () {
117 117
118 { 118 {
119 await servers[0].config.enableTranscoding(false, true) 119 await servers[0].config.enableTranscoding(false, true)
120 const { uuid } = await servers[0].videos.quickUpload({ name: 'webtorrent' }) 120 const { uuid } = await servers[0].videos.quickUpload({ name: 'web-video' })
121 webtorrentId = uuid 121 webVideoId = uuid
122 } 122 }
123 123
124 await waitJobs(servers) 124 await waitJobs(servers)
@@ -128,27 +128,27 @@ describe('Test videos files', function () {
128 const expectedStatus = HttpStatusCode.NOT_FOUND_404 128 const expectedStatus = HttpStatusCode.NOT_FOUND_404
129 129
130 await servers[0].videos.removeHLSPlaylist({ videoId: 404, expectedStatus }) 130 await servers[0].videos.removeHLSPlaylist({ videoId: 404, expectedStatus })
131 await servers[0].videos.removeAllWebTorrentFiles({ videoId: 404, expectedStatus }) 131 await servers[0].videos.removeAllWebVideoFiles({ videoId: 404, expectedStatus })
132 132
133 await servers[0].videos.removeHLSFile({ videoId: 404, fileId: hlsFileId, expectedStatus }) 133 await servers[0].videos.removeHLSFile({ videoId: 404, fileId: hlsFileId, expectedStatus })
134 await servers[0].videos.removeWebTorrentFile({ videoId: 404, fileId: webtorrentFileId, expectedStatus }) 134 await servers[0].videos.removeWebVideoFile({ videoId: 404, fileId: webVideoFileId, expectedStatus })
135 }) 135 })
136 136
137 it('Should not delete unknown files', async function () { 137 it('Should not delete unknown files', async function () {
138 const expectedStatus = HttpStatusCode.NOT_FOUND_404 138 const expectedStatus = HttpStatusCode.NOT_FOUND_404
139 139
140 await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: webtorrentFileId, expectedStatus }) 140 await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: webVideoFileId, expectedStatus })
141 await servers[0].videos.removeWebTorrentFile({ videoId: validId1, fileId: hlsFileId, expectedStatus }) 141 await servers[0].videos.removeWebVideoFile({ videoId: validId1, fileId: hlsFileId, expectedStatus })
142 }) 142 })
143 143
144 it('Should not delete files of a remote video', async function () { 144 it('Should not delete files of a remote video', async function () {
145 const expectedStatus = HttpStatusCode.BAD_REQUEST_400 145 const expectedStatus = HttpStatusCode.BAD_REQUEST_400
146 146
147 await servers[0].videos.removeHLSPlaylist({ videoId: remoteId, expectedStatus }) 147 await servers[0].videos.removeHLSPlaylist({ videoId: remoteId, expectedStatus })
148 await servers[0].videos.removeAllWebTorrentFiles({ videoId: remoteId, expectedStatus }) 148 await servers[0].videos.removeAllWebVideoFiles({ videoId: remoteId, expectedStatus })
149 149
150 await servers[0].videos.removeHLSFile({ videoId: remoteId, fileId: remoteHLSFileId, expectedStatus }) 150 await servers[0].videos.removeHLSFile({ videoId: remoteId, fileId: remoteHLSFileId, expectedStatus })
151 await servers[0].videos.removeWebTorrentFile({ videoId: remoteId, fileId: remoteWebtorrentFileId, expectedStatus }) 151 await servers[0].videos.removeWebVideoFile({ videoId: remoteId, fileId: remoteWebVideoFileId, expectedStatus })
152 }) 152 })
153 153
154 it('Should not delete files by a non admin user', async function () { 154 it('Should not delete files by a non admin user', async function () {
@@ -157,35 +157,35 @@ describe('Test videos files', function () {
157 await servers[0].videos.removeHLSPlaylist({ videoId: validId1, token: userToken, expectedStatus }) 157 await servers[0].videos.removeHLSPlaylist({ videoId: validId1, token: userToken, expectedStatus })
158 await servers[0].videos.removeHLSPlaylist({ videoId: validId1, token: moderatorToken, expectedStatus }) 158 await servers[0].videos.removeHLSPlaylist({ videoId: validId1, token: moderatorToken, expectedStatus })
159 159
160 await servers[0].videos.removeAllWebTorrentFiles({ videoId: validId1, token: userToken, expectedStatus }) 160 await servers[0].videos.removeAllWebVideoFiles({ videoId: validId1, token: userToken, expectedStatus })
161 await servers[0].videos.removeAllWebTorrentFiles({ videoId: validId1, token: moderatorToken, expectedStatus }) 161 await servers[0].videos.removeAllWebVideoFiles({ videoId: validId1, token: moderatorToken, expectedStatus })
162 162
163 await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: hlsFileId, token: userToken, expectedStatus }) 163 await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: hlsFileId, token: userToken, expectedStatus })
164 await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: hlsFileId, token: moderatorToken, expectedStatus }) 164 await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: hlsFileId, token: moderatorToken, expectedStatus })
165 165
166 await servers[0].videos.removeWebTorrentFile({ videoId: validId1, fileId: webtorrentFileId, token: userToken, expectedStatus }) 166 await servers[0].videos.removeWebVideoFile({ videoId: validId1, fileId: webVideoFileId, token: userToken, expectedStatus })
167 await servers[0].videos.removeWebTorrentFile({ videoId: validId1, fileId: webtorrentFileId, token: moderatorToken, expectedStatus }) 167 await servers[0].videos.removeWebVideoFile({ videoId: validId1, fileId: webVideoFileId, token: moderatorToken, expectedStatus })
168 }) 168 })
169 169
170 it('Should not delete files if the files are not available', async function () { 170 it('Should not delete files if the files are not available', async function () {
171 await servers[0].videos.removeHLSPlaylist({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 171 await servers[0].videos.removeHLSPlaylist({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
172 await servers[0].videos.removeAllWebTorrentFiles({ videoId: webtorrentId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 172 await servers[0].videos.removeAllWebVideoFiles({ videoId: webVideoId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
173 173
174 await servers[0].videos.removeHLSFile({ videoId: hlsId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) 174 await servers[0].videos.removeHLSFile({ videoId: hlsId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
175 await servers[0].videos.removeWebTorrentFile({ videoId: webtorrentId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) 175 await servers[0].videos.removeWebVideoFile({ videoId: webVideoId, fileId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
176 }) 176 })
177 177
178 it('Should not delete files if no both versions are available', async function () { 178 it('Should not delete files if no both versions are available', async function () {
179 await servers[0].videos.removeHLSPlaylist({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 179 await servers[0].videos.removeHLSPlaylist({ videoId: hlsId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
180 await servers[0].videos.removeAllWebTorrentFiles({ videoId: webtorrentId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 180 await servers[0].videos.removeAllWebVideoFiles({ videoId: webVideoId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
181 }) 181 })
182 182
183 it('Should delete files if both versions are available', async function () { 183 it('Should delete files if both versions are available', async function () {
184 await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: hlsFileId }) 184 await servers[0].videos.removeHLSFile({ videoId: validId1, fileId: hlsFileId })
185 await servers[0].videos.removeWebTorrentFile({ videoId: validId1, fileId: webtorrentFileId }) 185 await servers[0].videos.removeWebVideoFile({ videoId: validId1, fileId: webVideoFileId })
186 186
187 await servers[0].videos.removeHLSPlaylist({ videoId: validId1 }) 187 await servers[0].videos.removeHLSPlaylist({ videoId: validId1 })
188 await servers[0].videos.removeAllWebTorrentFiles({ videoId: validId2 }) 188 await servers[0].videos.removeAllWebVideoFiles({ videoId: validId2 })
189 }) 189 })
190 }) 190 })
191 191
diff --git a/server/tests/api/check-params/video-imports.ts b/server/tests/api/check-params/video-imports.ts
index 7f19b9ee9..8c6f43c12 100644
--- a/server/tests/api/check-params/video-imports.ts
+++ b/server/tests/api/check-params/video-imports.ts
@@ -244,7 +244,7 @@ describe('Test video imports API validator', function () {
244 it('Should fail with a big thumbnail file', async function () { 244 it('Should fail with a big thumbnail file', async function () {
245 const fields = baseCorrectParams 245 const fields = baseCorrectParams
246 const attaches = { 246 const attaches = {
247 thumbnailfile: buildAbsoluteFixturePath('preview-big.png') 247 thumbnailfile: buildAbsoluteFixturePath('custom-preview-big.png')
248 } 248 }
249 249
250 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) 250 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches })
@@ -262,7 +262,7 @@ describe('Test video imports API validator', function () {
262 it('Should fail with a big preview file', async function () { 262 it('Should fail with a big preview file', async function () {
263 const fields = baseCorrectParams 263 const fields = baseCorrectParams
264 const attaches = { 264 const attaches = {
265 previewfile: buildAbsoluteFixturePath('preview-big.png') 265 previewfile: buildAbsoluteFixturePath('custom-preview-big.png')
266 } 266 }
267 267
268 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) 268 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches })
diff --git a/server/tests/api/check-params/video-passwords.ts b/server/tests/api/check-params/video-passwords.ts
new file mode 100644
index 000000000..4e936b5d2
--- /dev/null
+++ b/server/tests/api/check-params/video-passwords.ts
@@ -0,0 +1,609 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2import {
3 FIXTURE_URLS,
4 checkBadCountPagination,
5 checkBadSortPagination,
6 checkBadStartPagination,
7 checkUploadVideoParam
8} from '@server/tests/shared'
9import { root } from '@shared/core-utils'
10import {
11 HttpStatusCode,
12 PeerTubeProblemDocument,
13 ServerErrorCode,
14 VideoCreateResult,
15 VideoPrivacy
16} from '@shared/models'
17import {
18 cleanupTests,
19 createSingleServer,
20 makePostBodyRequest,
21 PeerTubeServer,
22 setAccessTokensToServers
23} from '@shared/server-commands'
24import { expect } from 'chai'
25import { join } from 'path'
26
27describe('Test video passwords validator', function () {
28 let path: string
29 let server: PeerTubeServer
30 let userAccessToken = ''
31 let video: VideoCreateResult
32 let channelId: number
33 let publicVideo: VideoCreateResult
34 let commentId: number
35 // ---------------------------------------------------------------
36
37 before(async function () {
38 this.timeout(50000)
39
40 server = await createSingleServer(1)
41
42 await setAccessTokensToServers([ server ])
43
44 await server.config.updateCustomSubConfig({
45 newConfig: {
46 live: {
47 enabled: true,
48 latencySetting: {
49 enabled: false
50 },
51 allowReplay: false
52 },
53 import: {
54 videos: {
55 http:{
56 enabled: true
57 }
58 }
59 }
60 }
61 })
62
63 userAccessToken = await server.users.generateUserAndToken('user1')
64
65 {
66 const body = await server.users.getMyInfo()
67 channelId = body.videoChannels[0].id
68 }
69
70 {
71 video = await server.videos.quickUpload({
72 name: 'password protected video',
73 privacy: VideoPrivacy.PASSWORD_PROTECTED,
74 videoPasswords: [ 'password1', 'password2' ]
75 })
76 }
77 path = '/api/v1/videos/'
78 })
79
80 async function checkVideoPasswordOptions (options: {
81 server: PeerTubeServer
82 token: string
83 videoPasswords: string[]
84 expectedStatus: HttpStatusCode
85 mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live'
86 }) {
87 const { server, token, videoPasswords, expectedStatus = HttpStatusCode.OK_200, mode } = options
88 const attaches = {
89 fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.webm')
90 }
91 const baseCorrectParams = {
92 name: 'my super name',
93 category: 5,
94 licence: 1,
95 language: 'pt',
96 nsfw: false,
97 commentsEnabled: true,
98 downloadEnabled: true,
99 waitTranscoding: true,
100 description: 'my super description',
101 support: 'my super support text',
102 tags: [ 'tag1', 'tag2' ],
103 privacy: VideoPrivacy.PASSWORD_PROTECTED,
104 channelId,
105 originallyPublishedAt: new Date().toISOString()
106 }
107 if (mode === 'uploadLegacy') {
108 const fields = { ...baseCorrectParams, videoPasswords }
109 return checkUploadVideoParam(server, token, { ...fields, ...attaches }, expectedStatus, 'legacy')
110 }
111
112 if (mode === 'uploadResumable') {
113 const fields = { ...baseCorrectParams, videoPasswords }
114 return checkUploadVideoParam(server, token, { ...fields, ...attaches }, expectedStatus, 'resumable')
115 }
116
117 if (mode === 'import') {
118 const attributes = { ...baseCorrectParams, targetUrl: FIXTURE_URLS.goodVideo, videoPasswords }
119 return server.imports.importVideo({ attributes, expectedStatus })
120 }
121
122 if (mode === 'updateVideo') {
123 const attributes = { ...baseCorrectParams, videoPasswords }
124 return server.videos.update({ token, expectedStatus, id: video.id, attributes })
125 }
126
127 if (mode === 'updatePasswords') {
128 return server.videoPasswords.updateAll({ token, expectedStatus, videoId: video.id, passwords: videoPasswords })
129 }
130
131 if (mode === 'live') {
132 const fields = { ...baseCorrectParams, videoPasswords }
133
134 return server.live.create({ fields, expectedStatus })
135 }
136 }
137
138 function validateVideoPasswordList (mode: 'uploadLegacy' | 'uploadResumable' | 'import' | 'updateVideo' | 'updatePasswords' | 'live') {
139
140 it('Should fail with a password protected privacy without providing a password', async function () {
141 await checkVideoPasswordOptions({
142 server,
143 token: server.accessToken,
144 videoPasswords: undefined,
145 expectedStatus: HttpStatusCode.BAD_REQUEST_400,
146 mode
147 })
148 })
149
150 it('Should fail with a password protected privacy and an empty password list', async function () {
151 const videoPasswords = []
152
153 await checkVideoPasswordOptions({
154 server,
155 token: server.accessToken,
156 videoPasswords,
157 expectedStatus: HttpStatusCode.BAD_REQUEST_400,
158 mode
159 })
160 })
161
162 it('Should fail with a password protected privacy and a too short password', async function () {
163 const videoPasswords = [ 'p' ]
164
165 await checkVideoPasswordOptions({
166 server,
167 token: server.accessToken,
168 videoPasswords,
169 expectedStatus: HttpStatusCode.BAD_REQUEST_400,
170 mode
171 })
172 })
173
174 it('Should fail with a password protected privacy and a too long password', async function () {
175 const videoPasswords = [ 'Very very very very very very very very very very very very very very very very very very long password' ]
176
177 await checkVideoPasswordOptions({
178 server,
179 token: server.accessToken,
180 videoPasswords,
181 expectedStatus: HttpStatusCode.BAD_REQUEST_400,
182 mode
183 })
184 })
185
186 it('Should fail with a password protected privacy and an empty password', async function () {
187 const videoPasswords = [ '' ]
188
189 await checkVideoPasswordOptions({
190 server,
191 token: server.accessToken,
192 videoPasswords,
193 expectedStatus: HttpStatusCode.BAD_REQUEST_400,
194 mode
195 })
196 })
197
198 it('Should fail with a password protected privacy and duplicated passwords', async function () {
199 const videoPasswords = [ 'password', 'password' ]
200
201 await checkVideoPasswordOptions({
202 server,
203 token: server.accessToken,
204 videoPasswords,
205 expectedStatus: HttpStatusCode.BAD_REQUEST_400,
206 mode
207 })
208 })
209
210 if (mode === 'updatePasswords') {
211 it('Should fail for an unauthenticated user', async function () {
212 const videoPasswords = [ 'password' ]
213 await checkVideoPasswordOptions({
214 server,
215 token: null,
216 videoPasswords,
217 expectedStatus: HttpStatusCode.UNAUTHORIZED_401,
218 mode
219 })
220 })
221
222 it('Should fail for an unauthorized user', async function () {
223 const videoPasswords = [ 'password' ]
224 await checkVideoPasswordOptions({
225 server,
226 token: userAccessToken,
227 videoPasswords,
228 expectedStatus: HttpStatusCode.FORBIDDEN_403,
229 mode
230 })
231 })
232 }
233
234 it('Should succeed with a password protected privacy and correct passwords', async function () {
235 const videoPasswords = [ 'password1', 'password2' ]
236 const expectedStatus = mode === 'updatePasswords' || mode === 'updateVideo'
237 ? HttpStatusCode.NO_CONTENT_204
238 : HttpStatusCode.OK_200
239
240 await checkVideoPasswordOptions({ server, token: server.accessToken, videoPasswords, expectedStatus, mode })
241 })
242 }
243
244 describe('When adding or updating a video', function () {
245 describe('Resumable upload', function () {
246 validateVideoPasswordList('uploadResumable')
247 })
248
249 describe('Legacy upload', function () {
250 validateVideoPasswordList('uploadLegacy')
251 })
252
253 describe('When importing a video', function () {
254 validateVideoPasswordList('import')
255 })
256
257 describe('When updating a video', function () {
258 validateVideoPasswordList('updateVideo')
259 })
260
261 describe('When updating the password list of a video', function () {
262 validateVideoPasswordList('updatePasswords')
263 })
264
265 describe('When creating a live', function () {
266 validateVideoPasswordList('live')
267 })
268 })
269
270 async function checkVideoAccessOptions (options: {
271 server: PeerTubeServer
272 token?: string
273 videoPassword?: string
274 expectedStatus: HttpStatusCode
275 mode: 'get' | 'getWithPassword' | 'getWithToken' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token'
276 }) {
277 const { server, token = null, videoPassword, expectedStatus, mode } = options
278
279 if (mode === 'get') {
280 return server.videos.get({ id: video.id, expectedStatus })
281 }
282
283 if (mode === 'getWithToken') {
284 return server.videos.getWithToken({
285 id: video.id,
286 token,
287 expectedStatus
288 })
289 }
290
291 if (mode === 'getWithPassword') {
292 return server.videos.getWithPassword({
293 id: video.id,
294 token,
295 expectedStatus,
296 password: videoPassword
297 })
298 }
299
300 if (mode === 'rate') {
301 return server.videos.rate({
302 id: video.id,
303 token,
304 expectedStatus,
305 rating: 'like',
306 videoPassword
307 })
308 }
309
310 if (mode === 'createThread') {
311 const fields = { text: 'super comment' }
312 const headers = videoPassword !== undefined && videoPassword !== null
313 ? { 'x-peertube-video-password': videoPassword }
314 : undefined
315 const body = await makePostBodyRequest({
316 url: server.url,
317 path: path + video.uuid + '/comment-threads',
318 token,
319 fields,
320 headers,
321 expectedStatus
322 })
323 return JSON.parse(body.text)
324 }
325
326 if (mode === 'replyThread') {
327 const fields = { text: 'super reply' }
328 const headers = videoPassword !== undefined && videoPassword !== null
329 ? { 'x-peertube-video-password': videoPassword }
330 : undefined
331 return makePostBodyRequest({
332 url: server.url,
333 path: path + video.uuid + '/comments/' + commentId,
334 token,
335 fields,
336 headers,
337 expectedStatus
338 })
339 }
340 if (mode === 'listThreads') {
341 return server.comments.listThreads({
342 videoId: video.id,
343 token,
344 expectedStatus,
345 videoPassword
346 })
347 }
348
349 if (mode === 'listCaptions') {
350 return server.captions.list({
351 videoId: video.id,
352 token,
353 expectedStatus,
354 videoPassword
355 })
356 }
357
358 if (mode === 'token') {
359 return server.videoToken.create({
360 videoId: video.id,
361 token,
362 expectedStatus,
363 videoPassword
364 })
365 }
366 }
367
368 function checkVideoError (error: any, mode: 'providePassword' | 'incorrectPassword') {
369 const serverCode = mode === 'providePassword'
370 ? ServerErrorCode.VIDEO_REQUIRES_PASSWORD
371 : ServerErrorCode.INCORRECT_VIDEO_PASSWORD
372
373 const message = mode === 'providePassword'
374 ? 'Please provide a password to access this password protected video'
375 : 'Incorrect video password. Access to the video is denied.'
376
377 if (!error.code) {
378 error = JSON.parse(error.text)
379 }
380
381 expect(error.code).to.equal(serverCode)
382 expect(error.detail).to.equal(message)
383 expect(error.error).to.equal(message)
384
385 expect(error.status).to.equal(HttpStatusCode.FORBIDDEN_403)
386 }
387
388 function validateVideoAccess (mode: 'get' | 'listCaptions' | 'createThread' | 'listThreads' | 'replyThread' | 'rate' | 'token') {
389 const requiresUserAuth = [ 'createThread', 'replyThread', 'rate' ].includes(mode)
390 let tokens: string[]
391 if (!requiresUserAuth) {
392 it('Should fail without providing a password for an unlogged user', async function () {
393 const body = await checkVideoAccessOptions({ server, expectedStatus: HttpStatusCode.FORBIDDEN_403, mode })
394 const error = body as unknown as PeerTubeProblemDocument
395
396 checkVideoError(error, 'providePassword')
397 })
398 }
399
400 it('Should fail without providing a password for an unauthorised user', async function () {
401 const tmp = mode === 'get' ? 'getWithToken' : mode
402
403 const body = await checkVideoAccessOptions({
404 server,
405 token: userAccessToken,
406 expectedStatus: HttpStatusCode.FORBIDDEN_403,
407 mode: tmp
408 })
409
410 const error = body as unknown as PeerTubeProblemDocument
411
412 checkVideoError(error, 'providePassword')
413 })
414
415 it('Should fail if a wrong password is entered', async function () {
416 const tmp = mode === 'get' ? 'getWithPassword' : mode
417 tokens = [ userAccessToken, server.accessToken ]
418
419 if (!requiresUserAuth) tokens.push(null)
420
421 for (const token of tokens) {
422 const body = await checkVideoAccessOptions({
423 server,
424 token,
425 videoPassword: 'toto',
426 expectedStatus: HttpStatusCode.FORBIDDEN_403,
427 mode: tmp
428 })
429 const error = body as unknown as PeerTubeProblemDocument
430
431 checkVideoError(error, 'incorrectPassword')
432 }
433 })
434
435 it('Should fail if an empty password is entered', async function () {
436 const tmp = mode === 'get' ? 'getWithPassword' : mode
437
438 for (const token of tokens) {
439 const body = await checkVideoAccessOptions({
440 server,
441 token,
442 videoPassword: '',
443 expectedStatus: HttpStatusCode.FORBIDDEN_403,
444 mode: tmp
445 })
446 const error = body as unknown as PeerTubeProblemDocument
447
448 checkVideoError(error, 'incorrectPassword')
449 }
450 })
451
452 it('Should fail if an inccorect password containing the correct password is entered', async function () {
453 const tmp = mode === 'get' ? 'getWithPassword' : mode
454
455 for (const token of tokens) {
456 const body = await checkVideoAccessOptions({
457 server,
458 token,
459 videoPassword: 'password11',
460 expectedStatus: HttpStatusCode.FORBIDDEN_403,
461 mode: tmp
462 })
463 const error = body as unknown as PeerTubeProblemDocument
464
465 checkVideoError(error, 'incorrectPassword')
466 }
467 })
468
469 it('Should succeed without providing a password for an authorised user', async function () {
470 const tmp = mode === 'get' ? 'getWithToken' : mode
471 const expectedStatus = mode === 'rate' ? HttpStatusCode.NO_CONTENT_204 : HttpStatusCode.OK_200
472
473 const body = await checkVideoAccessOptions({ server, token: server.accessToken, expectedStatus, mode: tmp })
474
475 if (mode === 'createThread') commentId = body.comment.id
476 })
477
478 it('Should succeed using correct passwords', async function () {
479 const tmp = mode === 'get' ? 'getWithPassword' : mode
480 const expectedStatus = mode === 'rate' ? HttpStatusCode.NO_CONTENT_204 : HttpStatusCode.OK_200
481
482 for (const token of tokens) {
483 await checkVideoAccessOptions({ server, videoPassword: 'password1', token, expectedStatus, mode: tmp })
484 await checkVideoAccessOptions({ server, videoPassword: 'password2', token, expectedStatus, mode: tmp })
485 }
486 })
487 }
488
489 describe('When accessing password protected video', function () {
490
491 describe('For getting a password protected video', function () {
492 validateVideoAccess('get')
493 })
494
495 describe('For rating a video', function () {
496 validateVideoAccess('rate')
497 })
498
499 describe('For creating a thread', function () {
500 validateVideoAccess('createThread')
501 })
502
503 describe('For replying to a thread', function () {
504 validateVideoAccess('replyThread')
505 })
506
507 describe('For listing threads', function () {
508 validateVideoAccess('listThreads')
509 })
510
511 describe('For getting captions', function () {
512 validateVideoAccess('listCaptions')
513 })
514
515 describe('For creating video file token', function () {
516 validateVideoAccess('token')
517 })
518 })
519
520 describe('When listing passwords', function () {
521 it('Should fail with a bad start pagination', async function () {
522 await checkBadStartPagination(server.url, path + video.uuid + '/passwords', server.accessToken)
523 })
524
525 it('Should fail with a bad count pagination', async function () {
526 await checkBadCountPagination(server.url, path + video.uuid + '/passwords', server.accessToken)
527 })
528
529 it('Should fail with an incorrect sort', async function () {
530 await checkBadSortPagination(server.url, path + video.uuid + '/passwords', server.accessToken)
531 })
532
533 it('Should fail for unauthenticated user', async function () {
534 await server.videoPasswords.list({
535 token: null,
536 expectedStatus: HttpStatusCode.UNAUTHORIZED_401,
537 videoId: video.id
538 })
539 })
540
541 it('Should fail for unauthorized user', async function () {
542 await server.videoPasswords.list({
543 token: userAccessToken,
544 expectedStatus: HttpStatusCode.FORBIDDEN_403,
545 videoId: video.id
546 })
547 })
548
549 it('Should succeed with the correct parameters', async function () {
550 await server.videoPasswords.list({
551 token: server.accessToken,
552 expectedStatus: HttpStatusCode.OK_200,
553 videoId: video.id
554 })
555 })
556 })
557
558 describe('When deleting a password', async function () {
559 const passwords = (await server.videoPasswords.list({ videoId: video.id })).data
560
561 it('Should fail with wrong password id', async function () {
562 await server.videoPasswords.remove({ id: -1, videoId: video.id, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
563 })
564
565 it('Should fail for unauthenticated user', async function () {
566 await server.videoPasswords.remove({
567 id: passwords[0].id,
568 token: null,
569 videoId: video.id,
570 expectedStatus: HttpStatusCode.FORBIDDEN_403
571 })
572 })
573
574 it('Should fail for unauthorized user', async function () {
575 await server.videoPasswords.remove({
576 id: passwords[0].id,
577 token: userAccessToken,
578 videoId: video.id,
579 expectedStatus: HttpStatusCode.BAD_REQUEST_400
580 })
581 })
582
583 it('Should fail for non password protected video', async function () {
584 publicVideo = await server.videos.quickUpload({ name: 'public video' })
585 await server.videoPasswords.remove({ id: passwords[0].id, videoId: publicVideo.id, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
586 })
587
588 it('Should fail for password not linked to correct video', async function () {
589 const video2 = await server.videos.quickUpload({
590 name: 'password protected video',
591 privacy: VideoPrivacy.PASSWORD_PROTECTED,
592 videoPasswords: [ 'password1', 'password2' ]
593 })
594 await server.videoPasswords.remove({ id: passwords[0].id, videoId: video2.id, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
595 })
596
597 it('Should succeed with correct parameter', async function () {
598 await server.videoPasswords.remove({ id: passwords[0].id, videoId: video.id, expectedStatus: HttpStatusCode.NO_CONTENT_204 })
599 })
600
601 it('Should fail for last password of a video', async function () {
602 await server.videoPasswords.remove({ id: passwords[1].id, videoId: video.id, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
603 })
604 })
605
606 after(async function () {
607 await cleanupTests([ server ])
608 })
609})
diff --git a/server/tests/api/check-params/video-playlists.ts b/server/tests/api/check-params/video-playlists.ts
index 8090897c1..8c3233e0b 100644
--- a/server/tests/api/check-params/video-playlists.ts
+++ b/server/tests/api/check-params/video-playlists.ts
@@ -196,7 +196,7 @@ describe('Test video playlists API validator', function () {
196 attributes: { 196 attributes: {
197 displayName: 'display name', 197 displayName: 'display name',
198 privacy: VideoPlaylistPrivacy.UNLISTED, 198 privacy: VideoPlaylistPrivacy.UNLISTED,
199 thumbnailfile: 'thumbnail.jpg', 199 thumbnailfile: 'custom-thumbnail.jpg',
200 videoChannelId: server.store.channel.id, 200 videoChannelId: server.store.channel.id,
201 201
202 ...attributes 202 ...attributes
@@ -260,7 +260,7 @@ describe('Test video playlists API validator', function () {
260 }) 260 })
261 261
262 it('Should fail with a thumbnail file too big', async function () { 262 it('Should fail with a thumbnail file too big', async function () {
263 const params = getBase({ thumbnailfile: 'preview-big.png' }) 263 const params = getBase({ thumbnailfile: 'custom-preview-big.png' })
264 264
265 await command.create(params) 265 await command.create(params)
266 await command.update(getUpdate(params, playlist.shortUUID)) 266 await command.update(getUpdate(params, playlist.shortUUID))
diff --git a/server/tests/api/check-params/video-storyboards.ts b/server/tests/api/check-params/video-storyboards.ts
new file mode 100644
index 000000000..c038e7370
--- /dev/null
+++ b/server/tests/api/check-params/video-storyboards.ts
@@ -0,0 +1,45 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { HttpStatusCode, VideoPrivacy } from '@shared/models'
4import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
5
6describe('Test video storyboards API validator', function () {
7 let server: PeerTubeServer
8
9 let publicVideo: { uuid: string }
10 let privateVideo: { uuid: string }
11
12 // ---------------------------------------------------------------
13
14 before(async function () {
15 this.timeout(120000)
16
17 server = await createSingleServer(1)
18 await setAccessTokensToServers([ server ])
19
20 publicVideo = await server.videos.quickUpload({ name: 'public' })
21 privateVideo = await server.videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE })
22 })
23
24 it('Should fail without a valid uuid', async function () {
25 await server.storyboard.list({ id: '4da6fde3-88f7-4d16-b119-108df563d0b0', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
26 })
27
28 it('Should receive 404 when passing a non existing video id', async function () {
29 await server.storyboard.list({ id: '4da6fde3-88f7-4d16-b119-108df5630b06', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
30 })
31
32 it('Should not get the private storyboard without the appropriate token', async function () {
33 await server.storyboard.list({ id: privateVideo.uuid, expectedStatus: HttpStatusCode.UNAUTHORIZED_401, token: null })
34 await server.storyboard.list({ id: publicVideo.uuid, expectedStatus: HttpStatusCode.OK_200, token: null })
35 })
36
37 it('Should succeed with the correct parameters', async function () {
38 await server.storyboard.list({ id: privateVideo.uuid })
39 await server.storyboard.list({ id: publicVideo.uuid })
40 })
41
42 after(async function () {
43 await cleanupTests([ server ])
44 })
45})
diff --git a/server/tests/api/check-params/video-studio.ts b/server/tests/api/check-params/video-studio.ts
index add8d9164..4ac0d93ed 100644
--- a/server/tests/api/check-params/video-studio.ts
+++ b/server/tests/api/check-params/video-studio.ts
@@ -293,7 +293,7 @@ describe('Test video studio API validator', function () {
293 it('Should succeed with the correct params', async function () { 293 it('Should succeed with the correct params', async function () {
294 this.timeout(120000) 294 this.timeout(120000)
295 295
296 await addWatermark('thumbnail.jpg', HttpStatusCode.NO_CONTENT_204) 296 await addWatermark('custom-thumbnail.jpg', HttpStatusCode.NO_CONTENT_204)
297 297
298 await waitJobs([ server ]) 298 await waitJobs([ server ])
299 }) 299 })
@@ -322,8 +322,8 @@ describe('Test video studio API validator', function () {
322 }) 322 })
323 323
324 it('Should fail with an invalid file', async function () { 324 it('Should fail with an invalid file', async function () {
325 await addIntroOutro('add-intro', 'thumbnail.jpg') 325 await addIntroOutro('add-intro', 'custom-thumbnail.jpg')
326 await addIntroOutro('add-outro', 'thumbnail.jpg') 326 await addIntroOutro('add-outro', 'custom-thumbnail.jpg')
327 }) 327 })
328 328
329 it('Should fail with a file that does not contain video stream', async function () { 329 it('Should fail with a file that does not contain video stream', async function () {
diff --git a/server/tests/api/check-params/video-token.ts b/server/tests/api/check-params/video-token.ts
index 7acb9d580..7cb3e84a2 100644
--- a/server/tests/api/check-params/video-token.ts
+++ b/server/tests/api/check-params/video-token.ts
@@ -5,9 +5,12 @@ import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServ
5 5
6describe('Test video tokens', function () { 6describe('Test video tokens', function () {
7 let server: PeerTubeServer 7 let server: PeerTubeServer
8 let videoId: string 8 let privateVideoId: string
9 let passwordProtectedVideoId: string
9 let userToken: string 10 let userToken: string
10 11
12 const videoPassword = 'password'
13
11 // --------------------------------------------------------------- 14 // ---------------------------------------------------------------
12 15
13 before(async function () { 16 before(async function () {
@@ -15,27 +18,50 @@ describe('Test video tokens', function () {
15 18
16 server = await createSingleServer(1) 19 server = await createSingleServer(1)
17 await setAccessTokensToServers([ server ]) 20 await setAccessTokensToServers([ server ])
18 21 {
19 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) 22 const { uuid } = await server.videos.quickUpload({ name: 'private video', privacy: VideoPrivacy.PRIVATE })
20 videoId = uuid 23 privateVideoId = uuid
21 24 }
25 {
26 const { uuid } = await server.videos.quickUpload({
27 name: 'password protected video',
28 privacy: VideoPrivacy.PASSWORD_PROTECTED,
29 videoPasswords: [ videoPassword ]
30 })
31 passwordProtectedVideoId = uuid
32 }
22 userToken = await server.users.generateUserAndToken('user1') 33 userToken = await server.users.generateUserAndToken('user1')
23 }) 34 })
24 35
25 it('Should not generate tokens for unauthenticated user', async function () { 36 it('Should not generate tokens on private video for unauthenticated user', async function () {
26 await server.videoToken.create({ videoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) 37 await server.videoToken.create({ videoId: privateVideoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
27 }) 38 })
28 39
29 it('Should not generate tokens of unknown video', async function () { 40 it('Should not generate tokens of unknown video', async function () {
30 await server.videoToken.create({ videoId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 }) 41 await server.videoToken.create({ videoId: 404, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
31 }) 42 })
32 43
44 it('Should not generate tokens with incorrect password', async function () {
45 await server.videoToken.create({
46 videoId: passwordProtectedVideoId,
47 token: null,
48 expectedStatus: HttpStatusCode.FORBIDDEN_403,
49 videoPassword: 'incorrectPassword'
50 })
51 })
52
33 it('Should not generate tokens of a non owned video', async function () { 53 it('Should not generate tokens of a non owned video', async function () {
34 await server.videoToken.create({ videoId, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 54 await server.videoToken.create({ videoId: privateVideoId, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
35 }) 55 })
36 56
37 it('Should generate token', async function () { 57 it('Should generate token', async function () {
38 await server.videoToken.create({ videoId }) 58 await server.videoToken.create({ videoId: privateVideoId })
59 })
60
61 it('Should generate token on password protected video', async function () {
62 await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword, token: null })
63 await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword, token: userToken })
64 await server.videoToken.create({ videoId: passwordProtectedVideoId, videoPassword })
39 }) 65 })
40 66
41 after(async function () { 67 after(async function () {
diff --git a/server/tests/api/check-params/videos-overviews.ts b/server/tests/api/check-params/videos-overviews.ts
index f9cdb7ab3..ae7de24dd 100644
--- a/server/tests/api/check-params/videos-overviews.ts
+++ b/server/tests/api/check-params/videos-overviews.ts
@@ -2,7 +2,7 @@
2 2
3import { cleanupTests, createSingleServer, PeerTubeServer } from '@shared/server-commands' 3import { cleanupTests, createSingleServer, PeerTubeServer } from '@shared/server-commands'
4 4
5describe('Test videos overview', function () { 5describe('Test videos overview API validator', function () {
6 let server: PeerTubeServer 6 let server: PeerTubeServer
7 7
8 // --------------------------------------------------------------- 8 // ---------------------------------------------------------------
diff --git a/server/tests/api/check-params/videos.ts b/server/tests/api/check-params/videos.ts
index 094ab6891..6ee1955a7 100644
--- a/server/tests/api/check-params/videos.ts
+++ b/server/tests/api/check-params/videos.ts
@@ -384,7 +384,7 @@ describe('Test videos API validator', function () {
384 it('Should fail with a big thumbnail file', async function () { 384 it('Should fail with a big thumbnail file', async function () {
385 const fields = baseCorrectParams 385 const fields = baseCorrectParams
386 const attaches = { 386 const attaches = {
387 thumbnailfile: join(root(), 'server', 'tests', 'fixtures', 'preview-big.png'), 387 thumbnailfile: join(root(), 'server', 'tests', 'fixtures', 'custom-preview-big.png'),
388 fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4') 388 fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4')
389 } 389 }
390 390
@@ -404,7 +404,7 @@ describe('Test videos API validator', function () {
404 it('Should fail with a big preview file', async function () { 404 it('Should fail with a big preview file', async function () {
405 const fields = baseCorrectParams 405 const fields = baseCorrectParams
406 const attaches = { 406 const attaches = {
407 previewfile: join(root(), 'server', 'tests', 'fixtures', 'preview-big.png'), 407 previewfile: join(root(), 'server', 'tests', 'fixtures', 'custom-preview-big.png'),
408 fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4') 408 fixture: join(root(), 'server', 'tests', 'fixtures', 'video_short.mp4')
409 } 409 }
410 410
@@ -615,7 +615,7 @@ describe('Test videos API validator', function () {
615 it('Should fail with a big thumbnail file', async function () { 615 it('Should fail with a big thumbnail file', async function () {
616 const fields = baseCorrectParams 616 const fields = baseCorrectParams
617 const attaches = { 617 const attaches = {
618 thumbnailfile: join(root(), 'server', 'tests', 'fixtures', 'preview-big.png') 618 thumbnailfile: join(root(), 'server', 'tests', 'fixtures', 'custom-preview-big.png')
619 } 619 }
620 620
621 await makeUploadRequest({ 621 await makeUploadRequest({
@@ -647,7 +647,7 @@ describe('Test videos API validator', function () {
647 it('Should fail with a big preview file', async function () { 647 it('Should fail with a big preview file', async function () {
648 const fields = baseCorrectParams 648 const fields = baseCorrectParams
649 const attaches = { 649 const attaches = {
650 previewfile: join(root(), 'server', 'tests', 'fixtures', 'preview-big.png') 650 previewfile: join(root(), 'server', 'tests', 'fixtures', 'custom-preview-big.png')
651 } 651 }
652 652
653 await makeUploadRequest({ 653 await makeUploadRequest({
diff --git a/server/tests/api/live/live.ts b/server/tests/api/live/live.ts
index 7ab67b126..2b302a8a2 100644
--- a/server/tests/api/live/live.ts
+++ b/server/tests/api/live/live.ts
@@ -2,7 +2,7 @@
2 2
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { basename, join } from 'path' 4import { basename, join } from 'path'
5import { SQLCommand, testImage, testLiveVideoResolutions } from '@server/tests/shared' 5import { SQLCommand, testImageGeneratedByFFmpeg, testLiveVideoResolutions } from '@server/tests/shared'
6import { getAllFiles, wait } from '@shared/core-utils' 6import { getAllFiles, wait } from '@shared/core-utils'
7import { ffprobePromise, getVideoStream } from '@shared/ffmpeg' 7import { ffprobePromise, getVideoStream } from '@shared/ffmpeg'
8import { 8import {
@@ -121,8 +121,8 @@ describe('Test live', function () {
121 expect(video.downloadEnabled).to.be.false 121 expect(video.downloadEnabled).to.be.false
122 expect(video.privacy.id).to.equal(VideoPrivacy.PUBLIC) 122 expect(video.privacy.id).to.equal(VideoPrivacy.PUBLIC)
123 123
124 await testImage(server.url, 'video_short1-preview.webm', video.previewPath) 124 await testImageGeneratedByFFmpeg(server.url, 'video_short1-preview.webm', video.previewPath)
125 await testImage(server.url, 'video_short1.webm', video.thumbnailPath) 125 await testImageGeneratedByFFmpeg(server.url, 'video_short1.webm', video.thumbnailPath)
126 126
127 const live = await server.live.get({ videoId: liveVideoUUID }) 127 const live = await server.live.get({ videoId: liveVideoUUID })
128 128
diff --git a/server/tests/api/object-storage/video-static-file-privacy.ts b/server/tests/api/object-storage/video-static-file-privacy.ts
index af9d681b2..64ab542a5 100644
--- a/server/tests/api/object-storage/video-static-file-privacy.ts
+++ b/server/tests/api/object-storage/video-static-file-privacy.ts
@@ -39,7 +39,7 @@ describe('Object storage for video static file privacy', function () {
39 const video = await server.videos.getWithToken({ id: uuid }) 39 const video = await server.videos.getWithToken({ id: uuid })
40 40
41 for (const file of video.files) { 41 for (const file of video.files) {
42 expectStartWith(file.fileUrl, server.url + '/object-storage-proxy/webseed/private/') 42 expectStartWith(file.fileUrl, server.url + '/object-storage-proxy/web-videos/private/')
43 43
44 await makeRawRequest({ url: file.fileUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) 44 await makeRawRequest({ url: file.fileUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
45 } 45 }
@@ -107,15 +107,20 @@ describe('Object storage for video static file privacy', function () {
107 describe('VOD', function () { 107 describe('VOD', function () {
108 let privateVideoUUID: string 108 let privateVideoUUID: string
109 let publicVideoUUID: string 109 let publicVideoUUID: string
110 let passwordProtectedVideoUUID: string
110 let userPrivateVideoUUID: string 111 let userPrivateVideoUUID: string
111 112
113 const correctPassword = 'my super password'
114 const correctPasswordHeader = { 'x-peertube-video-password': correctPassword }
115 const incorrectPasswordHeader = { 'x-peertube-video-password': correctPassword + 'toto' }
116
112 // --------------------------------------------------------------------------- 117 // ---------------------------------------------------------------------------
113 118
114 async function getSampleFileUrls (videoId: string) { 119 async function getSampleFileUrls (videoId: string) {
115 const video = await server.videos.getWithToken({ id: videoId }) 120 const video = await server.videos.getWithToken({ id: videoId })
116 121
117 return { 122 return {
118 webTorrentFile: video.files[0].fileUrl, 123 webVideoFile: video.files[0].fileUrl,
119 hlsFile: getHLS(video).files[0].fileUrl 124 hlsFile: getHLS(video).files[0].fileUrl
120 } 125 }
121 } 126 }
@@ -140,6 +145,22 @@ describe('Object storage for video static file privacy', function () {
140 await checkPrivateVODFiles(privateVideoUUID) 145 await checkPrivateVODFiles(privateVideoUUID)
141 }) 146 })
142 147
148 it('Should upload a password protected video and have appropriate object storage ACL', async function () {
149 this.timeout(120000)
150
151 {
152 const { uuid } = await server.videos.quickUpload({
153 name: 'video',
154 privacy: VideoPrivacy.PASSWORD_PROTECTED,
155 videoPasswords: [ correctPassword ]
156 })
157 passwordProtectedVideoUUID = uuid
158 }
159 await waitJobs([ server ])
160
161 await checkPrivateVODFiles(passwordProtectedVideoUUID)
162 })
163
143 it('Should upload a public video and have appropriate object storage ACL', async function () { 164 it('Should upload a public video and have appropriate object storage ACL', async function () {
144 this.timeout(120000) 165 this.timeout(120000)
145 166
@@ -154,13 +175,49 @@ describe('Object storage for video static file privacy', function () {
154 it('Should not get files without appropriate OAuth token', async function () { 175 it('Should not get files without appropriate OAuth token', async function () {
155 this.timeout(60000) 176 this.timeout(60000)
156 177
157 const { webTorrentFile, hlsFile } = await getSampleFileUrls(privateVideoUUID) 178 const { webVideoFile, hlsFile } = await getSampleFileUrls(privateVideoUUID)
179
180 await makeRawRequest({ url: webVideoFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
181 await makeRawRequest({ url: webVideoFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
182
183 await makeRawRequest({ url: hlsFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
184 await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
185 })
186
187 it('Should not get files without appropriate password or appropriate OAuth token', async function () {
188 this.timeout(60000)
189
190 const { webVideoFile, hlsFile } = await getSampleFileUrls(passwordProtectedVideoUUID)
158 191
159 await makeRawRequest({ url: webTorrentFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 192 await makeRawRequest({ url: webVideoFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
160 await makeRawRequest({ url: webTorrentFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) 193 await makeRawRequest({
194 url: webVideoFile,
195 token: null,
196 headers: incorrectPasswordHeader,
197 expectedStatus: HttpStatusCode.FORBIDDEN_403
198 })
199 await makeRawRequest({ url: webVideoFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
200 await makeRawRequest({
201 url: webVideoFile,
202 token: null,
203 headers: correctPasswordHeader,
204 expectedStatus: HttpStatusCode.OK_200
205 })
161 206
162 await makeRawRequest({ url: hlsFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 207 await makeRawRequest({ url: hlsFile, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
208 await makeRawRequest({
209 url: hlsFile,
210 token: null,
211 headers: incorrectPasswordHeader,
212 expectedStatus: HttpStatusCode.FORBIDDEN_403
213 })
163 await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) 214 await makeRawRequest({ url: hlsFile, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
215 await makeRawRequest({
216 url: hlsFile,
217 token: null,
218 headers: correctPasswordHeader,
219 expectedStatus: HttpStatusCode.OK_200
220 })
164 }) 221 })
165 222
166 it('Should not get HLS file of another video', async function () { 223 it('Should not get HLS file of another video', async function () {
@@ -176,21 +233,50 @@ describe('Object storage for video static file privacy', function () {
176 await makeRawRequest({ url: goodUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) 233 await makeRawRequest({ url: goodUrl, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
177 }) 234 })
178 235
179 it('Should correctly check OAuth or video file token', async function () { 236 it('Should correctly check OAuth, video file token of private video', async function () {
180 this.timeout(60000) 237 this.timeout(60000)
181 238
182 const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID }) 239 const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID })
183 const goodVideoFileToken = await server.videoToken.getVideoFileToken({ videoId: privateVideoUUID }) 240 const goodVideoFileToken = await server.videoToken.getVideoFileToken({ videoId: privateVideoUUID })
184 241
185 const { webTorrentFile, hlsFile } = await getSampleFileUrls(privateVideoUUID) 242 const { webVideoFile, hlsFile } = await getSampleFileUrls(privateVideoUUID)
243
244 for (const url of [ webVideoFile, hlsFile ]) {
245 await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
246 await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
247 await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
248
249 await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
250 await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 })
251
252 }
253 })
254
255 it('Should correctly check OAuth, video file token or video password of password protected video', async function () {
256 this.timeout(60000)
186 257
187 for (const url of [ webTorrentFile, hlsFile ]) { 258 const badVideoFileToken = await server.videoToken.getVideoFileToken({ token: userToken, videoId: userPrivateVideoUUID })
259 const goodVideoFileToken = await server.videoToken.getVideoFileToken({
260 videoId: passwordProtectedVideoUUID,
261 videoPassword: correctPassword
262 })
263
264 const { webVideoFile, hlsFile } = await getSampleFileUrls(passwordProtectedVideoUUID)
265
266 for (const url of [ hlsFile, webVideoFile ]) {
188 await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 267 await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
189 await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 268 await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
190 await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) 269 await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
191 270
192 await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 271 await makeRawRequest({ url, query: { videoFileToken: badVideoFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
193 await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 }) 272 await makeRawRequest({ url, query: { videoFileToken: goodVideoFileToken }, expectedStatus: HttpStatusCode.OK_200 })
273
274 await makeRawRequest({
275 url,
276 headers: incorrectPasswordHeader,
277 expectedStatus: HttpStatusCode.FORBIDDEN_403
278 })
279 await makeRawRequest({ url, headers: correctPasswordHeader, expectedStatus: HttpStatusCode.OK_200 })
194 } 280 }
195 }) 281 })
196 282
@@ -232,16 +318,26 @@ describe('Object storage for video static file privacy', function () {
232 let permanentLiveId: string 318 let permanentLiveId: string
233 let permanentLive: LiveVideo 319 let permanentLive: LiveVideo
234 320
321 let passwordProtectedLiveId: string
322 let passwordProtectedLive: LiveVideo
323
324 const correctPassword = 'my super password'
325
235 let unrelatedFileToken: string 326 let unrelatedFileToken: string
236 327
237 // --------------------------------------------------------------------------- 328 // ---------------------------------------------------------------------------
238 329
239 async function checkLiveFiles (live: LiveVideo, liveId: string) { 330 async function checkLiveFiles (live: LiveVideo, liveId: string, videoPassword?: string) {
240 const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) 331 const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey })
241 await server.live.waitUntilPublished({ videoId: liveId }) 332 await server.live.waitUntilPublished({ videoId: liveId })
242 333
243 const video = await server.videos.getWithToken({ id: liveId }) 334 const video = videoPassword
244 const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid }) 335 ? await server.videos.getWithPassword({ id: liveId, password: videoPassword })
336 : await server.videos.getWithToken({ id: liveId })
337
338 const fileToken = videoPassword
339 ? await server.videoToken.getVideoFileToken({ token: null, videoId: video.uuid, videoPassword })
340 : await server.videoToken.getVideoFileToken({ videoId: video.uuid })
245 341
246 const hls = video.streamingPlaylists[0] 342 const hls = video.streamingPlaylists[0]
247 343
@@ -253,10 +349,19 @@ describe('Object storage for video static file privacy', function () {
253 349
254 await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 }) 350 await makeRawRequest({ url, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
255 await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 }) 351 await makeRawRequest({ url, query: { videoFileToken: fileToken }, expectedStatus: HttpStatusCode.OK_200 })
256 352 if (videoPassword) {
353 await makeRawRequest({ url, headers: { 'x-peertube-video-password': videoPassword }, expectedStatus: HttpStatusCode.OK_200 })
354 }
257 await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 355 await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
258 await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 356 await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
259 await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 357 await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
358 if (videoPassword) {
359 await makeRawRequest({
360 url,
361 headers: { 'x-peertube-video-password': 'incorrectPassword' },
362 expectedStatus: HttpStatusCode.FORBIDDEN_403
363 })
364 }
260 } 365 }
261 366
262 await stopFfmpeg(ffmpegCommand) 367 await stopFfmpeg(ffmpegCommand)
@@ -326,6 +431,17 @@ describe('Object storage for video static file privacy', function () {
326 permanentLiveId = video.uuid 431 permanentLiveId = video.uuid
327 permanentLive = live 432 permanentLive = live
328 } 433 }
434
435 {
436 const { video, live } = await server.live.quickCreate({
437 saveReplay: false,
438 permanentLive: false,
439 privacy: VideoPrivacy.PASSWORD_PROTECTED,
440 videoPasswords: [ correctPassword ]
441 })
442 passwordProtectedLiveId = video.uuid
443 passwordProtectedLive = live
444 }
329 }) 445 })
330 446
331 it('Should create a private normal live and have a private static path', async function () { 447 it('Should create a private normal live and have a private static path', async function () {
@@ -340,6 +456,12 @@ describe('Object storage for video static file privacy', function () {
340 await checkLiveFiles(permanentLive, permanentLiveId) 456 await checkLiveFiles(permanentLive, permanentLiveId)
341 }) 457 })
342 458
459 it('Should create a password protected live and have a private static path', async function () {
460 this.timeout(240000)
461
462 await checkLiveFiles(passwordProtectedLive, passwordProtectedLiveId, correctPassword)
463 })
464
343 it('Should reinject video file token in permanent live', async function () { 465 it('Should reinject video file token in permanent live', async function () {
344 this.timeout(240000) 466 this.timeout(240000)
345 467
@@ -412,11 +534,11 @@ describe('Object storage for video static file privacy', function () {
412 534
413 it('Should not be able to access object storage proxy', async function () { 535 it('Should not be able to access object storage proxy', async function () {
414 const privateVideo = await server.videos.getWithToken({ id: videoUUID }) 536 const privateVideo = await server.videos.getWithToken({ id: videoUUID })
415 const webtorrentFilename = extractFilenameFromUrl(privateVideo.files[0].fileUrl) 537 const webVideoFilename = extractFilenameFromUrl(privateVideo.files[0].fileUrl)
416 const hlsFilename = extractFilenameFromUrl(getHLS(privateVideo).files[0].fileUrl) 538 const hlsFilename = extractFilenameFromUrl(getHLS(privateVideo).files[0].fileUrl)
417 539
418 await makeRawRequest({ 540 await makeRawRequest({
419 url: server.url + '/object-storage-proxy/webseed/private/' + webtorrentFilename, 541 url: server.url + '/object-storage-proxy/web-videos/private/' + webVideoFilename,
420 token: server.accessToken, 542 token: server.accessToken,
421 expectedStatus: HttpStatusCode.BAD_REQUEST_400 543 expectedStatus: HttpStatusCode.BAD_REQUEST_400
422 }) 544 })
diff --git a/server/tests/api/object-storage/videos.ts b/server/tests/api/object-storage/videos.ts
index f837d9966..dcc52ef06 100644
--- a/server/tests/api/object-storage/videos.ts
+++ b/server/tests/api/object-storage/videos.ts
@@ -41,8 +41,8 @@ async function checkFiles (options: {
41 playlistBucket: string 41 playlistBucket: string
42 playlistPrefix?: string 42 playlistPrefix?: string
43 43
44 webtorrentBucket: string 44 webVideoBucket: string
45 webtorrentPrefix?: string 45 webVideoPrefix?: string
46}) { 46}) {
47 const { 47 const {
48 server, 48 server,
@@ -50,20 +50,20 @@ async function checkFiles (options: {
50 originSQLCommand, 50 originSQLCommand,
51 video, 51 video,
52 playlistBucket, 52 playlistBucket,
53 webtorrentBucket, 53 webVideoBucket,
54 baseMockUrl, 54 baseMockUrl,
55 playlistPrefix, 55 playlistPrefix,
56 webtorrentPrefix 56 webVideoPrefix
57 } = options 57 } = options
58 58
59 let allFiles = video.files 59 let allFiles = video.files
60 60
61 for (const file of video.files) { 61 for (const file of video.files) {
62 const baseUrl = baseMockUrl 62 const baseUrl = baseMockUrl
63 ? `${baseMockUrl}/${webtorrentBucket}/` 63 ? `${baseMockUrl}/${webVideoBucket}/`
64 : `http://${webtorrentBucket}.${ObjectStorageCommand.getMockEndpointHost()}/` 64 : `http://${webVideoBucket}.${ObjectStorageCommand.getMockEndpointHost()}/`
65 65
66 const prefix = webtorrentPrefix || '' 66 const prefix = webVideoPrefix || ''
67 const start = baseUrl + prefix 67 const start = baseUrl + prefix
68 68
69 expectStartWith(file.fileUrl, start) 69 expectStartWith(file.fileUrl, start)
@@ -134,8 +134,8 @@ function runTestSuite (options: {
134 playlistBucket: string 134 playlistBucket: string
135 playlistPrefix?: string 135 playlistPrefix?: string
136 136
137 webtorrentBucket: string 137 webVideoBucket: string
138 webtorrentPrefix?: string 138 webVideoPrefix?: string
139 139
140 useMockBaseUrl?: boolean 140 useMockBaseUrl?: boolean
141}) { 141}) {
@@ -161,7 +161,7 @@ function runTestSuite (options: {
161 : undefined 161 : undefined
162 162
163 await objectStorage.createMockBucket(options.playlistBucket) 163 await objectStorage.createMockBucket(options.playlistBucket)
164 await objectStorage.createMockBucket(options.webtorrentBucket) 164 await objectStorage.createMockBucket(options.webVideoBucket)
165 165
166 const config = { 166 const config = {
167 object_storage: { 167 object_storage: {
@@ -181,11 +181,11 @@ function runTestSuite (options: {
181 : undefined 181 : undefined
182 }, 182 },
183 183
184 videos: { 184 web_videos: {
185 bucket_name: options.webtorrentBucket, 185 bucket_name: options.webVideoBucket,
186 prefix: options.webtorrentPrefix, 186 prefix: options.webVideoPrefix,
187 base_url: baseMockUrl 187 base_url: baseMockUrl
188 ? `${baseMockUrl}/${options.webtorrentBucket}` 188 ? `${baseMockUrl}/${options.webVideoBucket}`
189 : undefined 189 : undefined
190 } 190 }
191 } 191 }
@@ -308,7 +308,7 @@ describe('Object storage for videos', function () {
308 bucket_name: 'aaa' 308 bucket_name: 'aaa'
309 }, 309 },
310 310
311 videos: { 311 web_videos: {
312 bucket_name: 'aaa' 312 bucket_name: 'aaa'
313 } 313 }
314 } 314 }
@@ -386,27 +386,27 @@ describe('Object storage for videos', function () {
386 describe('Test simple object storage', function () { 386 describe('Test simple object storage', function () {
387 runTestSuite({ 387 runTestSuite({
388 playlistBucket: objectStorage.getMockBucketName('streaming-playlists'), 388 playlistBucket: objectStorage.getMockBucketName('streaming-playlists'),
389 webtorrentBucket: objectStorage.getMockBucketName('videos') 389 webVideoBucket: objectStorage.getMockBucketName('web-videos')
390 }) 390 })
391 }) 391 })
392 392
393 describe('Test object storage with prefix', function () { 393 describe('Test object storage with prefix', function () {
394 runTestSuite({ 394 runTestSuite({
395 playlistBucket: objectStorage.getMockBucketName('mybucket'), 395 playlistBucket: objectStorage.getMockBucketName('mybucket'),
396 webtorrentBucket: objectStorage.getMockBucketName('mybucket'), 396 webVideoBucket: objectStorage.getMockBucketName('mybucket'),
397 397
398 playlistPrefix: 'streaming-playlists_', 398 playlistPrefix: 'streaming-playlists_',
399 webtorrentPrefix: 'webtorrent_' 399 webVideoPrefix: 'webvideo_'
400 }) 400 })
401 }) 401 })
402 402
403 describe('Test object storage with prefix and base URL', function () { 403 describe('Test object storage with prefix and base URL', function () {
404 runTestSuite({ 404 runTestSuite({
405 playlistBucket: objectStorage.getMockBucketName('mybucket'), 405 playlistBucket: objectStorage.getMockBucketName('mybucket'),
406 webtorrentBucket: objectStorage.getMockBucketName('mybucket'), 406 webVideoBucket: objectStorage.getMockBucketName('mybucket'),
407 407
408 playlistPrefix: 'streaming-playlists/', 408 playlistPrefix: 'streaming-playlists/',
409 webtorrentPrefix: 'webtorrent/', 409 webVideoPrefix: 'webvideo/',
410 410
411 useMockBaseUrl: true 411 useMockBaseUrl: true
412 }) 412 })
@@ -431,7 +431,7 @@ describe('Object storage for videos', function () {
431 runTestSuite({ 431 runTestSuite({
432 maxUploadPart, 432 maxUploadPart,
433 playlistBucket: objectStorage.getMockBucketName('streaming-playlists'), 433 playlistBucket: objectStorage.getMockBucketName('streaming-playlists'),
434 webtorrentBucket: objectStorage.getMockBucketName('videos'), 434 webVideoBucket: objectStorage.getMockBucketName('web-videos'),
435 fixture 435 fixture
436 }) 436 })
437 }) 437 })
diff --git a/server/tests/api/redundancy/redundancy.ts b/server/tests/api/redundancy/redundancy.ts
index 5262c503f..0c5c27225 100644
--- a/server/tests/api/redundancy/redundancy.ts
+++ b/server/tests/api/redundancy/redundancy.ts
@@ -43,7 +43,7 @@ async function checkMagnetWebseeds (file: VideoFile, baseWebseeds: string[], ser
43 } 43 }
44} 44}
45 45
46async function createServers (strategy: VideoRedundancyStrategy | null, additionalParams: any = {}, withWebtorrent = true) { 46async function createServers (strategy: VideoRedundancyStrategy | null, additionalParams: any = {}, withWebVideo = true) {
47 const strategies: any[] = [] 47 const strategies: any[] = []
48 48
49 if (strategy !== null) { 49 if (strategy !== null) {
@@ -60,8 +60,8 @@ async function createServers (strategy: VideoRedundancyStrategy | null, addition
60 60
61 const config = { 61 const config = {
62 transcoding: { 62 transcoding: {
63 webtorrent: { 63 web_videos: {
64 enabled: withWebtorrent 64 enabled: withWebVideo
65 }, 65 },
66 hls: { 66 hls: {
67 enabled: true 67 enabled: true
@@ -100,7 +100,7 @@ async function createServers (strategy: VideoRedundancyStrategy | null, addition
100} 100}
101 101
102async function ensureSameFilenames (videoUUID: string) { 102async function ensureSameFilenames (videoUUID: string) {
103 let webtorrentFilenames: string[] 103 let webVideoFilenames: string[]
104 let hlsFilenames: string[] 104 let hlsFilenames: string[]
105 105
106 for (const server of servers) { 106 for (const server of servers) {
@@ -108,24 +108,24 @@ async function ensureSameFilenames (videoUUID: string) {
108 108
109 // Ensure we use the same filenames that the origin 109 // Ensure we use the same filenames that the origin
110 110
111 const localWebtorrentFilenames = video.files.map(f => basename(f.fileUrl)).sort() 111 const localWebVideoFilenames = video.files.map(f => basename(f.fileUrl)).sort()
112 const localHLSFilenames = video.streamingPlaylists[0].files.map(f => basename(f.fileUrl)).sort() 112 const localHLSFilenames = video.streamingPlaylists[0].files.map(f => basename(f.fileUrl)).sort()
113 113
114 if (webtorrentFilenames) expect(webtorrentFilenames).to.deep.equal(localWebtorrentFilenames) 114 if (webVideoFilenames) expect(webVideoFilenames).to.deep.equal(localWebVideoFilenames)
115 else webtorrentFilenames = localWebtorrentFilenames 115 else webVideoFilenames = localWebVideoFilenames
116 116
117 if (hlsFilenames) expect(hlsFilenames).to.deep.equal(localHLSFilenames) 117 if (hlsFilenames) expect(hlsFilenames).to.deep.equal(localHLSFilenames)
118 else hlsFilenames = localHLSFilenames 118 else hlsFilenames = localHLSFilenames
119 } 119 }
120 120
121 return { webtorrentFilenames, hlsFilenames } 121 return { webVideoFilenames, hlsFilenames }
122} 122}
123 123
124async function check1WebSeed (videoUUID?: string) { 124async function check1WebSeed (videoUUID?: string) {
125 if (!videoUUID) videoUUID = video1Server2.uuid 125 if (!videoUUID) videoUUID = video1Server2.uuid
126 126
127 const webseeds = [ 127 const webseeds = [
128 `${servers[1].url}/static/webseed/` 128 `${servers[1].url}/static/web-videos/`
129 ] 129 ]
130 130
131 for (const server of servers) { 131 for (const server of servers) {
@@ -145,7 +145,7 @@ async function check2Webseeds (videoUUID?: string) {
145 145
146 const webseeds = [ 146 const webseeds = [
147 `${servers[0].url}/static/redundancy/`, 147 `${servers[0].url}/static/redundancy/`,
148 `${servers[1].url}/static/webseed/` 148 `${servers[1].url}/static/web-videos/`
149 ] 149 ]
150 150
151 for (const server of servers) { 151 for (const server of servers) {
@@ -156,11 +156,11 @@ async function check2Webseeds (videoUUID?: string) {
156 } 156 }
157 } 157 }
158 158
159 const { webtorrentFilenames } = await ensureSameFilenames(videoUUID) 159 const { webVideoFilenames } = await ensureSameFilenames(videoUUID)
160 160
161 const directories = [ 161 const directories = [
162 servers[0].getDirectoryPath('redundancy'), 162 servers[0].getDirectoryPath('redundancy'),
163 servers[1].getDirectoryPath('videos') 163 servers[1].getDirectoryPath('web-videos')
164 ] 164 ]
165 165
166 for (const directory of directories) { 166 for (const directory of directories) {
@@ -168,7 +168,7 @@ async function check2Webseeds (videoUUID?: string) {
168 expect(files).to.have.length.at.least(4) 168 expect(files).to.have.length.at.least(4)
169 169
170 // Ensure we files exist on disk 170 // Ensure we files exist on disk
171 expect(files.find(f => webtorrentFilenames.includes(f))).to.exist 171 expect(files.find(f => webVideoFilenames.includes(f))).to.exist
172 } 172 }
173} 173}
174 174
diff --git a/server/tests/api/runners/runner-studio-transcoding.ts b/server/tests/api/runners/runner-studio-transcoding.ts
index 41c556775..443a9d02a 100644
--- a/server/tests/api/runners/runner-studio-transcoding.ts
+++ b/server/tests/api/runners/runner-studio-transcoding.ts
@@ -104,7 +104,7 @@ describe('Test runner video studio transcoding', function () {
104 { 104 {
105 name: 'add-watermark' as 'add-watermark', 105 name: 'add-watermark' as 'add-watermark',
106 options: { 106 options: {
107 file: 'thumbnail.png' 107 file: 'custom-thumbnail.png'
108 } 108 }
109 }, 109 },
110 { 110 {
diff --git a/server/tests/api/runners/runner-vod-transcoding.ts b/server/tests/api/runners/runner-vod-transcoding.ts
index d9da0f40d..ca16d9c10 100644
--- a/server/tests/api/runners/runner-vod-transcoding.ts
+++ b/server/tests/api/runners/runner-vod-transcoding.ts
@@ -424,7 +424,7 @@ describe('Test runner VOD transcoding', function () {
424 424
425 await servers[0].config.enableTranscoding(true, true) 425 await servers[0].config.enableTranscoding(true, true)
426 426
427 const attributes = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' } 427 const attributes = { name: 'audio_with_preview', previewfile: 'custom-preview.jpg', fixture: 'sample.ogg' }
428 const { uuid } = await servers[0].videos.upload({ attributes, mode: 'legacy' }) 428 const { uuid } = await servers[0].videos.upload({ attributes, mode: 'legacy' })
429 videoUUID = uuid 429 videoUUID = uuid
430 430
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts
index 011ba268c..0e700eddb 100644
--- a/server/tests/api/server/config.ts
+++ b/server/tests/api/server/config.ts
@@ -46,6 +46,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
46 expect(data.cache.previews.size).to.equal(1) 46 expect(data.cache.previews.size).to.equal(1)
47 expect(data.cache.captions.size).to.equal(1) 47 expect(data.cache.captions.size).to.equal(1)
48 expect(data.cache.torrents.size).to.equal(1) 48 expect(data.cache.torrents.size).to.equal(1)
49 expect(data.cache.storyboards.size).to.equal(1)
49 50
50 expect(data.signup.enabled).to.be.true 51 expect(data.signup.enabled).to.be.true
51 expect(data.signup.limit).to.equal(4) 52 expect(data.signup.limit).to.equal(4)
@@ -78,7 +79,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
78 expect(data.transcoding.resolutions['1440p']).to.be.true 79 expect(data.transcoding.resolutions['1440p']).to.be.true
79 expect(data.transcoding.resolutions['2160p']).to.be.true 80 expect(data.transcoding.resolutions['2160p']).to.be.true
80 expect(data.transcoding.alwaysTranscodeOriginalResolution).to.be.true 81 expect(data.transcoding.alwaysTranscodeOriginalResolution).to.be.true
81 expect(data.transcoding.webtorrent.enabled).to.be.true 82 expect(data.transcoding.webVideos.enabled).to.be.true
82 expect(data.transcoding.hls.enabled).to.be.true 83 expect(data.transcoding.hls.enabled).to.be.true
83 84
84 expect(data.live.enabled).to.be.false 85 expect(data.live.enabled).to.be.false
@@ -154,6 +155,7 @@ function checkUpdatedConfig (data: CustomConfig) {
154 expect(data.cache.previews.size).to.equal(2) 155 expect(data.cache.previews.size).to.equal(2)
155 expect(data.cache.captions.size).to.equal(3) 156 expect(data.cache.captions.size).to.equal(3)
156 expect(data.cache.torrents.size).to.equal(4) 157 expect(data.cache.torrents.size).to.equal(4)
158 expect(data.cache.storyboards.size).to.equal(5)
157 159
158 expect(data.signup.enabled).to.be.false 160 expect(data.signup.enabled).to.be.false
159 expect(data.signup.limit).to.equal(5) 161 expect(data.signup.limit).to.equal(5)
@@ -190,7 +192,7 @@ function checkUpdatedConfig (data: CustomConfig) {
190 expect(data.transcoding.resolutions['2160p']).to.be.false 192 expect(data.transcoding.resolutions['2160p']).to.be.false
191 expect(data.transcoding.alwaysTranscodeOriginalResolution).to.be.false 193 expect(data.transcoding.alwaysTranscodeOriginalResolution).to.be.false
192 expect(data.transcoding.hls.enabled).to.be.false 194 expect(data.transcoding.hls.enabled).to.be.false
193 expect(data.transcoding.webtorrent.enabled).to.be.true 195 expect(data.transcoding.webVideos.enabled).to.be.true
194 196
195 expect(data.live.enabled).to.be.true 197 expect(data.live.enabled).to.be.true
196 expect(data.live.allowReplay).to.be.true 198 expect(data.live.allowReplay).to.be.true
@@ -290,6 +292,9 @@ const newCustomConfig: CustomConfig = {
290 }, 292 },
291 torrents: { 293 torrents: {
292 size: 4 294 size: 4
295 },
296 storyboards: {
297 size: 5
293 } 298 }
294 }, 299 },
295 signup: { 300 signup: {
@@ -339,7 +344,7 @@ const newCustomConfig: CustomConfig = {
339 '2160p': false 344 '2160p': false
340 }, 345 },
341 alwaysTranscodeOriginalResolution: false, 346 alwaysTranscodeOriginalResolution: false,
342 webtorrent: { 347 webVideos: {
343 enabled: true 348 enabled: true
344 }, 349 },
345 hls: { 350 hls: {
diff --git a/server/tests/api/server/follows.ts b/server/tests/api/server/follows.ts
index 2a5fff82b..e3e4605ee 100644
--- a/server/tests/api/server/follows.ts
+++ b/server/tests/api/server/follows.ts
@@ -6,611 +6,636 @@ import { Video, VideoPrivacy } from '@shared/models'
6import { cleanupTests, createMultipleServers, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@shared/server-commands' 6import { cleanupTests, createMultipleServers, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@shared/server-commands'
7 7
8describe('Test follows', function () { 8describe('Test follows', function () {
9 let servers: PeerTubeServer[] = []
10 9
11 before(async function () { 10 describe('Complex follow', function () {
12 this.timeout(120000) 11 let servers: PeerTubeServer[] = []
13 12
14 servers = await createMultipleServers(3) 13 before(async function () {
14 this.timeout(120000)
15 15
16 // Get the access tokens 16 servers = await createMultipleServers(3)
17 await setAccessTokensToServers(servers)
18 })
19 17
20 describe('Data propagation after follow', function () { 18 // Get the access tokens
19 await setAccessTokensToServers(servers)
20 })
21 21
22 it('Should not have followers/followings', async function () { 22 describe('Data propagation after follow', function () {
23 for (const server of servers) {
24 const bodies = await Promise.all([
25 server.follows.getFollowers({ start: 0, count: 5, sort: 'createdAt' }),
26 server.follows.getFollowings({ start: 0, count: 5, sort: 'createdAt' })
27 ])
28 23
29 for (const body of bodies) { 24 it('Should not have followers/followings', async function () {
30 expect(body.total).to.equal(0) 25 for (const server of servers) {
26 const bodies = await Promise.all([
27 server.follows.getFollowers({ start: 0, count: 5, sort: 'createdAt' }),
28 server.follows.getFollowings({ start: 0, count: 5, sort: 'createdAt' })
29 ])
31 30
32 const follows = body.data 31 for (const body of bodies) {
33 expect(follows).to.be.an('array') 32 expect(body.total).to.equal(0)
34 expect(follows).to.have.lengthOf(0) 33
34 const follows = body.data
35 expect(follows).to.be.an('array')
36 expect(follows).to.have.lengthOf(0)
37 }
35 } 38 }
36 } 39 })
37 }) 40
41 it('Should have server 1 following root account of server 2 and server 3', async function () {
42 this.timeout(30000)
38 43
39 it('Should have server 1 following root account of server 2 and server 3', async function () { 44 await servers[0].follows.follow({
40 this.timeout(30000) 45 hosts: [ servers[2].url ],
46 handles: [ 'root@' + servers[1].host ]
47 })
41 48
42 await servers[0].follows.follow({ 49 await waitJobs(servers)
43 hosts: [ servers[2].url ],
44 handles: [ 'root@' + servers[1].host ]
45 }) 50 })
46 51
47 await waitJobs(servers) 52 it('Should have 2 followings on server 1', async function () {
48 }) 53 const body = await servers[0].follows.getFollowings({ start: 0, count: 1, sort: 'createdAt' })
54 expect(body.total).to.equal(2)
49 55
50 it('Should have 2 followings on server 1', async function () { 56 let follows = body.data
51 const body = await servers[0].follows.getFollowings({ start: 0, count: 1, sort: 'createdAt' }) 57 expect(follows).to.be.an('array')
52 expect(body.total).to.equal(2) 58 expect(follows).to.have.lengthOf(1)
53 59
54 let follows = body.data 60 const body2 = await servers[0].follows.getFollowings({ start: 1, count: 1, sort: 'createdAt' })
55 expect(follows).to.be.an('array') 61 follows = follows.concat(body2.data)
56 expect(follows).to.have.lengthOf(1)
57 62
58 const body2 = await servers[0].follows.getFollowings({ start: 1, count: 1, sort: 'createdAt' }) 63 const server2Follow = follows.find(f => f.following.host === servers[1].host)
59 follows = follows.concat(body2.data) 64 const server3Follow = follows.find(f => f.following.host === servers[2].host)
60 65
61 const server2Follow = follows.find(f => f.following.host === servers[1].host) 66 expect(server2Follow).to.not.be.undefined
62 const server3Follow = follows.find(f => f.following.host === servers[2].host) 67 expect(server2Follow.following.name).to.equal('root')
68 expect(server2Follow.state).to.equal('accepted')
63 69
64 expect(server2Follow).to.not.be.undefined 70 expect(server3Follow).to.not.be.undefined
65 expect(server2Follow.following.name).to.equal('root') 71 expect(server3Follow.following.name).to.equal('peertube')
66 expect(server2Follow.state).to.equal('accepted') 72 expect(server3Follow.state).to.equal('accepted')
73 })
67 74
68 expect(server3Follow).to.not.be.undefined 75 it('Should have 0 followings on server 2 and 3', async function () {
69 expect(server3Follow.following.name).to.equal('peertube') 76 for (const server of [ servers[1], servers[2] ]) {
70 expect(server3Follow.state).to.equal('accepted') 77 const body = await server.follows.getFollowings({ start: 0, count: 5, sort: 'createdAt' })
71 }) 78 expect(body.total).to.equal(0)
72 79
73 it('Should have 0 followings on server 2 and 3', async function () { 80 const follows = body.data
74 for (const server of [ servers[1], servers[2] ]) { 81 expect(follows).to.be.an('array')
75 const body = await server.follows.getFollowings({ start: 0, count: 5, sort: 'createdAt' }) 82 expect(follows).to.have.lengthOf(0)
76 expect(body.total).to.equal(0) 83 }
84 })
85
86 it('Should have 1 followers on server 3', async function () {
87 const body = await servers[2].follows.getFollowers({ start: 0, count: 1, sort: 'createdAt' })
88 expect(body.total).to.equal(1)
77 89
78 const follows = body.data 90 const follows = body.data
79 expect(follows).to.be.an('array') 91 expect(follows).to.be.an('array')
80 expect(follows).to.have.lengthOf(0) 92 expect(follows).to.have.lengthOf(1)
81 } 93 expect(follows[0].follower.host).to.equal(servers[0].host)
82 }) 94 })
83 95
84 it('Should have 1 followers on server 3', async function () { 96 it('Should have 0 followers on server 1 and 2', async function () {
85 const body = await servers[2].follows.getFollowers({ start: 0, count: 1, sort: 'createdAt' }) 97 for (const server of [ servers[0], servers[1] ]) {
86 expect(body.total).to.equal(1) 98 const body = await server.follows.getFollowers({ start: 0, count: 5, sort: 'createdAt' })
99 expect(body.total).to.equal(0)
87 100
88 const follows = body.data 101 const follows = body.data
89 expect(follows).to.be.an('array') 102 expect(follows).to.be.an('array')
90 expect(follows).to.have.lengthOf(1) 103 expect(follows).to.have.lengthOf(0)
91 expect(follows[0].follower.host).to.equal(servers[0].host) 104 }
92 }) 105 })
93 106
94 it('Should have 0 followers on server 1 and 2', async function () { 107 it('Should search/filter followings on server 1', async function () {
95 for (const server of [ servers[0], servers[1] ]) { 108 const sort = 'createdAt'
96 const body = await server.follows.getFollowers({ start: 0, count: 5, sort: 'createdAt' }) 109 const start = 0
97 expect(body.total).to.equal(0) 110 const count = 1
98 111
99 const follows = body.data 112 {
100 expect(follows).to.be.an('array') 113 const search = ':' + servers[1].port
101 expect(follows).to.have.lengthOf(0)
102 }
103 })
104 114
105 it('Should search/filter followings on server 1', async function () { 115 {
106 const sort = 'createdAt' 116 const body = await servers[0].follows.getFollowings({ start, count, sort, search })
107 const start = 0 117 expect(body.total).to.equal(1)
108 const count = 1
109 118
110 { 119 const follows = body.data
111 const search = ':' + servers[1].port 120 expect(follows).to.have.lengthOf(1)
121 expect(follows[0].following.host).to.equal(servers[1].host)
122 }
112 123
113 { 124 {
114 const body = await servers[0].follows.getFollowings({ start, count, sort, search }) 125 const body = await servers[0].follows.getFollowings({ start, count, sort, search, state: 'accepted' })
115 expect(body.total).to.equal(1) 126 expect(body.total).to.equal(1)
127 expect(body.data).to.have.lengthOf(1)
128 }
116 129
117 const follows = body.data 130 {
118 expect(follows).to.have.lengthOf(1) 131 const body = await servers[0].follows.getFollowings({ start, count, sort, search, state: 'accepted', actorType: 'Person' })
119 expect(follows[0].following.host).to.equal(servers[1].host) 132 expect(body.total).to.equal(1)
120 } 133 expect(body.data).to.have.lengthOf(1)
134 }
121 135
122 { 136 {
123 const body = await servers[0].follows.getFollowings({ start, count, sort, search, state: 'accepted' }) 137 const body = await servers[0].follows.getFollowings({
124 expect(body.total).to.equal(1) 138 start,
125 expect(body.data).to.have.lengthOf(1) 139 count,
140 sort,
141 search,
142 state: 'accepted',
143 actorType: 'Application'
144 })
145 expect(body.total).to.equal(0)
146 expect(body.data).to.have.lengthOf(0)
147 }
148
149 {
150 const body = await servers[0].follows.getFollowings({ start, count, sort, search, state: 'pending' })
151 expect(body.total).to.equal(0)
152 expect(body.data).to.have.lengthOf(0)
153 }
126 } 154 }
127 155
128 { 156 {
129 const body = await servers[0].follows.getFollowings({ start, count, sort, search, state: 'accepted', actorType: 'Person' }) 157 const body = await servers[0].follows.getFollowings({ start, count, sort, search: 'root' })
130 expect(body.total).to.equal(1) 158 expect(body.total).to.equal(1)
131 expect(body.data).to.have.lengthOf(1) 159 expect(body.data).to.have.lengthOf(1)
132 } 160 }
133 161
134 { 162 {
135 const body = await servers[0].follows.getFollowings({ 163 const body = await servers[0].follows.getFollowings({ start, count, sort, search: 'bla' })
136 start,
137 count,
138 sort,
139 search,
140 state: 'accepted',
141 actorType: 'Application'
142 })
143 expect(body.total).to.equal(0) 164 expect(body.total).to.equal(0)
144 expect(body.data).to.have.lengthOf(0)
145 }
146 165
147 {
148 const body = await servers[0].follows.getFollowings({ start, count, sort, search, state: 'pending' })
149 expect(body.total).to.equal(0)
150 expect(body.data).to.have.lengthOf(0) 166 expect(body.data).to.have.lengthOf(0)
151 } 167 }
152 } 168 })
153 169
154 { 170 it('Should search/filter followers on server 2', async function () {
155 const body = await servers[0].follows.getFollowings({ start, count, sort, search: 'root' }) 171 const start = 0
156 expect(body.total).to.equal(1) 172 const count = 5
157 expect(body.data).to.have.lengthOf(1) 173 const sort = 'createdAt'
158 }
159 174
160 { 175 {
161 const body = await servers[0].follows.getFollowings({ start, count, sort, search: 'bla' }) 176 const search = servers[0].port + ''
162 expect(body.total).to.equal(0)
163 177
164 expect(body.data).to.have.lengthOf(0) 178 {
165 } 179 const body = await servers[2].follows.getFollowers({ start, count, sort, search })
166 }) 180 expect(body.total).to.equal(1)
167 181
168 it('Should search/filter followers on server 2', async function () { 182 const follows = body.data
169 const start = 0 183 expect(follows).to.have.lengthOf(1)
170 const count = 5 184 expect(follows[0].following.host).to.equal(servers[2].host)
171 const sort = 'createdAt' 185 }
172 186
173 { 187 {
174 const search = servers[0].port + '' 188 const body = await servers[2].follows.getFollowers({ start, count, sort, search, state: 'accepted' })
189 expect(body.total).to.equal(1)
190 expect(body.data).to.have.lengthOf(1)
191 }
175 192
176 { 193 {
177 const body = await servers[2].follows.getFollowers({ start, count, sort, search }) 194 const body = await servers[2].follows.getFollowers({ start, count, sort, search, state: 'accepted', actorType: 'Person' })
178 expect(body.total).to.equal(1) 195 expect(body.total).to.equal(0)
196 expect(body.data).to.have.lengthOf(0)
197 }
179 198
180 const follows = body.data 199 {
181 expect(follows).to.have.lengthOf(1) 200 const body = await servers[2].follows.getFollowers({
182 expect(follows[0].following.host).to.equal(servers[2].host) 201 start,
183 } 202 count,
203 sort,
204 search,
205 state: 'accepted',
206 actorType: 'Application'
207 })
208 expect(body.total).to.equal(1)
209 expect(body.data).to.have.lengthOf(1)
210 }
184 211
185 { 212 {
186 const body = await servers[2].follows.getFollowers({ start, count, sort, search, state: 'accepted' }) 213 const body = await servers[2].follows.getFollowers({ start, count, sort, search, state: 'pending' })
187 expect(body.total).to.equal(1) 214 expect(body.total).to.equal(0)
188 expect(body.data).to.have.lengthOf(1) 215 expect(body.data).to.have.lengthOf(0)
216 }
189 } 217 }
190 218
191 { 219 {
192 const body = await servers[2].follows.getFollowers({ start, count, sort, search, state: 'accepted', actorType: 'Person' }) 220 const body = await servers[2].follows.getFollowers({ start, count, sort, search: 'bla' })
193 expect(body.total).to.equal(0) 221 expect(body.total).to.equal(0)
194 expect(body.data).to.have.lengthOf(0)
195 }
196 222
197 { 223 const follows = body.data
198 const body = await servers[2].follows.getFollowers({ 224 expect(follows).to.have.lengthOf(0)
199 start,
200 count,
201 sort,
202 search,
203 state: 'accepted',
204 actorType: 'Application'
205 })
206 expect(body.total).to.equal(1)
207 expect(body.data).to.have.lengthOf(1)
208 } 225 }
226 })
209 227
210 { 228 it('Should have the correct follows counts', async function () {
211 const body = await servers[2].follows.getFollowers({ start, count, sort, search, state: 'pending' }) 229 await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[0].host, followers: 0, following: 2 })
212 expect(body.total).to.equal(0) 230 await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 1, following: 0 })
213 expect(body.data).to.have.lengthOf(0) 231 await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 })
214 } 232
215 } 233 // Server 2 and 3 does not know server 1 follow another server (there was not a refresh)
234 await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 })
235 await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 1, following: 0 })
236 await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[1].host, followers: 0, following: 0 })
237
238 await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 })
239 await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 })
240 })
241
242 it('Should unfollow server 3 on server 1', async function () {
243 this.timeout(15000)
244
245 await servers[0].follows.unfollow({ target: servers[2] })
216 246
217 { 247 await waitJobs(servers)
218 const body = await servers[2].follows.getFollowers({ start, count, sort, search: 'bla' }) 248 })
249
250 it('Should not follow server 3 on server 1 anymore', async function () {
251 const body = await servers[0].follows.getFollowings({ start: 0, count: 2, sort: 'createdAt' })
252 expect(body.total).to.equal(1)
253
254 const follows = body.data
255 expect(follows).to.be.an('array')
256 expect(follows).to.have.lengthOf(1)
257
258 expect(follows[0].following.host).to.equal(servers[1].host)
259 })
260
261 it('Should not have server 1 as follower on server 3 anymore', async function () {
262 const body = await servers[2].follows.getFollowers({ start: 0, count: 1, sort: 'createdAt' })
219 expect(body.total).to.equal(0) 263 expect(body.total).to.equal(0)
220 264
221 const follows = body.data 265 const follows = body.data
266 expect(follows).to.be.an('array')
222 expect(follows).to.have.lengthOf(0) 267 expect(follows).to.have.lengthOf(0)
223 } 268 })
224 })
225 269
226 it('Should have the correct follows counts', async function () { 270 it('Should have the correct follows counts after the unfollow', async function () {
227 await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[0].host, followers: 0, following: 2 }) 271 await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 })
228 await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 1, following: 0 }) 272 await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 1, following: 0 })
229 await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 }) 273 await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[2].host, followers: 0, following: 0 })
230 274
231 // Server 2 and 3 does not know server 1 follow another server (there was not a refresh) 275 await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 })
232 await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 }) 276 await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 1, following: 0 })
233 await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 1, following: 0 }) 277 await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[1].host, followers: 0, following: 0 })
234 await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[1].host, followers: 0, following: 0 })
235 278
236 await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 }) 279 await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[0].host, followers: 0, following: 0 })
237 await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 }) 280 await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[2].host, followers: 0, following: 0 })
238 }) 281 })
239 282
240 it('Should unfollow server 3 on server 1', async function () { 283 it('Should upload a video on server 2 and 3 and propagate only the video of server 2', async function () {
241 this.timeout(15000) 284 this.timeout(160000)
242 285
243 await servers[0].follows.unfollow({ target: servers[2] }) 286 await servers[1].videos.upload({ attributes: { name: 'server2' } })
287 await servers[2].videos.upload({ attributes: { name: 'server3' } })
244 288
245 await waitJobs(servers) 289 await waitJobs(servers)
246 })
247 290
248 it('Should not follow server 3 on server 1 anymore', async function () { 291 {
249 const body = await servers[0].follows.getFollowings({ start: 0, count: 2, sort: 'createdAt' }) 292 const { total, data } = await servers[0].videos.list()
250 expect(body.total).to.equal(1) 293 expect(total).to.equal(1)
294 expect(data[0].name).to.equal('server2')
295 }
251 296
252 const follows = body.data 297 {
253 expect(follows).to.be.an('array') 298 const { total, data } = await servers[1].videos.list()
254 expect(follows).to.have.lengthOf(1) 299 expect(total).to.equal(1)
300 expect(data[0].name).to.equal('server2')
301 }
255 302
256 expect(follows[0].following.host).to.equal(servers[1].host) 303 {
257 }) 304 const { total, data } = await servers[2].videos.list()
305 expect(total).to.equal(1)
306 expect(data[0].name).to.equal('server3')
307 }
308 })
258 309
259 it('Should not have server 1 as follower on server 3 anymore', async function () { 310 it('Should remove account follow', async function () {
260 const body = await servers[2].follows.getFollowers({ start: 0, count: 1, sort: 'createdAt' }) 311 this.timeout(15000)
261 expect(body.total).to.equal(0)
262 312
263 const follows = body.data 313 await servers[0].follows.unfollow({ target: 'root@' + servers[1].host })
264 expect(follows).to.be.an('array')
265 expect(follows).to.have.lengthOf(0)
266 })
267 314
268 it('Should have the correct follows counts after the unfollow', async function () { 315 await waitJobs(servers)
269 await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 }) 316 })
270 await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 1, following: 0 })
271 await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[2].host, followers: 0, following: 0 })
272 317
273 await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 }) 318 it('Should have removed the account follow', async function () {
274 await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 1, following: 0 }) 319 await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 0, following: 0 })
275 await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[1].host, followers: 0, following: 0 }) 320 await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 0, following: 0 })
276 321
277 await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[0].host, followers: 0, following: 0 }) 322 {
278 await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[2].host, followers: 0, following: 0 }) 323 const { total, data } = await servers[0].follows.getFollowings()
279 }) 324 expect(total).to.equal(0)
325 expect(data).to.have.lengthOf(0)
326 }
280 327
281 it('Should upload a video on server 2 and 3 and propagate only the video of server 2', async function () { 328 {
282 this.timeout(160000) 329 const { total, data } = await servers[0].videos.list()
330 expect(total).to.equal(0)
331 expect(data).to.have.lengthOf(0)
332 }
333 })
283 334
284 await servers[1].videos.upload({ attributes: { name: 'server2' } }) 335 it('Should follow a channel', async function () {
285 await servers[2].videos.upload({ attributes: { name: 'server3' } }) 336 this.timeout(15000)
286 337
287 await waitJobs(servers) 338 await servers[0].follows.follow({
339 handles: [ 'root_channel@' + servers[1].host ]
340 })
288 341
289 { 342 await waitJobs(servers)
290 const { total, data } = await servers[0].videos.list()
291 expect(total).to.equal(1)
292 expect(data[0].name).to.equal('server2')
293 }
294 343
295 { 344 await expectChannelsFollows({ server: servers[0], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 })
296 const { total, data } = await servers[1].videos.list() 345 await expectChannelsFollows({ server: servers[1], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 })
297 expect(total).to.equal(1)
298 expect(data[0].name).to.equal('server2')
299 }
300 346
301 { 347 {
302 const { total, data } = await servers[2].videos.list() 348 const { total, data } = await servers[0].follows.getFollowings()
303 expect(total).to.equal(1) 349 expect(total).to.equal(1)
304 expect(data[0].name).to.equal('server3') 350 expect(data).to.have.lengthOf(1)
305 } 351 }
352
353 {
354 const { total, data } = await servers[0].videos.list()
355 expect(total).to.equal(1)
356 expect(data).to.have.lengthOf(1)
357 }
358 })
306 }) 359 })
307 360
308 it('Should remove account follow', async function () { 361 describe('Should propagate data on a new server follow', function () {
309 this.timeout(15000) 362 let video4: Video
310 363
311 await servers[0].follows.unfollow({ target: 'root@' + servers[1].host }) 364 before(async function () {
365 this.timeout(240000)
312 366
313 await waitJobs(servers) 367 const video4Attributes = {
314 }) 368 name: 'server3-4',
369 category: 2,
370 nsfw: true,
371 licence: 6,
372 tags: [ 'tag1', 'tag2', 'tag3' ]
373 }
315 374
316 it('Should have removed the account follow', async function () { 375 await servers[2].videos.upload({ attributes: { name: 'server3-2' } })
317 await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 0, following: 0 }) 376 await servers[2].videos.upload({ attributes: { name: 'server3-3' } })
318 await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 0, following: 0 })
319 377
320 { 378 const video4CreateResult = await servers[2].videos.upload({ attributes: video4Attributes })
321 const { total, data } = await servers[0].follows.getFollowings()
322 expect(total).to.equal(0)
323 expect(data).to.have.lengthOf(0)
324 }
325 379
326 { 380 await servers[2].videos.upload({ attributes: { name: 'server3-5' } })
327 const { total, data } = await servers[0].videos.list() 381 await servers[2].videos.upload({ attributes: { name: 'server3-6' } })
328 expect(total).to.equal(0)
329 expect(data).to.have.lengthOf(0)
330 }
331 })
332 382
333 it('Should follow a channel', async function () { 383 {
334 this.timeout(15000) 384 const userAccessToken = await servers[2].users.generateUserAndToken('captain')
335 385
336 await servers[0].follows.follow({ 386 await servers[2].videos.rate({ id: video4CreateResult.id, rating: 'like' })
337 handles: [ 'root_channel@' + servers[1].host ] 387 await servers[2].videos.rate({ token: userAccessToken, id: video4CreateResult.id, rating: 'dislike' })
338 }) 388 }
339 389
340 await waitJobs(servers) 390 {
391 await servers[2].comments.createThread({ videoId: video4CreateResult.id, text: 'my super first comment' })
341 392
342 await expectChannelsFollows({ server: servers[0], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 }) 393 await servers[2].comments.addReplyToLastThread({ text: 'my super answer to thread 1' })
343 await expectChannelsFollows({ server: servers[1], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 }) 394 await servers[2].comments.addReplyToLastReply({ text: 'my super answer to answer of thread 1' })
395 await servers[2].comments.addReplyToLastThread({ text: 'my second answer to thread 1' })
396 }
344 397
345 { 398 {
346 const { total, data } = await servers[0].follows.getFollowings() 399 const { id: threadId } = await servers[2].comments.createThread({ videoId: video4CreateResult.id, text: 'will be deleted' })
347 expect(total).to.equal(1) 400 await servers[2].comments.addReplyToLastThread({ text: 'answer to deleted' })
348 expect(data).to.have.lengthOf(1)
349 }
350 401
351 { 402 const { id: replyId } = await servers[2].comments.addReplyToLastThread({ text: 'will also be deleted' })
352 const { total, data } = await servers[0].videos.list()
353 expect(total).to.equal(1)
354 expect(data).to.have.lengthOf(1)
355 }
356 })
357 })
358 403
359 describe('Should propagate data on a new server follow', function () { 404 await servers[2].comments.addReplyToLastReply({ text: 'my second answer to deleted' })
360 let video4: Video
361 405
362 before(async function () { 406 await servers[2].comments.delete({ videoId: video4CreateResult.id, commentId: threadId })
363 this.timeout(120000) 407 await servers[2].comments.delete({ videoId: video4CreateResult.id, commentId: replyId })
408 }
364 409
365 const video4Attributes = { 410 await servers[2].captions.add({
366 name: 'server3-4', 411 language: 'ar',
367 category: 2, 412 videoId: video4CreateResult.id,
368 nsfw: true, 413 fixture: 'subtitle-good2.vtt'
369 licence: 6, 414 })
370 tags: [ 'tag1', 'tag2', 'tag3' ]
371 }
372 415
373 await servers[2].videos.upload({ attributes: { name: 'server3-2' } }) 416 await waitJobs(servers)
374 await servers[2].videos.upload({ attributes: { name: 'server3-3' } })
375 const video4CreateResult = await servers[2].videos.upload({ attributes: video4Attributes })
376 await servers[2].videos.upload({ attributes: { name: 'server3-5' } })
377 await servers[2].videos.upload({ attributes: { name: 'server3-6' } })
378 417
379 { 418 // Server 1 follows server 3
380 const userAccessToken = await servers[2].users.generateUserAndToken('captain') 419 await servers[0].follows.follow({ hosts: [ servers[2].url ] })
381 420
382 await servers[2].videos.rate({ id: video4CreateResult.id, rating: 'like' }) 421 await waitJobs(servers)
383 await servers[2].videos.rate({ token: userAccessToken, id: video4CreateResult.id, rating: 'dislike' }) 422 })
384 }
385 423
386 { 424 it('Should have the correct follows counts', async function () {
387 await servers[2].comments.createThread({ videoId: video4CreateResult.id, text: 'my super first comment' }) 425 await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[0].host, followers: 0, following: 2 })
426 await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 0, following: 0 })
427 await expectChannelsFollows({ server: servers[0], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 })
428 await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 })
388 429
389 await servers[2].comments.addReplyToLastThread({ text: 'my super answer to thread 1' }) 430 await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 })
390 await servers[2].comments.addReplyToLastReply({ text: 'my super answer to answer of thread 1' }) 431 await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[1].host, followers: 0, following: 0 })
391 await servers[2].comments.addReplyToLastThread({ text: 'my second answer to thread 1' }) 432 await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 0, following: 0 })
392 } 433 await expectChannelsFollows({ server: servers[1], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 })
393 434
394 { 435 await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 })
395 const { id: threadId } = await servers[2].comments.createThread({ videoId: video4CreateResult.id, text: 'will be deleted' }) 436 await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 })
396 await servers[2].comments.addReplyToLastThread({ text: 'answer to deleted' }) 437 })
397 438
398 const { id: replyId } = await servers[2].comments.addReplyToLastThread({ text: 'will also be deleted' }) 439 it('Should have propagated videos', async function () {
440 const { total, data } = await servers[0].videos.list()
441 expect(total).to.equal(7)
442
443 const video2 = data.find(v => v.name === 'server3-2')
444 video4 = data.find(v => v.name === 'server3-4')
445 const video6 = data.find(v => v.name === 'server3-6')
446
447 expect(video2).to.not.be.undefined
448 expect(video4).to.not.be.undefined
449 expect(video6).to.not.be.undefined
450
451 const isLocal = false
452 const checkAttributes = {
453 name: 'server3-4',
454 category: 2,
455 licence: 6,
456 language: 'zh',
457 nsfw: true,
458 description: 'my super description',
459 support: 'my super support text',
460 account: {
461 name: 'root',
462 host: servers[2].host
463 },
464 isLocal,
465 commentsEnabled: true,
466 downloadEnabled: true,
467 duration: 5,
468 tags: [ 'tag1', 'tag2', 'tag3' ],
469 privacy: VideoPrivacy.PUBLIC,
470 likes: 1,
471 dislikes: 1,
472 channel: {
473 displayName: 'Main root channel',
474 name: 'root_channel',
475 description: '',
476 isLocal
477 },
478 fixture: 'video_short.webm',
479 files: [
480 {
481 resolution: 720,
482 size: 218910
483 }
484 ]
485 }
486 await completeVideoCheck({
487 server: servers[0],
488 originServer: servers[2],
489 videoUUID: video4.uuid,
490 attributes: checkAttributes
491 })
492 })
399 493
400 await servers[2].comments.addReplyToLastReply({ text: 'my second answer to deleted' }) 494 it('Should have propagated comments', async function () {
495 const { total, data } = await servers[0].comments.listThreads({ videoId: video4.id, sort: 'createdAt' })
401 496
402 await servers[2].comments.delete({ videoId: video4CreateResult.id, commentId: threadId }) 497 expect(total).to.equal(2)
403 await servers[2].comments.delete({ videoId: video4CreateResult.id, commentId: replyId }) 498 expect(data).to.be.an('array')
404 } 499 expect(data).to.have.lengthOf(2)
405 500
406 await servers[2].captions.add({ 501 {
407 language: 'ar', 502 const comment = data[0]
408 videoId: video4CreateResult.id, 503 expect(comment.inReplyToCommentId).to.be.null
409 fixture: 'subtitle-good2.vtt' 504 expect(comment.text).equal('my super first comment')
410 }) 505 expect(comment.videoId).to.equal(video4.id)
506 expect(comment.id).to.equal(comment.threadId)
507 expect(comment.account.name).to.equal('root')
508 expect(comment.account.host).to.equal(servers[2].host)
509 expect(comment.totalReplies).to.equal(3)
510 expect(dateIsValid(comment.createdAt as string)).to.be.true
511 expect(dateIsValid(comment.updatedAt as string)).to.be.true
512
513 const threadId = comment.threadId
514
515 const tree = await servers[0].comments.getThread({ videoId: video4.id, threadId })
516 expect(tree.comment.text).equal('my super first comment')
517 expect(tree.children).to.have.lengthOf(2)
518
519 const firstChild = tree.children[0]
520 expect(firstChild.comment.text).to.equal('my super answer to thread 1')
521 expect(firstChild.children).to.have.lengthOf(1)
522
523 const childOfFirstChild = firstChild.children[0]
524 expect(childOfFirstChild.comment.text).to.equal('my super answer to answer of thread 1')
525 expect(childOfFirstChild.children).to.have.lengthOf(0)
526
527 const secondChild = tree.children[1]
528 expect(secondChild.comment.text).to.equal('my second answer to thread 1')
529 expect(secondChild.children).to.have.lengthOf(0)
530 }
411 531
412 await waitJobs(servers) 532 {
533 const deletedComment = data[1]
534 expect(deletedComment).to.not.be.undefined
535 expect(deletedComment.isDeleted).to.be.true
536 expect(deletedComment.deletedAt).to.not.be.null
537 expect(deletedComment.text).to.equal('')
538 expect(deletedComment.inReplyToCommentId).to.be.null
539 expect(deletedComment.account).to.be.null
540 expect(deletedComment.totalReplies).to.equal(2)
541 expect(dateIsValid(deletedComment.deletedAt as string)).to.be.true
542
543 const tree = await servers[0].comments.getThread({ videoId: video4.id, threadId: deletedComment.threadId })
544 const [ commentRoot, deletedChildRoot ] = tree.children
545
546 expect(deletedChildRoot).to.not.be.undefined
547 expect(deletedChildRoot.comment.isDeleted).to.be.true
548 expect(deletedChildRoot.comment.deletedAt).to.not.be.null
549 expect(deletedChildRoot.comment.text).to.equal('')
550 expect(deletedChildRoot.comment.inReplyToCommentId).to.equal(deletedComment.id)
551 expect(deletedChildRoot.comment.account).to.be.null
552 expect(deletedChildRoot.children).to.have.lengthOf(1)
553
554 const answerToDeletedChild = deletedChildRoot.children[0]
555 expect(answerToDeletedChild.comment).to.not.be.undefined
556 expect(answerToDeletedChild.comment.inReplyToCommentId).to.equal(deletedChildRoot.comment.id)
557 expect(answerToDeletedChild.comment.text).to.equal('my second answer to deleted')
558 expect(answerToDeletedChild.comment.account.name).to.equal('root')
559
560 expect(commentRoot.comment).to.not.be.undefined
561 expect(commentRoot.comment.inReplyToCommentId).to.equal(deletedComment.id)
562 expect(commentRoot.comment.text).to.equal('answer to deleted')
563 expect(commentRoot.comment.account.name).to.equal('root')
564 }
565 })
413 566
414 // Server 1 follows server 3 567 it('Should have propagated captions', async function () {
415 await servers[0].follows.follow({ hosts: [ servers[2].url ] }) 568 const body = await servers[0].captions.list({ videoId: video4.id })
569 expect(body.total).to.equal(1)
570 expect(body.data).to.have.lengthOf(1)
416 571
417 await waitJobs(servers) 572 const caption1 = body.data[0]
418 }) 573 expect(caption1.language.id).to.equal('ar')
574 expect(caption1.language.label).to.equal('Arabic')
575 expect(caption1.captionPath).to.match(new RegExp('^/lazy-static/video-captions/.+-ar.vtt$'))
576 await testCaptionFile(servers[0].url, caption1.captionPath, 'Subtitle good 2.')
577 })
419 578
420 it('Should have the correct follows counts', async function () { 579 it('Should unfollow server 3 on server 1 and does not list server 3 videos', async function () {
421 await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[0].host, followers: 0, following: 2 }) 580 this.timeout(5000)
422 await expectAccountFollows({ server: servers[0], handle: 'root@' + servers[1].host, followers: 0, following: 0 })
423 await expectChannelsFollows({ server: servers[0], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 })
424 await expectAccountFollows({ server: servers[0], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 })
425 581
426 await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 }) 582 await servers[0].follows.unfollow({ target: servers[2] })
427 await expectAccountFollows({ server: servers[1], handle: 'peertube@' + servers[1].host, followers: 0, following: 0 })
428 await expectAccountFollows({ server: servers[1], handle: 'root@' + servers[1].host, followers: 0, following: 0 })
429 await expectChannelsFollows({ server: servers[1], handle: 'root_channel@' + servers[1].host, followers: 1, following: 0 })
430 583
431 await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[0].host, followers: 0, following: 1 }) 584 await waitJobs(servers)
432 await expectAccountFollows({ server: servers[2], handle: 'peertube@' + servers[2].host, followers: 1, following: 0 })
433 })
434 585
435 it('Should have propagated videos', async function () { 586 const { total } = await servers[0].videos.list()
436 const { total, data } = await servers[0].videos.list() 587 expect(total).to.equal(1)
437 expect(total).to.equal(7)
438
439 const video2 = data.find(v => v.name === 'server3-2')
440 video4 = data.find(v => v.name === 'server3-4')
441 const video6 = data.find(v => v.name === 'server3-6')
442
443 expect(video2).to.not.be.undefined
444 expect(video4).to.not.be.undefined
445 expect(video6).to.not.be.undefined
446
447 const isLocal = false
448 const checkAttributes = {
449 name: 'server3-4',
450 category: 2,
451 licence: 6,
452 language: 'zh',
453 nsfw: true,
454 description: 'my super description',
455 support: 'my super support text',
456 account: {
457 name: 'root',
458 host: servers[2].host
459 },
460 isLocal,
461 commentsEnabled: true,
462 downloadEnabled: true,
463 duration: 5,
464 tags: [ 'tag1', 'tag2', 'tag3' ],
465 privacy: VideoPrivacy.PUBLIC,
466 likes: 1,
467 dislikes: 1,
468 channel: {
469 displayName: 'Main root channel',
470 name: 'root_channel',
471 description: '',
472 isLocal
473 },
474 fixture: 'video_short.webm',
475 files: [
476 {
477 resolution: 720,
478 size: 218910
479 }
480 ]
481 }
482 await completeVideoCheck({
483 server: servers[0],
484 originServer: servers[2],
485 videoUUID: video4.uuid,
486 attributes: checkAttributes
487 }) 588 })
488 }) 589 })
489 590
490 it('Should have propagated comments', async function () { 591 after(async function () {
491 const { total, data } = await servers[0].comments.listThreads({ videoId: video4.id, sort: 'createdAt' }) 592 await cleanupTests(servers)
492
493 expect(total).to.equal(2)
494 expect(data).to.be.an('array')
495 expect(data).to.have.lengthOf(2)
496
497 {
498 const comment = data[0]
499 expect(comment.inReplyToCommentId).to.be.null
500 expect(comment.text).equal('my super first comment')
501 expect(comment.videoId).to.equal(video4.id)
502 expect(comment.id).to.equal(comment.threadId)
503 expect(comment.account.name).to.equal('root')
504 expect(comment.account.host).to.equal(servers[2].host)
505 expect(comment.totalReplies).to.equal(3)
506 expect(dateIsValid(comment.createdAt as string)).to.be.true
507 expect(dateIsValid(comment.updatedAt as string)).to.be.true
508
509 const threadId = comment.threadId
510
511 const tree = await servers[0].comments.getThread({ videoId: video4.id, threadId })
512 expect(tree.comment.text).equal('my super first comment')
513 expect(tree.children).to.have.lengthOf(2)
514
515 const firstChild = tree.children[0]
516 expect(firstChild.comment.text).to.equal('my super answer to thread 1')
517 expect(firstChild.children).to.have.lengthOf(1)
518
519 const childOfFirstChild = firstChild.children[0]
520 expect(childOfFirstChild.comment.text).to.equal('my super answer to answer of thread 1')
521 expect(childOfFirstChild.children).to.have.lengthOf(0)
522
523 const secondChild = tree.children[1]
524 expect(secondChild.comment.text).to.equal('my second answer to thread 1')
525 expect(secondChild.children).to.have.lengthOf(0)
526 }
527
528 {
529 const deletedComment = data[1]
530 expect(deletedComment).to.not.be.undefined
531 expect(deletedComment.isDeleted).to.be.true
532 expect(deletedComment.deletedAt).to.not.be.null
533 expect(deletedComment.text).to.equal('')
534 expect(deletedComment.inReplyToCommentId).to.be.null
535 expect(deletedComment.account).to.be.null
536 expect(deletedComment.totalReplies).to.equal(2)
537 expect(dateIsValid(deletedComment.deletedAt as string)).to.be.true
538
539 const tree = await servers[0].comments.getThread({ videoId: video4.id, threadId: deletedComment.threadId })
540 const [ commentRoot, deletedChildRoot ] = tree.children
541
542 expect(deletedChildRoot).to.not.be.undefined
543 expect(deletedChildRoot.comment.isDeleted).to.be.true
544 expect(deletedChildRoot.comment.deletedAt).to.not.be.null
545 expect(deletedChildRoot.comment.text).to.equal('')
546 expect(deletedChildRoot.comment.inReplyToCommentId).to.equal(deletedComment.id)
547 expect(deletedChildRoot.comment.account).to.be.null
548 expect(deletedChildRoot.children).to.have.lengthOf(1)
549
550 const answerToDeletedChild = deletedChildRoot.children[0]
551 expect(answerToDeletedChild.comment).to.not.be.undefined
552 expect(answerToDeletedChild.comment.inReplyToCommentId).to.equal(deletedChildRoot.comment.id)
553 expect(answerToDeletedChild.comment.text).to.equal('my second answer to deleted')
554 expect(answerToDeletedChild.comment.account.name).to.equal('root')
555
556 expect(commentRoot.comment).to.not.be.undefined
557 expect(commentRoot.comment.inReplyToCommentId).to.equal(deletedComment.id)
558 expect(commentRoot.comment.text).to.equal('answer to deleted')
559 expect(commentRoot.comment.account.name).to.equal('root')
560 }
561 }) 593 })
594 })
562 595
563 it('Should have propagated captions', async function () { 596 describe('Simple data propagation propagate data on a new channel follow', function () {
564 const body = await servers[0].captions.list({ videoId: video4.id }) 597 let servers: PeerTubeServer[] = []
565 expect(body.total).to.equal(1)
566 expect(body.data).to.have.lengthOf(1)
567 598
568 const caption1 = body.data[0] 599 before(async function () {
569 expect(caption1.language.id).to.equal('ar') 600 this.timeout(120000)
570 expect(caption1.language.label).to.equal('Arabic')
571 expect(caption1.captionPath).to.match(new RegExp('^/lazy-static/video-captions/.+-ar.vtt$'))
572 await testCaptionFile(servers[0].url, caption1.captionPath, 'Subtitle good 2.')
573 })
574 601
575 it('Should unfollow server 3 on server 1 and does not list server 3 videos', async function () { 602 servers = await createMultipleServers(3)
576 this.timeout(5000) 603 await setAccessTokensToServers(servers)
577 604
578 await servers[0].follows.unfollow({ target: servers[2] }) 605 await servers[0].videos.upload({ attributes: { name: 'video to add' } })
579 606
580 await waitJobs(servers) 607 await waitJobs(servers)
581 608
582 const { total } = await servers[0].videos.list() 609 for (const server of [ servers[1], servers[2] ]) {
583 expect(total).to.equal(1) 610 const video = await server.videos.find({ name: 'video to add' })
611 expect(video).to.not.exist
612 }
584 }) 613 })
585 })
586
587 describe('Should propagate data on a new channel follow', function () {
588 614
589 before(async function () { 615 it('Should have propagated video after new channel follow', async function () {
590 this.timeout(60000) 616 this.timeout(60000)
591 617
592 await servers[2].videos.upload({ attributes: { name: 'server3-7' } }) 618 await servers[1].follows.follow({ handles: [ 'root_channel@' + servers[0].host ] })
593 619
594 await waitJobs(servers) 620 await waitJobs(servers)
595 621
596 const video = await servers[0].videos.find({ name: 'server3-7' }) 622 const video = await servers[1].videos.find({ name: 'video to add' })
597 expect(video).to.not.exist 623 expect(video).to.exist
598 }) 624 })
599 625
600 it('Should have propagated channel video', async function () { 626 it('Should have propagated video after new account follow', async function () {
601 this.timeout(60000) 627 this.timeout(60000)
602 628
603 await servers[0].follows.follow({ handles: [ 'root_channel@' + servers[2].host ] }) 629 await servers[2].follows.follow({ handles: [ 'root@' + servers[0].host ] })
604 630
605 await waitJobs(servers) 631 await waitJobs(servers)
606 632
607 const video = await servers[0].videos.find({ name: 'server3-7' }) 633 const video = await servers[2].videos.find({ name: 'video to add' })
608
609 expect(video).to.exist 634 expect(video).to.exist
610 }) 635 })
611 })
612 636
613 after(async function () { 637 after(async function () {
614 await cleanupTests(servers) 638 await cleanupTests(servers)
639 })
615 }) 640 })
616}) 641})
diff --git a/server/tests/api/server/stats.ts b/server/tests/api/server/stats.ts
index aad0d231a..a1bf189fa 100644
--- a/server/tests/api/server/stats.ts
+++ b/server/tests/api/server/stats.ts
@@ -194,7 +194,7 @@ describe('Test stats (excluding redundancy)', function () {
194 newConfig: { 194 newConfig: {
195 transcoding: { 195 transcoding: {
196 enabled: true, 196 enabled: true,
197 webtorrent: { 197 webVideos: {
198 enabled: true 198 enabled: true
199 }, 199 },
200 hls: { 200 hls: {
diff --git a/server/tests/api/transcoding/audio-only.ts b/server/tests/api/transcoding/audio-only.ts
index 1e31418e7..f4cc012ef 100644
--- a/server/tests/api/transcoding/audio-only.ts
+++ b/server/tests/api/transcoding/audio-only.ts
@@ -14,7 +14,7 @@ import {
14describe('Test audio only video transcoding', function () { 14describe('Test audio only video transcoding', function () {
15 let servers: PeerTubeServer[] = [] 15 let servers: PeerTubeServer[] = []
16 let videoUUID: string 16 let videoUUID: string
17 let webtorrentAudioFileUrl: string 17 let webVideoAudioFileUrl: string
18 let fragmentedAudioFileUrl: string 18 let fragmentedAudioFileUrl: string
19 19
20 before(async function () { 20 before(async function () {
@@ -37,7 +37,7 @@ describe('Test audio only video transcoding', function () {
37 hls: { 37 hls: {
38 enabled: true 38 enabled: true
39 }, 39 },
40 webtorrent: { 40 web_videos: {
41 enabled: true 41 enabled: true
42 } 42 }
43 } 43 }
@@ -71,7 +71,7 @@ describe('Test audio only video transcoding', function () {
71 } 71 }
72 72
73 if (server.serverNumber === 1) { 73 if (server.serverNumber === 1) {
74 webtorrentAudioFileUrl = video.files[2].fileUrl 74 webVideoAudioFileUrl = video.files[2].fileUrl
75 fragmentedAudioFileUrl = video.streamingPlaylists[0].files[2].fileUrl 75 fragmentedAudioFileUrl = video.streamingPlaylists[0].files[2].fileUrl
76 } 76 }
77 } 77 }
@@ -79,7 +79,7 @@ describe('Test audio only video transcoding', function () {
79 79
80 it('0p transcoded video should not have video', async function () { 80 it('0p transcoded video should not have video', async function () {
81 const paths = [ 81 const paths = [
82 servers[0].servers.buildWebTorrentFilePath(webtorrentAudioFileUrl), 82 servers[0].servers.buildWebVideoFilePath(webVideoAudioFileUrl),
83 servers[0].servers.buildFragmentedFilePath(videoUUID, fragmentedAudioFileUrl) 83 servers[0].servers.buildFragmentedFilePath(videoUUID, fragmentedAudioFileUrl)
84 ] 84 ]
85 85
diff --git a/server/tests/api/transcoding/create-transcoding.ts b/server/tests/api/transcoding/create-transcoding.ts
index d6f5b01dc..9a891043c 100644
--- a/server/tests/api/transcoding/create-transcoding.ts
+++ b/server/tests/api/transcoding/create-transcoding.ts
@@ -96,12 +96,12 @@ function runTests (enableObjectStorage: boolean) {
96 } 96 }
97 }) 97 })
98 98
99 it('Should generate WebTorrent', async function () { 99 it('Should generate Web Video', async function () {
100 this.timeout(60000) 100 this.timeout(60000)
101 101
102 await servers[0].videos.runTranscoding({ 102 await servers[0].videos.runTranscoding({
103 videoId: videoUUID, 103 videoId: videoUUID,
104 transcodingType: 'webtorrent' 104 transcodingType: 'web-video'
105 }) 105 })
106 106
107 await waitJobs(servers) 107 await waitJobs(servers)
@@ -117,13 +117,13 @@ function runTests (enableObjectStorage: boolean) {
117 } 117 }
118 }) 118 })
119 119
120 it('Should generate WebTorrent from HLS only video', async function () { 120 it('Should generate Web Video from HLS only video', async function () {
121 this.timeout(60000) 121 this.timeout(60000)
122 122
123 await servers[0].videos.removeAllWebTorrentFiles({ videoId: videoUUID }) 123 await servers[0].videos.removeAllWebVideoFiles({ videoId: videoUUID })
124 await waitJobs(servers) 124 await waitJobs(servers)
125 125
126 await servers[0].videos.runTranscoding({ videoId: videoUUID, transcodingType: 'webtorrent' }) 126 await servers[0].videos.runTranscoding({ videoId: videoUUID, transcodingType: 'web-video' })
127 await waitJobs(servers) 127 await waitJobs(servers)
128 128
129 for (const server of servers) { 129 for (const server of servers) {
@@ -137,13 +137,13 @@ function runTests (enableObjectStorage: boolean) {
137 } 137 }
138 }) 138 })
139 139
140 it('Should only generate WebTorrent', async function () { 140 it('Should only generate Web Video', async function () {
141 this.timeout(60000) 141 this.timeout(60000)
142 142
143 await servers[0].videos.removeHLSPlaylist({ videoId: videoUUID }) 143 await servers[0].videos.removeHLSPlaylist({ videoId: videoUUID })
144 await waitJobs(servers) 144 await waitJobs(servers)
145 145
146 await servers[0].videos.runTranscoding({ videoId: videoUUID, transcodingType: 'webtorrent' }) 146 await servers[0].videos.runTranscoding({ videoId: videoUUID, transcodingType: 'web-video' })
147 await waitJobs(servers) 147 await waitJobs(servers)
148 148
149 for (const server of servers) { 149 for (const server of servers) {
@@ -165,7 +165,7 @@ function runTests (enableObjectStorage: boolean) {
165 enabled: true, 165 enabled: true,
166 resolutions: ConfigCommand.getCustomConfigResolutions(false), 166 resolutions: ConfigCommand.getCustomConfigResolutions(false),
167 167
168 webtorrent: { 168 webVideos: {
169 enabled: true 169 enabled: true
170 }, 170 },
171 hls: { 171 hls: {
@@ -201,7 +201,7 @@ function runTests (enableObjectStorage: boolean) {
201 enabled: true, 201 enabled: true,
202 resolutions: ConfigCommand.getCustomConfigResolutions(true), 202 resolutions: ConfigCommand.getCustomConfigResolutions(true),
203 203
204 webtorrent: { 204 webVideos: {
205 enabled: true 205 enabled: true
206 }, 206 },
207 hls: { 207 hls: {
diff --git a/server/tests/api/transcoding/hls.ts b/server/tests/api/transcoding/hls.ts
index c668d7e0b..d67043c2a 100644
--- a/server/tests/api/transcoding/hls.ts
+++ b/server/tests/api/transcoding/hls.ts
@@ -75,8 +75,8 @@ describe('Test HLS videos', function () {
75 75
76 it('Should have the playlists/segment deleted from the disk', async function () { 76 it('Should have the playlists/segment deleted from the disk', async function () {
77 for (const server of servers) { 77 for (const server of servers) {
78 await checkDirectoryIsEmpty(server, 'videos', [ 'private' ]) 78 await checkDirectoryIsEmpty(server, 'web-videos', [ 'private' ])
79 await checkDirectoryIsEmpty(server, join('videos', 'private')) 79 await checkDirectoryIsEmpty(server, join('web-videos', 'private'))
80 80
81 await checkDirectoryIsEmpty(server, join('streaming-playlists', 'hls'), [ 'private' ]) 81 await checkDirectoryIsEmpty(server, join('streaming-playlists', 'hls'), [ 'private' ])
82 await checkDirectoryIsEmpty(server, join('streaming-playlists', 'hls', 'private')) 82 await checkDirectoryIsEmpty(server, join('streaming-playlists', 'hls', 'private'))
@@ -111,7 +111,7 @@ describe('Test HLS videos', function () {
111 await doubleFollow(servers[0], servers[1]) 111 await doubleFollow(servers[0], servers[1])
112 }) 112 })
113 113
114 describe('With WebTorrent & HLS enabled', function () { 114 describe('With Web Video & HLS enabled', function () {
115 runTestSuite(false) 115 runTestSuite(false)
116 }) 116 })
117 117
@@ -136,7 +136,7 @@ describe('Test HLS videos', function () {
136 hls: { 136 hls: {
137 enabled: true 137 enabled: true
138 }, 138 },
139 webtorrent: { 139 webVideos: {
140 enabled: false 140 enabled: false
141 } 141 }
142 } 142 }
diff --git a/server/tests/api/transcoding/transcoder.ts b/server/tests/api/transcoding/transcoder.ts
index 8a0a7f6d2..5386d236f 100644
--- a/server/tests/api/transcoding/transcoder.ts
+++ b/server/tests/api/transcoding/transcoder.ts
@@ -31,7 +31,7 @@ function updateConfigForTranscoding (server: PeerTubeServer) {
31 allowAdditionalExtensions: true, 31 allowAdditionalExtensions: true,
32 allowAudioFiles: true, 32 allowAudioFiles: true,
33 hls: { enabled: true }, 33 hls: { enabled: true },
34 webtorrent: { enabled: true }, 34 webVideos: { enabled: true },
35 resolutions: { 35 resolutions: {
36 '0p': false, 36 '0p': false,
37 '144p': true, 37 '144p': true,
@@ -251,7 +251,7 @@ describe('Test video transcoding', function () {
251 expect(videoDetails.files).to.have.lengthOf(5) 251 expect(videoDetails.files).to.have.lengthOf(5)
252 252
253 const file = videoDetails.files.find(f => f.resolution.id === 240) 253 const file = videoDetails.files.find(f => f.resolution.id === 240)
254 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 254 const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl)
255 const probe = await getAudioStream(path) 255 const probe = await getAudioStream(path)
256 256
257 if (probe.audioStream) { 257 if (probe.audioStream) {
@@ -281,7 +281,7 @@ describe('Test video transcoding', function () {
281 const videoDetails = await server.videos.get({ id: video.id }) 281 const videoDetails = await server.videos.get({ id: video.id })
282 282
283 const file = videoDetails.files.find(f => f.resolution.id === 240) 283 const file = videoDetails.files.find(f => f.resolution.id === 240)
284 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 284 const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl)
285 285
286 expect(await hasAudioStream(path)).to.be.false 286 expect(await hasAudioStream(path)).to.be.false
287 } 287 }
@@ -310,7 +310,7 @@ describe('Test video transcoding', function () {
310 const fixtureVideoProbe = await getAudioStream(fixturePath) 310 const fixtureVideoProbe = await getAudioStream(fixturePath)
311 311
312 const file = videoDetails.files.find(f => f.resolution.id === 240) 312 const file = videoDetails.files.find(f => f.resolution.id === 240)
313 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 313 const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl)
314 314
315 const videoProbe = await getAudioStream(path) 315 const videoProbe = await getAudioStream(path)
316 316
@@ -333,7 +333,7 @@ describe('Test video transcoding', function () {
333 newConfig: { 333 newConfig: {
334 transcoding: { 334 transcoding: {
335 hls: { enabled: true }, 335 hls: { enabled: true },
336 webtorrent: { enabled: true }, 336 webVideos: { enabled: true },
337 resolutions: { 337 resolutions: {
338 '0p': false, 338 '0p': false,
339 '144p': false, 339 '144p': false,
@@ -353,7 +353,7 @@ describe('Test video transcoding', function () {
353 it('Should merge an audio file with the preview file', async function () { 353 it('Should merge an audio file with the preview file', async function () {
354 this.timeout(60_000) 354 this.timeout(60_000)
355 355
356 const attributes = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' } 356 const attributes = { name: 'audio_with_preview', previewfile: 'custom-preview.jpg', fixture: 'sample.ogg' }
357 await servers[1].videos.upload({ attributes, mode }) 357 await servers[1].videos.upload({ attributes, mode })
358 358
359 await waitJobs(servers) 359 await waitJobs(servers)
@@ -405,7 +405,7 @@ describe('Test video transcoding', function () {
405 newConfig: { 405 newConfig: {
406 transcoding: { 406 transcoding: {
407 hls: { enabled: true }, 407 hls: { enabled: true },
408 webtorrent: { enabled: true }, 408 webVideos: { enabled: true },
409 resolutions: { 409 resolutions: {
410 '0p': true, 410 '0p': true,
411 '144p': false, 411 '144p': false,
@@ -416,7 +416,7 @@ describe('Test video transcoding', function () {
416 } 416 }
417 }) 417 })
418 418
419 const attributes = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' } 419 const attributes = { name: 'audio_with_preview', previewfile: 'custom-preview.jpg', fixture: 'sample.ogg' }
420 const { id } = await servers[1].videos.upload({ attributes, mode }) 420 const { id } = await servers[1].videos.upload({ attributes, mode })
421 421
422 await waitJobs(servers) 422 await waitJobs(servers)
@@ -472,14 +472,14 @@ describe('Test video transcoding', function () {
472 472
473 for (const resolution of [ 144, 240, 360, 480 ]) { 473 for (const resolution of [ 144, 240, 360, 480 ]) {
474 const file = videoDetails.files.find(f => f.resolution.id === resolution) 474 const file = videoDetails.files.find(f => f.resolution.id === resolution)
475 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 475 const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl)
476 const fps = await getVideoStreamFPS(path) 476 const fps = await getVideoStreamFPS(path)
477 477
478 expect(fps).to.be.below(31) 478 expect(fps).to.be.below(31)
479 } 479 }
480 480
481 const file = videoDetails.files.find(f => f.resolution.id === 720) 481 const file = videoDetails.files.find(f => f.resolution.id === 720)
482 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 482 const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl)
483 const fps = await getVideoStreamFPS(path) 483 const fps = await getVideoStreamFPS(path)
484 484
485 expect(fps).to.be.above(58).and.below(62) 485 expect(fps).to.be.above(58).and.below(62)
@@ -516,14 +516,14 @@ describe('Test video transcoding', function () {
516 516
517 { 517 {
518 const file = video.files.find(f => f.resolution.id === 240) 518 const file = video.files.find(f => f.resolution.id === 240)
519 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 519 const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl)
520 const fps = await getVideoStreamFPS(path) 520 const fps = await getVideoStreamFPS(path)
521 expect(fps).to.be.equal(25) 521 expect(fps).to.be.equal(25)
522 } 522 }
523 523
524 { 524 {
525 const file = video.files.find(f => f.resolution.id === 720) 525 const file = video.files.find(f => f.resolution.id === 720)
526 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 526 const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl)
527 const fps = await getVideoStreamFPS(path) 527 const fps = await getVideoStreamFPS(path)
528 expect(fps).to.be.equal(59) 528 expect(fps).to.be.equal(59)
529 } 529 }
@@ -556,7 +556,7 @@ describe('Test video transcoding', function () {
556 556
557 for (const resolution of [ 240, 360, 480, 720, 1080 ]) { 557 for (const resolution of [ 240, 360, 480, 720, 1080 ]) {
558 const file = video.files.find(f => f.resolution.id === resolution) 558 const file = video.files.find(f => f.resolution.id === resolution)
559 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 559 const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl)
560 560
561 const bitrate = await getVideoStreamBitrate(path) 561 const bitrate = await getVideoStreamBitrate(path)
562 const fps = await getVideoStreamFPS(path) 562 const fps = await getVideoStreamFPS(path)
@@ -586,7 +586,7 @@ describe('Test video transcoding', function () {
586 '1440p': true, 586 '1440p': true,
587 '2160p': true 587 '2160p': true
588 }, 588 },
589 webtorrent: { enabled: true }, 589 webVideos: { enabled: true },
590 hls: { enabled: true } 590 hls: { enabled: true }
591 } 591 }
592 } 592 }
@@ -607,7 +607,7 @@ describe('Test video transcoding', function () {
607 for (const r of resolutions) { 607 for (const r of resolutions) {
608 const file = video.files.find(f => f.resolution.id === r) 608 const file = video.files.find(f => f.resolution.id === r)
609 609
610 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 610 const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl)
611 const bitrate = await getVideoStreamBitrate(path) 611 const bitrate = await getVideoStreamBitrate(path)
612 612
613 const inputBitrate = 60_000 613 const inputBitrate = 60_000
@@ -631,7 +631,7 @@ describe('Test video transcoding', function () {
631 { 631 {
632 const video = await servers[1].videos.get({ id: videoUUID }) 632 const video = await servers[1].videos.get({ id: videoUUID })
633 const file = video.files.find(f => f.resolution.id === 240) 633 const file = video.files.find(f => f.resolution.id === 240)
634 const path = servers[1].servers.buildWebTorrentFilePath(file.fileUrl) 634 const path = servers[1].servers.buildWebVideoFilePath(file.fileUrl)
635 635
636 const probe = await ffprobePromise(path) 636 const probe = await ffprobePromise(path)
637 const metadata = new VideoFileMetadata(probe) 637 const metadata = new VideoFileMetadata(probe)
@@ -704,14 +704,14 @@ describe('Test video transcoding', function () {
704 expect(transcodingJobs).to.have.lengthOf(16) 704 expect(transcodingJobs).to.have.lengthOf(16)
705 705
706 const hlsJobs = transcodingJobs.filter(j => j.data.type === 'new-resolution-to-hls') 706 const hlsJobs = transcodingJobs.filter(j => j.data.type === 'new-resolution-to-hls')
707 const webtorrentJobs = transcodingJobs.filter(j => j.data.type === 'new-resolution-to-webtorrent') 707 const webVideoJobs = transcodingJobs.filter(j => j.data.type === 'new-resolution-to-web-video')
708 const optimizeJobs = transcodingJobs.filter(j => j.data.type === 'optimize-to-webtorrent') 708 const optimizeJobs = transcodingJobs.filter(j => j.data.type === 'optimize-to-web-video')
709 709
710 expect(hlsJobs).to.have.lengthOf(8) 710 expect(hlsJobs).to.have.lengthOf(8)
711 expect(webtorrentJobs).to.have.lengthOf(7) 711 expect(webVideoJobs).to.have.lengthOf(7)
712 expect(optimizeJobs).to.have.lengthOf(1) 712 expect(optimizeJobs).to.have.lengthOf(1)
713 713
714 for (const j of optimizeJobs.concat(hlsJobs.concat(webtorrentJobs))) { 714 for (const j of optimizeJobs.concat(hlsJobs.concat(webVideoJobs))) {
715 expect(j.priority).to.be.greaterThan(100) 715 expect(j.priority).to.be.greaterThan(100)
716 expect(j.priority).to.be.lessThan(150) 716 expect(j.priority).to.be.lessThan(150)
717 } 717 }
@@ -728,7 +728,7 @@ describe('Test video transcoding', function () {
728 transcoding: { 728 transcoding: {
729 enabled: true, 729 enabled: true,
730 hls: { enabled: true }, 730 hls: { enabled: true },
731 webtorrent: { enabled: true }, 731 webVideos: { enabled: true },
732 resolutions: { 732 resolutions: {
733 '0p': false, 733 '0p': false,
734 '144p': false, 734 '144p': false,
diff --git a/server/tests/api/transcoding/update-while-transcoding.ts b/server/tests/api/transcoding/update-while-transcoding.ts
index 61655f102..cfb4fa0cc 100644
--- a/server/tests/api/transcoding/update-while-transcoding.ts
+++ b/server/tests/api/transcoding/update-while-transcoding.ts
@@ -96,7 +96,7 @@ describe('Test update video privacy while transcoding', function () {
96 await doubleFollow(servers[0], servers[1]) 96 await doubleFollow(servers[0], servers[1])
97 }) 97 })
98 98
99 describe('With WebTorrent & HLS enabled', function () { 99 describe('With Web Video & HLS enabled', function () {
100 runTestSuite(false) 100 runTestSuite(false)
101 }) 101 })
102 102
@@ -121,7 +121,7 @@ describe('Test update video privacy while transcoding', function () {
121 hls: { 121 hls: {
122 enabled: true 122 enabled: true
123 }, 123 },
124 webtorrent: { 124 webVideos: {
125 enabled: false 125 enabled: false
126 } 126 }
127 } 127 }
diff --git a/server/tests/api/transcoding/video-studio.ts b/server/tests/api/transcoding/video-studio.ts
index d1298caf7..ba68f8e24 100644
--- a/server/tests/api/transcoding/video-studio.ts
+++ b/server/tests/api/transcoding/video-studio.ts
@@ -241,7 +241,7 @@ describe('Test video studio', function () {
241 { 241 {
242 name: 'add-watermark', 242 name: 'add-watermark',
243 options: { 243 options: {
244 file: 'thumbnail.png' 244 file: 'custom-thumbnail.png'
245 } 245 }
246 } 246 }
247 ]) 247 ])
@@ -273,11 +273,11 @@ describe('Test video studio', function () {
273 describe('HLS only studio edition', function () { 273 describe('HLS only studio edition', function () {
274 274
275 before(async function () { 275 before(async function () {
276 // Disable webtorrent 276 // Disable Web Videos
277 await servers[0].config.updateExistingSubConfig({ 277 await servers[0].config.updateExistingSubConfig({
278 newConfig: { 278 newConfig: {
279 transcoding: { 279 transcoding: {
280 webtorrent: { 280 webVideos: {
281 enabled: false 281 enabled: false
282 } 282 }
283 } 283 }
@@ -354,8 +354,8 @@ describe('Test video studio', function () {
354 expect(oldFileUrls).to.not.include(f.fileUrl) 354 expect(oldFileUrls).to.not.include(f.fileUrl)
355 } 355 }
356 356
357 for (const webtorrentFile of video.files) { 357 for (const webVideoFile of video.files) {
358 expectStartWith(webtorrentFile.fileUrl, objectStorage.getMockWebVideosBaseUrl()) 358 expectStartWith(webVideoFile.fileUrl, objectStorage.getMockWebVideosBaseUrl())
359 } 359 }
360 360
361 for (const hlsFile of video.streamingPlaylists[0].files) { 361 for (const hlsFile of video.streamingPlaylists[0].files) {
diff --git a/server/tests/api/users/user-videos.ts b/server/tests/api/users/user-videos.ts
index 696949504..77226e48e 100644
--- a/server/tests/api/users/user-videos.ts
+++ b/server/tests/api/users/user-videos.ts
@@ -184,12 +184,12 @@ describe('Test user videos', function () {
184 } 184 }
185 }) 185 })
186 186
187 it('Should disable webtorrent, enable HLS, and update my quota', async function () { 187 it('Should disable web videos, enable HLS, and update my quota', async function () {
188 this.timeout(160000) 188 this.timeout(160000)
189 189
190 { 190 {
191 const config = await server.config.getCustomConfig() 191 const config = await server.config.getCustomConfig()
192 config.transcoding.webtorrent.enabled = false 192 config.transcoding.webVideos.enabled = false
193 config.transcoding.hls.enabled = true 193 config.transcoding.hls.enabled = true
194 config.transcoding.enabled = true 194 config.transcoding.enabled = true
195 await server.config.updateCustomSubConfig({ newConfig: config }) 195 await server.config.updateCustomSubConfig({ newConfig: config })
diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts
index 1c00f9a93..67ade1d0d 100644
--- a/server/tests/api/users/users.ts
+++ b/server/tests/api/users/users.ts
@@ -229,25 +229,13 @@ describe('Test users', function () {
229 }) 229 })
230 230
231 it('Should be able to change the p2p attribute', async function () { 231 it('Should be able to change the p2p attribute', async function () {
232 { 232 await server.users.updateMe({
233 await server.users.updateMe({ 233 token: userToken,
234 token: userToken, 234 p2pEnabled: true
235 webTorrentEnabled: false 235 })
236 })
237
238 const user = await server.users.getMyInfo({ token: userToken })
239 expect(user.p2pEnabled).to.be.false
240 }
241
242 {
243 await server.users.updateMe({
244 token: userToken,
245 p2pEnabled: true
246 })
247 236
248 const user = await server.users.getMyInfo({ token: userToken }) 237 const user = await server.users.getMyInfo({ token: userToken })
249 expect(user.p2pEnabled).to.be.true 238 expect(user.p2pEnabled).to.be.true
250 }
251 }) 239 })
252 240
253 it('Should be able to change the email attribute', async function () { 241 it('Should be able to change the email attribute', async function () {
diff --git a/server/tests/api/videos/index.ts b/server/tests/api/videos/index.ts
index 357c08199..9c79b3aa6 100644
--- a/server/tests/api/videos/index.ts
+++ b/server/tests/api/videos/index.ts
@@ -20,3 +20,4 @@ import './videos-history'
20import './videos-overview' 20import './videos-overview'
21import './video-source' 21import './video-source'
22import './video-static-file-privacy' 22import './video-static-file-privacy'
23import './video-storyboard'
diff --git a/server/tests/api/videos/multiple-servers.ts b/server/tests/api/videos/multiple-servers.ts
index 27ba00d3d..e9aa0e3a1 100644
--- a/server/tests/api/videos/multiple-servers.ts
+++ b/server/tests/api/videos/multiple-servers.ts
@@ -9,7 +9,7 @@ import {
9 completeVideoCheck, 9 completeVideoCheck,
10 dateIsValid, 10 dateIsValid,
11 saveVideoInServers, 11 saveVideoInServers,
12 testImage 12 testImageGeneratedByFFmpeg
13} from '@server/tests/shared' 13} from '@server/tests/shared'
14import { buildAbsoluteFixturePath, wait } from '@shared/core-utils' 14import { buildAbsoluteFixturePath, wait } from '@shared/core-utils'
15import { HttpStatusCode, VideoCommentThreadTree, VideoPrivacy } from '@shared/models' 15import { HttpStatusCode, VideoCommentThreadTree, VideoPrivacy } from '@shared/models'
@@ -70,8 +70,9 @@ describe('Test multiple servers', function () {
70 }) 70 })
71 71
72 describe('Should upload the video and propagate on each server', function () { 72 describe('Should upload the video and propagate on each server', function () {
73
73 it('Should upload the video on server 1 and propagate on each server', async function () { 74 it('Should upload the video on server 1 and propagate on each server', async function () {
74 this.timeout(25000) 75 this.timeout(60000)
75 76
76 const attributes = { 77 const attributes = {
77 name: 'my super name for server 1', 78 name: 'my super name for server 1',
@@ -175,8 +176,8 @@ describe('Test multiple servers', function () {
175 support: 'my super support text for server 2', 176 support: 'my super support text for server 2',
176 tags: [ 'tag1p2', 'tag2p2', 'tag3p2' ], 177 tags: [ 'tag1p2', 'tag2p2', 'tag3p2' ],
177 fixture: 'video_short2.webm', 178 fixture: 'video_short2.webm',
178 thumbnailfile: 'thumbnail.jpg', 179 thumbnailfile: 'custom-thumbnail.jpg',
179 previewfile: 'preview.jpg' 180 previewfile: 'custom-preview.jpg'
180 } 181 }
181 await servers[1].videos.upload({ token: userAccessToken, attributes, mode: 'resumable' }) 182 await servers[1].videos.upload({ token: userAccessToken, attributes, mode: 'resumable' })
182 183
@@ -229,8 +230,8 @@ describe('Test multiple servers', function () {
229 size: 750000 230 size: 750000
230 } 231 }
231 ], 232 ],
232 thumbnailfile: 'thumbnail', 233 thumbnailfile: 'custom-thumbnail',
233 previewfile: 'preview' 234 previewfile: 'custom-preview'
234 } 235 }
235 236
236 const { data } = await server.videos.list() 237 const { data } = await server.videos.list()
@@ -619,9 +620,9 @@ describe('Test multiple servers', function () {
619 description: 'my super description updated', 620 description: 'my super description updated',
620 support: 'my super support text updated', 621 support: 'my super support text updated',
621 tags: [ 'tag_up_1', 'tag_up_2' ], 622 tags: [ 'tag_up_1', 'tag_up_2' ],
622 thumbnailfile: 'thumbnail.jpg', 623 thumbnailfile: 'custom-thumbnail.jpg',
623 originallyPublishedAt: '2019-02-11T13:38:14.449Z', 624 originallyPublishedAt: '2019-02-11T13:38:14.449Z',
624 previewfile: 'preview.jpg' 625 previewfile: 'custom-preview.jpg'
625 } 626 }
626 627
627 updatedAtMin = new Date() 628 updatedAtMin = new Date()
@@ -674,8 +675,8 @@ describe('Test multiple servers', function () {
674 size: 292677 675 size: 292677
675 } 676 }
676 ], 677 ],
677 thumbnailfile: 'thumbnail', 678 thumbnailfile: 'custom-thumbnail',
678 previewfile: 'preview' 679 previewfile: 'custom-preview'
679 } 680 }
680 await completeVideoCheck({ server, originServer: servers[2], videoUUID: videoUpdated.uuid, attributes: checkAttributes }) 681 await completeVideoCheck({ server, originServer: servers[2], videoUUID: videoUpdated.uuid, attributes: checkAttributes })
681 } 682 }
@@ -685,7 +686,7 @@ describe('Test multiple servers', function () {
685 this.timeout(30000) 686 this.timeout(30000)
686 687
687 const attributes = { 688 const attributes = {
688 thumbnailfile: 'thumbnail.jpg' 689 thumbnailfile: 'custom-thumbnail.jpg'
689 } 690 }
690 691
691 updatedAtMin = new Date() 692 updatedAtMin = new Date()
@@ -761,7 +762,7 @@ describe('Test multiple servers', function () {
761 for (const server of servers) { 762 for (const server of servers) {
762 const video = await server.videos.get({ id: videoUUID }) 763 const video = await server.videos.get({ id: videoUUID })
763 764
764 await testImage(server.url, 'video_short1-preview.webm', video.previewPath) 765 await testImageGeneratedByFFmpeg(server.url, 'video_short1-preview.webm', video.previewPath)
765 } 766 }
766 }) 767 })
767 }) 768 })
diff --git a/server/tests/api/videos/resumable-upload.ts b/server/tests/api/videos/resumable-upload.ts
index 2fbefb392..91eb61833 100644
--- a/server/tests/api/videos/resumable-upload.ts
+++ b/server/tests/api/videos/resumable-upload.ts
@@ -93,10 +93,10 @@ describe('Test resumable upload', function () {
93 expect((await stat(filePath)).size).to.equal(expectedSize) 93 expect((await stat(filePath)).size).to.equal(expectedSize)
94 } 94 }
95 95
96 async function countResumableUploads () { 96 async function countResumableUploads (wait?: number) {
97 const subPath = join('tmp', 'resumable-uploads') 97 const subPath = join('tmp', 'resumable-uploads')
98 const filePath = server.servers.buildDirectory(subPath) 98 const filePath = server.servers.buildDirectory(subPath)
99 99 await new Promise(resolve => setTimeout(resolve, wait))
100 const files = await readdir(filePath) 100 const files = await readdir(filePath)
101 return files.length 101 return files.length
102 } 102 }
@@ -122,14 +122,20 @@ describe('Test resumable upload', function () {
122 122
123 describe('Directory cleaning', function () { 123 describe('Directory cleaning', function () {
124 124
125 // FIXME: https://github.com/kukhariev/node-uploadx/pull/524/files#r852989382 125 it('Should correctly delete files after an upload', async function () {
126 // it('Should correctly delete files after an upload', async function () { 126 const uploadId = await prepareUpload()
127 // const uploadId = await prepareUpload() 127 await sendChunks({ pathUploadId: uploadId })
128 // await sendChunks({ pathUploadId: uploadId }) 128 await server.videos.endResumableUpload({ pathUploadId: uploadId })
129 // await server.videos.endResumableUpload({ pathUploadId: uploadId }) 129
130 expect(await countResumableUploads()).to.equal(0)
131 })
132
133 it('Should correctly delete corrupt files', async function () {
134 const uploadId = await prepareUpload({ size: 8 * 1024 })
135 await sendChunks({ pathUploadId: uploadId, size: 8 * 1024, expectedStatus: HttpStatusCode.UNPROCESSABLE_ENTITY_422 })
130 136
131 // expect(await countResumableUploads()).to.equal(0) 137 expect(await countResumableUploads(2000)).to.equal(0)
132 // }) 138 })
133 139
134 it('Should not delete files after an unfinished upload', async function () { 140 it('Should not delete files after an unfinished upload', async function () {
135 await prepareUpload() 141 await prepareUpload()
@@ -254,6 +260,24 @@ describe('Test resumable upload', function () {
254 expect(result2.headers['x-resumable-upload-cached']).to.not.exist 260 expect(result2.headers['x-resumable-upload-cached']).to.not.exist
255 }) 261 })
256 262
263 it('Should not cache after video deletion', async function () {
264 const originalName = 'toto.mp4'
265 const lastModified = new Date().getTime()
266
267 const uploadId1 = await prepareUpload({ originalName, lastModified })
268 const result1 = await sendChunks({ pathUploadId: uploadId1 })
269 await server.videos.remove({ id: result1.body.video.uuid })
270
271 const uploadId2 = await prepareUpload({ originalName, lastModified })
272 const result2 = await sendChunks({ pathUploadId: uploadId2 })
273 expect(result1.body.video.uuid).to.not.equal(result2.body.video.uuid)
274
275 expect(result2.headers['x-resumable-upload-cached']).to.not.exist
276
277 await checkFileSize(uploadId1, null)
278 await checkFileSize(uploadId2, null)
279 })
280
257 it('Should refuse an invalid digest', async function () { 281 it('Should refuse an invalid digest', async function () {
258 const uploadId = await prepareUpload({ token: server.accessToken }) 282 const uploadId = await prepareUpload({ token: server.accessToken })
259 283
diff --git a/server/tests/api/videos/single-server.ts b/server/tests/api/videos/single-server.ts
index 0cb64d5a5..66414aa5b 100644
--- a/server/tests/api/videos/single-server.ts
+++ b/server/tests/api/videos/single-server.ts
@@ -1,7 +1,7 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { checkVideoFilesWereRemoved, completeVideoCheck, testImage } from '@server/tests/shared' 4import { checkVideoFilesWereRemoved, completeVideoCheck, testImageGeneratedByFFmpeg } from '@server/tests/shared'
5import { wait } from '@shared/core-utils' 5import { wait } from '@shared/core-utils'
6import { Video, VideoPrivacy } from '@shared/models' 6import { Video, VideoPrivacy } from '@shared/models'
7import { 7import {
@@ -260,7 +260,7 @@ describe('Test a single server', function () {
260 260
261 for (const video of data) { 261 for (const video of data) {
262 const videoName = video.name.replace(' name', '') 262 const videoName = video.name.replace(' name', '')
263 await testImage(server.url, videoName, video.thumbnailPath) 263 await testImageGeneratedByFFmpeg(server.url, videoName, video.thumbnailPath)
264 } 264 }
265 }) 265 })
266 266
diff --git a/server/tests/api/videos/video-files.ts b/server/tests/api/videos/video-files.ts
index 8c913bf31..0a183c44d 100644
--- a/server/tests/api/videos/video-files.ts
+++ b/server/tests/api/videos/video-files.ts
@@ -48,10 +48,10 @@ describe('Test videos files', function () {
48 await waitJobs(servers) 48 await waitJobs(servers)
49 }) 49 })
50 50
51 it('Should delete webtorrent files', async function () { 51 it('Should delete web video files', async function () {
52 this.timeout(30_000) 52 this.timeout(30_000)
53 53
54 await servers[0].videos.removeAllWebTorrentFiles({ videoId: validId1 }) 54 await servers[0].videos.removeAllWebVideoFiles({ videoId: validId1 })
55 55
56 await waitJobs(servers) 56 await waitJobs(servers)
57 57
@@ -80,15 +80,15 @@ describe('Test videos files', function () {
80 }) 80 })
81 81
82 describe('When deleting a specific file', function () { 82 describe('When deleting a specific file', function () {
83 let webtorrentId: string 83 let webVideoId: string
84 let hlsId: string 84 let hlsId: string
85 85
86 before(async function () { 86 before(async function () {
87 this.timeout(120_000) 87 this.timeout(120_000)
88 88
89 { 89 {
90 const { uuid } = await servers[0].videos.quickUpload({ name: 'webtorrent' }) 90 const { uuid } = await servers[0].videos.quickUpload({ name: 'web-video' })
91 webtorrentId = uuid 91 webVideoId = uuid
92 } 92 }
93 93
94 { 94 {
@@ -99,38 +99,38 @@ describe('Test videos files', function () {
99 await waitJobs(servers) 99 await waitJobs(servers)
100 }) 100 })
101 101
102 it('Shoulde delete a webtorrent file', async function () { 102 it('Shoulde delete a web video file', async function () {
103 this.timeout(30_000) 103 this.timeout(30_000)
104 104
105 const video = await servers[0].videos.get({ id: webtorrentId }) 105 const video = await servers[0].videos.get({ id: webVideoId })
106 const files = video.files 106 const files = video.files
107 107
108 await servers[0].videos.removeWebTorrentFile({ videoId: webtorrentId, fileId: files[0].id }) 108 await servers[0].videos.removeWebVideoFile({ videoId: webVideoId, fileId: files[0].id })
109 109
110 await waitJobs(servers) 110 await waitJobs(servers)
111 111
112 for (const server of servers) { 112 for (const server of servers) {
113 const video = await server.videos.get({ id: webtorrentId }) 113 const video = await server.videos.get({ id: webVideoId })
114 114
115 expect(video.files).to.have.lengthOf(files.length - 1) 115 expect(video.files).to.have.lengthOf(files.length - 1)
116 expect(video.files.find(f => f.id === files[0].id)).to.not.exist 116 expect(video.files.find(f => f.id === files[0].id)).to.not.exist
117 } 117 }
118 }) 118 })
119 119
120 it('Should delete all webtorrent files', async function () { 120 it('Should delete all web video files', async function () {
121 this.timeout(30_000) 121 this.timeout(30_000)
122 122
123 const video = await servers[0].videos.get({ id: webtorrentId }) 123 const video = await servers[0].videos.get({ id: webVideoId })
124 const files = video.files 124 const files = video.files
125 125
126 for (const file of files) { 126 for (const file of files) {
127 await servers[0].videos.removeWebTorrentFile({ videoId: webtorrentId, fileId: file.id }) 127 await servers[0].videos.removeWebVideoFile({ videoId: webVideoId, fileId: file.id })
128 } 128 }
129 129
130 await waitJobs(servers) 130 await waitJobs(servers)
131 131
132 for (const server of servers) { 132 for (const server of servers) {
133 const video = await server.videos.get({ id: webtorrentId }) 133 const video = await server.videos.get({ id: webVideoId })
134 134
135 expect(video.files).to.have.lengthOf(0) 135 expect(video.files).to.have.lengthOf(0)
136 } 136 }
@@ -182,16 +182,16 @@ describe('Test videos files', function () {
182 it('Should not delete last file of a video', async function () { 182 it('Should not delete last file of a video', async function () {
183 this.timeout(60_000) 183 this.timeout(60_000)
184 184
185 const webtorrentOnly = await servers[0].videos.get({ id: hlsId }) 185 const webVideoOnly = await servers[0].videos.get({ id: hlsId })
186 const hlsOnly = await servers[0].videos.get({ id: webtorrentId }) 186 const hlsOnly = await servers[0].videos.get({ id: webVideoId })
187 187
188 for (let i = 0; i < 4; i++) { 188 for (let i = 0; i < 4; i++) {
189 await servers[0].videos.removeWebTorrentFile({ videoId: webtorrentOnly.id, fileId: webtorrentOnly.files[i].id }) 189 await servers[0].videos.removeWebVideoFile({ videoId: webVideoOnly.id, fileId: webVideoOnly.files[i].id })
190 await servers[0].videos.removeHLSFile({ videoId: hlsOnly.id, fileId: hlsOnly.streamingPlaylists[0].files[i].id }) 190 await servers[0].videos.removeHLSFile({ videoId: hlsOnly.id, fileId: hlsOnly.streamingPlaylists[0].files[i].id })
191 } 191 }
192 192
193 const expectedStatus = HttpStatusCode.BAD_REQUEST_400 193 const expectedStatus = HttpStatusCode.BAD_REQUEST_400
194 await servers[0].videos.removeWebTorrentFile({ videoId: webtorrentOnly.id, fileId: webtorrentOnly.files[4].id, expectedStatus }) 194 await servers[0].videos.removeWebVideoFile({ videoId: webVideoOnly.id, fileId: webVideoOnly.files[4].id, expectedStatus })
195 await servers[0].videos.removeHLSFile({ videoId: hlsOnly.id, fileId: hlsOnly.streamingPlaylists[0].files[4].id, expectedStatus }) 195 await servers[0].videos.removeHLSFile({ videoId: hlsOnly.id, fileId: hlsOnly.streamingPlaylists[0].files[4].id, expectedStatus })
196 }) 196 })
197 }) 197 })
diff --git a/server/tests/api/videos/video-imports.ts b/server/tests/api/videos/video-imports.ts
index 192b2aeb9..b78b4f344 100644
--- a/server/tests/api/videos/video-imports.ts
+++ b/server/tests/api/videos/video-imports.ts
@@ -3,7 +3,7 @@
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { pathExists, readdir, remove } from 'fs-extra' 4import { pathExists, readdir, remove } from 'fs-extra'
5import { join } from 'path' 5import { join } from 'path'
6import { FIXTURE_URLS, testCaptionFile, testImage } from '@server/tests/shared' 6import { FIXTURE_URLS, testCaptionFile, testImageGeneratedByFFmpeg } from '@server/tests/shared'
7import { areHttpImportTestsDisabled } from '@shared/core-utils' 7import { areHttpImportTestsDisabled } from '@shared/core-utils'
8import { CustomConfig, HttpStatusCode, Video, VideoImportState, VideoPrivacy, VideoResolution, VideoState } from '@shared/models' 8import { CustomConfig, HttpStatusCode, Video, VideoImportState, VideoPrivacy, VideoResolution, VideoState } from '@shared/models'
9import { 9import {
@@ -67,7 +67,7 @@ async function checkVideoServer2 (server: PeerTubeServer, id: number | string) {
67 expect(video.description).to.equal('my super description') 67 expect(video.description).to.equal('my super description')
68 expect(video.tags).to.deep.equal([ 'supertag1', 'supertag2' ]) 68 expect(video.tags).to.deep.equal([ 'supertag1', 'supertag2' ])
69 69
70 await testImage(server.url, 'thumbnail', video.thumbnailPath) 70 await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', video.thumbnailPath)
71 71
72 expect(video.files).to.have.lengthOf(1) 72 expect(video.files).to.have.lengthOf(1)
73 73
@@ -119,15 +119,15 @@ describe('Test video imports', function () {
119 expect(video.name).to.equal('small video - youtube') 119 expect(video.name).to.equal('small video - youtube')
120 120
121 { 121 {
122 expect(video.thumbnailPath).to.match(new RegExp(`^/static/thumbnails/.+.jpg$`)) 122 expect(video.thumbnailPath).to.match(new RegExp(`^/lazy-static/thumbnails/.+.jpg$`))
123 expect(video.previewPath).to.match(new RegExp(`^/lazy-static/previews/.+.jpg$`)) 123 expect(video.previewPath).to.match(new RegExp(`^/lazy-static/previews/.+.jpg$`))
124 124
125 const suffix = mode === 'yt-dlp' 125 const suffix = mode === 'yt-dlp'
126 ? '_yt_dlp' 126 ? '_yt_dlp'
127 : '' 127 : ''
128 128
129 await testImage(servers[0].url, 'video_import_thumbnail' + suffix, video.thumbnailPath) 129 await testImageGeneratedByFFmpeg(servers[0].url, 'video_import_thumbnail' + suffix, video.thumbnailPath)
130 await testImage(servers[0].url, 'video_import_preview' + suffix, video.previewPath) 130 await testImageGeneratedByFFmpeg(servers[0].url, 'video_import_preview' + suffix, video.previewPath)
131 } 131 }
132 132
133 const bodyCaptions = await servers[0].captions.list({ videoId: video.id }) 133 const bodyCaptions = await servers[0].captions.list({ videoId: video.id })
@@ -266,7 +266,7 @@ describe('Test video imports', function () {
266 name: 'my super name', 266 name: 'my super name',
267 description: 'my super description', 267 description: 'my super description',
268 tags: [ 'supertag1', 'supertag2' ], 268 tags: [ 'supertag1', 'supertag2' ],
269 thumbnailfile: 'thumbnail.jpg' 269 thumbnailfile: 'custom-thumbnail.jpg'
270 } 270 }
271 }) 271 })
272 expect(video.name).to.equal('my super name') 272 expect(video.name).to.equal('my super name')
@@ -328,7 +328,7 @@ describe('Test video imports', function () {
328 '1440p': false, 328 '1440p': false,
329 '2160p': false 329 '2160p': false
330 }, 330 },
331 webtorrent: { enabled: true }, 331 webVideos: { enabled: true },
332 hls: { enabled: false } 332 hls: { enabled: false }
333 } 333 }
334 } 334 }
diff --git a/server/tests/api/videos/video-passwords.ts b/server/tests/api/videos/video-passwords.ts
new file mode 100644
index 000000000..e01a93a4d
--- /dev/null
+++ b/server/tests/api/videos/video-passwords.ts
@@ -0,0 +1,97 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import {
5 cleanupTests,
6 createSingleServer,
7 VideoPasswordsCommand,
8 PeerTubeServer,
9 setAccessTokensToServers,
10 setDefaultAccountAvatar,
11 setDefaultChannelAvatar
12} from '@shared/server-commands'
13import { VideoPrivacy } from '@shared/models'
14
15describe('Test video passwords', function () {
16 let server: PeerTubeServer
17 let videoUUID: string
18
19 let userAccessTokenServer1: string
20
21 let videoPasswords: string[] = []
22 let command: VideoPasswordsCommand
23
24 before(async function () {
25 this.timeout(30000)
26
27 server = await createSingleServer(1)
28
29 await setAccessTokensToServers([ server ])
30
31 for (let i = 0; i < 10; i++) {
32 videoPasswords.push(`password ${i + 1}`)
33 }
34 const { uuid } = await server.videos.upload({ attributes: { privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords } })
35 videoUUID = uuid
36
37 await setDefaultChannelAvatar(server)
38 await setDefaultAccountAvatar(server)
39
40 userAccessTokenServer1 = await server.users.generateUserAndToken('user1')
41 await setDefaultChannelAvatar(server, 'user1_channel')
42 await setDefaultAccountAvatar(server, userAccessTokenServer1)
43
44 command = server.videoPasswords
45 })
46
47 it('Should list video passwords', async function () {
48 const body = await command.list({ videoId: videoUUID })
49
50 expect(body.total).to.equal(10)
51 expect(body.data).to.be.an('array')
52 expect(body.data).to.have.lengthOf(10)
53 })
54
55 it('Should filter passwords on this video', async function () {
56 const body = await command.list({ videoId: videoUUID, count: 2, start: 3, sort: 'createdAt' })
57
58 expect(body.total).to.equal(10)
59 expect(body.data).to.be.an('array')
60 expect(body.data).to.have.lengthOf(2)
61 expect(body.data[0].password).to.equal('password 4')
62 expect(body.data[1].password).to.equal('password 5')
63 })
64
65 it('Should update password for this video', async function () {
66 videoPasswords = [ 'my super new password 1', 'my super new password 2' ]
67
68 await command.updateAll({ videoId: videoUUID, passwords: videoPasswords })
69 const body = await command.list({ videoId: videoUUID })
70 expect(body.total).to.equal(2)
71 expect(body.data).to.be.an('array')
72 expect(body.data).to.have.lengthOf(2)
73 expect(body.data[0].password).to.equal('my super new password 2')
74 expect(body.data[1].password).to.equal('my super new password 1')
75 })
76
77 it('Should delete one password', async function () {
78 {
79 const body = await command.list({ videoId: videoUUID })
80 expect(body.total).to.equal(2)
81 expect(body.data).to.be.an('array')
82 expect(body.data).to.have.lengthOf(2)
83 await command.remove({ id: body.data[0].id, videoId: videoUUID })
84 }
85 {
86 const body = await command.list({ videoId: videoUUID })
87
88 expect(body.total).to.equal(1)
89 expect(body.data).to.be.an('array')
90 expect(body.data).to.have.lengthOf(1)
91 }
92 })
93
94 after(async function () {
95 await cleanupTests([ server ])
96 })
97})
diff --git a/server/tests/api/videos/video-playlist-thumbnails.ts b/server/tests/api/videos/video-playlist-thumbnails.ts
index 356939b93..c274c20bf 100644
--- a/server/tests/api/videos/video-playlist-thumbnails.ts
+++ b/server/tests/api/videos/video-playlist-thumbnails.ts
@@ -1,7 +1,7 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { testImage } from '@server/tests/shared' 4import { testImageGeneratedByFFmpeg } from '@server/tests/shared'
5import { VideoPlaylistPrivacy } from '@shared/models' 5import { VideoPlaylistPrivacy } from '@shared/models'
6import { 6import {
7 cleanupTests, 7 cleanupTests,
@@ -83,7 +83,7 @@ describe('Playlist thumbnail', function () {
83 83
84 for (const server of servers) { 84 for (const server of servers) {
85 const p = await getPlaylistWithoutThumbnail(server) 85 const p = await getPlaylistWithoutThumbnail(server)
86 await testImage(server.url, 'thumbnail-playlist', p.thumbnailPath) 86 await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', p.thumbnailPath)
87 } 87 }
88 }) 88 })
89 89
@@ -95,7 +95,7 @@ describe('Playlist thumbnail', function () {
95 displayName: 'playlist with thumbnail', 95 displayName: 'playlist with thumbnail',
96 privacy: VideoPlaylistPrivacy.PUBLIC, 96 privacy: VideoPlaylistPrivacy.PUBLIC,
97 videoChannelId: servers[1].store.channel.id, 97 videoChannelId: servers[1].store.channel.id,
98 thumbnailfile: 'thumbnail.jpg' 98 thumbnailfile: 'custom-thumbnail.jpg'
99 } 99 }
100 }) 100 })
101 playlistWithThumbnailId = created.id 101 playlistWithThumbnailId = created.id
@@ -110,7 +110,7 @@ describe('Playlist thumbnail', function () {
110 110
111 for (const server of servers) { 111 for (const server of servers) {
112 const p = await getPlaylistWithThumbnail(server) 112 const p = await getPlaylistWithThumbnail(server)
113 await testImage(server.url, 'thumbnail', p.thumbnailPath) 113 await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailPath)
114 } 114 }
115 }) 115 })
116 116
@@ -135,7 +135,7 @@ describe('Playlist thumbnail', function () {
135 135
136 for (const server of servers) { 136 for (const server of servers) {
137 const p = await getPlaylistWithoutThumbnail(server) 137 const p = await getPlaylistWithoutThumbnail(server)
138 await testImage(server.url, 'thumbnail-playlist', p.thumbnailPath) 138 await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', p.thumbnailPath)
139 } 139 }
140 }) 140 })
141 141
@@ -160,7 +160,7 @@ describe('Playlist thumbnail', function () {
160 160
161 for (const server of servers) { 161 for (const server of servers) {
162 const p = await getPlaylistWithThumbnail(server) 162 const p = await getPlaylistWithThumbnail(server)
163 await testImage(server.url, 'thumbnail', p.thumbnailPath) 163 await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailPath)
164 } 164 }
165 }) 165 })
166 166
@@ -176,7 +176,7 @@ describe('Playlist thumbnail', function () {
176 176
177 for (const server of servers) { 177 for (const server of servers) {
178 const p = await getPlaylistWithoutThumbnail(server) 178 const p = await getPlaylistWithoutThumbnail(server)
179 await testImage(server.url, 'thumbnail-playlist', p.thumbnailPath) 179 await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', p.thumbnailPath)
180 } 180 }
181 }) 181 })
182 182
@@ -192,7 +192,7 @@ describe('Playlist thumbnail', function () {
192 192
193 for (const server of servers) { 193 for (const server of servers) {
194 const p = await getPlaylistWithThumbnail(server) 194 const p = await getPlaylistWithThumbnail(server)
195 await testImage(server.url, 'thumbnail', p.thumbnailPath) 195 await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailPath)
196 } 196 }
197 }) 197 })
198 198
@@ -224,7 +224,7 @@ describe('Playlist thumbnail', function () {
224 224
225 for (const server of servers) { 225 for (const server of servers) {
226 const p = await getPlaylistWithThumbnail(server) 226 const p = await getPlaylistWithThumbnail(server)
227 await testImage(server.url, 'thumbnail', p.thumbnailPath) 227 await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', p.thumbnailPath)
228 } 228 }
229 }) 229 })
230 230
diff --git a/server/tests/api/videos/video-playlists.ts b/server/tests/api/videos/video-playlists.ts
index d9c5bdf16..3bfa874cb 100644
--- a/server/tests/api/videos/video-playlists.ts
+++ b/server/tests/api/videos/video-playlists.ts
@@ -1,7 +1,7 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { checkPlaylistFilesWereRemoved, testImage } from '@server/tests/shared' 4import { checkPlaylistFilesWereRemoved, testImageGeneratedByFFmpeg } from '@server/tests/shared'
5import { wait } from '@shared/core-utils' 5import { wait } from '@shared/core-utils'
6import { uuidToShort } from '@shared/extra-utils' 6import { uuidToShort } from '@shared/extra-utils'
7import { 7import {
@@ -133,7 +133,7 @@ describe('Test video playlists', function () {
133 displayName: 'my super playlist', 133 displayName: 'my super playlist',
134 privacy: VideoPlaylistPrivacy.PUBLIC, 134 privacy: VideoPlaylistPrivacy.PUBLIC,
135 description: 'my super description', 135 description: 'my super description',
136 thumbnailfile: 'thumbnail.jpg', 136 thumbnailfile: 'custom-thumbnail.jpg',
137 videoChannelId: servers[0].store.channel.id 137 videoChannelId: servers[0].store.channel.id
138 } 138 }
139 }) 139 })
@@ -225,7 +225,7 @@ describe('Test video playlists', function () {
225 displayName: 'my super playlist', 225 displayName: 'my super playlist',
226 privacy: VideoPlaylistPrivacy.PUBLIC, 226 privacy: VideoPlaylistPrivacy.PUBLIC,
227 description: 'my super description', 227 description: 'my super description',
228 thumbnailfile: 'thumbnail.jpg', 228 thumbnailfile: 'custom-thumbnail.jpg',
229 videoChannelId: servers[0].store.channel.id 229 videoChannelId: servers[0].store.channel.id
230 } 230 }
231 }) 231 })
@@ -286,7 +286,7 @@ describe('Test video playlists', function () {
286 attributes: { 286 attributes: {
287 displayName: 'playlist 3', 287 displayName: 'playlist 3',
288 privacy: VideoPlaylistPrivacy.PUBLIC, 288 privacy: VideoPlaylistPrivacy.PUBLIC,
289 thumbnailfile: 'thumbnail.jpg', 289 thumbnailfile: 'custom-thumbnail.jpg',
290 videoChannelId: servers[1].store.channel.id 290 videoChannelId: servers[1].store.channel.id
291 } 291 }
292 }) 292 })
@@ -314,11 +314,11 @@ describe('Test video playlists', function () {
314 314
315 const playlist2 = body.data.find(p => p.displayName === 'playlist 2') 315 const playlist2 = body.data.find(p => p.displayName === 'playlist 2')
316 expect(playlist2).to.not.be.undefined 316 expect(playlist2).to.not.be.undefined
317 await testImage(server.url, 'thumbnail-playlist', playlist2.thumbnailPath) 317 await testImageGeneratedByFFmpeg(server.url, 'thumbnail-playlist', playlist2.thumbnailPath)
318 318
319 const playlist3 = body.data.find(p => p.displayName === 'playlist 3') 319 const playlist3 = body.data.find(p => p.displayName === 'playlist 3')
320 expect(playlist3).to.not.be.undefined 320 expect(playlist3).to.not.be.undefined
321 await testImage(server.url, 'thumbnail', playlist3.thumbnailPath) 321 await testImageGeneratedByFFmpeg(server.url, 'custom-thumbnail', playlist3.thumbnailPath)
322 } 322 }
323 323
324 const body = await servers[2].playlists.list({ start: 0, count: 5 }) 324 const body = await servers[2].playlists.list({ start: 0, count: 5 })
@@ -336,7 +336,7 @@ describe('Test video playlists', function () {
336 336
337 const playlist2 = body.data.find(p => p.displayName === 'playlist 2') 337 const playlist2 = body.data.find(p => p.displayName === 'playlist 2')
338 expect(playlist2).to.not.be.undefined 338 expect(playlist2).to.not.be.undefined
339 await testImage(servers[2].url, 'thumbnail-playlist', playlist2.thumbnailPath) 339 await testImageGeneratedByFFmpeg(servers[2].url, 'thumbnail-playlist', playlist2.thumbnailPath)
340 340
341 expect(body.data.find(p => p.displayName === 'playlist 3')).to.not.be.undefined 341 expect(body.data.find(p => p.displayName === 'playlist 3')).to.not.be.undefined
342 }) 342 })
@@ -474,7 +474,7 @@ describe('Test video playlists', function () {
474 await servers[1].playlists.get({ playlistId: unlistedPlaylist.id, expectedStatus: 404 }) 474 await servers[1].playlists.get({ playlistId: unlistedPlaylist.id, expectedStatus: 404 })
475 }) 475 })
476 476
477 it('Should get unlisted plyaylist using uuid or shortUUID', async function () { 477 it('Should get unlisted playlist using uuid or shortUUID', async function () {
478 await servers[1].playlists.get({ playlistId: unlistedPlaylist.uuid }) 478 await servers[1].playlists.get({ playlistId: unlistedPlaylist.uuid })
479 await servers[1].playlists.get({ playlistId: unlistedPlaylist.shortUUID }) 479 await servers[1].playlists.get({ playlistId: unlistedPlaylist.shortUUID })
480 }) 480 })
@@ -502,7 +502,7 @@ describe('Test video playlists', function () {
502 displayName: 'playlist 3 updated', 502 displayName: 'playlist 3 updated',
503 description: 'description updated', 503 description: 'description updated',
504 privacy: VideoPlaylistPrivacy.UNLISTED, 504 privacy: VideoPlaylistPrivacy.UNLISTED,
505 thumbnailfile: 'thumbnail.jpg', 505 thumbnailfile: 'custom-thumbnail.jpg',
506 videoChannelId: servers[1].store.channel.id 506 videoChannelId: servers[1].store.channel.id
507 }, 507 },
508 playlistId: playlistServer2Id2 508 playlistId: playlistServer2Id2
@@ -686,7 +686,7 @@ describe('Test video playlists', function () {
686 await waitJobs(servers) 686 await waitJobs(servers)
687 }) 687 })
688 688
689 it('Should update the element type if the video is private', async function () { 689 it('Should update the element type if the video is private/password protected', async function () {
690 this.timeout(20000) 690 this.timeout(20000)
691 691
692 const name = 'video 89' 692 const name = 'video 89'
@@ -703,6 +703,19 @@ describe('Test video playlists', function () {
703 } 703 }
704 704
705 { 705 {
706 await servers[0].videos.update({
707 id: video1,
708 attributes: { privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords: [ 'password' ] }
709 })
710 await waitJobs(servers)
711
712 await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
713 await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3)
714 await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3)
715 await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3)
716 }
717
718 {
706 await servers[0].videos.update({ id: video1, attributes: { privacy: VideoPrivacy.PUBLIC } }) 719 await servers[0].videos.update({ id: video1, attributes: { privacy: VideoPrivacy.PUBLIC } })
707 await waitJobs(servers) 720 await waitJobs(servers)
708 721
diff --git a/server/tests/api/videos/video-static-file-privacy.ts b/server/tests/api/videos/video-static-file-privacy.ts
index 542848533..0a9864134 100644
--- a/server/tests/api/videos/video-static-file-privacy.ts
+++ b/server/tests/api/videos/video-static-file-privacy.ts
@@ -41,7 +41,7 @@ describe('Test video static file privacy', function () {
41 41
42 for (const file of video.files) { 42 for (const file of video.files) {
43 expect(file.fileDownloadUrl).to.not.include('/private/') 43 expect(file.fileDownloadUrl).to.not.include('/private/')
44 expectStartWith(file.fileUrl, server.url + '/static/webseed/private/') 44 expectStartWith(file.fileUrl, server.url + '/static/web-videos/private/')
45 45
46 const torrent = await parseTorrentVideo(server, file) 46 const torrent = await parseTorrentVideo(server, file)
47 expect(torrent.urlList).to.have.lengthOf(0) 47 expect(torrent.urlList).to.have.lengthOf(0)
@@ -90,7 +90,7 @@ describe('Test video static file privacy', function () {
90 } 90 }
91 } 91 }
92 92
93 it('Should upload a private/internal video and have a private static path', async function () { 93 it('Should upload a private/internal/password protected video and have a private static path', async function () {
94 this.timeout(120000) 94 this.timeout(120000)
95 95
96 for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) { 96 for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) {
@@ -99,6 +99,15 @@ describe('Test video static file privacy', function () {
99 99
100 await checkPrivateFiles(uuid) 100 await checkPrivateFiles(uuid)
101 } 101 }
102
103 const { uuid } = await server.videos.quickUpload({
104 name: 'video',
105 privacy: VideoPrivacy.PASSWORD_PROTECTED,
106 videoPasswords: [ 'my super password' ]
107 })
108 await waitJobs([ server ])
109
110 await checkPrivateFiles(uuid)
102 }) 111 })
103 112
104 it('Should upload a public video and update it as private/internal to have a private static path', async function () { 113 it('Should upload a public video and update it as private/internal to have a private static path', async function () {
@@ -185,8 +194,9 @@ describe('Test video static file privacy', function () {
185 expectedStatus: HttpStatusCode 194 expectedStatus: HttpStatusCode
186 token: string 195 token: string
187 videoFileToken: string 196 videoFileToken: string
197 videoPassword?: string
188 }) { 198 }) {
189 const { id, expectedStatus, token, videoFileToken } = options 199 const { id, expectedStatus, token, videoFileToken, videoPassword } = options
190 200
191 const video = await server.videos.getWithToken({ id }) 201 const video = await server.videos.getWithToken({ id })
192 202
@@ -196,6 +206,12 @@ describe('Test video static file privacy', function () {
196 206
197 await makeRawRequest({ url: file.fileUrl, query: { videoFileToken }, expectedStatus }) 207 await makeRawRequest({ url: file.fileUrl, query: { videoFileToken }, expectedStatus })
198 await makeRawRequest({ url: file.fileDownloadUrl, query: { videoFileToken }, expectedStatus }) 208 await makeRawRequest({ url: file.fileDownloadUrl, query: { videoFileToken }, expectedStatus })
209
210 if (videoPassword) {
211 const headers = { 'x-peertube-video-password': videoPassword }
212 await makeRawRequest({ url: file.fileUrl, headers, expectedStatus })
213 await makeRawRequest({ url: file.fileDownloadUrl, headers, expectedStatus })
214 }
199 } 215 }
200 216
201 const hls = video.streamingPlaylists[0] 217 const hls = video.streamingPlaylists[0]
@@ -204,6 +220,12 @@ describe('Test video static file privacy', function () {
204 220
205 await makeRawRequest({ url: hls.playlistUrl, query: { videoFileToken }, expectedStatus }) 221 await makeRawRequest({ url: hls.playlistUrl, query: { videoFileToken }, expectedStatus })
206 await makeRawRequest({ url: hls.segmentsSha256Url, query: { videoFileToken }, expectedStatus }) 222 await makeRawRequest({ url: hls.segmentsSha256Url, query: { videoFileToken }, expectedStatus })
223
224 if (videoPassword) {
225 const headers = { 'x-peertube-video-password': videoPassword }
226 await makeRawRequest({ url: hls.playlistUrl, token: null, headers, expectedStatus })
227 await makeRawRequest({ url: hls.segmentsSha256Url, token: null, headers, expectedStatus })
228 }
207 } 229 }
208 230
209 before(async function () { 231 before(async function () {
@@ -216,13 +238,53 @@ describe('Test video static file privacy', function () {
216 it('Should not be able to access a private video files without OAuth token and file token', async function () { 238 it('Should not be able to access a private video files without OAuth token and file token', async function () {
217 this.timeout(120000) 239 this.timeout(120000)
218 240
219 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.INTERNAL }) 241 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
220 await waitJobs([ server ]) 242 await waitJobs([ server ])
221 243
222 await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403, token: null, videoFileToken: null }) 244 await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.FORBIDDEN_403, token: null, videoFileToken: null })
223 }) 245 })
224 246
225 it('Should not be able to access an internal video files without appropriate OAuth token and file token', async function () { 247 it('Should not be able to access password protected video files without OAuth token, file token and password', async function () {
248 this.timeout(120000)
249 const videoPassword = 'my super password'
250
251 const { uuid } = await server.videos.quickUpload({
252 name: 'password protected video',
253 privacy: VideoPrivacy.PASSWORD_PROTECTED,
254 videoPasswords: [ videoPassword ]
255 })
256 await waitJobs([ server ])
257
258 await checkVideoFiles({
259 id: uuid,
260 expectedStatus: HttpStatusCode.FORBIDDEN_403,
261 token: null,
262 videoFileToken: null,
263 videoPassword: null
264 })
265 })
266
267 it('Should not be able to access an password video files with incorrect OAuth token, file token and password', async function () {
268 this.timeout(120000)
269 const videoPassword = 'my super password'
270
271 const { uuid } = await server.videos.quickUpload({
272 name: 'password protected video',
273 privacy: VideoPrivacy.PASSWORD_PROTECTED,
274 videoPasswords: [ videoPassword ]
275 })
276 await waitJobs([ server ])
277
278 await checkVideoFiles({
279 id: uuid,
280 expectedStatus: HttpStatusCode.FORBIDDEN_403,
281 token: userToken,
282 videoFileToken: unrelatedFileToken,
283 videoPassword: 'incorrectPassword'
284 })
285 })
286
287 it('Should not be able to access an private video files without appropriate OAuth token and file token', async function () {
226 this.timeout(120000) 288 this.timeout(120000)
227 289
228 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE }) 290 const { uuid } = await server.videos.quickUpload({ name: 'video', privacy: VideoPrivacy.PRIVATE })
@@ -247,6 +309,23 @@ describe('Test video static file privacy', function () {
247 await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken }) 309 await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken })
248 }) 310 })
249 311
312 it('Should be able to access a password protected video files with appropriate OAuth token or file token', async function () {
313 this.timeout(120000)
314 const videoPassword = 'my super password'
315
316 const { uuid } = await server.videos.quickUpload({
317 name: 'video',
318 privacy: VideoPrivacy.PASSWORD_PROTECTED,
319 videoPasswords: [ videoPassword ]
320 })
321
322 const videoFileToken = await server.videoToken.getVideoFileToken({ token: null, videoId: uuid, videoPassword })
323
324 await waitJobs([ server ])
325
326 await checkVideoFiles({ id: uuid, expectedStatus: HttpStatusCode.OK_200, token: server.accessToken, videoFileToken, videoPassword })
327 })
328
250 it('Should reinject video file token', async function () { 329 it('Should reinject video file token', async function () {
251 this.timeout(120000) 330 this.timeout(120000)
252 331
@@ -294,13 +373,20 @@ describe('Test video static file privacy', function () {
294 let permanentLiveId: string 373 let permanentLiveId: string
295 let permanentLive: LiveVideo 374 let permanentLive: LiveVideo
296 375
376 let passwordProtectedLiveId: string
377 let passwordProtectedLive: LiveVideo
378
379 const correctPassword = 'my super password'
380
297 let unrelatedFileToken: string 381 let unrelatedFileToken: string
298 382
299 async function checkLiveFiles (live: LiveVideo, liveId: string) { 383 async function checkLiveFiles (options: { live: LiveVideo, liveId: string, videoPassword?: string }) {
384 const { live, liveId, videoPassword } = options
300 const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey }) 385 const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey })
301 await server.live.waitUntilPublished({ videoId: liveId }) 386 await server.live.waitUntilPublished({ videoId: liveId })
302 387
303 const video = await server.videos.getWithToken({ id: liveId }) 388 const video = await server.videos.getWithToken({ id: liveId })
389
304 const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid }) 390 const fileToken = await server.videoToken.getVideoFileToken({ videoId: video.uuid })
305 391
306 const hls = video.streamingPlaylists[0] 392 const hls = video.streamingPlaylists[0]
@@ -314,6 +400,16 @@ describe('Test video static file privacy', function () {
314 await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 400 await makeRawRequest({ url, token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
315 await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 401 await makeRawRequest({ url, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
316 await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) 402 await makeRawRequest({ url, query: { videoFileToken: unrelatedFileToken }, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
403
404 if (videoPassword) {
405 await makeRawRequest({ url, headers: { 'x-peertube-video-password': videoPassword }, expectedStatus: HttpStatusCode.OK_200 })
406 await makeRawRequest({
407 url,
408 headers: { 'x-peertube-video-password': 'incorrectPassword' },
409 expectedStatus: HttpStatusCode.FORBIDDEN_403
410 })
411 }
412
317 } 413 }
318 414
319 await stopFfmpeg(ffmpegCommand) 415 await stopFfmpeg(ffmpegCommand)
@@ -381,18 +477,35 @@ describe('Test video static file privacy', function () {
381 permanentLiveId = video.uuid 477 permanentLiveId = video.uuid
382 permanentLive = live 478 permanentLive = live
383 } 479 }
480
481 {
482 const { video, live } = await server.live.quickCreate({
483 saveReplay: false,
484 permanentLive: false,
485 privacy: VideoPrivacy.PASSWORD_PROTECTED,
486 videoPasswords: [ correctPassword ]
487 })
488 passwordProtectedLiveId = video.uuid
489 passwordProtectedLive = live
490 }
384 }) 491 })
385 492
386 it('Should create a private normal live and have a private static path', async function () { 493 it('Should create a private normal live and have a private static path', async function () {
387 this.timeout(240000) 494 this.timeout(240000)
388 495
389 await checkLiveFiles(normalLive, normalLiveId) 496 await checkLiveFiles({ live: normalLive, liveId: normalLiveId })
390 }) 497 })
391 498
392 it('Should create a private permanent live and have a private static path', async function () { 499 it('Should create a private permanent live and have a private static path', async function () {
393 this.timeout(240000) 500 this.timeout(240000)
394 501
395 await checkLiveFiles(permanentLive, permanentLiveId) 502 await checkLiveFiles({ live: permanentLive, liveId: permanentLiveId })
503 })
504
505 it('Should create a password protected live and have a private static path', async function () {
506 this.timeout(240000)
507
508 await checkLiveFiles({ live: passwordProtectedLive, liveId: passwordProtectedLiveId, videoPassword: correctPassword })
396 }) 509 })
397 510
398 it('Should reinject video file token on permanent live', async function () { 511 it('Should reinject video file token on permanent live', async function () {
diff --git a/server/tests/api/videos/video-storyboard.ts b/server/tests/api/videos/video-storyboard.ts
new file mode 100644
index 000000000..fc4b4450f
--- /dev/null
+++ b/server/tests/api/videos/video-storyboard.ts
@@ -0,0 +1,213 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { readdir } from 'fs-extra'
5import { basename } from 'path'
6import { FIXTURE_URLS } from '@server/tests/shared'
7import { areHttpImportTestsDisabled } from '@shared/core-utils'
8import { HttpStatusCode, VideoPrivacy } from '@shared/models'
9import {
10 cleanupTests,
11 createMultipleServers,
12 doubleFollow,
13 makeGetRequest,
14 PeerTubeServer,
15 sendRTMPStream,
16 setAccessTokensToServers,
17 setDefaultVideoChannel,
18 stopFfmpeg,
19 waitJobs
20} from '@shared/server-commands'
21
22async function checkStoryboard (options: {
23 server: PeerTubeServer
24 uuid: string
25 tilesCount?: number
26 minSize?: number
27}) {
28 const { server, uuid, tilesCount, minSize = 1000 } = options
29
30 const { storyboards } = await server.storyboard.list({ id: uuid })
31
32 expect(storyboards).to.have.lengthOf(1)
33
34 const storyboard = storyboards[0]
35
36 expect(storyboard.spriteDuration).to.equal(1)
37 expect(storyboard.spriteHeight).to.equal(108)
38 expect(storyboard.spriteWidth).to.equal(192)
39 expect(storyboard.storyboardPath).to.exist
40
41 if (tilesCount) {
42 expect(storyboard.totalWidth).to.equal(192 * Math.min(tilesCount, 10))
43 expect(storyboard.totalHeight).to.equal(108 * Math.max((tilesCount / 10), 1))
44 }
45
46 const { body } = await makeGetRequest({ url: server.url, path: storyboard.storyboardPath, expectedStatus: HttpStatusCode.OK_200 })
47 expect(body.length).to.be.above(minSize)
48}
49
50describe('Test video storyboard', function () {
51 let servers: PeerTubeServer[]
52
53 let baseUUID: string
54
55 before(async function () {
56 this.timeout(120000)
57
58 servers = await createMultipleServers(2)
59 await setAccessTokensToServers(servers)
60 await setDefaultVideoChannel(servers)
61
62 await doubleFollow(servers[0], servers[1])
63 })
64
65 it('Should generate a storyboard after upload without transcoding', async function () {
66 this.timeout(60000)
67
68 // 5s video
69 const { uuid } = await servers[0].videos.quickUpload({ name: 'upload', fixture: 'video_short.webm' })
70 baseUUID = uuid
71 await waitJobs(servers)
72
73 for (const server of servers) {
74 await checkStoryboard({ server, uuid, tilesCount: 5 })
75 }
76 })
77
78 it('Should generate a storyboard after upload without transcoding with a long video', async function () {
79 this.timeout(60000)
80
81 // 124s video
82 const { uuid } = await servers[0].videos.quickUpload({ name: 'upload', fixture: 'video_very_long_10p.mp4' })
83 await waitJobs(servers)
84
85 for (const server of servers) {
86 await checkStoryboard({ server, uuid, tilesCount: 100 })
87 }
88 })
89
90 it('Should generate a storyboard after upload with transcoding', async function () {
91 this.timeout(60000)
92
93 await servers[0].config.enableMinimumTranscoding()
94
95 // 5s video
96 const { uuid } = await servers[0].videos.quickUpload({ name: 'upload', fixture: 'video_short.webm' })
97 await waitJobs(servers)
98
99 for (const server of servers) {
100 await checkStoryboard({ server, uuid, tilesCount: 5 })
101 }
102 })
103
104 it('Should generate a storyboard after an audio upload', async function () {
105 this.timeout(60000)
106
107 // 6s audio
108 const attributes = { name: 'audio', fixture: 'sample.ogg' }
109 const { uuid } = await servers[0].videos.upload({ attributes, mode: 'legacy' })
110 await waitJobs(servers)
111
112 for (const server of servers) {
113 try {
114 await checkStoryboard({ server, uuid, tilesCount: 6, minSize: 250 })
115 } catch { // FIXME: to remove after ffmpeg CI upgrade, ffmpeg CI version (4.3) generates a 7.6s length video
116 await checkStoryboard({ server, uuid, tilesCount: 8, minSize: 250 })
117 }
118 }
119 })
120
121 it('Should generate a storyboard after HTTP import', async function () {
122 this.timeout(60000)
123
124 if (areHttpImportTestsDisabled()) return
125
126 // 3s video
127 const { video } = await servers[0].imports.importVideo({
128 attributes: {
129 targetUrl: FIXTURE_URLS.goodVideo,
130 channelId: servers[0].store.channel.id,
131 privacy: VideoPrivacy.PUBLIC
132 }
133 })
134 await waitJobs(servers)
135
136 for (const server of servers) {
137 await checkStoryboard({ server, uuid: video.uuid, tilesCount: 3 })
138 }
139 })
140
141 it('Should generate a storyboard after torrent import', async function () {
142 this.timeout(60000)
143
144 if (areHttpImportTestsDisabled()) return
145
146 // 10s video
147 const { video } = await servers[0].imports.importVideo({
148 attributes: {
149 magnetUri: FIXTURE_URLS.magnet,
150 channelId: servers[0].store.channel.id,
151 privacy: VideoPrivacy.PUBLIC
152 }
153 })
154 await waitJobs(servers)
155
156 for (const server of servers) {
157 await checkStoryboard({ server, uuid: video.uuid, tilesCount: 10 })
158 }
159 })
160
161 it('Should generate a storyboard after a live', async function () {
162 this.timeout(240000)
163
164 await servers[0].config.enableLive({ allowReplay: true, transcoding: true, resolutions: 'min' })
165
166 const { live, video } = await servers[0].live.quickCreate({
167 saveReplay: true,
168 permanentLive: false,
169 privacy: VideoPrivacy.PUBLIC
170 })
171
172 const ffmpegCommand = sendRTMPStream({ rtmpBaseUrl: live.rtmpUrl, streamKey: live.streamKey })
173 await servers[0].live.waitUntilPublished({ videoId: video.id })
174
175 await stopFfmpeg(ffmpegCommand)
176
177 await servers[0].live.waitUntilReplacedByReplay({ videoId: video.id })
178 await waitJobs(servers)
179
180 for (const server of servers) {
181 await checkStoryboard({ server, uuid: video.uuid })
182 }
183 })
184
185 it('Should cleanup storyboards on video deletion', async function () {
186 this.timeout(60000)
187
188 const { storyboards } = await servers[0].storyboard.list({ id: baseUUID })
189 const storyboardName = basename(storyboards[0].storyboardPath)
190
191 const listFiles = () => {
192 const storyboardPath = servers[0].getDirectoryPath('storyboards')
193 return readdir(storyboardPath)
194 }
195
196 {
197 const storyboads = await listFiles()
198 expect(storyboads).to.include(storyboardName)
199 }
200
201 await servers[0].videos.remove({ id: baseUUID })
202 await waitJobs(servers)
203
204 {
205 const storyboads = await listFiles()
206 expect(storyboads).to.not.include(storyboardName)
207 }
208 })
209
210 after(async function () {
211 await cleanupTests(servers)
212 })
213})
diff --git a/server/tests/api/videos/videos-common-filters.ts b/server/tests/api/videos/videos-common-filters.ts
index 30251706b..73c066bfb 100644
--- a/server/tests/api/videos/videos-common-filters.ts
+++ b/server/tests/api/videos/videos-common-filters.ts
@@ -154,7 +154,7 @@ describe('Test videos filter', function () {
154 server: PeerTubeServer 154 server: PeerTubeServer
155 path: string 155 path: string
156 isLocal?: boolean 156 isLocal?: boolean
157 hasWebtorrentFiles?: boolean 157 hasWebVideoFiles?: boolean
158 hasHLSFiles?: boolean 158 hasHLSFiles?: boolean
159 include?: VideoInclude 159 include?: VideoInclude
160 privacyOneOf?: VideoPrivacy[] 160 privacyOneOf?: VideoPrivacy[]
@@ -174,7 +174,7 @@ describe('Test videos filter', function () {
174 'include', 174 'include',
175 'category', 175 'category',
176 'tagsAllOf', 176 'tagsAllOf',
177 'hasWebtorrentFiles', 177 'hasWebVideoFiles',
178 'hasHLSFiles', 178 'hasHLSFiles',
179 'privacyOneOf', 179 'privacyOneOf',
180 'excludeAlreadyWatched' 180 'excludeAlreadyWatched'
@@ -463,14 +463,14 @@ describe('Test videos filter', function () {
463 } 463 }
464 }) 464 })
465 465
466 it('Should filter by HLS or WebTorrent files', async function () { 466 it('Should filter by HLS or Web Video files', async function () {
467 this.timeout(360000) 467 this.timeout(360000)
468 468
469 const finderFactory = (name: string) => (videos: Video[]) => videos.some(v => v.name === name) 469 const finderFactory = (name: string) => (videos: Video[]) => videos.some(v => v.name === name)
470 470
471 await servers[0].config.enableTranscoding(true, false) 471 await servers[0].config.enableTranscoding(true, false)
472 await servers[0].videos.upload({ attributes: { name: 'webtorrent video' } }) 472 await servers[0].videos.upload({ attributes: { name: 'web video video' } })
473 const hasWebtorrent = finderFactory('webtorrent video') 473 const hasWebVideo = finderFactory('web video video')
474 474
475 await waitJobs(servers) 475 await waitJobs(servers)
476 476
@@ -481,24 +481,24 @@ describe('Test videos filter', function () {
481 await waitJobs(servers) 481 await waitJobs(servers)
482 482
483 await servers[0].config.enableTranscoding(true, true) 483 await servers[0].config.enableTranscoding(true, true)
484 await servers[0].videos.upload({ attributes: { name: 'hls and webtorrent video' } }) 484 await servers[0].videos.upload({ attributes: { name: 'hls and web video video' } })
485 const hasBoth = finderFactory('hls and webtorrent video') 485 const hasBoth = finderFactory('hls and web video video')
486 486
487 await waitJobs(servers) 487 await waitJobs(servers)
488 488
489 for (const path of paths) { 489 for (const path of paths) {
490 { 490 {
491 const videos = await listVideos({ server: servers[0], path, hasWebtorrentFiles: true }) 491 const videos = await listVideos({ server: servers[0], path, hasWebVideoFiles: true })
492 492
493 expect(hasWebtorrent(videos)).to.be.true 493 expect(hasWebVideo(videos)).to.be.true
494 expect(hasHLS(videos)).to.be.false 494 expect(hasHLS(videos)).to.be.false
495 expect(hasBoth(videos)).to.be.true 495 expect(hasBoth(videos)).to.be.true
496 } 496 }
497 497
498 { 498 {
499 const videos = await listVideos({ server: servers[0], path, hasWebtorrentFiles: false }) 499 const videos = await listVideos({ server: servers[0], path, hasWebVideoFiles: false })
500 500
501 expect(hasWebtorrent(videos)).to.be.false 501 expect(hasWebVideo(videos)).to.be.false
502 expect(hasHLS(videos)).to.be.true 502 expect(hasHLS(videos)).to.be.true
503 expect(hasBoth(videos)).to.be.false 503 expect(hasBoth(videos)).to.be.false
504 } 504 }
@@ -506,7 +506,7 @@ describe('Test videos filter', function () {
506 { 506 {
507 const videos = await listVideos({ server: servers[0], path, hasHLSFiles: true }) 507 const videos = await listVideos({ server: servers[0], path, hasHLSFiles: true })
508 508
509 expect(hasWebtorrent(videos)).to.be.false 509 expect(hasWebVideo(videos)).to.be.false
510 expect(hasHLS(videos)).to.be.true 510 expect(hasHLS(videos)).to.be.true
511 expect(hasBoth(videos)).to.be.true 511 expect(hasBoth(videos)).to.be.true
512 } 512 }
@@ -514,23 +514,23 @@ describe('Test videos filter', function () {
514 { 514 {
515 const videos = await listVideos({ server: servers[0], path, hasHLSFiles: false }) 515 const videos = await listVideos({ server: servers[0], path, hasHLSFiles: false })
516 516
517 expect(hasWebtorrent(videos)).to.be.true 517 expect(hasWebVideo(videos)).to.be.true
518 expect(hasHLS(videos)).to.be.false 518 expect(hasHLS(videos)).to.be.false
519 expect(hasBoth(videos)).to.be.false 519 expect(hasBoth(videos)).to.be.false
520 } 520 }
521 521
522 { 522 {
523 const videos = await listVideos({ server: servers[0], path, hasHLSFiles: false, hasWebtorrentFiles: false }) 523 const videos = await listVideos({ server: servers[0], path, hasHLSFiles: false, hasWebVideoFiles: false })
524 524
525 expect(hasWebtorrent(videos)).to.be.false 525 expect(hasWebVideo(videos)).to.be.false
526 expect(hasHLS(videos)).to.be.false 526 expect(hasHLS(videos)).to.be.false
527 expect(hasBoth(videos)).to.be.false 527 expect(hasBoth(videos)).to.be.false
528 } 528 }
529 529
530 { 530 {
531 const videos = await listVideos({ server: servers[0], path, hasHLSFiles: true, hasWebtorrentFiles: true }) 531 const videos = await listVideos({ server: servers[0], path, hasHLSFiles: true, hasWebVideoFiles: true })
532 532
533 expect(hasWebtorrent(videos)).to.be.false 533 expect(hasWebVideo(videos)).to.be.false
534 expect(hasHLS(videos)).to.be.false 534 expect(hasHLS(videos)).to.be.false
535 expect(hasBoth(videos)).to.be.true 535 expect(hasBoth(videos)).to.be.true
536 } 536 }
diff --git a/server/tests/cli/create-generate-storyboard-job.ts b/server/tests/cli/create-generate-storyboard-job.ts
new file mode 100644
index 000000000..02a4be8ae
--- /dev/null
+++ b/server/tests/cli/create-generate-storyboard-job.ts
@@ -0,0 +1,120 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { readdir, remove } from 'fs-extra'
5import { join } from 'path'
6import { HttpStatusCode } from '@shared/models'
7import {
8 cleanupTests,
9 createMultipleServers,
10 doubleFollow,
11 makeGetRequest,
12 PeerTubeServer,
13 setAccessTokensToServers,
14 waitJobs
15} from '@shared/server-commands'
16import { SQLCommand } from '../shared'
17
18function listStoryboardFiles (server: PeerTubeServer) {
19 const storage = server.getDirectoryPath('storyboards')
20
21 return readdir(storage)
22}
23
24describe('Test create generate storyboard job', function () {
25 let servers: PeerTubeServer[] = []
26 const uuids: string[] = []
27 let sql: SQLCommand
28 let existingStoryboardName: string
29
30 before(async function () {
31 this.timeout(120000)
32
33 // Run server 2 to have transcoding enabled
34 servers = await createMultipleServers(2)
35 await setAccessTokensToServers(servers)
36
37 await doubleFollow(servers[0], servers[1])
38
39 for (let i = 0; i < 3; i++) {
40 const { uuid } = await servers[0].videos.quickUpload({ name: 'video ' + i })
41 uuids.push(uuid)
42 }
43
44 await waitJobs(servers)
45
46 const storage = servers[0].getDirectoryPath('storyboards')
47 for (const storyboard of await listStoryboardFiles(servers[0])) {
48 await remove(join(storage, storyboard))
49 }
50
51 sql = new SQLCommand(servers[0])
52 await sql.deleteAll('storyboard')
53
54 const { uuid } = await servers[0].videos.quickUpload({ name: 'video 4' })
55 uuids.push(uuid)
56
57 await waitJobs(servers)
58
59 const storyboards = await listStoryboardFiles(servers[0])
60 existingStoryboardName = storyboards[0]
61 })
62
63 it('Should create a storyboard of a video', async function () {
64 this.timeout(120000)
65
66 for (const uuid of [ uuids[0], uuids[3] ]) {
67 const command = `npm run create-generate-storyboard-job -- -v ${uuid}`
68 await servers[0].cli.execWithEnv(command)
69 }
70
71 await waitJobs(servers)
72
73 {
74 const storyboards = await listStoryboardFiles(servers[0])
75 expect(storyboards).to.have.lengthOf(2)
76 expect(storyboards).to.not.include(existingStoryboardName)
77
78 existingStoryboardName = storyboards[0]
79 }
80
81 for (const server of servers) {
82 for (const uuid of [ uuids[0], uuids[3] ]) {
83 const { storyboards } = await server.storyboard.list({ id: uuid })
84 expect(storyboards).to.have.lengthOf(1)
85
86 await makeGetRequest({ url: server.url, path: storyboards[0].storyboardPath, expectedStatus: HttpStatusCode.OK_200 })
87 }
88 }
89 })
90
91 it('Should create missing storyboards', async function () {
92 this.timeout(120000)
93
94 const command = `npm run create-generate-storyboard-job -- -a`
95 await servers[0].cli.execWithEnv(command)
96
97 await waitJobs(servers)
98
99 {
100 const storyboards = await listStoryboardFiles(servers[0])
101 expect(storyboards).to.have.lengthOf(4)
102 expect(storyboards).to.include(existingStoryboardName)
103 }
104
105 for (const server of servers) {
106 for (const uuid of uuids) {
107 const { storyboards } = await server.storyboard.list({ id: uuid })
108 expect(storyboards).to.have.lengthOf(1)
109
110 await makeGetRequest({ url: server.url, path: storyboards[0].storyboardPath, expectedStatus: HttpStatusCode.OK_200 })
111 }
112 }
113 })
114
115 after(async function () {
116 await sql.cleanup()
117
118 await cleanupTests(servers)
119 })
120})
diff --git a/server/tests/cli/create-move-video-storage-job.ts b/server/tests/cli/create-move-video-storage-job.ts
index 253fc983e..fc6a8e648 100644
--- a/server/tests/cli/create-move-video-storage-job.ts
+++ b/server/tests/cli/create-move-video-storage-job.ts
@@ -109,8 +109,8 @@ describe('Test create move video storage job', function () {
109 }) 109 })
110 110
111 it('Should not have files on disk anymore', async function () { 111 it('Should not have files on disk anymore', async function () {
112 await checkDirectoryIsEmpty(servers[0], 'videos', [ 'private' ]) 112 await checkDirectoryIsEmpty(servers[0], 'web-videos', [ 'private' ])
113 await checkDirectoryIsEmpty(servers[0], join('videos', 'private')) 113 await checkDirectoryIsEmpty(servers[0], join('web-videos', 'private'))
114 114
115 await checkDirectoryIsEmpty(servers[0], join('streaming-playlists', 'hls'), [ 'private' ]) 115 await checkDirectoryIsEmpty(servers[0], join('streaming-playlists', 'hls'), [ 'private' ])
116 await checkDirectoryIsEmpty(servers[0], join('streaming-playlists', 'hls', 'private')) 116 await checkDirectoryIsEmpty(servers[0], join('streaming-playlists', 'hls', 'private'))
diff --git a/server/tests/cli/index.ts b/server/tests/cli/index.ts
index 8579be39c..94444ace3 100644
--- a/server/tests/cli/index.ts
+++ b/server/tests/cli/index.ts
@@ -1,5 +1,6 @@
1// Order of the tests we want to execute 1// Order of the tests we want to execute
2import './create-import-video-file-job' 2import './create-import-video-file-job'
3import './create-generate-storyboard-job'
3import './create-move-video-storage-job' 4import './create-move-video-storage-job'
4import './peertube' 5import './peertube'
5import './plugins' 6import './plugins'
diff --git a/server/tests/cli/prune-storage.ts b/server/tests/cli/prune-storage.ts
index 8bdf2136d..561ed6a68 100644
--- a/server/tests/cli/prune-storage.ts
+++ b/server/tests/cli/prune-storage.ts
@@ -35,10 +35,10 @@ async function assertNotExists (server: PeerTubeServer, directory: string, subst
35 35
36async function assertCountAreOkay (servers: PeerTubeServer[]) { 36async function assertCountAreOkay (servers: PeerTubeServer[]) {
37 for (const server of servers) { 37 for (const server of servers) {
38 const videosCount = await countFiles(server, 'videos') 38 const videosCount = await countFiles(server, 'web-videos')
39 expect(videosCount).to.equal(9) // 2 videos with 4 resolutions + private directory 39 expect(videosCount).to.equal(9) // 2 videos with 4 resolutions + private directory
40 40
41 const privateVideosCount = await countFiles(server, 'videos/private') 41 const privateVideosCount = await countFiles(server, 'web-videos/private')
42 expect(privateVideosCount).to.equal(4) 42 expect(privateVideosCount).to.equal(4)
43 43
44 const torrentsCount = await countFiles(server, 'torrents') 44 const torrentsCount = await countFiles(server, 'torrents')
@@ -48,7 +48,7 @@ async function assertCountAreOkay (servers: PeerTubeServer[]) {
48 expect(previewsCount).to.equal(3) 48 expect(previewsCount).to.equal(3)
49 49
50 const thumbnailsCount = await countFiles(server, 'thumbnails') 50 const thumbnailsCount = await countFiles(server, 'thumbnails')
51 expect(thumbnailsCount).to.equal(7) // 3 local videos, 1 local playlist, 2 remotes videos and 1 remote playlist 51 expect(thumbnailsCount).to.equal(5) // 3 local videos, 1 local playlist, 2 remotes videos (lazy downloaded) and 1 remote playlist
52 52
53 const avatarsCount = await countFiles(server, 'avatars') 53 const avatarsCount = await countFiles(server, 'avatars')
54 expect(avatarsCount).to.equal(4) 54 expect(avatarsCount).to.equal(4)
@@ -85,7 +85,7 @@ describe('Test prune storage scripts', function () {
85 displayName: 'playlist', 85 displayName: 'playlist',
86 privacy: VideoPlaylistPrivacy.PUBLIC, 86 privacy: VideoPlaylistPrivacy.PUBLIC,
87 videoChannelId: server.store.channel.id, 87 videoChannelId: server.store.channel.id,
88 thumbnailfile: 'thumbnail.jpg' 88 thumbnailfile: 'custom-thumbnail.jpg'
89 } 89 }
90 }) 90 })
91 } 91 }
@@ -131,8 +131,8 @@ describe('Test prune storage scripts', function () {
131 it('Should create some dirty files', async function () { 131 it('Should create some dirty files', async function () {
132 for (let i = 0; i < 2; i++) { 132 for (let i = 0; i < 2; i++) {
133 { 133 {
134 const basePublic = servers[0].servers.buildDirectory('videos') 134 const basePublic = servers[0].servers.buildDirectory('web-videos')
135 const basePrivate = servers[0].servers.buildDirectory(join('videos', 'private')) 135 const basePrivate = servers[0].servers.buildDirectory(join('web-videos', 'private'))
136 136
137 const n1 = buildUUID() + '.mp4' 137 const n1 = buildUUID() + '.mp4'
138 const n2 = buildUUID() + '.webm' 138 const n2 = buildUUID() + '.webm'
diff --git a/server/tests/cli/regenerate-thumbnails.ts b/server/tests/cli/regenerate-thumbnails.ts
index 16a8adcda..66de7f79c 100644
--- a/server/tests/cli/regenerate-thumbnails.ts
+++ b/server/tests/cli/regenerate-thumbnails.ts
@@ -60,6 +60,9 @@ describe('Test regenerate thumbnails script', function () {
60 60
61 remoteVideo = await servers[0].videos.get({ id: videoUUID }) 61 remoteVideo = await servers[0].videos.get({ id: videoUUID })
62 62
63 // Load remote thumbnail on disk
64 await makeGetRequest({ url: servers[0].url, path: remoteVideo.thumbnailPath, expectedStatus: HttpStatusCode.OK_200 })
65
63 thumbnailRemotePath = join(servers[0].servers.buildDirectory('thumbnails'), basename(remoteVideo.thumbnailPath)) 66 thumbnailRemotePath = join(servers[0].servers.buildDirectory('thumbnails'), basename(remoteVideo.thumbnailPath))
64 } 67 }
65 68
diff --git a/server/tests/client.ts b/server/tests/client.ts
index e84251561..68f3a1d14 100644
--- a/server/tests/client.ts
+++ b/server/tests/client.ts
@@ -56,6 +56,7 @@ describe('Test a client controllers', function () {
56 let privateVideoId: string 56 let privateVideoId: string
57 let internalVideoId: string 57 let internalVideoId: string
58 let unlistedVideoId: string 58 let unlistedVideoId: string
59 let passwordProtectedVideoId: string
59 60
60 let playlistIds: (string | number)[] = [] 61 let playlistIds: (string | number)[] = []
61 62
@@ -92,7 +93,12 @@ describe('Test a client controllers', function () {
92 { 93 {
93 ({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE })); 94 ({ uuid: privateVideoId } = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE }));
94 ({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED })); 95 ({ uuid: unlistedVideoId } = await servers[0].videos.quickUpload({ name: 'unlisted', privacy: VideoPrivacy.UNLISTED }));
95 ({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL })) 96 ({ uuid: internalVideoId } = await servers[0].videos.quickUpload({ name: 'internal', privacy: VideoPrivacy.INTERNAL }));
97 ({ uuid: passwordProtectedVideoId } = await servers[0].videos.quickUpload({
98 name: 'password protected',
99 privacy: VideoPrivacy.PASSWORD_PROTECTED,
100 videoPasswords: [ 'password' ]
101 }))
96 } 102 }
97 103
98 // Playlist 104 // Playlist
@@ -502,9 +508,9 @@ describe('Test a client controllers', function () {
502 } 508 }
503 }) 509 })
504 510
505 it('Should not display internal/private video', async function () { 511 it('Should not display internal/private/password protected video', async function () {
506 for (const basePath of watchVideoBasePaths) { 512 for (const basePath of watchVideoBasePaths) {
507 for (const id of [ privateVideoId, internalVideoId ]) { 513 for (const id of [ privateVideoId, internalVideoId, passwordProtectedVideoId ]) {
508 const res = await makeGetRequest({ 514 const res = await makeGetRequest({
509 url: servers[0].url, 515 url: servers[0].url,
510 path: basePath + id, 516 path: basePath + id,
@@ -514,6 +520,7 @@ describe('Test a client controllers', function () {
514 520
515 expect(res.text).to.not.contain('internal') 521 expect(res.text).to.not.contain('internal')
516 expect(res.text).to.not.contain('private') 522 expect(res.text).to.not.contain('private')
523 expect(res.text).to.not.contain('password protected')
517 } 524 }
518 } 525 }
519 }) 526 })
diff --git a/server/tests/feeds/feeds.ts b/server/tests/feeds/feeds.ts
index 8433c873e..1754ac466 100644
--- a/server/tests/feeds/feeds.ts
+++ b/server/tests/feeds/feeds.ts
@@ -47,7 +47,7 @@ describe('Test syndication feeds', () => {
47 serverHLSOnly = await createSingleServer(3, { 47 serverHLSOnly = await createSingleServer(3, {
48 transcoding: { 48 transcoding: {
49 enabled: true, 49 enabled: true,
50 webtorrent: { enabled: false }, 50 web_videos: { enabled: false },
51 hls: { enabled: true } 51 hls: { enabled: true }
52 } 52 }
53 }) 53 })
@@ -99,6 +99,13 @@ describe('Test syndication feeds', () => {
99 await servers[0].comments.createThread({ videoId: id, text: 'comment on unlisted video' }) 99 await servers[0].comments.createThread({ videoId: id, text: 'comment on unlisted video' })
100 } 100 }
101 101
102 {
103 const attributes = { name: 'password protected video', privacy: VideoPrivacy.PASSWORD_PROTECTED, videoPasswords: [ 'password' ] }
104 const { id } = await servers[0].videos.upload({ attributes })
105
106 await servers[0].comments.createThread({ videoId: id, text: 'comment on password protected video' })
107 }
108
102 await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } }) 109 await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } })
103 110
104 await waitJobs([ ...servers, serverHLSOnly ]) 111 await waitJobs([ ...servers, serverHLSOnly ])
@@ -445,7 +452,7 @@ describe('Test syndication feeds', () => {
445 452
446 describe('Video comments feed', function () { 453 describe('Video comments feed', function () {
447 454
448 it('Should contain valid comments (covers JSON feed 1.0 endpoint) and not from unlisted videos', async function () { 455 it('Should contain valid comments (covers JSON feed 1.0 endpoint) and not from unlisted/password protected videos', async function () {
449 for (const server of servers) { 456 for (const server of servers) {
450 const json = await server.feed.getJSON({ feed: 'video-comments', ignoreCache: true }) 457 const json = await server.feed.getJSON({ feed: 'video-comments', ignoreCache: true })
451 458
diff --git a/server/tests/fixtures/custom-preview-big.png b/server/tests/fixtures/custom-preview-big.png
new file mode 100644
index 000000000..03d171af3
--- /dev/null
+++ b/server/tests/fixtures/custom-preview-big.png
Binary files differ
diff --git a/server/tests/fixtures/custom-preview.jpg b/server/tests/fixtures/custom-preview.jpg
new file mode 100644
index 000000000..5a039d830
--- /dev/null
+++ b/server/tests/fixtures/custom-preview.jpg
Binary files differ
diff --git a/server/tests/fixtures/custom-thumbnail-big.jpg b/server/tests/fixtures/custom-thumbnail-big.jpg
new file mode 100644
index 000000000..08375e425
--- /dev/null
+++ b/server/tests/fixtures/custom-thumbnail-big.jpg
Binary files differ
diff --git a/server/tests/fixtures/custom-thumbnail.jpg b/server/tests/fixtures/custom-thumbnail.jpg
new file mode 100644
index 000000000..ef818442d
--- /dev/null
+++ b/server/tests/fixtures/custom-thumbnail.jpg
Binary files differ
diff --git a/server/tests/fixtures/custom-thumbnail.png b/server/tests/fixtures/custom-thumbnail.png
new file mode 100644
index 000000000..9f34daec1
--- /dev/null
+++ b/server/tests/fixtures/custom-thumbnail.png
Binary files differ
diff --git a/server/tests/fixtures/preview-big.png b/server/tests/fixtures/preview-big.png
deleted file mode 100644
index 612e297f1..000000000
--- a/server/tests/fixtures/preview-big.png
+++ /dev/null
Binary files differ
diff --git a/server/tests/fixtures/preview.jpg b/server/tests/fixtures/preview.jpg
deleted file mode 100644
index 1421da738..000000000
--- a/server/tests/fixtures/preview.jpg
+++ /dev/null
Binary files differ
diff --git a/server/tests/fixtures/thumbnail-big.jpg b/server/tests/fixtures/thumbnail-big.jpg
deleted file mode 100644
index 537720d24..000000000
--- a/server/tests/fixtures/thumbnail-big.jpg
+++ /dev/null
Binary files differ
diff --git a/server/tests/fixtures/thumbnail.jpg b/server/tests/fixtures/thumbnail.jpg
deleted file mode 100644
index 1e2897fb8..000000000
--- a/server/tests/fixtures/thumbnail.jpg
+++ /dev/null
Binary files differ
diff --git a/server/tests/fixtures/thumbnail.png b/server/tests/fixtures/thumbnail.png
deleted file mode 100644
index b331aba3b..000000000
--- a/server/tests/fixtures/thumbnail.png
+++ /dev/null
Binary files differ
diff --git a/server/tests/fixtures/video_short1-preview.webm.jpg b/server/tests/fixtures/video_short1-preview.webm.jpg
index d65af1f21..15454942d 100644
--- a/server/tests/fixtures/video_short1-preview.webm.jpg
+++ b/server/tests/fixtures/video_short1-preview.webm.jpg
Binary files differ
diff --git a/server/tests/fixtures/video_short1.webm.jpg b/server/tests/fixtures/video_short1.webm.jpg
index 0ab7c58ad..b2740d73d 100644
--- a/server/tests/fixtures/video_short1.webm.jpg
+++ b/server/tests/fixtures/video_short1.webm.jpg
Binary files differ
diff --git a/server/tests/fixtures/video_short2.webm.jpg b/server/tests/fixtures/video_short2.webm.jpg
index 1e2897fb8..afe476c7f 100644
--- a/server/tests/fixtures/video_short2.webm.jpg
+++ b/server/tests/fixtures/video_short2.webm.jpg
Binary files differ
diff --git a/server/tests/fixtures/video_very_long_10p.mp4 b/server/tests/fixtures/video_very_long_10p.mp4
new file mode 100644
index 000000000..852297933
--- /dev/null
+++ b/server/tests/fixtures/video_very_long_10p.mp4
Binary files differ
diff --git a/server/tests/helpers/image.ts b/server/tests/helpers/image.ts
index 530c9bacd..6021ffc48 100644
--- a/server/tests/helpers/image.ts
+++ b/server/tests/helpers/image.ts
@@ -35,28 +35,28 @@ describe('Image helpers', function () {
35 const thumbnailSize = { width: 280, height: 157 } 35 const thumbnailSize = { width: 280, height: 157 }
36 36
37 it('Should skip processing if the source image is okay', async function () { 37 it('Should skip processing if the source image is okay', async function () {
38 const input = buildAbsoluteFixturePath('thumbnail.jpg') 38 const input = buildAbsoluteFixturePath('custom-thumbnail.jpg')
39 await processImage({ path: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true }) 39 await processImage({ path: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true })
40 40
41 await checkBuffers(input, imageDestJPG, true) 41 await checkBuffers(input, imageDestJPG, true)
42 }) 42 })
43 43
44 it('Should not skip processing if the source image does not have the appropriate extension', async function () { 44 it('Should not skip processing if the source image does not have the appropriate extension', async function () {
45 const input = buildAbsoluteFixturePath('thumbnail.png') 45 const input = buildAbsoluteFixturePath('custom-thumbnail.png')
46 await processImage({ path: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true }) 46 await processImage({ path: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true })
47 47
48 await checkBuffers(input, imageDestJPG, false) 48 await checkBuffers(input, imageDestJPG, false)
49 }) 49 })
50 50
51 it('Should not skip processing if the source image does not have the appropriate size', async function () { 51 it('Should not skip processing if the source image does not have the appropriate size', async function () {
52 const input = buildAbsoluteFixturePath('preview.jpg') 52 const input = buildAbsoluteFixturePath('custom-preview.jpg')
53 await processImage({ path: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true }) 53 await processImage({ path: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true })
54 54
55 await checkBuffers(input, imageDestJPG, false) 55 await checkBuffers(input, imageDestJPG, false)
56 }) 56 })
57 57
58 it('Should not skip processing if the source image does not have the appropriate size', async function () { 58 it('Should not skip processing if the source image does not have the appropriate size', async function () {
59 const input = buildAbsoluteFixturePath('thumbnail-big.jpg') 59 const input = buildAbsoluteFixturePath('custom-thumbnail-big.jpg')
60 await processImage({ path: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true }) 60 await processImage({ path: input, destination: imageDestJPG, newSize: thumbnailSize, keepOriginal: true })
61 61
62 await checkBuffers(input, imageDestJPG, false) 62 await checkBuffers(input, imageDestJPG, false)
diff --git a/server/tests/peertube-runner/studio-transcoding.ts b/server/tests/peertube-runner/studio-transcoding.ts
index 988201947..c265d7934 100644
--- a/server/tests/peertube-runner/studio-transcoding.ts
+++ b/server/tests/peertube-runner/studio-transcoding.ts
@@ -44,8 +44,8 @@ describe('Test studio transcoding in peertube-runner program', function () {
44 } 44 }
45 45
46 if (objectStorage) { 46 if (objectStorage) {
47 for (const webtorrentFile of video.files) { 47 for (const webVideoFile of video.files) {
48 expectStartWith(webtorrentFile.fileUrl, objectStorage.getMockWebVideosBaseUrl()) 48 expectStartWith(webVideoFile.fileUrl, objectStorage.getMockWebVideosBaseUrl())
49 } 49 }
50 50
51 for (const hlsFile of video.streamingPlaylists[0].files) { 51 for (const hlsFile of video.streamingPlaylists[0].files) {
diff --git a/server/tests/peertube-runner/vod-transcoding.ts b/server/tests/peertube-runner/vod-transcoding.ts
index c3f41c097..eef6faf4e 100644
--- a/server/tests/peertube-runner/vod-transcoding.ts
+++ b/server/tests/peertube-runner/vod-transcoding.ts
@@ -24,13 +24,13 @@ describe('Test VOD transcoding in peertube-runner program', function () {
24 let peertubeRunner: PeerTubeRunnerProcess 24 let peertubeRunner: PeerTubeRunnerProcess
25 25
26 function runSuite (options: { 26 function runSuite (options: {
27 webtorrentEnabled: boolean 27 webVideoEnabled: boolean
28 hlsEnabled: boolean 28 hlsEnabled: boolean
29 objectStorage?: ObjectStorageCommand 29 objectStorage?: ObjectStorageCommand
30 }) { 30 }) {
31 const { webtorrentEnabled, hlsEnabled, objectStorage } = options 31 const { webVideoEnabled, hlsEnabled, objectStorage } = options
32 32
33 const objectStorageBaseUrlWebTorrent = objectStorage 33 const objectStorageBaseUrlWebVideo = objectStorage
34 ? objectStorage.getMockWebVideosBaseUrl() 34 ? objectStorage.getMockWebVideosBaseUrl()
35 : undefined 35 : undefined
36 36
@@ -46,13 +46,13 @@ describe('Test VOD transcoding in peertube-runner program', function () {
46 await waitJobs(servers, { runnerJobs: true }) 46 await waitJobs(servers, { runnerJobs: true })
47 47
48 for (const server of servers) { 48 for (const server of servers) {
49 if (webtorrentEnabled) { 49 if (webVideoEnabled) {
50 await completeWebVideoFilesCheck({ 50 await completeWebVideoFilesCheck({
51 server, 51 server,
52 originServer: servers[0], 52 originServer: servers[0],
53 fixture: 'video_short.mp4', 53 fixture: 'video_short.mp4',
54 videoUUID: uuid, 54 videoUUID: uuid,
55 objectStorageBaseUrl: objectStorageBaseUrlWebTorrent, 55 objectStorageBaseUrl: objectStorageBaseUrlWebVideo,
56 files: [ 56 files: [
57 { resolution: 0 }, 57 { resolution: 0 },
58 { resolution: 144 }, 58 { resolution: 144 },
@@ -66,7 +66,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
66 66
67 if (hlsEnabled) { 67 if (hlsEnabled) {
68 await completeCheckHlsPlaylist({ 68 await completeCheckHlsPlaylist({
69 hlsOnly: !webtorrentEnabled, 69 hlsOnly: !webVideoEnabled,
70 servers, 70 servers,
71 videoUUID: uuid, 71 videoUUID: uuid,
72 objectStorageBaseUrl: objectStorageBaseUrlHLS, 72 objectStorageBaseUrl: objectStorageBaseUrlHLS,
@@ -84,13 +84,13 @@ describe('Test VOD transcoding in peertube-runner program', function () {
84 await waitJobs(servers, { runnerJobs: true }) 84 await waitJobs(servers, { runnerJobs: true })
85 85
86 for (const server of servers) { 86 for (const server of servers) {
87 if (webtorrentEnabled) { 87 if (webVideoEnabled) {
88 await completeWebVideoFilesCheck({ 88 await completeWebVideoFilesCheck({
89 server, 89 server,
90 originServer: servers[0], 90 originServer: servers[0],
91 fixture: 'video_short.webm', 91 fixture: 'video_short.webm',
92 videoUUID: uuid, 92 videoUUID: uuid,
93 objectStorageBaseUrl: objectStorageBaseUrlWebTorrent, 93 objectStorageBaseUrl: objectStorageBaseUrlWebVideo,
94 files: [ 94 files: [
95 { resolution: 0 }, 95 { resolution: 0 },
96 { resolution: 144 }, 96 { resolution: 144 },
@@ -104,7 +104,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
104 104
105 if (hlsEnabled) { 105 if (hlsEnabled) {
106 await completeCheckHlsPlaylist({ 106 await completeCheckHlsPlaylist({
107 hlsOnly: !webtorrentEnabled, 107 hlsOnly: !webVideoEnabled,
108 servers, 108 servers,
109 videoUUID: uuid, 109 videoUUID: uuid,
110 objectStorageBaseUrl: objectStorageBaseUrlHLS, 110 objectStorageBaseUrl: objectStorageBaseUrlHLS,
@@ -123,13 +123,13 @@ describe('Test VOD transcoding in peertube-runner program', function () {
123 await waitJobs(servers, { runnerJobs: true }) 123 await waitJobs(servers, { runnerJobs: true })
124 124
125 for (const server of servers) { 125 for (const server of servers) {
126 if (webtorrentEnabled) { 126 if (webVideoEnabled) {
127 await completeWebVideoFilesCheck({ 127 await completeWebVideoFilesCheck({
128 server, 128 server,
129 originServer: servers[0], 129 originServer: servers[0],
130 fixture: 'sample.ogg', 130 fixture: 'sample.ogg',
131 videoUUID: uuid, 131 videoUUID: uuid,
132 objectStorageBaseUrl: objectStorageBaseUrlWebTorrent, 132 objectStorageBaseUrl: objectStorageBaseUrlWebVideo,
133 files: [ 133 files: [
134 { resolution: 0 }, 134 { resolution: 0 },
135 { resolution: 144 }, 135 { resolution: 144 },
@@ -142,7 +142,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
142 142
143 if (hlsEnabled) { 143 if (hlsEnabled) {
144 await completeCheckHlsPlaylist({ 144 await completeCheckHlsPlaylist({
145 hlsOnly: !webtorrentEnabled, 145 hlsOnly: !webVideoEnabled,
146 servers, 146 servers,
147 videoUUID: uuid, 147 videoUUID: uuid,
148 objectStorageBaseUrl: objectStorageBaseUrlHLS, 148 objectStorageBaseUrl: objectStorageBaseUrlHLS,
@@ -159,13 +159,13 @@ describe('Test VOD transcoding in peertube-runner program', function () {
159 159
160 await waitJobs(servers, { runnerJobs: true }) 160 await waitJobs(servers, { runnerJobs: true })
161 161
162 if (webtorrentEnabled) { 162 if (webVideoEnabled) {
163 await completeWebVideoFilesCheck({ 163 await completeWebVideoFilesCheck({
164 server: servers[0], 164 server: servers[0],
165 originServer: servers[0], 165 originServer: servers[0],
166 fixture: 'video_short.mp4', 166 fixture: 'video_short.mp4',
167 videoUUID: uuid, 167 videoUUID: uuid,
168 objectStorageBaseUrl: objectStorageBaseUrlWebTorrent, 168 objectStorageBaseUrl: objectStorageBaseUrlWebVideo,
169 files: [ 169 files: [
170 { resolution: 0 }, 170 { resolution: 0 },
171 { resolution: 144 }, 171 { resolution: 144 },
@@ -179,7 +179,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
179 179
180 if (hlsEnabled) { 180 if (hlsEnabled) {
181 await completeCheckHlsPlaylist({ 181 await completeCheckHlsPlaylist({
182 hlsOnly: !webtorrentEnabled, 182 hlsOnly: !webVideoEnabled,
183 servers: [ servers[0] ], 183 servers: [ servers[0] ],
184 videoUUID: uuid, 184 videoUUID: uuid,
185 objectStorageBaseUrl: objectStorageBaseUrlHLS, 185 objectStorageBaseUrl: objectStorageBaseUrlHLS,
@@ -203,7 +203,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
203 203
204 await servers[0].config.enableTranscoding(true, true, true) 204 await servers[0].config.enableTranscoding(true, true, true)
205 205
206 await servers[0].videos.runTranscoding({ transcodingType: 'webtorrent', videoId: uuid }) 206 await servers[0].videos.runTranscoding({ transcodingType: 'web-video', videoId: uuid })
207 await waitJobs(servers, { runnerJobs: true }) 207 await waitJobs(servers, { runnerJobs: true })
208 208
209 await completeWebVideoFilesCheck({ 209 await completeWebVideoFilesCheck({
@@ -211,7 +211,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
211 originServer: servers[0], 211 originServer: servers[0],
212 fixture: 'video_short.mp4', 212 fixture: 'video_short.mp4',
213 videoUUID: uuid, 213 videoUUID: uuid,
214 objectStorageBaseUrl: objectStorageBaseUrlWebTorrent, 214 objectStorageBaseUrl: objectStorageBaseUrlWebVideo,
215 files: [ 215 files: [
216 { resolution: 0 }, 216 { resolution: 0 },
217 { resolution: 144 }, 217 { resolution: 144 },
@@ -262,7 +262,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
262 await servers[0].config.enableTranscoding(true, false, true) 262 await servers[0].config.enableTranscoding(true, false, true)
263 }) 263 })
264 264
265 runSuite({ webtorrentEnabled: true, hlsEnabled: false }) 265 runSuite({ webVideoEnabled: true, hlsEnabled: false })
266 }) 266 })
267 267
268 describe('HLS videos only enabled', function () { 268 describe('HLS videos only enabled', function () {
@@ -271,7 +271,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
271 await servers[0].config.enableTranscoding(false, true, true) 271 await servers[0].config.enableTranscoding(false, true, true)
272 }) 272 })
273 273
274 runSuite({ webtorrentEnabled: false, hlsEnabled: true }) 274 runSuite({ webVideoEnabled: false, hlsEnabled: true })
275 }) 275 })
276 276
277 describe('Web video & HLS enabled', function () { 277 describe('Web video & HLS enabled', function () {
@@ -280,7 +280,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
280 await servers[0].config.enableTranscoding(true, true, true) 280 await servers[0].config.enableTranscoding(true, true, true)
281 }) 281 })
282 282
283 runSuite({ webtorrentEnabled: true, hlsEnabled: true }) 283 runSuite({ webVideoEnabled: true, hlsEnabled: true })
284 }) 284 })
285 }) 285 })
286 286
@@ -306,7 +306,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
306 await servers[0].config.enableTranscoding(true, false, true) 306 await servers[0].config.enableTranscoding(true, false, true)
307 }) 307 })
308 308
309 runSuite({ webtorrentEnabled: true, hlsEnabled: false, objectStorage }) 309 runSuite({ webVideoEnabled: true, hlsEnabled: false, objectStorage })
310 }) 310 })
311 311
312 describe('HLS videos only enabled', function () { 312 describe('HLS videos only enabled', function () {
@@ -315,7 +315,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
315 await servers[0].config.enableTranscoding(false, true, true) 315 await servers[0].config.enableTranscoding(false, true, true)
316 }) 316 })
317 317
318 runSuite({ webtorrentEnabled: false, hlsEnabled: true, objectStorage }) 318 runSuite({ webVideoEnabled: false, hlsEnabled: true, objectStorage })
319 }) 319 })
320 320
321 describe('Web video & HLS enabled', function () { 321 describe('Web video & HLS enabled', function () {
@@ -324,7 +324,7 @@ describe('Test VOD transcoding in peertube-runner program', function () {
324 await servers[0].config.enableTranscoding(true, true, true) 324 await servers[0].config.enableTranscoding(true, true, true)
325 }) 325 })
326 326
327 runSuite({ webtorrentEnabled: true, hlsEnabled: true, objectStorage }) 327 runSuite({ webVideoEnabled: true, hlsEnabled: true, objectStorage })
328 }) 328 })
329 329
330 after(async function () { 330 after(async function () {
diff --git a/server/tests/plugins/filter-hooks.ts b/server/tests/plugins/filter-hooks.ts
index a02a53c50..a75a8c8fa 100644
--- a/server/tests/plugins/filter-hooks.ts
+++ b/server/tests/plugins/filter-hooks.ts
@@ -493,7 +493,7 @@ describe('Test plugin filter hooks', function () {
493 await servers[0].config.updateCustomSubConfig({ 493 await servers[0].config.updateCustomSubConfig({
494 newConfig: { 494 newConfig: {
495 transcoding: { 495 transcoding: {
496 webtorrent: { 496 webVideos: {
497 enabled: true 497 enabled: true
498 }, 498 },
499 hls: { 499 hls: {
diff --git a/server/tests/plugins/plugin-helpers.ts b/server/tests/plugins/plugin-helpers.ts
index e951a1299..f5a0cbe85 100644
--- a/server/tests/plugins/plugin-helpers.ts
+++ b/server/tests/plugins/plugin-helpers.ts
@@ -302,11 +302,11 @@ describe('Test plugin helpers', function () {
302 302
303 // Video files check 303 // Video files check
304 { 304 {
305 expect(body.webtorrent.videoFiles).to.be.an('array') 305 expect(body.webVideo.videoFiles).to.be.an('array')
306 expect(body.hls.videoFiles).to.be.an('array') 306 expect(body.hls.videoFiles).to.be.an('array')
307 307
308 for (const resolution of [ 144, 240, 360, 480, 720 ]) { 308 for (const resolution of [ 144, 240, 360, 480, 720 ]) {
309 for (const files of [ body.webtorrent.videoFiles, body.hls.videoFiles ]) { 309 for (const files of [ body.webVideo.videoFiles, body.hls.videoFiles ]) {
310 const file = files.find(f => f.resolution === resolution) 310 const file = files.find(f => f.resolution === resolution)
311 expect(file).to.exist 311 expect(file).to.exist
312 312
@@ -318,7 +318,7 @@ describe('Test plugin helpers', function () {
318 } 318 }
319 } 319 }
320 320
321 videoPath = body.webtorrent.videoFiles[0].path 321 videoPath = body.webVideo.videoFiles[0].path
322 } 322 }
323 323
324 // Thumbnails check 324 // Thumbnails check
diff --git a/server/tests/plugins/plugin-transcoding.ts b/server/tests/plugins/plugin-transcoding.ts
index 689eec5ac..21f82fbac 100644
--- a/server/tests/plugins/plugin-transcoding.ts
+++ b/server/tests/plugins/plugin-transcoding.ts
@@ -35,7 +35,7 @@ function updateConf (server: PeerTubeServer, vodProfile: string, liveProfile: st
35 hls: { 35 hls: {
36 enabled: true 36 enabled: true
37 }, 37 },
38 webtorrent: { 38 webVideos: {
39 enabled: true 39 enabled: true
40 }, 40 },
41 resolutions: { 41 resolutions: {
@@ -247,7 +247,7 @@ describe('Test transcoding plugins', function () {
247 247
248 const video = await server.videos.get({ id: videoUUID }) 248 const video = await server.videos.get({ id: videoUUID })
249 249
250 const path = server.servers.buildWebTorrentFilePath(video.files[0].fileUrl) 250 const path = server.servers.buildWebVideoFilePath(video.files[0].fileUrl)
251 const audioProbe = await getAudioStream(path) 251 const audioProbe = await getAudioStream(path)
252 expect(audioProbe.audioStream.codec_name).to.equal('opus') 252 expect(audioProbe.audioStream.codec_name).to.equal('opus')
253 253
diff --git a/server/tests/shared/checks.ts b/server/tests/shared/checks.ts
index feaef37c6..90179c6ac 100644
--- a/server/tests/shared/checks.ts
+++ b/server/tests/shared/checks.ts
@@ -61,6 +61,16 @@ async function testImageSize (url: string, imageName: string, imageHTTPPath: str
61 expect(body.length).to.be.below(maxLength, 'the generated image is way larger than the recorded fixture') 61 expect(body.length).to.be.below(maxLength, 'the generated image is way larger than the recorded fixture')
62} 62}
63 63
64async function testImageGeneratedByFFmpeg (url: string, imageName: string, imageHTTPPath: string, extension = '.jpg') {
65 if (process.env.ENABLE_FFMPEG_THUMBNAIL_PIXEL_COMPARISON_TESTS !== 'true') {
66 console.log(
67 'Pixel comparison of image generated by ffmpeg is disabled. ' +
68 'You can enable it using `ENABLE_FFMPEG_THUMBNAIL_PIXEL_COMPARISON_TESTS=true env variable')
69 }
70
71 return testImage(url, imageName, imageHTTPPath, extension)
72}
73
64async function testImage (url: string, imageName: string, imageHTTPPath: string, extension = '.jpg') { 74async function testImage (url: string, imageName: string, imageHTTPPath: string, extension = '.jpg') {
65 const res = await makeGetRequest({ 75 const res = await makeGetRequest({
66 url, 76 url,
@@ -148,6 +158,7 @@ async function checkVideoDuration (server: PeerTubeServer, videoUUID: string, du
148 158
149export { 159export {
150 dateIsValid, 160 dateIsValid,
161 testImageGeneratedByFFmpeg,
151 testImageSize, 162 testImageSize,
152 testImage, 163 testImage,
153 expectLogDoesNotContain, 164 expectLogDoesNotContain,
diff --git a/server/tests/shared/videos.ts b/server/tests/shared/videos.ts
index 856fabd11..e09bd60b5 100644
--- a/server/tests/shared/videos.ts
+++ b/server/tests/shared/videos.ts
@@ -7,7 +7,7 @@ import { loadLanguages, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO
7import { getLowercaseExtension, pick, uuidRegex } from '@shared/core-utils' 7import { getLowercaseExtension, pick, uuidRegex } from '@shared/core-utils'
8import { HttpStatusCode, VideoCaption, VideoDetails, VideoPrivacy, VideoResolution } from '@shared/models' 8import { HttpStatusCode, VideoCaption, VideoDetails, VideoPrivacy, VideoResolution } from '@shared/models'
9import { makeRawRequest, PeerTubeServer, VideoEdit, waitJobs } from '@shared/server-commands' 9import { makeRawRequest, PeerTubeServer, VideoEdit, waitJobs } from '@shared/server-commands'
10import { dateIsValid, expectStartWith, testImage } from './checks' 10import { dateIsValid, expectStartWith, testImageGeneratedByFFmpeg } from './checks'
11import { checkWebTorrentWorks } from './webtorrent' 11import { checkWebTorrentWorks } from './webtorrent'
12 12
13loadLanguages() 13loadLanguages()
@@ -28,7 +28,7 @@ async function completeWebVideoFilesCheck (options: {
28 const serverConfig = await originServer.config.getConfig() 28 const serverConfig = await originServer.config.getConfig()
29 const requiresAuth = video.privacy.id === VideoPrivacy.PRIVATE || video.privacy.id === VideoPrivacy.INTERNAL 29 const requiresAuth = video.privacy.id === VideoPrivacy.PRIVATE || video.privacy.id === VideoPrivacy.INTERNAL
30 30
31 const transcodingEnabled = serverConfig.transcoding.webtorrent.enabled 31 const transcodingEnabled = serverConfig.transcoding.web_videos.enabled
32 32
33 for (const attributeFile of files) { 33 for (const attributeFile of files) {
34 const file = video.files.find(f => f.resolution.id === attributeFile.resolution) 34 const file = video.files.find(f => f.resolution.id === attributeFile.resolution)
@@ -51,11 +51,12 @@ async function completeWebVideoFilesCheck (options: {
51 expect(file.torrentUrl).to.match(new RegExp(`${server.url}/lazy-static/torrents/${nameReg}.torrent`)) 51 expect(file.torrentUrl).to.match(new RegExp(`${server.url}/lazy-static/torrents/${nameReg}.torrent`))
52 52
53 if (objectStorageBaseUrl && requiresAuth) { 53 if (objectStorageBaseUrl && requiresAuth) {
54 expect(file.fileUrl).to.match(new RegExp(`${originServer.url}/object-storage-proxy/webseed/${privatePath}${nameReg}${extension}`)) 54 const regexp = new RegExp(`${originServer.url}/object-storage-proxy/web-videos/${privatePath}${nameReg}${extension}`)
55 expect(file.fileUrl).to.match(regexp)
55 } else if (objectStorageBaseUrl) { 56 } else if (objectStorageBaseUrl) {
56 expectStartWith(file.fileUrl, objectStorageBaseUrl) 57 expectStartWith(file.fileUrl, objectStorageBaseUrl)
57 } else { 58 } else {
58 expect(file.fileUrl).to.match(new RegExp(`${originServer.url}/static/webseed/${privatePath}${nameReg}${extension}`)) 59 expect(file.fileUrl).to.match(new RegExp(`${originServer.url}/static/web-videos/${privatePath}${nameReg}${extension}`))
59 } 60 }
60 61
61 expect(file.fileDownloadUrl).to.match(new RegExp(`${originServer.url}/download/videos/${nameReg}${extension}`)) 62 expect(file.fileDownloadUrl).to.match(new RegExp(`${originServer.url}/download/videos/${nameReg}${extension}`))
@@ -197,11 +198,11 @@ async function completeVideoCheck (options: {
197 expect(video.downloadEnabled).to.equal(attributes.downloadEnabled) 198 expect(video.downloadEnabled).to.equal(attributes.downloadEnabled)
198 199
199 expect(video.thumbnailPath).to.exist 200 expect(video.thumbnailPath).to.exist
200 await testImage(server.url, attributes.thumbnailfile || attributes.fixture, video.thumbnailPath) 201 await testImageGeneratedByFFmpeg(server.url, attributes.thumbnailfile || attributes.fixture, video.thumbnailPath)
201 202
202 if (attributes.previewfile) { 203 if (attributes.previewfile) {
203 expect(video.previewPath).to.exist 204 expect(video.previewPath).to.exist
204 await testImage(server.url, attributes.previewfile, video.previewPath) 205 await testImageGeneratedByFFmpeg(server.url, attributes.previewfile, video.previewPath)
205 } 206 }
206 207
207 await completeWebVideoFilesCheck({ server, originServer, videoUUID: video.uuid, ...pick(attributes, [ 'fixture', 'files' ]) }) 208 await completeWebVideoFilesCheck({ server, originServer, videoUUID: video.uuid, ...pick(attributes, [ 'fixture', 'files' ]) })
@@ -215,22 +216,22 @@ async function checkVideoFilesWereRemoved (options: {
215}) { 216}) {
216 const { video, server, captions = [], onlyVideoFiles = false } = options 217 const { video, server, captions = [], onlyVideoFiles = false } = options
217 218
218 const webtorrentFiles = video.files || [] 219 const webVideoFiles = video.files || []
219 const hlsFiles = video.streamingPlaylists[0]?.files || [] 220 const hlsFiles = video.streamingPlaylists[0]?.files || []
220 221
221 const thumbnailName = basename(video.thumbnailPath) 222 const thumbnailName = basename(video.thumbnailPath)
222 const previewName = basename(video.previewPath) 223 const previewName = basename(video.previewPath)
223 224
224 const torrentNames = webtorrentFiles.concat(hlsFiles).map(f => basename(f.torrentUrl)) 225 const torrentNames = webVideoFiles.concat(hlsFiles).map(f => basename(f.torrentUrl))
225 226
226 const captionNames = captions.map(c => basename(c.captionPath)) 227 const captionNames = captions.map(c => basename(c.captionPath))
227 228
228 const webtorrentFilenames = webtorrentFiles.map(f => basename(f.fileUrl)) 229 const webVideoFilenames = webVideoFiles.map(f => basename(f.fileUrl))
229 const hlsFilenames = hlsFiles.map(f => basename(f.fileUrl)) 230 const hlsFilenames = hlsFiles.map(f => basename(f.fileUrl))
230 231
231 let directories: { [ directory: string ]: string[] } = { 232 let directories: { [ directory: string ]: string[] } = {
232 videos: webtorrentFilenames, 233 videos: webVideoFilenames,
233 redundancy: webtorrentFilenames, 234 redundancy: webVideoFilenames,
234 [join('playlists', 'hls')]: hlsFilenames, 235 [join('playlists', 'hls')]: hlsFilenames,
235 [join('redundancy', 'hls')]: hlsFilenames 236 [join('redundancy', 'hls')]: hlsFilenames
236 } 237 }
diff --git a/server/tools/peertube-redundancy.ts b/server/tools/peertube-redundancy.ts
index fd6c760b2..c24eb5233 100644
--- a/server/tools/peertube-redundancy.ts
+++ b/server/tools/peertube-redundancy.ts
@@ -65,19 +65,19 @@ async function listRedundanciesCLI (target: VideoRedundanciesTarget) {
65 }) as any 65 }) as any
66 66
67 for (const redundancy of data) { 67 for (const redundancy of data) {
68 const webtorrentFiles = redundancy.redundancies.files 68 const webVideoFiles = redundancy.redundancies.files
69 const streamingPlaylists = redundancy.redundancies.streamingPlaylists 69 const streamingPlaylists = redundancy.redundancies.streamingPlaylists
70 70
71 let totalSize = '' 71 let totalSize = ''
72 if (target === 'remote-videos') { 72 if (target === 'remote-videos') {
73 const tmp = webtorrentFiles.concat(streamingPlaylists) 73 const tmp = webVideoFiles.concat(streamingPlaylists)
74 .reduce((a, b) => a + b.size, 0) 74 .reduce((a, b) => a + b.size, 0)
75 75
76 totalSize = bytes(tmp) 76 totalSize = bytes(tmp)
77 } 77 }
78 78
79 const instances = uniqify( 79 const instances = uniqify(
80 webtorrentFiles.concat(streamingPlaylists) 80 webVideoFiles.concat(streamingPlaylists)
81 .map(r => r.fileUrl) 81 .map(r => r.fileUrl)
82 .map(u => new URL(u).host) 82 .map(u => new URL(u).host)
83 ) 83 )
@@ -86,7 +86,7 @@ async function listRedundanciesCLI (target: VideoRedundanciesTarget) {
86 redundancy.id.toString(), 86 redundancy.id.toString(),
87 redundancy.name, 87 redundancy.name,
88 redundancy.url, 88 redundancy.url,
89 webtorrentFiles.length, 89 webVideoFiles.length,
90 streamingPlaylists.length, 90 streamingPlaylists.length,
91 instances.join('\n'), 91 instances.join('\n'),
92 totalSize 92 totalSize
diff --git a/server/types/express.d.ts b/server/types/express.d.ts
index 510b9f94e..9c1be9bd1 100644
--- a/server/types/express.d.ts
+++ b/server/types/express.d.ts
@@ -18,6 +18,7 @@ import {
18 MVideoId, 18 MVideoId,
19 MVideoImmutable, 19 MVideoImmutable,
20 MVideoLiveFormattable, 20 MVideoLiveFormattable,
21 MVideoPassword,
21 MVideoPlaylistFull, 22 MVideoPlaylistFull,
22 MVideoPlaylistFullSummary 23 MVideoPlaylistFullSummary
23} from '@server/types/models' 24} from '@server/types/models'
@@ -165,6 +166,8 @@ declare module 'express' {
165 videoCommentFull?: MCommentOwnerVideoReply 166 videoCommentFull?: MCommentOwnerVideoReply
166 videoCommentThread?: MComment 167 videoCommentThread?: MComment
167 168
169 videoPassword?: MVideoPassword
170
168 follow?: MActorFollowActorsDefault 171 follow?: MActorFollowActorsDefault
169 subscription?: MActorFollowActorsDefaultSubscription 172 subscription?: MActorFollowActorsDefaultSubscription
170 173
diff --git a/server/types/models/video/index.ts b/server/types/models/video/index.ts
index 6e45fcc79..7f05db666 100644
--- a/server/types/models/video/index.ts
+++ b/server/types/models/video/index.ts
@@ -1,6 +1,7 @@
1export * from './local-video-viewer-watch-section' 1export * from './local-video-viewer-watch-section'
2export * from './local-video-viewer-watch-section' 2export * from './local-video-viewer-watch-section'
3export * from './local-video-viewer' 3export * from './local-video-viewer'
4export * from './storyboard'
4export * from './schedule-video-update' 5export * from './schedule-video-update'
5export * from './tag' 6export * from './tag'
6export * from './thumbnail' 7export * from './thumbnail'
@@ -16,6 +17,7 @@ export * from './video-import'
16export * from './video-live-replay-setting' 17export * from './video-live-replay-setting'
17export * from './video-live-session' 18export * from './video-live-session'
18export * from './video-live' 19export * from './video-live'
20export * from './video-password'
19export * from './video-playlist' 21export * from './video-playlist'
20export * from './video-playlist-element' 22export * from './video-playlist-element'
21export * from './video-rate' 23export * from './video-rate'
diff --git a/server/types/models/video/storyboard.ts b/server/types/models/video/storyboard.ts
new file mode 100644
index 000000000..a0403d4f0
--- /dev/null
+++ b/server/types/models/video/storyboard.ts
@@ -0,0 +1,15 @@
1import { StoryboardModel } from '@server/models/video/storyboard'
2import { PickWith } from '@shared/typescript-utils'
3import { MVideo } from './video'
4
5type Use<K extends keyof StoryboardModel, M> = PickWith<StoryboardModel, K, M>
6
7// ############################################################################
8
9export type MStoryboard = Omit<StoryboardModel, 'Video'>
10
11// ############################################################################
12
13export type MStoryboardVideo =
14 MStoryboard &
15 Use<'Video', MVideo>
diff --git a/server/types/models/video/video-caption.ts b/server/types/models/video/video-caption.ts
index 8cd801064..d3adec362 100644
--- a/server/types/models/video/video-caption.ts
+++ b/server/types/models/video/video-caption.ts
@@ -11,7 +11,7 @@ export type MVideoCaption = Omit<VideoCaptionModel, 'Video'>
11// ############################################################################ 11// ############################################################################
12 12
13export type MVideoCaptionLanguage = Pick<MVideoCaption, 'language'> 13export type MVideoCaptionLanguage = Pick<MVideoCaption, 'language'>
14export type MVideoCaptionLanguageUrl = Pick<MVideoCaption, 'language' | 'fileUrl' | 'getFileUrl'> 14export type MVideoCaptionLanguageUrl = Pick<MVideoCaption, 'language' | 'fileUrl' | 'filename' | 'getFileUrl' | 'getCaptionStaticPath'>
15 15
16export type MVideoCaptionVideo = 16export type MVideoCaptionVideo =
17 MVideoCaption & 17 MVideoCaption &
diff --git a/server/types/models/video/video-file.ts b/server/types/models/video/video-file.ts
index 55603e59c..68106788d 100644
--- a/server/types/models/video/video-file.ts
+++ b/server/types/models/video/video-file.ts
@@ -38,6 +38,6 @@ export function isStreamingPlaylistFile (file: any): file is MVideoFileStreaming
38 return !!file.videoStreamingPlaylistId 38 return !!file.videoStreamingPlaylistId
39} 39}
40 40
41export function isWebtorrentFile (file: any): file is MVideoFileVideo { 41export function isWebVideoFile (file: any): file is MVideoFileVideo {
42 return !!file.videoId 42 return !!file.videoId
43} 43}
diff --git a/server/types/models/video/video-password.ts b/server/types/models/video/video-password.ts
new file mode 100644
index 000000000..313cc3e0c
--- /dev/null
+++ b/server/types/models/video/video-password.ts
@@ -0,0 +1,3 @@
1import { VideoPasswordModel } from '@server/models/video/video-password'
2
3export type MVideoPassword = Omit<VideoPasswordModel, 'Video'>
diff --git a/server/types/models/video/video.ts b/server/types/models/video/video.ts
index 58ae7baad..53ee94269 100644
--- a/server/types/models/video/video.ts
+++ b/server/types/models/video/video.ts
@@ -3,6 +3,7 @@ import { VideoModel } from '../../../models/video/video'
3import { MTrackerUrl } from '../server/tracker' 3import { MTrackerUrl } from '../server/tracker'
4import { MUserVideoHistoryTime } from '../user/user-video-history' 4import { MUserVideoHistoryTime } from '../user/user-video-history'
5import { MScheduleVideoUpdate } from './schedule-video-update' 5import { MScheduleVideoUpdate } from './schedule-video-update'
6import { MStoryboard } from './storyboard'
6import { MTag } from './tag' 7import { MTag } from './tag'
7import { MThumbnail } from './thumbnail' 8import { MThumbnail } from './thumbnail'
8import { MVideoBlacklist, MVideoBlacklistLight, MVideoBlacklistUnfederated } from './video-blacklist' 9import { MVideoBlacklist, MVideoBlacklistLight, MVideoBlacklistUnfederated } from './video-blacklist'
@@ -32,7 +33,7 @@ type Use<K extends keyof VideoModel, M> = PickWith<VideoModel, K, M>
32export type MVideo = 33export type MVideo =
33 Omit<VideoModel, 'VideoChannel' | 'Tags' | 'Thumbnails' | 'VideoPlaylistElements' | 'VideoAbuses' | 34 Omit<VideoModel, 'VideoChannel' | 'Tags' | 'Thumbnails' | 'VideoPlaylistElements' | 'VideoAbuses' |
34 'VideoFiles' | 'VideoStreamingPlaylists' | 'VideoShares' | 'AccountVideoRates' | 'VideoComments' | 'VideoViews' | 'UserVideoHistories' | 35 'VideoFiles' | 'VideoStreamingPlaylists' | 'VideoShares' | 'AccountVideoRates' | 'VideoComments' | 'VideoViews' | 'UserVideoHistories' |
35 'ScheduleVideoUpdate' | 'VideoBlacklist' | 'VideoImport' | 'VideoCaptions' | 'VideoLive' | 'Trackers'> 36 'ScheduleVideoUpdate' | 'VideoBlacklist' | 'VideoImport' | 'VideoCaptions' | 'VideoLive' | 'Trackers' | 'VideoPasswords' | 'Storyboard'>
36 37
37// ############################################################################ 38// ############################################################################
38 39
@@ -46,7 +47,7 @@ export type MVideoFeed = Pick<MVideo, 'name' | 'uuid'>
46 47
47// ############################################################################ 48// ############################################################################
48 49
49// Video raw associations: schedules, video files, tags, thumbnails, captions, streaming playlists 50// Video raw associations: schedules, video files, tags, thumbnails, captions, streaming playlists, passwords
50 51
51// "With" to not confuse with the VideoFile model 52// "With" to not confuse with the VideoFile model
52export type MVideoWithFile = 53export type MVideoWithFile =
@@ -173,9 +174,10 @@ export type MVideoAP =
173 Use<'VideoBlacklist', MVideoBlacklistUnfederated> & 174 Use<'VideoBlacklist', MVideoBlacklistUnfederated> &
174 Use<'VideoFiles', MVideoFileRedundanciesOpt[]> & 175 Use<'VideoFiles', MVideoFileRedundanciesOpt[]> &
175 Use<'Thumbnails', MThumbnail[]> & 176 Use<'Thumbnails', MThumbnail[]> &
176 Use<'VideoLive', MVideoLive> 177 Use<'VideoLive', MVideoLive> &
178 Use<'Storyboard', MStoryboard>
177 179
178export type MVideoAPWithoutCaption = Omit<MVideoAP, 'VideoCaptions'> 180export type MVideoAPLight = Omit<MVideoAP, 'VideoCaptions' | 'Storyboard'>
179 181
180export type MVideoDetails = 182export type MVideoDetails =
181 MVideo & 183 MVideo &
diff --git a/server/types/plugins/register-server-option.model.ts b/server/types/plugins/register-server-option.model.ts
index df419fff4..103ef234b 100644
--- a/server/types/plugins/register-server-option.model.ts
+++ b/server/types/plugins/register-server-option.model.ts
@@ -41,7 +41,17 @@ export type PeerTubeHelpers = {
41 ffprobe: (path: string) => Promise<any> 41 ffprobe: (path: string) => Promise<any>
42 42
43 getFiles: (id: number | string) => Promise<{ 43 getFiles: (id: number | string) => Promise<{
44 webtorrent: { 44 webtorrent: { // TODO: remove in v7
45 videoFiles: {
46 path: string // Could be null if using remote storage
47 url: string
48 resolution: number
49 size: number
50 fps: number
51 }[]
52 }
53
54 webVideo: {
45 videoFiles: { 55 videoFiles: {
46 path: string // Could be null if using remote storage 56 path: string // Could be null if using remote storage
47 url: string 57 url: string
diff --git a/shared/core-utils/common/array.ts b/shared/core-utils/common/array.ts
index e1b422165..878ed1ffe 100644
--- a/shared/core-utils/common/array.ts
+++ b/shared/core-utils/common/array.ts
@@ -20,8 +20,22 @@ function uniqify <T> (elements: T[]) {
20 return Array.from(new Set(elements)) 20 return Array.from(new Set(elements))
21} 21}
22 22
23// Thanks: https://stackoverflow.com/a/12646864
24function shuffle <T> (elements: T[]) {
25 const shuffled = [ ...elements ]
26
27 for (let i = shuffled.length - 1; i > 0; i--) {
28 const j = Math.floor(Math.random() * (i + 1));
29
30 [ shuffled[i], shuffled[j] ] = [ shuffled[j], shuffled[i] ]
31 }
32
33 return shuffled
34}
35
23export { 36export {
24 uniqify, 37 uniqify,
25 findCommonElement, 38 findCommonElement,
39 shuffle,
26 arrayify 40 arrayify
27} 41}
diff --git a/shared/core-utils/videos/common.ts b/shared/core-utils/videos/common.ts
index 2c6efdb7f..0431edaaf 100644
--- a/shared/core-utils/videos/common.ts
+++ b/shared/core-utils/videos/common.ts
@@ -3,7 +3,7 @@ import { VideoPrivacy } from '../../models/videos/video-privacy.enum'
3import { VideoDetails } from '../../models/videos/video.model' 3import { VideoDetails } from '../../models/videos/video.model'
4 4
5function getAllPrivacies () { 5function getAllPrivacies () {
6 return [ VideoPrivacy.PUBLIC, VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED ] 6 return [ VideoPrivacy.PUBLIC, VideoPrivacy.INTERNAL, VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.PASSWORD_PROTECTED ]
7} 7}
8 8
9function getAllFiles (video: Partial<Pick<VideoDetails, 'files' | 'streamingPlaylists'>>) { 9function getAllFiles (video: Partial<Pick<VideoDetails, 'files' | 'streamingPlaylists'>>) {
diff --git a/shared/ffmpeg/ffmpeg-images.ts b/shared/ffmpeg/ffmpeg-images.ts
index 2db63bd8b..618fac7d1 100644
--- a/shared/ffmpeg/ffmpeg-images.ts
+++ b/shared/ffmpeg/ffmpeg-images.ts
@@ -1,4 +1,5 @@
1import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper' 1import { FFmpegCommandWrapper, FFmpegCommandWrapperOptions } from './ffmpeg-command-wrapper'
2import { getVideoStreamDuration } from './ffprobe'
2 3
3export class FFmpegImage { 4export class FFmpegImage {
4 private readonly commandWrapper: FFmpegCommandWrapper 5 private readonly commandWrapper: FFmpegCommandWrapper
@@ -36,24 +37,56 @@ export class FFmpegImage {
36 37
37 async generateThumbnailFromVideo (options: { 38 async generateThumbnailFromVideo (options: {
38 fromPath: string 39 fromPath: string
39 folder: string 40 output: string
40 imageName: string
41 }) { 41 }) {
42 const { fromPath, folder, imageName } = options 42 const { fromPath, output } = options
43 43
44 const pendingImageName = 'pending-' + imageName 44 let duration = await getVideoStreamDuration(fromPath)
45 if (isNaN(duration)) duration = 0
45 46
46 const thumbnailOptions = { 47 this.commandWrapper.buildCommand(fromPath)
47 filename: pendingImageName, 48 .seekInput(duration / 2)
48 count: 1, 49 .videoFilter('thumbnail=500')
49 folder 50 .outputOption('-frames:v 1')
51 .output(output)
52
53 return this.commandWrapper.runCommand()
54 }
55
56 async generateStoryboardFromVideo (options: {
57 path: string
58 destination: string
59
60 sprites: {
61 size: {
62 width: number
63 height: number
64 }
65
66 count: {
67 width: number
68 height: number
69 }
70
71 duration: number
50 } 72 }
73 }) {
74 const { path, destination, sprites } = options
75
76 const command = this.commandWrapper.buildCommand(path)
51 77
52 return new Promise<string>((res, rej) => { 78 const filter = [
53 this.commandWrapper.buildCommand(fromPath) 79 `setpts=N/round(FRAME_RATE)/TB`,
54 .on('error', rej) 80 `select='not(mod(t,${options.sprites.duration}))'`,
55 .on('end', () => res(imageName)) 81 `scale=${sprites.size.width}:${sprites.size.height}`,
56 .thumbnail(thumbnailOptions) 82 `tile=layout=${sprites.count.width}x${sprites.count.height}`
57 }) 83 ].join(',')
84
85 command.outputOption('-filter_complex', filter)
86 command.outputOption('-frames:v', '1')
87 command.outputOption('-q:v', '2')
88 command.output(destination)
89
90 return this.commandWrapper.runCommand()
58 } 91 }
59} 92}
diff --git a/shared/models/activitypub/activity.ts b/shared/models/activitypub/activity.ts
index fd5d38316..10cf53ead 100644
--- a/shared/models/activitypub/activity.ts
+++ b/shared/models/activitypub/activity.ts
@@ -1,20 +1,34 @@
1import { ActivityPubActor } from './activitypub-actor' 1import { ActivityPubActor } from './activitypub-actor'
2import { ActivityPubSignature } from './activitypub-signature' 2import { ActivityPubSignature } from './activitypub-signature'
3import { ActivityFlagReasonObject, CacheFileObject, VideoObject, WatchActionObject } from './objects' 3import {
4import { AbuseObject } from './objects/abuse-object' 4 ActivityFlagReasonObject,
5import { DislikeObject } from './objects/dislike-object' 5 ActivityObject,
6import { APObject } from './objects/object.model' 6 APObjectId,
7import { PlaylistObject } from './objects/playlist-object' 7 CacheFileObject,
8import { VideoCommentObject } from './objects/video-comment-object' 8 PlaylistObject,
9 VideoCommentObject,
10 VideoObject,
11 WatchActionObject
12} from './objects'
13
14export type ActivityUpdateObject =
15 Extract<ActivityObject, VideoObject | CacheFileObject | PlaylistObject | ActivityPubActor | string> | ActivityPubActor
16
17// Cannot Extract from Activity because of circular reference
18export type ActivityUndoObject =
19 ActivityFollow | ActivityLike | ActivityDislike | ActivityCreate<CacheFileObject | string> | ActivityAnnounce
20
21export type ActivityCreateObject =
22 Extract<ActivityObject, VideoObject | CacheFileObject | WatchActionObject | VideoCommentObject | PlaylistObject | string>
9 23
10export type Activity = 24export type Activity =
11 ActivityCreate | 25 ActivityCreate<ActivityCreateObject> |
12 ActivityUpdate | 26 ActivityUpdate<ActivityUpdateObject> |
13 ActivityDelete | 27 ActivityDelete |
14 ActivityFollow | 28 ActivityFollow |
15 ActivityAccept | 29 ActivityAccept |
16 ActivityAnnounce | 30 ActivityAnnounce |
17 ActivityUndo | 31 ActivityUndo<ActivityUndoObject> |
18 ActivityLike | 32 ActivityLike |
19 ActivityReject | 33 ActivityReject |
20 ActivityView | 34 ActivityView |
@@ -50,19 +64,19 @@ export interface BaseActivity {
50 signature?: ActivityPubSignature 64 signature?: ActivityPubSignature
51} 65}
52 66
53export interface ActivityCreate extends BaseActivity { 67export interface ActivityCreate <T extends ActivityCreateObject> extends BaseActivity {
54 type: 'Create' 68 type: 'Create'
55 object: VideoObject | AbuseObject | DislikeObject | VideoCommentObject | CacheFileObject | PlaylistObject | WatchActionObject 69 object: T
56} 70}
57 71
58export interface ActivityUpdate extends BaseActivity { 72export interface ActivityUpdate <T extends ActivityUpdateObject> extends BaseActivity {
59 type: 'Update' 73 type: 'Update'
60 object: VideoObject | ActivityPubActor | CacheFileObject | PlaylistObject 74 object: T
61} 75}
62 76
63export interface ActivityDelete extends BaseActivity { 77export interface ActivityDelete extends BaseActivity {
64 type: 'Delete' 78 type: 'Delete'
65 object: string | { id: string } 79 object: APObjectId
66} 80}
67 81
68export interface ActivityFollow extends BaseActivity { 82export interface ActivityFollow extends BaseActivity {
@@ -82,23 +96,23 @@ export interface ActivityReject extends BaseActivity {
82 96
83export interface ActivityAnnounce extends BaseActivity { 97export interface ActivityAnnounce extends BaseActivity {
84 type: 'Announce' 98 type: 'Announce'
85 object: APObject 99 object: APObjectId
86} 100}
87 101
88export interface ActivityUndo extends BaseActivity { 102export interface ActivityUndo <T extends ActivityUndoObject> extends BaseActivity {
89 type: 'Undo' 103 type: 'Undo'
90 object: ActivityFollow | ActivityLike | ActivityDislike | ActivityCreate | ActivityAnnounce 104 object: T
91} 105}
92 106
93export interface ActivityLike extends BaseActivity { 107export interface ActivityLike extends BaseActivity {
94 type: 'Like' 108 type: 'Like'
95 object: APObject 109 object: APObjectId
96} 110}
97 111
98export interface ActivityView extends BaseActivity { 112export interface ActivityView extends BaseActivity {
99 type: 'View' 113 type: 'View'
100 actor: string 114 actor: string
101 object: APObject 115 object: APObjectId
102 116
103 // If sending a "viewer" event 117 // If sending a "viewer" event
104 expires?: string 118 expires?: string
@@ -108,13 +122,13 @@ export interface ActivityDislike extends BaseActivity {
108 id: string 122 id: string
109 type: 'Dislike' 123 type: 'Dislike'
110 actor: string 124 actor: string
111 object: APObject 125 object: APObjectId
112} 126}
113 127
114export interface ActivityFlag extends BaseActivity { 128export interface ActivityFlag extends BaseActivity {
115 type: 'Flag' 129 type: 'Flag'
116 content: string 130 content: string
117 object: APObject | APObject[] 131 object: APObjectId | APObjectId[]
118 tag?: ActivityFlagReasonObject[] 132 tag?: ActivityFlagReasonObject[]
119 startAt?: number 133 startAt?: number
120 endAt?: number 134 endAt?: number
diff --git a/shared/models/activitypub/objects/activitypub-object.ts b/shared/models/activitypub/objects/activitypub-object.ts
new file mode 100644
index 000000000..faeac2618
--- /dev/null
+++ b/shared/models/activitypub/objects/activitypub-object.ts
@@ -0,0 +1,17 @@
1import { AbuseObject } from './abuse-object'
2import { CacheFileObject } from './cache-file-object'
3import { PlaylistObject } from './playlist-object'
4import { VideoCommentObject } from './video-comment-object'
5import { VideoObject } from './video-object'
6import { WatchActionObject } from './watch-action-object'
7
8export type ActivityObject =
9 VideoObject |
10 AbuseObject |
11 VideoCommentObject |
12 CacheFileObject |
13 PlaylistObject |
14 WatchActionObject |
15 string
16
17export type APObjectId = string | { id: string }
diff --git a/shared/models/activitypub/objects/common-objects.ts b/shared/models/activitypub/objects/common-objects.ts
index 9bf994379..db9c73658 100644
--- a/shared/models/activitypub/objects/common-objects.ts
+++ b/shared/models/activitypub/objects/common-objects.ts
@@ -114,10 +114,7 @@ export type ActivityUrlObject =
114 | ActivityVideoFileMetadataUrlObject 114 | ActivityVideoFileMetadataUrlObject
115 | ActivityTrackerUrlObject 115 | ActivityTrackerUrlObject
116 116
117export interface ActivityPubAttributedTo { 117export type ActivityPubAttributedTo = { type: 'Group' | 'Person', id: string } | string
118 type: 'Group' | 'Person'
119 id: string
120}
121 118
122export interface ActivityTombstoneObject { 119export interface ActivityTombstoneObject {
123 '@context'?: any 120 '@context'?: any
diff --git a/shared/models/activitypub/objects/dislike-object.ts b/shared/models/activitypub/objects/dislike-object.ts
deleted file mode 100644
index 7218fb784..000000000
--- a/shared/models/activitypub/objects/dislike-object.ts
+++ /dev/null
@@ -1,6 +0,0 @@
1export interface DislikeObject {
2 id: string
3 type: 'Dislike'
4 actor: string
5 object: string
6}
diff --git a/shared/models/activitypub/objects/index.ts b/shared/models/activitypub/objects/index.ts
index 9aa3c462c..753e02003 100644
--- a/shared/models/activitypub/objects/index.ts
+++ b/shared/models/activitypub/objects/index.ts
@@ -1,10 +1,9 @@
1export * from './abuse-object' 1export * from './abuse-object'
2export * from './activitypub-object'
2export * from './cache-file-object' 3export * from './cache-file-object'
3export * from './common-objects' 4export * from './common-objects'
4export * from './dislike-object'
5export * from './object.model'
6export * from './playlist-element-object' 5export * from './playlist-element-object'
7export * from './playlist-object' 6export * from './playlist-object'
8export * from './video-comment-object' 7export * from './video-comment-object'
9export * from './video-torrent-object' 8export * from './video-object'
10export * from './watch-action-object' 9export * from './watch-action-object'
diff --git a/shared/models/activitypub/objects/object.model.ts b/shared/models/activitypub/objects/object.model.ts
deleted file mode 100644
index 3fd33800a..000000000
--- a/shared/models/activitypub/objects/object.model.ts
+++ /dev/null
@@ -1 +0,0 @@
1export type APObject = string | { id: string }
diff --git a/shared/models/activitypub/objects/playlist-object.ts b/shared/models/activitypub/objects/playlist-object.ts
index 842c03790..0ccb71828 100644
--- a/shared/models/activitypub/objects/playlist-object.ts
+++ b/shared/models/activitypub/objects/playlist-object.ts
@@ -1,4 +1,4 @@
1import { ActivityIconObject } from './common-objects' 1import { ActivityIconObject, ActivityPubAttributedTo } from './common-objects'
2 2
3export interface PlaylistObject { 3export interface PlaylistObject {
4 id: string 4 id: string
@@ -12,7 +12,7 @@ export interface PlaylistObject {
12 uuid: string 12 uuid: string
13 13
14 totalItems: number 14 totalItems: number
15 attributedTo: string[] 15 attributedTo: ActivityPubAttributedTo[]
16 16
17 icon?: ActivityIconObject 17 icon?: ActivityIconObject
18 18
diff --git a/shared/models/activitypub/objects/video-comment-object.ts b/shared/models/activitypub/objects/video-comment-object.ts
index ba9001730..fb1e6f8db 100644
--- a/shared/models/activitypub/objects/video-comment-object.ts
+++ b/shared/models/activitypub/objects/video-comment-object.ts
@@ -1,4 +1,4 @@
1import { ActivityTagObject } from './common-objects' 1import { ActivityPubAttributedTo, ActivityTagObject } from './common-objects'
2 2
3export interface VideoCommentObject { 3export interface VideoCommentObject {
4 type: 'Note' 4 type: 'Note'
@@ -11,6 +11,6 @@ export interface VideoCommentObject {
11 published: string 11 published: string
12 updated: string 12 updated: string
13 url: string 13 url: string
14 attributedTo: string 14 attributedTo: ActivityPubAttributedTo
15 tag: ActivityTagObject[] 15 tag: ActivityTagObject[]
16} 16}
diff --git a/shared/models/activitypub/objects/video-torrent-object.ts b/shared/models/activitypub/objects/video-object.ts
index 23d54bdbd..a252a2df0 100644
--- a/shared/models/activitypub/objects/video-torrent-object.ts
+++ b/shared/models/activitypub/objects/video-object.ts
@@ -51,6 +51,22 @@ export interface VideoObject {
51 51
52 attributedTo: ActivityPubAttributedTo[] 52 attributedTo: ActivityPubAttributedTo[]
53 53
54 preview?: ActivityPubStoryboard[]
55
54 to?: string[] 56 to?: string[]
55 cc?: string[] 57 cc?: string[]
56} 58}
59
60export interface ActivityPubStoryboard {
61 type: 'Image'
62 rel: [ 'storyboard' ]
63 url: {
64 href: string
65 mediaType: string
66 width: number
67 height: number
68 tileWidth: number
69 tileHeight: number
70 tileDuration: string
71 }[]
72}
diff --git a/shared/models/metrics/playback-metric-create.model.ts b/shared/models/metrics/playback-metric-create.model.ts
index d669ab690..3a8f328c8 100644
--- a/shared/models/metrics/playback-metric-create.model.ts
+++ b/shared/models/metrics/playback-metric-create.model.ts
@@ -1,7 +1,7 @@
1import { VideoResolution } from '../videos' 1import { VideoResolution } from '../videos'
2 2
3export interface PlaybackMetricCreate { 3export interface PlaybackMetricCreate {
4 playerMode: 'p2p-media-loader' | 'webtorrent' 4 playerMode: 'p2p-media-loader' | 'webtorrent' | 'web-video' // FIXME: remove webtorrent player mode not used anymore in PeerTube v6
5 5
6 resolution?: VideoResolution 6 resolution?: VideoResolution
7 fps?: number 7 fps?: number
diff --git a/shared/models/plugins/client/client-hook.model.ts b/shared/models/plugins/client/client-hook.model.ts
index bc3f5dd9f..4a0818c99 100644
--- a/shared/models/plugins/client/client-hook.model.ts
+++ b/shared/models/plugins/client/client-hook.model.ts
@@ -59,6 +59,10 @@ export const clientFilterHookObject = {
59 'filter:internal.video-watch.player.build-options.params': true, 59 'filter:internal.video-watch.player.build-options.params': true,
60 'filter:internal.video-watch.player.build-options.result': true, 60 'filter:internal.video-watch.player.build-options.result': true,
61 61
62 // Filter the options to load a new video in our player
63 'filter:internal.video-watch.player.load-options.params': true,
64 'filter:internal.video-watch.player.load-options.result': true,
65
62 // Filter our SVG icons content 66 // Filter our SVG icons content
63 'filter:internal.common.svg-icons.get-content.params': true, 67 'filter:internal.common.svg-icons.get-content.params': true,
64 'filter:internal.common.svg-icons.get-content.result': true, 68 'filter:internal.common.svg-icons.get-content.result': true,
diff --git a/shared/models/search/videos-common-query.model.ts b/shared/models/search/videos-common-query.model.ts
index da479c928..2c52ca8cf 100644
--- a/shared/models/search/videos-common-query.model.ts
+++ b/shared/models/search/videos-common-query.model.ts
@@ -30,7 +30,9 @@ export interface VideosCommonQuery {
30 tagsAllOf?: string[] 30 tagsAllOf?: string[]
31 31
32 hasHLSFiles?: boolean 32 hasHLSFiles?: boolean
33 hasWebtorrentFiles?: boolean 33
34 hasWebtorrentFiles?: boolean // TODO: remove in v7
35 hasWebVideoFiles?: boolean
34 36
35 skipCount?: boolean 37 skipCount?: boolean
36 38
diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts
index 4202589f3..9aa66f2b8 100644
--- a/shared/models/server/custom-config.model.ts
+++ b/shared/models/server/custom-config.model.ts
@@ -78,6 +78,10 @@ export interface CustomConfig {
78 torrents: { 78 torrents: {
79 size: number 79 size: number
80 } 80 }
81
82 storyboards: {
83 size: number
84 }
81 } 85 }
82 86
83 signup: { 87 signup: {
@@ -129,7 +133,7 @@ export interface CustomConfig {
129 133
130 alwaysTranscodeOriginalResolution: boolean 134 alwaysTranscodeOriginalResolution: boolean
131 135
132 webtorrent: { 136 webVideos: {
133 enabled: boolean 137 enabled: boolean
134 } 138 }
135 139
diff --git a/shared/models/server/job.model.ts b/shared/models/server/job.model.ts
index 22ecee324..c14806dab 100644
--- a/shared/models/server/job.model.ts
+++ b/shared/models/server/job.model.ts
@@ -30,6 +30,7 @@ export type JobType =
30 | 'video-studio-edition' 30 | 'video-studio-edition'
31 | 'video-transcoding' 31 | 'video-transcoding'
32 | 'videos-views-stats' 32 | 'videos-views-stats'
33 | 'generate-video-storyboard'
33 34
34export interface Job { 35export interface Job {
35 id: number | string 36 id: number | string
@@ -147,17 +148,17 @@ export interface HLSTranscodingPayload extends BaseTranscodingPayload {
147 fps: number 148 fps: number
148 copyCodecs: boolean 149 copyCodecs: boolean
149 150
150 deleteWebTorrentFiles: boolean 151 deleteWebVideoFiles: boolean
151} 152}
152 153
153export interface NewWebTorrentResolutionTranscodingPayload extends BaseTranscodingPayload { 154export interface NewWebVideoResolutionTranscodingPayload extends BaseTranscodingPayload {
154 type: 'new-resolution-to-webtorrent' 155 type: 'new-resolution-to-web-video'
155 resolution: VideoResolution 156 resolution: VideoResolution
156 fps: number 157 fps: number
157} 158}
158 159
159export interface MergeAudioTranscodingPayload extends BaseTranscodingPayload { 160export interface MergeAudioTranscodingPayload extends BaseTranscodingPayload {
160 type: 'merge-audio-to-webtorrent' 161 type: 'merge-audio-to-web-video'
161 162
162 resolution: VideoResolution 163 resolution: VideoResolution
163 fps: number 164 fps: number
@@ -166,7 +167,7 @@ export interface MergeAudioTranscodingPayload extends BaseTranscodingPayload {
166} 167}
167 168
168export interface OptimizeTranscodingPayload extends BaseTranscodingPayload { 169export interface OptimizeTranscodingPayload extends BaseTranscodingPayload {
169 type: 'optimize-to-webtorrent' 170 type: 'optimize-to-web-video'
170 171
171 quickTranscode: boolean 172 quickTranscode: boolean
172 173
@@ -175,7 +176,7 @@ export interface OptimizeTranscodingPayload extends BaseTranscodingPayload {
175 176
176export type VideoTranscodingPayload = 177export type VideoTranscodingPayload =
177 HLSTranscodingPayload 178 HLSTranscodingPayload
178 | NewWebTorrentResolutionTranscodingPayload 179 | NewWebVideoResolutionTranscodingPayload
179 | OptimizeTranscodingPayload 180 | OptimizeTranscodingPayload
180 | MergeAudioTranscodingPayload 181 | MergeAudioTranscodingPayload
181 182
@@ -294,3 +295,10 @@ export interface TranscodingJobBuilderPayload {
294 priority?: number 295 priority?: number
295 }[][] 296 }[][]
296} 297}
298
299// ---------------------------------------------------------------------------
300
301export interface GenerateStoryboardPayload {
302 videoUUID: string
303 federate: boolean
304}
diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts
index 024ed35bf..288cf84cd 100644
--- a/shared/models/server/server-config.model.ts
+++ b/shared/models/server/server-config.model.ts
@@ -140,7 +140,7 @@ export interface ServerConfig {
140 enabled: boolean 140 enabled: boolean
141 } 141 }
142 142
143 webtorrent: { 143 web_videos: {
144 enabled: boolean 144 enabled: boolean
145 } 145 }
146 146
diff --git a/shared/models/server/server-error-code.enum.ts b/shared/models/server/server-error-code.enum.ts
index 2b093380c..77d1e1d3f 100644
--- a/shared/models/server/server-error-code.enum.ts
+++ b/shared/models/server/server-error-code.enum.ts
@@ -49,7 +49,10 @@ export const enum ServerErrorCode {
49 49
50 RUNNER_JOB_NOT_IN_PROCESSING_STATE = 'runner_job_not_in_processing_state', 50 RUNNER_JOB_NOT_IN_PROCESSING_STATE = 'runner_job_not_in_processing_state',
51 RUNNER_JOB_NOT_IN_PENDING_STATE = 'runner_job_not_in_pending_state', 51 RUNNER_JOB_NOT_IN_PENDING_STATE = 'runner_job_not_in_pending_state',
52 UNKNOWN_RUNNER_TOKEN = 'unknown_runner_token' 52 UNKNOWN_RUNNER_TOKEN = 'unknown_runner_token',
53
54 VIDEO_REQUIRES_PASSWORD = 'video_requires_password',
55 INCORRECT_VIDEO_PASSWORD = 'incorrect_video_password'
53} 56}
54 57
55/** 58/**
diff --git a/shared/models/users/user-update-me.model.ts b/shared/models/users/user-update-me.model.ts
index f3cceb5f2..c1d5ffba4 100644
--- a/shared/models/users/user-update-me.model.ts
+++ b/shared/models/users/user-update-me.model.ts
@@ -5,8 +5,6 @@ export interface UserUpdateMe {
5 description?: string 5 description?: string
6 nsfwPolicy?: NSFWPolicyType 6 nsfwPolicy?: NSFWPolicyType
7 7
8 // FIXME: deprecated in favour of p2pEnabled in 4.1
9 webTorrentEnabled?: boolean
10 p2pEnabled?: boolean 8 p2pEnabled?: boolean
11 9
12 autoPlayVideo?: boolean 10 autoPlayVideo?: boolean
diff --git a/shared/models/users/user.model.ts b/shared/models/users/user.model.ts
index 0761c1e32..9de4118b4 100644
--- a/shared/models/users/user.model.ts
+++ b/shared/models/users/user.model.ts
@@ -22,8 +22,6 @@ export interface User {
22 autoPlayNextVideo: boolean 22 autoPlayNextVideo: boolean
23 autoPlayNextVideoPlaylist: boolean 23 autoPlayNextVideoPlaylist: boolean
24 24
25 // @deprecated in favour of p2pEnabled
26 webTorrentEnabled: boolean
27 p2pEnabled: boolean 25 p2pEnabled: boolean
28 26
29 videosHistoryEnabled: boolean 27 videosHistoryEnabled: boolean
diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts
index 4c1790228..b3ce6ad3f 100644
--- a/shared/models/videos/index.ts
+++ b/shared/models/videos/index.ts
@@ -15,6 +15,7 @@ export * from './channel-sync'
15 15
16export * from './nsfw-policy.type' 16export * from './nsfw-policy.type'
17 17
18export * from './storyboard.model'
18export * from './thumbnail.type' 19export * from './thumbnail.type'
19 20
20export * from './video-constant.model' 21export * from './video-constant.model'
@@ -39,3 +40,4 @@ export * from './video-update.model'
39export * from './video-view.model' 40export * from './video-view.model'
40export * from './video.model' 41export * from './video.model'
41export * from './video-create-result.model' 42export * from './video-create-result.model'
43export * from './video-password.model'
diff --git a/shared/models/videos/storyboard.model.ts b/shared/models/videos/storyboard.model.ts
new file mode 100644
index 000000000..c92c81f09
--- /dev/null
+++ b/shared/models/videos/storyboard.model.ts
@@ -0,0 +1,11 @@
1export interface Storyboard {
2 storyboardPath: string
3
4 totalHeight: number
5 totalWidth: number
6
7 spriteHeight: number
8 spriteWidth: number
9
10 spriteDuration: number
11}
diff --git a/shared/models/videos/transcoding/video-transcoding-create.model.ts b/shared/models/videos/transcoding/video-transcoding-create.model.ts
index aeb393e57..c6e756a0a 100644
--- a/shared/models/videos/transcoding/video-transcoding-create.model.ts
+++ b/shared/models/videos/transcoding/video-transcoding-create.model.ts
@@ -1,3 +1,3 @@
1export interface VideoTranscodingCreate { 1export interface VideoTranscodingCreate {
2 transcodingType: 'hls' | 'webtorrent' 2 transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7
3} 3}
diff --git a/shared/models/videos/video-create.model.ts b/shared/models/videos/video-create.model.ts
index 732d508d1..7a34b5afe 100644
--- a/shared/models/videos/video-create.model.ts
+++ b/shared/models/videos/video-create.model.ts
@@ -18,6 +18,7 @@ export interface VideoCreate {
18 privacy: VideoPrivacy 18 privacy: VideoPrivacy
19 scheduleUpdate?: VideoScheduleUpdate 19 scheduleUpdate?: VideoScheduleUpdate
20 originallyPublishedAt?: Date | string 20 originallyPublishedAt?: Date | string
21 videoPasswords?: string[]
21 22
22 thumbnailfile?: Blob | string 23 thumbnailfile?: Blob | string
23 previewfile?: Blob | string 24 previewfile?: Blob | string
diff --git a/shared/models/videos/video-password.model.ts b/shared/models/videos/video-password.model.ts
new file mode 100644
index 000000000..c0280b9b9
--- /dev/null
+++ b/shared/models/videos/video-password.model.ts
@@ -0,0 +1,7 @@
1export interface VideoPassword {
2 id: number
3 password: string
4 videoId: number
5 createdAt: Date | string
6 updatedAt: Date | string
7}
diff --git a/shared/models/videos/video-privacy.enum.ts b/shared/models/videos/video-privacy.enum.ts
index 39fd0529f..12e1d196f 100644
--- a/shared/models/videos/video-privacy.enum.ts
+++ b/shared/models/videos/video-privacy.enum.ts
@@ -2,5 +2,6 @@ export const enum VideoPrivacy {
2 PUBLIC = 1, 2 PUBLIC = 1,
3 UNLISTED = 2, 3 UNLISTED = 2,
4 PRIVATE = 3, 4 PRIVATE = 3,
5 INTERNAL = 4 5 INTERNAL = 4,
6 PASSWORD_PROTECTED = 5
6} 7}
diff --git a/shared/models/videos/video-update.model.ts b/shared/models/videos/video-update.model.ts
index 86653b959..43537b5af 100644
--- a/shared/models/videos/video-update.model.ts
+++ b/shared/models/videos/video-update.model.ts
@@ -19,6 +19,7 @@ export interface VideoUpdate {
19 previewfile?: Blob 19 previewfile?: Blob
20 scheduleUpdate?: VideoScheduleUpdate 20 scheduleUpdate?: VideoScheduleUpdate
21 originallyPublishedAt?: Date | string 21 originallyPublishedAt?: Date | string
22 videoPasswords?: string[]
22 23
23 pluginData?: any 24 pluginData?: any
24} 25}
diff --git a/shared/models/videos/video.model.ts b/shared/models/videos/video.model.ts
index 06ffb327c..9004efb35 100644
--- a/shared/models/videos/video.model.ts
+++ b/shared/models/videos/video.model.ts
@@ -7,7 +7,7 @@ import { VideoScheduleUpdate } from './video-schedule-update.model'
7import { VideoState } from './video-state.enum' 7import { VideoState } from './video-state.enum'
8import { VideoStreamingPlaylist } from './video-streaming-playlist.model' 8import { VideoStreamingPlaylist } from './video-streaming-playlist.model'
9 9
10export interface Video { 10export interface Video extends Partial<VideoAdditionalAttributes> {
11 id: number 11 id: number
12 uuid: string 12 uuid: string
13 shortUUID: string 13 shortUUID: string
@@ -57,20 +57,22 @@ export interface Video {
57 } 57 }
58 58
59 pluginData?: any 59 pluginData?: any
60}
60 61
61 // Additional attributes dependending on the query 62// Not included by default, needs query params
62 waitTranscoding?: boolean 63export interface VideoAdditionalAttributes {
63 state?: VideoConstant<VideoState> 64 waitTranscoding: boolean
64 scheduledUpdate?: VideoScheduleUpdate 65 state: VideoConstant<VideoState>
66 scheduledUpdate: VideoScheduleUpdate
65 67
66 blacklisted?: boolean 68 blacklisted: boolean
67 blacklistedReason?: string 69 blacklistedReason: string
68 70
69 blockedOwner?: boolean 71 blockedOwner: boolean
70 blockedServer?: boolean 72 blockedServer: boolean
71 73
72 files?: VideoFile[] 74 files: VideoFile[]
73 streamingPlaylists?: VideoStreamingPlaylist[] 75 streamingPlaylists: VideoStreamingPlaylist[]
74} 76}
75 77
76export interface VideoDetails extends Video { 78export interface VideoDetails extends Video {
diff --git a/shared/server-commands/requests/requests.ts b/shared/server-commands/requests/requests.ts
index e3f1817f1..8227017eb 100644
--- a/shared/server-commands/requests/requests.ts
+++ b/shared/server-commands/requests/requests.ts
@@ -29,6 +29,7 @@ function makeRawRequest (options: {
29 range?: string 29 range?: string
30 query?: { [ id: string ]: string } 30 query?: { [ id: string ]: string }
31 method?: 'GET' | 'POST' 31 method?: 'GET' | 'POST'
32 headers?: { [ name: string ]: string }
32}) { 33}) {
33 const { host, protocol, pathname } = new URL(options.url) 34 const { host, protocol, pathname } = new URL(options.url)
34 35
@@ -37,7 +38,7 @@ function makeRawRequest (options: {
37 path: pathname, 38 path: pathname,
38 contentType: undefined, 39 contentType: undefined,
39 40
40 ...pick(options, [ 'expectedStatus', 'range', 'token', 'query' ]) 41 ...pick(options, [ 'expectedStatus', 'range', 'token', 'query', 'headers' ])
41 } 42 }
42 43
43 if (options.method === 'POST') { 44 if (options.method === 'POST') {
@@ -132,6 +133,7 @@ function makePutBodyRequest (options: {
132 token?: string 133 token?: string
133 fields: { [ fieldName: string ]: any } 134 fields: { [ fieldName: string ]: any }
134 expectedStatus?: HttpStatusCode 135 expectedStatus?: HttpStatusCode
136 headers?: { [name: string]: string }
135}) { 137}) {
136 const req = request(options.url).put(options.path) 138 const req = request(options.url).put(options.path)
137 .send(options.fields) 139 .send(options.fields)
diff --git a/shared/server-commands/server/config-command.ts b/shared/server-commands/server/config-command.ts
index b94bd2625..7f1e9d977 100644
--- a/shared/server-commands/server/config-command.ts
+++ b/shared/server-commands/server/config-command.ts
@@ -131,7 +131,7 @@ export class ConfigCommand extends AbstractCommand {
131 } 131 }
132 132
133 // TODO: convert args to object 133 // TODO: convert args to object
134 enableTranscoding (webtorrent = true, hls = true, with0p = false) { 134 enableTranscoding (webVideo = true, hls = true, with0p = false) {
135 return this.updateExistingSubConfig({ 135 return this.updateExistingSubConfig({
136 newConfig: { 136 newConfig: {
137 transcoding: { 137 transcoding: {
@@ -142,8 +142,8 @@ export class ConfigCommand extends AbstractCommand {
142 142
143 resolutions: ConfigCommand.getCustomConfigResolutions(true, with0p), 143 resolutions: ConfigCommand.getCustomConfigResolutions(true, with0p),
144 144
145 webtorrent: { 145 webVideos: {
146 enabled: webtorrent 146 enabled: webVideo
147 }, 147 },
148 hls: { 148 hls: {
149 enabled: hls 149 enabled: hls
@@ -154,19 +154,23 @@ export class ConfigCommand extends AbstractCommand {
154 } 154 }
155 155
156 // TODO: convert args to object 156 // TODO: convert args to object
157 enableMinimumTranscoding (webtorrent = true, hls = true) { 157 enableMinimumTranscoding (webVideo = true, hls = true) {
158 return this.updateExistingSubConfig({ 158 return this.updateExistingSubConfig({
159 newConfig: { 159 newConfig: {
160 transcoding: { 160 transcoding: {
161 enabled: true, 161 enabled: true,
162
163 allowAudioFiles: true,
164 allowAdditionalExtensions: true,
165
162 resolutions: { 166 resolutions: {
163 ...ConfigCommand.getCustomConfigResolutions(false), 167 ...ConfigCommand.getCustomConfigResolutions(false),
164 168
165 '240p': true 169 '240p': true
166 }, 170 },
167 171
168 webtorrent: { 172 webVideos: {
169 enabled: webtorrent 173 enabled: webVideo
170 }, 174 },
171 hls: { 175 hls: {
172 enabled: hls 176 enabled: hls
@@ -368,6 +372,9 @@ export class ConfigCommand extends AbstractCommand {
368 }, 372 },
369 torrents: { 373 torrents: {
370 size: 4 374 size: 4
375 },
376 storyboards: {
377 size: 5
371 } 378 }
372 }, 379 },
373 signup: { 380 signup: {
@@ -417,7 +424,7 @@ export class ConfigCommand extends AbstractCommand {
417 '2160p': false 424 '2160p': false
418 }, 425 },
419 alwaysTranscodeOriginalResolution: true, 426 alwaysTranscodeOriginalResolution: true,
420 webtorrent: { 427 webVideos: {
421 enabled: true 428 enabled: true
422 }, 429 },
423 hls: { 430 hls: {
diff --git a/shared/server-commands/server/jobs.ts b/shared/server-commands/server/jobs.ts
index ff3098063..8f131fba4 100644
--- a/shared/server-commands/server/jobs.ts
+++ b/shared/server-commands/server/jobs.ts
@@ -33,6 +33,8 @@ async function waitJobs (
33 33
34 // Check if each server has pending request 34 // Check if each server has pending request
35 for (const server of servers) { 35 for (const server of servers) {
36 if (process.env.DEBUG) console.log('Checking ' + server.url)
37
36 for (const state of states) { 38 for (const state of states) {
37 39
38 const jobPromise = server.jobs.list({ 40 const jobPromise = server.jobs.list({
@@ -45,6 +47,10 @@ async function waitJobs (
45 .then(jobs => { 47 .then(jobs => {
46 if (jobs.length !== 0) { 48 if (jobs.length !== 0) {
47 pendingRequests = true 49 pendingRequests = true
50
51 if (process.env.DEBUG) {
52 console.log(jobs)
53 }
48 } 54 }
49 }) 55 })
50 56
@@ -55,6 +61,10 @@ async function waitJobs (
55 .then(obj => { 61 .then(obj => {
56 if (obj.activityPubMessagesWaiting !== 0) { 62 if (obj.activityPubMessagesWaiting !== 0) {
57 pendingRequests = true 63 pendingRequests = true
64
65 if (process.env.DEBUG) {
66 console.log('AP messages waiting: ' + obj.activityPubMessagesWaiting)
67 }
58 } 68 }
59 }) 69 })
60 tasks.push(debugPromise) 70 tasks.push(debugPromise)
@@ -65,12 +75,15 @@ async function waitJobs (
65 for (const job of data) { 75 for (const job of data) {
66 if (job.state.id !== RunnerJobState.COMPLETED) { 76 if (job.state.id !== RunnerJobState.COMPLETED) {
67 pendingRequests = true 77 pendingRequests = true
78
79 if (process.env.DEBUG) {
80 console.log(job)
81 }
68 } 82 }
69 } 83 }
70 }) 84 })
71 tasks.push(runnerJobsPromise) 85 tasks.push(runnerJobsPromise)
72 } 86 }
73
74 } 87 }
75 88
76 return tasks 89 return tasks
diff --git a/shared/server-commands/server/object-storage-command.ts b/shared/server-commands/server/object-storage-command.ts
index 7d8ec93cd..6bb232c36 100644
--- a/shared/server-commands/server/object-storage-command.ts
+++ b/shared/server-commands/server/object-storage-command.ts
@@ -42,7 +42,7 @@ export class ObjectStorageCommand {
42 bucket_name: this.getMockStreamingPlaylistsBucketName() 42 bucket_name: this.getMockStreamingPlaylistsBucketName()
43 }, 43 },
44 44
45 videos: { 45 web_videos: {
46 bucket_name: this.getMockWebVideosBucketName() 46 bucket_name: this.getMockWebVideosBucketName()
47 } 47 }
48 } 48 }
@@ -136,9 +136,9 @@ export class ObjectStorageCommand {
136 prefix: `test:server-${serverNumber}-streaming-playlists:` 136 prefix: `test:server-${serverNumber}-streaming-playlists:`
137 }, 137 },
138 138
139 videos: { 139 web_videos: {
140 bucket_name: this.DEFAULT_SCALEWAY_BUCKET, 140 bucket_name: this.DEFAULT_SCALEWAY_BUCKET,
141 prefix: `test:server-${serverNumber}-videos:` 141 prefix: `test:server-${serverNumber}-web-videos:`
142 } 142 }
143 } 143 }
144 } 144 }
diff --git a/shared/server-commands/server/server.ts b/shared/server-commands/server/server.ts
index 70f7a3ee2..38568a890 100644
--- a/shared/server-commands/server/server.ts
+++ b/shared/server-commands/server/server.ts
@@ -32,8 +32,10 @@ import {
32 HistoryCommand, 32 HistoryCommand,
33 ImportsCommand, 33 ImportsCommand,
34 LiveCommand, 34 LiveCommand,
35 VideoPasswordsCommand,
35 PlaylistsCommand, 36 PlaylistsCommand,
36 ServicesCommand, 37 ServicesCommand,
38 StoryboardCommand,
37 StreamingPlaylistsCommand, 39 StreamingPlaylistsCommand,
38 VideosCommand, 40 VideosCommand,
39 VideoStudioCommand, 41 VideoStudioCommand,
@@ -146,6 +148,9 @@ export class PeerTubeServer {
146 twoFactor?: TwoFactorCommand 148 twoFactor?: TwoFactorCommand
147 videoToken?: VideoTokenCommand 149 videoToken?: VideoTokenCommand
148 registrations?: RegistrationsCommand 150 registrations?: RegistrationsCommand
151 videoPasswords?: VideoPasswordsCommand
152
153 storyboard?: StoryboardCommand
149 154
150 runners?: RunnersCommand 155 runners?: RunnersCommand
151 runnerRegistrationTokens?: RunnerRegistrationTokensCommand 156 runnerRegistrationTokens?: RunnerRegistrationTokensCommand
@@ -232,7 +237,7 @@ export class PeerTubeServer {
232 } 237 }
233 238
234 // Share the environment 239 // Share the environment
235 const env = Object.create(process.env) 240 const env = { ...process.env }
236 env['NODE_ENV'] = 'test' 241 env['NODE_ENV'] = 'test'
237 env['NODE_APP_INSTANCE'] = this.internalServerNumber.toString() 242 env['NODE_APP_INSTANCE'] = this.internalServerNumber.toString()
238 env['NODE_CONFIG'] = JSON.stringify(configOverride) 243 env['NODE_CONFIG'] = JSON.stringify(configOverride)
@@ -365,12 +370,13 @@ export class PeerTubeServer {
365 tmp_persistent: this.getDirectoryPath('tmp-persistent') + '/', 370 tmp_persistent: this.getDirectoryPath('tmp-persistent') + '/',
366 bin: this.getDirectoryPath('bin') + '/', 371 bin: this.getDirectoryPath('bin') + '/',
367 avatars: this.getDirectoryPath('avatars') + '/', 372 avatars: this.getDirectoryPath('avatars') + '/',
368 videos: this.getDirectoryPath('videos') + '/', 373 web_videos: this.getDirectoryPath('web-videos') + '/',
369 streaming_playlists: this.getDirectoryPath('streaming-playlists') + '/', 374 streaming_playlists: this.getDirectoryPath('streaming-playlists') + '/',
370 redundancy: this.getDirectoryPath('redundancy') + '/', 375 redundancy: this.getDirectoryPath('redundancy') + '/',
371 logs: this.getDirectoryPath('logs') + '/', 376 logs: this.getDirectoryPath('logs') + '/',
372 previews: this.getDirectoryPath('previews') + '/', 377 previews: this.getDirectoryPath('previews') + '/',
373 thumbnails: this.getDirectoryPath('thumbnails') + '/', 378 thumbnails: this.getDirectoryPath('thumbnails') + '/',
379 storyboards: this.getDirectoryPath('storyboards') + '/',
374 torrents: this.getDirectoryPath('torrents') + '/', 380 torrents: this.getDirectoryPath('torrents') + '/',
375 captions: this.getDirectoryPath('captions') + '/', 381 captions: this.getDirectoryPath('captions') + '/',
376 cache: this.getDirectoryPath('cache') + '/', 382 cache: this.getDirectoryPath('cache') + '/',
@@ -434,8 +440,11 @@ export class PeerTubeServer {
434 this.videoToken = new VideoTokenCommand(this) 440 this.videoToken = new VideoTokenCommand(this)
435 this.registrations = new RegistrationsCommand(this) 441 this.registrations = new RegistrationsCommand(this)
436 442
443 this.storyboard = new StoryboardCommand(this)
444
437 this.runners = new RunnersCommand(this) 445 this.runners = new RunnersCommand(this)
438 this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this) 446 this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this)
439 this.runnerJobs = new RunnerJobsCommand(this) 447 this.runnerJobs = new RunnerJobsCommand(this)
448 this.videoPasswords = new VideoPasswordsCommand(this)
440 } 449 }
441} 450}
diff --git a/shared/server-commands/server/servers-command.ts b/shared/server-commands/server/servers-command.ts
index 19645cb93..c91c2b008 100644
--- a/shared/server-commands/server/servers-command.ts
+++ b/shared/server-commands/server/servers-command.ts
@@ -77,8 +77,8 @@ export class ServersCommand extends AbstractCommand {
77 return join(root(), 'test' + this.server.internalServerNumber, directory) 77 return join(root(), 'test' + this.server.internalServerNumber, directory)
78 } 78 }
79 79
80 buildWebTorrentFilePath (fileUrl: string) { 80 buildWebVideoFilePath (fileUrl: string) {
81 return this.buildDirectory(join('videos', basename(fileUrl))) 81 return this.buildDirectory(join('web-videos', basename(fileUrl)))
82 } 82 }
83 83
84 buildFragmentedFilePath (videoUUID: string, fileUrl: string) { 84 buildFragmentedFilePath (videoUUID: string, fileUrl: string) {
diff --git a/shared/server-commands/shared/abstract-command.ts b/shared/server-commands/shared/abstract-command.ts
index ca4ffada9..463acc26b 100644
--- a/shared/server-commands/shared/abstract-command.ts
+++ b/shared/server-commands/shared/abstract-command.ts
@@ -101,25 +101,29 @@ abstract class AbstractCommand {
101 101
102 protected putBodyRequest (options: InternalCommonCommandOptions & { 102 protected putBodyRequest (options: InternalCommonCommandOptions & {
103 fields?: { [ fieldName: string ]: any } 103 fields?: { [ fieldName: string ]: any }
104 headers?: { [name: string]: string }
104 }) { 105 }) {
105 const { fields } = options 106 const { fields, headers } = options
106 107
107 return makePutBodyRequest({ 108 return makePutBodyRequest({
108 ...this.buildCommonRequestOptions(options), 109 ...this.buildCommonRequestOptions(options),
109 110
110 fields 111 fields,
112 headers
111 }) 113 })
112 } 114 }
113 115
114 protected postBodyRequest (options: InternalCommonCommandOptions & { 116 protected postBodyRequest (options: InternalCommonCommandOptions & {
115 fields?: { [ fieldName: string ]: any } 117 fields?: { [ fieldName: string ]: any }
118 headers?: { [name: string]: string }
116 }) { 119 }) {
117 const { fields } = options 120 const { fields, headers } = options
118 121
119 return makePostBodyRequest({ 122 return makePostBodyRequest({
120 ...this.buildCommonRequestOptions(options), 123 ...this.buildCommonRequestOptions(options),
121 124
122 fields 125 fields,
126 headers
123 }) 127 })
124 } 128 }
125 129
@@ -206,6 +210,12 @@ abstract class AbstractCommand {
206 210
207 return expectedStatus !== undefined ? expectedStatus : defaultExpectedStatus 211 return expectedStatus !== undefined ? expectedStatus : defaultExpectedStatus
208 } 212 }
213
214 protected buildVideoPasswordHeader (videoPassword: string) {
215 return videoPassword !== undefined && videoPassword !== null
216 ? { 'x-peertube-video-password': videoPassword }
217 : undefined
218 }
209} 219}
210 220
211export { 221export {
diff --git a/shared/server-commands/videos/captions-command.ts b/shared/server-commands/videos/captions-command.ts
index 62bf9c5e6..a26fcb57d 100644
--- a/shared/server-commands/videos/captions-command.ts
+++ b/shared/server-commands/videos/captions-command.ts
@@ -34,14 +34,16 @@ export class CaptionsCommand extends AbstractCommand {
34 34
35 list (options: OverrideCommandOptions & { 35 list (options: OverrideCommandOptions & {
36 videoId: string | number 36 videoId: string | number
37 videoPassword?: string
37 }) { 38 }) {
38 const { videoId } = options 39 const { videoId, videoPassword } = options
39 const path = '/api/v1/videos/' + videoId + '/captions' 40 const path = '/api/v1/videos/' + videoId + '/captions'
40 41
41 return this.getRequestBody<ResultList<VideoCaption>>({ 42 return this.getRequestBody<ResultList<VideoCaption>>({
42 ...options, 43 ...options,
43 44
44 path, 45 path,
46 headers: this.buildVideoPasswordHeader(videoPassword),
45 implicitToken: false, 47 implicitToken: false,
46 defaultExpectedStatus: HttpStatusCode.OK_200 48 defaultExpectedStatus: HttpStatusCode.OK_200
47 }) 49 })
diff --git a/shared/server-commands/videos/comments-command.ts b/shared/server-commands/videos/comments-command.ts
index 154ec0c24..0dab1b66a 100644
--- a/shared/server-commands/videos/comments-command.ts
+++ b/shared/server-commands/videos/comments-command.ts
@@ -36,11 +36,12 @@ export class CommentsCommand extends AbstractCommand {
36 36
37 listThreads (options: OverrideCommandOptions & { 37 listThreads (options: OverrideCommandOptions & {
38 videoId: number | string 38 videoId: number | string
39 videoPassword?: string
39 start?: number 40 start?: number
40 count?: number 41 count?: number
41 sort?: string 42 sort?: string
42 }) { 43 }) {
43 const { start, count, sort, videoId } = options 44 const { start, count, sort, videoId, videoPassword } = options
44 const path = '/api/v1/videos/' + videoId + '/comment-threads' 45 const path = '/api/v1/videos/' + videoId + '/comment-threads'
45 46
46 return this.getRequestBody<VideoCommentThreads>({ 47 return this.getRequestBody<VideoCommentThreads>({
@@ -48,6 +49,7 @@ export class CommentsCommand extends AbstractCommand {
48 49
49 path, 50 path,
50 query: { start, count, sort }, 51 query: { start, count, sort },
52 headers: this.buildVideoPasswordHeader(videoPassword),
51 implicitToken: false, 53 implicitToken: false,
52 defaultExpectedStatus: HttpStatusCode.OK_200 54 defaultExpectedStatus: HttpStatusCode.OK_200
53 }) 55 })
@@ -72,8 +74,9 @@ export class CommentsCommand extends AbstractCommand {
72 async createThread (options: OverrideCommandOptions & { 74 async createThread (options: OverrideCommandOptions & {
73 videoId: number | string 75 videoId: number | string
74 text: string 76 text: string
77 videoPassword?: string
75 }) { 78 }) {
76 const { videoId, text } = options 79 const { videoId, text, videoPassword } = options
77 const path = '/api/v1/videos/' + videoId + '/comment-threads' 80 const path = '/api/v1/videos/' + videoId + '/comment-threads'
78 81
79 const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({ 82 const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({
@@ -81,6 +84,7 @@ export class CommentsCommand extends AbstractCommand {
81 84
82 path, 85 path,
83 fields: { text }, 86 fields: { text },
87 headers: this.buildVideoPasswordHeader(videoPassword),
84 implicitToken: true, 88 implicitToken: true,
85 defaultExpectedStatus: HttpStatusCode.OK_200 89 defaultExpectedStatus: HttpStatusCode.OK_200
86 })) 90 }))
@@ -95,8 +99,9 @@ export class CommentsCommand extends AbstractCommand {
95 videoId: number | string 99 videoId: number | string
96 toCommentId: number 100 toCommentId: number
97 text: string 101 text: string
102 videoPassword?: string
98 }) { 103 }) {
99 const { videoId, toCommentId, text } = options 104 const { videoId, toCommentId, text, videoPassword } = options
100 const path = '/api/v1/videos/' + videoId + '/comments/' + toCommentId 105 const path = '/api/v1/videos/' + videoId + '/comments/' + toCommentId
101 106
102 const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({ 107 const body = await unwrapBody<{ comment: VideoComment }>(this.postBodyRequest({
@@ -104,6 +109,7 @@ export class CommentsCommand extends AbstractCommand {
104 109
105 path, 110 path,
106 fields: { text }, 111 fields: { text },
112 headers: this.buildVideoPasswordHeader(videoPassword),
107 implicitToken: true, 113 implicitToken: true,
108 defaultExpectedStatus: HttpStatusCode.OK_200 114 defaultExpectedStatus: HttpStatusCode.OK_200
109 })) 115 }))
diff --git a/shared/server-commands/videos/index.ts b/shared/server-commands/videos/index.ts
index c17f6ef20..106d80af0 100644
--- a/shared/server-commands/videos/index.ts
+++ b/shared/server-commands/videos/index.ts
@@ -11,9 +11,11 @@ export * from './live-command'
11export * from './live' 11export * from './live'
12export * from './playlists-command' 12export * from './playlists-command'
13export * from './services-command' 13export * from './services-command'
14export * from './storyboard-command'
14export * from './streaming-playlists-command' 15export * from './streaming-playlists-command'
15export * from './comments-command' 16export * from './comments-command'
16export * from './video-studio-command' 17export * from './video-studio-command'
17export * from './video-token-command' 18export * from './video-token-command'
18export * from './views-command' 19export * from './views-command'
19export * from './videos-command' 20export * from './videos-command'
21export * from './video-passwords-command'
diff --git a/shared/server-commands/videos/live-command.ts b/shared/server-commands/videos/live-command.ts
index 44d625970..6006d9fe9 100644
--- a/shared/server-commands/videos/live-command.ts
+++ b/shared/server-commands/videos/live-command.ts
@@ -120,8 +120,13 @@ export class LiveCommand extends AbstractCommand {
120 saveReplay: boolean 120 saveReplay: boolean
121 permanentLive: boolean 121 permanentLive: boolean
122 privacy?: VideoPrivacy 122 privacy?: VideoPrivacy
123 videoPasswords?: string[]
123 }) { 124 }) {
124 const { saveReplay, permanentLive, privacy = VideoPrivacy.PUBLIC } = options 125 const { saveReplay, permanentLive, privacy = VideoPrivacy.PUBLIC, videoPasswords } = options
126
127 const replaySettings = privacy === VideoPrivacy.PASSWORD_PROTECTED
128 ? { privacy: VideoPrivacy.PRIVATE }
129 : { privacy }
125 130
126 const { uuid } = await this.create({ 131 const { uuid } = await this.create({
127 ...options, 132 ...options,
@@ -130,9 +135,10 @@ export class LiveCommand extends AbstractCommand {
130 name: 'live', 135 name: 'live',
131 permanentLive, 136 permanentLive,
132 saveReplay, 137 saveReplay,
133 replaySettings: { privacy }, 138 replaySettings,
134 channelId: this.server.store.channel.id, 139 channelId: this.server.store.channel.id,
135 privacy 140 privacy,
141 videoPasswords
136 } 142 }
137 }) 143 })
138 144
diff --git a/shared/server-commands/videos/storyboard-command.ts b/shared/server-commands/videos/storyboard-command.ts
new file mode 100644
index 000000000..06d90fc12
--- /dev/null
+++ b/shared/server-commands/videos/storyboard-command.ts
@@ -0,0 +1,19 @@
1import { HttpStatusCode, Storyboard } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3
4export class StoryboardCommand extends AbstractCommand {
5
6 list (options: OverrideCommandOptions & {
7 id: number | string
8 }) {
9 const path = '/api/v1/videos/' + options.id + '/storyboards'
10
11 return this.getRequestBody<{ storyboards: Storyboard[] }>({
12 ...options,
13
14 path,
15 implicitToken: true,
16 defaultExpectedStatus: HttpStatusCode.OK_200
17 })
18 }
19}
diff --git a/shared/server-commands/videos/video-passwords-command.ts b/shared/server-commands/videos/video-passwords-command.ts
new file mode 100644
index 000000000..bf10335b4
--- /dev/null
+++ b/shared/server-commands/videos/video-passwords-command.ts
@@ -0,0 +1,55 @@
1import { HttpStatusCode, ResultList, VideoPassword } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3export class VideoPasswordsCommand extends AbstractCommand {
4
5 list (options: OverrideCommandOptions & {
6 videoId: number | string
7 start?: number
8 count?: number
9 sort?: string
10 }) {
11 const { start, count, sort, videoId } = options
12 const path = '/api/v1/videos/' + videoId + '/passwords'
13
14 return this.getRequestBody<ResultList<VideoPassword>>({
15 ...options,
16
17 path,
18 query: { start, count, sort },
19 implicitToken: true,
20 defaultExpectedStatus: HttpStatusCode.OK_200
21 })
22 }
23
24 updateAll (options: OverrideCommandOptions & {
25 videoId: number | string
26 passwords: string[]
27 }) {
28 const { videoId, passwords } = options
29 const path = `/api/v1/videos/${videoId}/passwords`
30
31 return this.putBodyRequest({
32 ...options,
33 path,
34 fields: { passwords },
35 implicitToken: true,
36 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
37 })
38 }
39
40 remove (options: OverrideCommandOptions & {
41 id: number
42 videoId: number | string
43 }) {
44 const { id, videoId } = options
45 const path = `/api/v1/videos/${videoId}/passwords/${id}`
46
47 return this.deleteRequest({
48 ...options,
49
50 path,
51 implicitToken: true,
52 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
53 })
54 }
55}
diff --git a/shared/server-commands/videos/video-studio-command.ts b/shared/server-commands/videos/video-studio-command.ts
index 9fe467cc2..675cd84b7 100644
--- a/shared/server-commands/videos/video-studio-command.ts
+++ b/shared/server-commands/videos/video-studio-command.ts
@@ -25,7 +25,7 @@ export class VideoStudioCommand extends AbstractCommand {
25 { 25 {
26 name: 'add-watermark', 26 name: 'add-watermark',
27 options: { 27 options: {
28 file: 'thumbnail.png' 28 file: 'custom-thumbnail.png'
29 } 29 }
30 }, 30 },
31 31
diff --git a/shared/server-commands/videos/video-token-command.ts b/shared/server-commands/videos/video-token-command.ts
index 0531bee65..c4ed29a8c 100644
--- a/shared/server-commands/videos/video-token-command.ts
+++ b/shared/server-commands/videos/video-token-command.ts
@@ -8,12 +8,14 @@ export class VideoTokenCommand extends AbstractCommand {
8 8
9 create (options: OverrideCommandOptions & { 9 create (options: OverrideCommandOptions & {
10 videoId: number | string 10 videoId: number | string
11 videoPassword?: string
11 }) { 12 }) {
12 const { videoId } = options 13 const { videoId, videoPassword } = options
13 const path = '/api/v1/videos/' + videoId + '/token' 14 const path = '/api/v1/videos/' + videoId + '/token'
14 15
15 return unwrapBody<VideoToken>(this.postBodyRequest({ 16 return unwrapBody<VideoToken>(this.postBodyRequest({
16 ...options, 17 ...options,
18 headers: this.buildVideoPasswordHeader(videoPassword),
17 19
18 path, 20 path,
19 implicitToken: true, 21 implicitToken: true,
@@ -23,6 +25,7 @@ export class VideoTokenCommand extends AbstractCommand {
23 25
24 async getVideoFileToken (options: OverrideCommandOptions & { 26 async getVideoFileToken (options: OverrideCommandOptions & {
25 videoId: number | string 27 videoId: number | string
28 videoPassword?: string
26 }) { 29 }) {
27 const { files } = await this.create(options) 30 const { files } = await this.create(options)
28 31
diff --git a/shared/server-commands/videos/videos-command.ts b/shared/server-commands/videos/videos-command.ts
index b5df9c325..9602fa7da 100644
--- a/shared/server-commands/videos/videos-command.ts
+++ b/shared/server-commands/videos/videos-command.ts
@@ -111,8 +111,9 @@ export class VideosCommand extends AbstractCommand {
111 rate (options: OverrideCommandOptions & { 111 rate (options: OverrideCommandOptions & {
112 id: number | string 112 id: number | string
113 rating: UserVideoRateType 113 rating: UserVideoRateType
114 videoPassword?: string
114 }) { 115 }) {
115 const { id, rating } = options 116 const { id, rating, videoPassword } = options
116 const path = '/api/v1/videos/' + id + '/rate' 117 const path = '/api/v1/videos/' + id + '/rate'
117 118
118 return this.putBodyRequest({ 119 return this.putBodyRequest({
@@ -120,6 +121,7 @@ export class VideosCommand extends AbstractCommand {
120 121
121 path, 122 path,
122 fields: { rating }, 123 fields: { rating },
124 headers: this.buildVideoPasswordHeader(videoPassword),
123 implicitToken: true, 125 implicitToken: true,
124 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 126 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
125 }) 127 })
@@ -151,6 +153,23 @@ export class VideosCommand extends AbstractCommand {
151 }) 153 })
152 } 154 }
153 155
156 getWithPassword (options: OverrideCommandOptions & {
157 id: number | string
158 password?: string
159 }) {
160 const path = '/api/v1/videos/' + options.id
161
162 return this.getRequestBody<VideoDetails>({
163 ...options,
164 headers:{
165 'x-peertube-video-password': options.password
166 },
167 path,
168 implicitToken: false,
169 defaultExpectedStatus: HttpStatusCode.OK_200
170 })
171 }
172
154 getSource (options: OverrideCommandOptions & { 173 getSource (options: OverrideCommandOptions & {
155 id: number | string 174 id: number | string
156 }) { 175 }) {
@@ -608,11 +627,13 @@ export class VideosCommand extends AbstractCommand {
608 nsfw?: boolean 627 nsfw?: boolean
609 privacy?: VideoPrivacy 628 privacy?: VideoPrivacy
610 fixture?: string 629 fixture?: string
630 videoPasswords?: string[]
611 }) { 631 }) {
612 const attributes: VideoEdit = { name: options.name } 632 const attributes: VideoEdit = { name: options.name }
613 if (options.nsfw) attributes.nsfw = options.nsfw 633 if (options.nsfw) attributes.nsfw = options.nsfw
614 if (options.privacy) attributes.privacy = options.privacy 634 if (options.privacy) attributes.privacy = options.privacy
615 if (options.fixture) attributes.fixture = options.fixture 635 if (options.fixture) attributes.fixture = options.fixture
636 if (options.videoPasswords) attributes.videoPasswords = options.videoPasswords
616 637
617 return this.upload({ ...options, attributes }) 638 return this.upload({ ...options, attributes })
618 } 639 }
@@ -665,10 +686,10 @@ export class VideosCommand extends AbstractCommand {
665 }) 686 })
666 } 687 }
667 688
668 removeAllWebTorrentFiles (options: OverrideCommandOptions & { 689 removeAllWebVideoFiles (options: OverrideCommandOptions & {
669 videoId: number | string 690 videoId: number | string
670 }) { 691 }) {
671 const path = '/api/v1/videos/' + options.videoId + '/webtorrent' 692 const path = '/api/v1/videos/' + options.videoId + '/web-videos'
672 693
673 return this.deleteRequest({ 694 return this.deleteRequest({
674 ...options, 695 ...options,
@@ -679,11 +700,11 @@ export class VideosCommand extends AbstractCommand {
679 }) 700 })
680 } 701 }
681 702
682 removeWebTorrentFile (options: OverrideCommandOptions & { 703 removeWebVideoFile (options: OverrideCommandOptions & {
683 videoId: number | string 704 videoId: number | string
684 fileId: number 705 fileId: number
685 }) { 706 }) {
686 const path = '/api/v1/videos/' + options.videoId + '/webtorrent/' + options.fileId 707 const path = '/api/v1/videos/' + options.videoId + '/web-videos/' + options.fileId
687 708
688 return this.deleteRequest({ 709 return this.deleteRequest({
689 ...options, 710 ...options,
@@ -696,7 +717,7 @@ export class VideosCommand extends AbstractCommand {
696 717
697 runTranscoding (options: OverrideCommandOptions & { 718 runTranscoding (options: OverrideCommandOptions & {
698 videoId: number | string 719 videoId: number | string
699 transcodingType: 'hls' | 'webtorrent' 720 transcodingType: 'hls' | 'webtorrent' | 'web-video'
700 }) { 721 }) {
701 const path = '/api/v1/videos/' + options.videoId + '/transcoding' 722 const path = '/api/v1/videos/' + options.videoId + '/transcoding'
702 723
diff --git a/support/doc/api/embeds.md b/support/doc/api/embeds.md
index 1dd1443e7..fd5507e38 100644
--- a/support/doc/api/embeds.md
+++ b/support/doc/api/embeds.md
@@ -52,7 +52,7 @@ player.pause()
52## Embed URL parameters 52## Embed URL parameters
53 53
54You can customize PeerTube player by specifying URL query parameters. 54You can customize PeerTube player by specifying URL query parameters.
55For example `https://my-instance.example.com/videos/embed/52a10666-3a18-4e73-93da-e8d3c12c305a??start=1s&stop=18s&loop=1&autoplay=1&muted=1&warningTitle=0&controlBar=0&peertubeLink=0&p2p=0` 55For example `https://my-instance.example.com/videos/embed/52a10666-3a18-4e73-93da-e8d3c12c305a?start=1s&stop=18s&loop=1&autoplay=1&muted=1&warningTitle=0&controlBar=0&peertubeLink=0&p2p=0`
56 56
57### start 57### start
58 58
@@ -108,6 +108,10 @@ Most web browsers disable video autoplay if the user did not interact with the v
108 108
109Value must be `0` or `1`. 109Value must be `0` or `1`.
110 110
111### playbackRate
112
113Force the default playback rate (`0.75`, `1.5` etc).
114
111### title 115### title
112 116
113Hide embed title. 117Hide embed title.
@@ -142,7 +146,7 @@ Value must be a valid color (`red` or `rgba(100, 100, 100, 0.5)`).
142 146
143Force a specific player engine. 147Force a specific player engine.
144 148
145Value must be a valid mode (`webtorrent` or `p2p-media-loader`). 149Value must be a valid mode (`web-video` or `p2p-media-loader`).
146 150
147### api 151### api
148 152
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index cd50e86a6..2dfad9987 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -300,6 +300,8 @@ tags:
300 - name: Runner Registration Token 300 - name: Runner Registration Token
301 description: | 301 description: |
302 Manage runner registration token 302 Manage runner registration token
303 - name: Video Passwords
304 description: Operation on video passwords
303 305
304x-tagGroups: 306x-tagGroups:
305 - name: Static endpoints 307 - name: Static endpoints
@@ -337,6 +339,7 @@ x-tagGroups:
337 - Video Transcoding 339 - Video Transcoding
338 - Live Videos 340 - Live Videos
339 - Channels Sync 341 - Channels Sync
342 - Video Passwords
340 - name: Search 343 - name: Search
341 tags: 344 tags:
342 - Search 345 - Search
@@ -363,11 +366,11 @@ x-tagGroups:
363 - Runners 366 - Runners
364 367
365paths: 368paths:
366 '/static/webseed/{filename}': 369 '/static/web-videos/{filename}':
367 get: 370 get:
368 tags: 371 tags:
369 - Static Video Files 372 - Static Video Files
370 summary: Get public WebTorrent video file 373 summary: Get public Web Video file
371 parameters: 374 parameters:
372 - $ref: '#/components/parameters/staticFilename' 375 - $ref: '#/components/parameters/staticFilename'
373 responses: 376 responses:
@@ -375,11 +378,11 @@ paths:
375 description: successful operation 378 description: successful operation
376 '404': 379 '404':
377 description: not found 380 description: not found
378 '/static/webseed/private/{filename}': 381 '/static/web-videos/private/{filename}':
379 get: 382 get:
380 tags: 383 tags:
381 - Static Video Files 384 - Static Video Files
382 summary: Get private WebTorrent video file 385 summary: Get private Web Video file
383 parameters: 386 parameters:
384 - $ref: '#/components/parameters/staticFilename' 387 - $ref: '#/components/parameters/staticFilename'
385 - $ref: '#/components/parameters/videoFileToken' 388 - $ref: '#/components/parameters/videoFileToken'
@@ -571,7 +574,7 @@ paths:
571 - $ref: '#/components/parameters/include' 574 - $ref: '#/components/parameters/include'
572 - $ref: '#/components/parameters/privacyOneOf' 575 - $ref: '#/components/parameters/privacyOneOf'
573 - $ref: '#/components/parameters/hasHLSFiles' 576 - $ref: '#/components/parameters/hasHLSFiles'
574 - $ref: '#/components/parameters/hasWebtorrentFiles' 577 - $ref: '#/components/parameters/hasWebVideoFiles'
575 responses: 578 responses:
576 '200': 579 '200':
577 description: successful operation 580 description: successful operation
@@ -655,7 +658,7 @@ paths:
655 - $ref: '#/components/parameters/include' 658 - $ref: '#/components/parameters/include'
656 - $ref: '#/components/parameters/privacyOneOf' 659 - $ref: '#/components/parameters/privacyOneOf'
657 - $ref: '#/components/parameters/hasHLSFiles' 660 - $ref: '#/components/parameters/hasHLSFiles'
658 - $ref: '#/components/parameters/hasWebtorrentFiles' 661 - $ref: '#/components/parameters/hasWebVideoFiles'
659 responses: 662 responses:
660 '200': 663 '200':
661 description: successful operation 664 description: successful operation
@@ -745,7 +748,7 @@ paths:
745 - $ref: '#/components/parameters/include' 748 - $ref: '#/components/parameters/include'
746 - $ref: '#/components/parameters/privacyOneOf' 749 - $ref: '#/components/parameters/privacyOneOf'
747 - $ref: '#/components/parameters/hasHLSFiles' 750 - $ref: '#/components/parameters/hasHLSFiles'
748 - $ref: '#/components/parameters/hasWebtorrentFiles' 751 - $ref: '#/components/parameters/hasWebVideoFiles'
749 - $ref: '#/components/parameters/skipCount' 752 - $ref: '#/components/parameters/skipCount'
750 - $ref: '#/components/parameters/start' 753 - $ref: '#/components/parameters/start'
751 - $ref: '#/components/parameters/count' 754 - $ref: '#/components/parameters/count'
@@ -915,7 +918,7 @@ paths:
915 description: > 918 description: >
916 Arises when: 919 Arises when:
917 - the emailer is disabled and the instance is open to registrations 920 - the emailer is disabled and the instance is open to registrations
918 - webtorrent and hls are disabled with transcoding enabled - you need at least one enabled 921 - web videos and hls are disabled with transcoding enabled - you need at least one enabled
919 delete: 922 delete:
920 summary: Delete instance runtime configuration 923 summary: Delete instance runtime configuration
921 operationId: delCustomConfig 924 operationId: delCustomConfig
@@ -1870,7 +1873,7 @@ paths:
1870 - $ref: '#/components/parameters/include' 1873 - $ref: '#/components/parameters/include'
1871 - $ref: '#/components/parameters/privacyOneOf' 1874 - $ref: '#/components/parameters/privacyOneOf'
1872 - $ref: '#/components/parameters/hasHLSFiles' 1875 - $ref: '#/components/parameters/hasHLSFiles'
1873 - $ref: '#/components/parameters/hasWebtorrentFiles' 1876 - $ref: '#/components/parameters/hasWebVideoFiles'
1874 - $ref: '#/components/parameters/skipCount' 1877 - $ref: '#/components/parameters/skipCount'
1875 - $ref: '#/components/parameters/start' 1878 - $ref: '#/components/parameters/start'
1876 - $ref: '#/components/parameters/count' 1879 - $ref: '#/components/parameters/count'
@@ -2359,6 +2362,7 @@ paths:
2359 - OAuth2: [] 2362 - OAuth2: []
2360 parameters: 2363 parameters:
2361 - $ref: '#/components/parameters/idOrUUID' 2364 - $ref: '#/components/parameters/idOrUUID'
2365 - $ref: '#/components/parameters/videoPasswordHeader'
2362 responses: 2366 responses:
2363 '200': 2367 '200':
2364 description: successful operation 2368 description: successful operation
@@ -2414,7 +2418,7 @@ paths:
2414 - $ref: '#/components/parameters/include' 2418 - $ref: '#/components/parameters/include'
2415 - $ref: '#/components/parameters/privacyOneOf' 2419 - $ref: '#/components/parameters/privacyOneOf'
2416 - $ref: '#/components/parameters/hasHLSFiles' 2420 - $ref: '#/components/parameters/hasHLSFiles'
2417 - $ref: '#/components/parameters/hasWebtorrentFiles' 2421 - $ref: '#/components/parameters/hasWebVideoFiles'
2418 - $ref: '#/components/parameters/skipCount' 2422 - $ref: '#/components/parameters/skipCount'
2419 - $ref: '#/components/parameters/start' 2423 - $ref: '#/components/parameters/start'
2420 - $ref: '#/components/parameters/count' 2424 - $ref: '#/components/parameters/count'
@@ -2578,6 +2582,8 @@ paths:
2578 format: date-time 2582 format: date-time
2579 scheduleUpdate: 2583 scheduleUpdate:
2580 $ref: '#/components/schemas/VideoScheduledUpdate' 2584 $ref: '#/components/schemas/VideoScheduledUpdate'
2585 videoPasswords:
2586 $ref: '#/components/schemas/AddVideoPasswords'
2581 encoding: 2587 encoding:
2582 thumbnailfile: 2588 thumbnailfile:
2583 contentType: image/jpeg 2589 contentType: image/jpeg
@@ -2590,6 +2596,7 @@ paths:
2590 - Video 2596 - Video
2591 parameters: 2597 parameters:
2592 - $ref: '#/components/parameters/idOrUUID' 2598 - $ref: '#/components/parameters/idOrUUID'
2599 - $ref: '#/components/parameters/videoPasswordHeader'
2593 responses: 2600 responses:
2594 '200': 2601 '200':
2595 description: successful operation 2602 description: successful operation
@@ -2597,6 +2604,8 @@ paths:
2597 application/json: 2604 application/json:
2598 schema: 2605 schema:
2599 $ref: '#/components/schemas/VideoDetails' 2606 $ref: '#/components/schemas/VideoDetails'
2607 '403':
2608 description: provide a correct password to access this password protected video
2600 delete: 2609 delete:
2601 summary: Delete a video 2610 summary: Delete a video
2602 operationId: delVideo 2611 operationId: delVideo
@@ -2618,6 +2627,7 @@ paths:
2618 - Video 2627 - Video
2619 parameters: 2628 parameters:
2620 - $ref: '#/components/parameters/idOrUUID' 2629 - $ref: '#/components/parameters/idOrUUID'
2630 - $ref: '#/components/parameters/videoPasswordHeader'
2621 responses: 2631 responses:
2622 '200': 2632 '200':
2623 description: successful operation 2633 description: successful operation
@@ -3267,6 +3277,7 @@ paths:
3267 - Live Videos 3277 - Live Videos
3268 parameters: 3278 parameters:
3269 - $ref: '#/components/parameters/idOrUUID' 3279 - $ref: '#/components/parameters/idOrUUID'
3280 - $ref: '#/components/parameters/videoPasswordHeader'
3270 responses: 3281 responses:
3271 '200': 3282 '200':
3272 description: successful operation 3283 description: successful operation
@@ -3657,6 +3668,27 @@ paths:
3657 items: 3668 items:
3658 $ref: '#/components/schemas/VideoBlacklist' 3669 $ref: '#/components/schemas/VideoBlacklist'
3659 3670
3671 /api/v1/videos/{id}/storyboards:
3672 get:
3673 summary: List storyboards of a video
3674 operationId: listVideoStoryboards
3675 tags:
3676 - Video
3677 parameters:
3678 - $ref: '#/components/parameters/idOrUUID'
3679 responses:
3680 '200':
3681 description: successful operation
3682 content:
3683 application/json:
3684 schema:
3685 type: object
3686 properties:
3687 storyboards:
3688 type: array
3689 items:
3690 $ref: '#/components/schemas/Storyboard'
3691
3660 /api/v1/videos/{id}/captions: 3692 /api/v1/videos/{id}/captions:
3661 get: 3693 get:
3662 summary: List captions of a video 3694 summary: List captions of a video
@@ -3665,6 +3697,7 @@ paths:
3665 - Video Captions 3697 - Video Captions
3666 parameters: 3698 parameters:
3667 - $ref: '#/components/parameters/idOrUUID' 3699 - $ref: '#/components/parameters/idOrUUID'
3700 - $ref: '#/components/parameters/videoPasswordHeader'
3668 responses: 3701 responses:
3669 '200': 3702 '200':
3670 description: successful operation 3703 description: successful operation
@@ -3728,6 +3761,70 @@ paths:
3728 '404': 3761 '404':
3729 description: video or language or caption for that language not found 3762 description: video or language or caption for that language not found
3730 3763
3764 /api/v1/videos/{id}/passwords:
3765 get:
3766 summary: List video passwords
3767 security:
3768 - OAuth2:
3769 - user
3770 tags:
3771 - Video Passwords
3772 parameters:
3773 - $ref: '#/components/parameters/idOrUUID'
3774 - $ref: '#/components/parameters/start'
3775 - $ref: '#/components/parameters/count'
3776 - $ref: '#/components/parameters/sort'
3777 responses:
3778 '204':
3779 description: successful operation
3780 content:
3781 application/json:
3782 schema:
3783 $ref: '#/components/schemas/VideoPasswordList'
3784 '400':
3785 description: video is not password protected
3786 put:
3787 summary: Update video passwords
3788 security:
3789 - OAuth2:
3790 - user
3791 tags:
3792 - Video Passwords
3793 parameters:
3794 - $ref: '#/components/parameters/idOrUUID'
3795 requestBody:
3796 content:
3797 application/json:
3798 schema:
3799 type: object
3800 properties:
3801 passwords:
3802 $ref: '#/components/schemas/AddVideoPasswords'
3803 responses:
3804 '204':
3805 description: successful operation
3806 '400':
3807 description: video is not password protected
3808
3809 /api/v1/videos/{id}/passwords/{videoPasswordId}:
3810 delete:
3811 summary: Delete a video password
3812 security:
3813 - OAuth2:
3814 - user
3815 tags:
3816 - Video Passwords
3817 parameters:
3818 - $ref: '#/components/parameters/idOrUUID'
3819 - $ref: '#/components/parameters/videoPasswordId'
3820 responses:
3821 '204':
3822 description: successful operation
3823 '403':
3824 description: cannot delete the last password of the protected video
3825 '400':
3826 description: video is not password protected
3827
3731 /api/v1/video-channels: 3828 /api/v1/video-channels:
3732 get: 3829 get:
3733 summary: List video channels 3830 summary: List video channels
@@ -3836,7 +3933,7 @@ paths:
3836 - $ref: '#/components/parameters/include' 3933 - $ref: '#/components/parameters/include'
3837 - $ref: '#/components/parameters/privacyOneOf' 3934 - $ref: '#/components/parameters/privacyOneOf'
3838 - $ref: '#/components/parameters/hasHLSFiles' 3935 - $ref: '#/components/parameters/hasHLSFiles'
3839 - $ref: '#/components/parameters/hasWebtorrentFiles' 3936 - $ref: '#/components/parameters/hasWebVideoFiles'
3840 - $ref: '#/components/parameters/skipCount' 3937 - $ref: '#/components/parameters/skipCount'
3841 - $ref: '#/components/parameters/start' 3938 - $ref: '#/components/parameters/start'
3842 - $ref: '#/components/parameters/count' 3939 - $ref: '#/components/parameters/count'
@@ -4554,6 +4651,7 @@ paths:
4554 - $ref: '#/components/parameters/start' 4651 - $ref: '#/components/parameters/start'
4555 - $ref: '#/components/parameters/count' 4652 - $ref: '#/components/parameters/count'
4556 - $ref: '#/components/parameters/commentsSort' 4653 - $ref: '#/components/parameters/commentsSort'
4654 - $ref: '#/components/parameters/videoPasswordHeader'
4557 responses: 4655 responses:
4558 '200': 4656 '200':
4559 description: successful operation 4657 description: successful operation
@@ -4600,6 +4698,7 @@ paths:
4600 parameters: 4698 parameters:
4601 - $ref: '#/components/parameters/idOrUUID' 4699 - $ref: '#/components/parameters/idOrUUID'
4602 - $ref: '#/components/parameters/threadId' 4700 - $ref: '#/components/parameters/threadId'
4701 - $ref: '#/components/parameters/videoPasswordHeader'
4603 responses: 4702 responses:
4604 '200': 4703 '200':
4605 description: successful operation 4704 description: successful operation
@@ -4618,6 +4717,7 @@ paths:
4618 parameters: 4717 parameters:
4619 - $ref: '#/components/parameters/idOrUUID' 4718 - $ref: '#/components/parameters/idOrUUID'
4620 - $ref: '#/components/parameters/commentId' 4719 - $ref: '#/components/parameters/commentId'
4720 - $ref: '#/components/parameters/videoPasswordHeader'
4621 responses: 4721 responses:
4622 '200': 4722 '200':
4623 description: successful operation 4723 description: successful operation
@@ -4668,6 +4768,7 @@ paths:
4668 - Video Rates 4768 - Video Rates
4669 parameters: 4769 parameters:
4670 - $ref: '#/components/parameters/idOrUUID' 4770 - $ref: '#/components/parameters/idOrUUID'
4771 - $ref: '#/components/parameters/videoPasswordHeader'
4671 requestBody: 4772 requestBody:
4672 content: 4773 content:
4673 application/json: 4774 application/json:
@@ -4703,15 +4804,15 @@ paths:
4703 description: successful operation 4804 description: successful operation
4704 '404': 4805 '404':
4705 description: video does not exist 4806 description: video does not exist
4706 '/api/v1/videos/{id}/webtorrent': 4807 '/api/v1/videos/{id}/web-videos':
4707 delete: 4808 delete:
4708 summary: Delete video WebTorrent files 4809 summary: Delete video Web Video files
4709 security: 4810 security:
4710 - OAuth2: 4811 - OAuth2:
4711 - admin 4812 - admin
4712 tags: 4813 tags:
4713 - Video Files 4814 - Video Files
4714 operationId: delVideoWebTorrent 4815 operationId: delVideoWebVideos
4715 parameters: 4816 parameters:
4716 - $ref: '#/components/parameters/idOrUUID' 4817 - $ref: '#/components/parameters/idOrUUID'
4717 responses: 4818 responses:
@@ -4741,7 +4842,7 @@ paths:
4741 type: string 4842 type: string
4742 enum: 4843 enum:
4743 - hls 4844 - hls
4744 - webtorrent 4845 - web-video
4745 required: 4846 required:
4746 - transcodingType 4847 - transcodingType
4747 responses: 4848 responses:
@@ -4779,7 +4880,7 @@ paths:
4779 - $ref: '#/components/parameters/privacyOneOf' 4880 - $ref: '#/components/parameters/privacyOneOf'
4780 - $ref: '#/components/parameters/uuids' 4881 - $ref: '#/components/parameters/uuids'
4781 - $ref: '#/components/parameters/hasHLSFiles' 4882 - $ref: '#/components/parameters/hasHLSFiles'
4782 - $ref: '#/components/parameters/hasWebtorrentFiles' 4883 - $ref: '#/components/parameters/hasWebVideoFiles'
4783 - $ref: '#/components/parameters/skipCount' 4884 - $ref: '#/components/parameters/skipCount'
4784 - $ref: '#/components/parameters/start' 4885 - $ref: '#/components/parameters/start'
4785 - $ref: '#/components/parameters/count' 4886 - $ref: '#/components/parameters/count'
@@ -6370,13 +6471,13 @@ components:
6370 schema: 6471 schema:
6371 type: boolean 6472 type: boolean
6372 description: '**PeerTube >= 4.0** Display only videos that have HLS files' 6473 description: '**PeerTube >= 4.0** Display only videos that have HLS files'
6373 hasWebtorrentFiles: 6474 hasWebVideoFiles:
6374 name: hasWebtorrentFiles 6475 name: hasWebVideoFiles
6375 in: query 6476 in: query
6376 required: false 6477 required: false
6377 schema: 6478 schema:
6378 type: boolean 6479 type: boolean
6379 description: '**PeerTube >= 4.0** Display only videos that have WebTorrent files' 6480 description: '**PeerTube >= 4.0** Display only videos that have Web Video files'
6380 privacyOneOf: 6481 privacyOneOf:
6381 name: privacyOneOf 6482 name: privacyOneOf
6382 in: query 6483 in: query
@@ -6525,7 +6626,20 @@ components:
6525 required: true 6626 required: true
6526 schema: 6627 schema:
6527 $ref: '#/components/schemas/UUIDv4' 6628 $ref: '#/components/schemas/UUIDv4'
6528 6629 videoPasswordId:
6630 name: videoPasswordId
6631 in: path
6632 required: true
6633 description: The video password id
6634 schema:
6635 $ref: '#/components/schemas/id'
6636 videoPasswordHeader:
6637 name: x-peertube-video-password
6638 description: Required on password protected video
6639 in: header
6640 required: false
6641 schema:
6642 type: string
6529 securitySchemes: 6643 securitySchemes:
6530 OAuth2: 6644 OAuth2:
6531 description: | 6645 description: |
@@ -7015,7 +7129,7 @@ components:
7015 maxLength: 120 7129 maxLength: 120
7016 thumbnailPath: 7130 thumbnailPath:
7017 type: string 7131 type: string
7018 example: /static/thumbnails/a65bc12f-9383-462e-81ae-8207e8b434ee.jpg 7132 example: /lazy-static/thumbnails/a65bc12f-9383-462e-81ae-8207e8b434ee.jpg
7019 previewPath: 7133 previewPath:
7020 type: string 7134 type: string
7021 example: /lazy-static/previews/a65bc12f-9383-462e-81ae-8207e8b434ee.jpg 7135 example: /lazy-static/previews/a65bc12f-9383-462e-81ae-8207e8b434ee.jpg
@@ -7108,7 +7222,7 @@ components:
7108 items: 7222 items:
7109 $ref: '#/components/schemas/VideoFile' 7223 $ref: '#/components/schemas/VideoFile'
7110 description: | 7224 description: |
7111 WebTorrent/raw video files. If WebTorrent is disabled on the server: 7225 Web compatible video files. If Web Video is disabled on the server:
7112 7226
7113 - field will be empty 7227 - field will be empty
7114 - video files will be found in `streamingPlaylists[].files` field 7228 - video files will be found in `streamingPlaylists[].files` field
@@ -7416,6 +7530,20 @@ components:
7416 type: array 7530 type: array
7417 items: 7531 items:
7418 $ref: '#/components/schemas/VideoCommentThreadTree' 7532 $ref: '#/components/schemas/VideoCommentThreadTree'
7533 Storyboard:
7534 properties:
7535 storyboardPath:
7536 type: string
7537 totalHeight:
7538 type: integer
7539 totalWidth:
7540 type: integer
7541 spriteHeight:
7542 type: integer
7543 spriteWidth:
7544 type: integer
7545 spriteDuration:
7546 type: integer
7419 VideoCaption: 7547 VideoCaption:
7420 properties: 7548 properties:
7421 language: 7549 language:
@@ -7640,7 +7768,7 @@ components:
7640 properties: 7768 properties:
7641 enabled: 7769 enabled:
7642 type: boolean 7770 type: boolean
7643 webtorrent: 7771 web_videos:
7644 type: object 7772 type: object
7645 properties: 7773 properties:
7646 enabled: 7774 enabled:
@@ -8006,15 +8134,15 @@ components:
8006 type: boolean 8134 type: boolean
8007 2160p: 8135 2160p:
8008 type: boolean 8136 type: boolean
8009 webtorrent: 8137 web_videos:
8010 type: object 8138 type: object
8011 description: WebTorrent-specific settings 8139 description: Web Video specific settings
8012 properties: 8140 properties:
8013 enabled: 8141 enabled:
8014 type: boolean 8142 type: boolean
8015 hls: 8143 hls:
8016 type: object 8144 type: object
8017 description: HLS-specific settings 8145 description: HLS specific settings
8018 properties: 8146 properties:
8019 enabled: 8147 enabled:
8020 type: boolean 8148 type: boolean
@@ -8228,6 +8356,8 @@ components:
8228 description: Video preview file 8356 description: Video preview file
8229 type: string 8357 type: string
8230 format: binary 8358 format: binary
8359 videoPasswords:
8360 $ref: '#/components/schemas/AddVideoPasswords'
8231 required: 8361 required:
8232 - channelId 8362 - channelId
8233 - name 8363 - name
@@ -9391,7 +9521,7 @@ components:
9391 type: string 9521 type: string
9392 enum: 9522 enum:
9393 - 'p2p-media-loader' 9523 - 'p2p-media-loader'
9394 - 'webtorrent' 9524 - 'web-video'
9395 resolution: 9525 resolution:
9396 type: number 9526 type: number
9397 description: Current player video resolution 9527 description: Current player video resolution
@@ -9616,6 +9746,29 @@ components:
9616 privatePayload: 9746 privatePayload:
9617 type: object 9747 type: object
9618 9748
9749 VideoPassword:
9750 properties:
9751 id:
9752 $ref: '#/components/schemas/id'
9753 password:
9754 type: string
9755 minLength: 2
9756 videoId:
9757 $ref: '#/components/schemas/id'
9758 VideoPasswordList:
9759 properties:
9760 total:
9761 type: integer
9762 example: 1
9763 data:
9764 type: array
9765 items:
9766 $ref: '#/components/schemas/VideoPassword'
9767 AddVideoPasswords:
9768 type: array
9769 items:
9770 $ref: "#/components/schemas/VideoPassword/properties/password"
9771 uniqueItems: true
9619 callbacks: 9772 callbacks:
9620 searchIndex: 9773 searchIndex:
9621 'https://search.example.org/api/v1/search/videos': 9774 'https://search.example.org/api/v1/search/videos':
diff --git a/support/doc/development/lib.md b/support/doc/development/lib.md
index 3cccaf3d0..25fe3068e 100644
--- a/support/doc/development/lib.md
+++ b/support/doc/development/lib.md
@@ -5,7 +5,7 @@
5### Build & Publish 5### Build & Publish
6 6
7``` 7```
8cd client/src/standalone/player/ 8cd client/src/standalone/embed-player-api/
9npm run build 9npm run build
10npm publish --access=public 10npm publish --access=public
11``` 11```
diff --git a/support/doc/development/release.md b/support/doc/development/release.md
index c4935524c..81e35c58d 100644
--- a/support/doc/development/release.md
+++ b/support/doc/development/release.md
@@ -28,9 +28,11 @@ NODE_APP_INSTANCE=6 NODE_ENV=test node dist/server --benchmark-startup
28 28
29## @peertube/embed-api 29## @peertube/embed-api
30 30
31At the root of PeerTube:
32
31``` 33```
32cd client/src/standalone/player 34cd client/src/standalone/embed-player-api
33npm version patch 35npm version patch
34npm run build 36cd ../../../../
35npm publish --access=public 37npm run release-embed-api
36``` 38```
diff --git a/support/doc/development/tests.md b/support/doc/development/tests.md
index e3a65c35f..1c2589c8a 100644
--- a/support/doc/development/tests.md
+++ b/support/doc/development/tests.md
@@ -71,6 +71,7 @@ Some env variables can be defined to disable/enable some tests:
71 * `ENABLE_OBJECT_STORAGE_TESTS=true`: enable object storage tests (needs `chocobozzz/s3-ninja` container first) 71 * `ENABLE_OBJECT_STORAGE_TESTS=true`: enable object storage tests (needs `chocobozzz/s3-ninja` container first)
72 * `AKISMET_KEY`: specify an Akismet key to test akismet external PeerTube plugin 72 * `AKISMET_KEY`: specify an Akismet key to test akismet external PeerTube plugin
73 * `OBJECT_STORAGE_SCALEWAY_KEY_ID` and `OBJECT_STORAGE_SCALEWAY_ACCESS_KEY`: specify Scaleway API keys to test object storage ACL (not supported by our `chocobozzz/s3-ninja` container) 73 * `OBJECT_STORAGE_SCALEWAY_KEY_ID` and `OBJECT_STORAGE_SCALEWAY_ACCESS_KEY`: specify Scaleway API keys to test object storage ACL (not supported by our `chocobozzz/s3-ninja` container)
74 * `ENABLE_FFMPEG_THUMBNAIL_PIXEL_COMPARISON_TESTS=true`: enable pixel comparison on images generated by ffmpeg. Disabled by default because a custom ffmpeg version may fails the tests
74 75
75 76
76### Debug server logs 77### Debug server logs
diff --git a/support/doc/tools.md b/support/doc/tools.md
index 39f5ab787..2b3ebf159 100644
--- a/support/doc/tools.md
+++ b/support/doc/tools.md
@@ -227,7 +227,7 @@ docker-compose exec -u peertube peertube npm run regenerate-thumbnails
227 227
228### create-import-video-file-job.js 228### create-import-video-file-job.js
229 229
230You can use this script to import a video file to replace an already uploaded file or to add a new webtorrent resolution to a video. PeerTube needs to be running. 230You can use this script to import a video file to replace an already uploaded file or to add a new web compatible resolution to a video. PeerTube needs to be running.
231You can then create a transcoding job using the web interface if you need to optimize your file or create an HLS version of it. 231You can then create a transcoding job using the web interface if you need to optimize your file or create an HLS version of it.
232 232
233```bash 233```bash
@@ -268,6 +268,35 @@ cd /var/www/peertube-docker
268docker-compose exec -u peertube peertube npm run create-move-video-storage-job -- --to-object-storage --all-videos 268docker-compose exec -u peertube peertube npm run create-move-video-storage-job -- --to-object-storage --all-videos
269``` 269```
270 270
271<!-- TODO: uncomment when PeerTube 6 is released
272### create-generate-storyboard-job
273
274**PeerTube >= 6.0**
275
276Use this script to generate storyboard of a specific video:
277
278```bash
279# Basic installation
280cd /var/www/peertube/peertube-latest
281sudo -u peertube NODE_CONFIG_DIR=/var/www/peertube/config NODE_ENV=production npm run create-generate-storyboard-job -- -v [videoUUID]
282
283# Docker installation
284cd /var/www/peertube-docker
285docker-compose exec -u peertube peertube npm run create-generate-storyboard-job -- -v [videoUUID]
286```
287
288The script can also generate all missing storyboards of local videos:
289
290```bash
291# Basic installation
292cd /var/www/peertube/peertube-latest
293sudo -u peertube NODE_CONFIG_DIR=/var/www/peertube/config NODE_ENV=production npm run create-generate-storyboard-job -- --all-videos
294
295# Docker installation
296cd /var/www/peertube-docker
297docker-compose exec -u peertube peertube npm run create-generate-storyboard-job -- --all-videos
298```
299-->
271 300
272### prune-storage.js 301### prune-storage.js
273 302
@@ -357,6 +386,15 @@ PeerTube >= 5.2 supports VOD or Live transcoding by a remote PeerTube runner.
357 386
358### Installation 387### Installation
359 388
389Ensure you have `ffmpeg` and `ffprobe` installed on your system:
390
391```bash
392ffprobe -version # Should be >= 4.3
393ffmpeg -version # Should be >= 4.3
394```
395
396Then install the CLI:
397
360```bash 398```bash
361sudo npm install -g @peertube/peertube-runner 399sudo npm install -g @peertube/peertube-runner
362``` 400```
@@ -385,7 +423,7 @@ peertube-runner server
385 423
386### Register 424### Register
387 425
388Then, you can register the runner on a new PeerTube instance so the runner can process its transcoding job: 426Then, you can register the runner to process transcoding job of a remote PeerTube instance:
389 427
390```bash 428```bash
391peertube-runner register --url http://peertube.example.com --registration-token ptrrt-... --runner-name my-runner-name 429peertube-runner register --url http://peertube.example.com --registration-token ptrrt-... --runner-name my-runner-name
diff --git a/support/docker/production/config/custom-environment-variables.yaml b/support/docker/production/config/custom-environment-variables.yaml
index 8fff54229..0058cbd64 100644
--- a/support/docker/production/config/custom-environment-variables.yaml
+++ b/support/docker/production/config/custom-environment-variables.yaml
@@ -1,7 +1,7 @@
1# 1#
2# This file will be read by node-config 2# This file will be read by node-config
3# See https://github.com/node-config/node-config/wiki/Environment-Variables#custom-environment-variables 3# See https://github.com/node-config/node-config/wiki/Environment-Variables#custom-environment-variables
4# 4#
5 5
6webserver: 6webserver:
7 hostname: "PEERTUBE_WEBSERVER_HOSTNAME" 7 hostname: "PEERTUBE_WEBSERVER_HOSTNAME"
@@ -86,10 +86,10 @@ object_storage:
86 base_url: "PEERTUBE_OBJECT_STORAGE_STREAMING_PLAYLISTS_BASE_URL" 86 base_url: "PEERTUBE_OBJECT_STORAGE_STREAMING_PLAYLISTS_BASE_URL"
87 upload_acl: "PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL" 87 upload_acl: "PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL"
88 88
89 videos: 89 web_videos:
90 bucket_name: "PEERTUBE_OBJECT_STORAGE_VIDEOS_BUCKET_NAME" 90 bucket_name: "PEERTUBE_OBJECT_STORAGE_WEB_VIDEOS_BUCKET_NAME"
91 prefix: "PEERTUBE_OBJECT_STORAGE_VIDEOS_PREFIX" 91 prefix: "PEERTUBE_OBJECT_STORAGE_WEB_VIDEOS_PREFIX"
92 base_url: "PEERTUBE_OBJECT_STORAGE_VIDEOS_BASE_URL" 92 base_url: "PEERTUBE_OBJECT_STORAGE_WEB_VIDEOS_BASE_URL"
93 93
94webadmin: 94webadmin:
95 configuration: 95 configuration:
@@ -177,9 +177,9 @@ transcoding:
177 2160p: 177 2160p:
178 __name: "PEERTUBE_TRANSCODING_2160P" 178 __name: "PEERTUBE_TRANSCODING_2160P"
179 __format: "json" 179 __format: "json"
180 webtorrent: 180 web_videos:
181 enabled: 181 enabled:
182 __name: "PEERTUBE_TRANSCODING_WEBTORRENT_ENABLED" 182 __name: "PEERTUBE_TRANSCODING_WEB_VIDEOS_ENABLED"
183 __format: "json" 183 __format: "json"
184 hls: 184 hls:
185 enabled: 185 enabled:
diff --git a/support/docker/production/config/production.yaml b/support/docker/production/config/production.yaml
index e3f6247d8..9c9581e40 100644
--- a/support/docker/production/config/production.yaml
+++ b/support/docker/production/config/production.yaml
@@ -47,7 +47,7 @@ storage:
47 tmp_persistent: '../data/tmp-persistent/' # As tmp but the directory is not cleaned up between PeerTube restarts 47 tmp_persistent: '../data/tmp-persistent/' # As tmp but the directory is not cleaned up between PeerTube restarts
48 bin: '../data/bin/' 48 bin: '../data/bin/'
49 avatars: '../data/avatars/' 49 avatars: '../data/avatars/'
50 videos: '../data/videos/' 50 web_videos: '../data/web-videos/'
51 streaming_playlists: '../data/streaming-playlists' 51 streaming_playlists: '../data/streaming-playlists'
52 redundancy: '../data/redundancy/' 52 redundancy: '../data/redundancy/'
53 logs: '../data/logs/' 53 logs: '../data/logs/'
diff --git a/support/nginx/peertube b/support/nginx/peertube
index 05a59c072..822f6f9ac 100644
--- a/support/nginx/peertube
+++ b/support/nginx/peertube
@@ -199,29 +199,7 @@ server {
199 alias /var/www/peertube/peertube-latest/client/dist/$1; 199 alias /var/www/peertube/peertube-latest/client/dist/$1;
200 } 200 }
201 201
202 # Bypass PeerTube for performance reasons. Optional. 202 location ~ ^(/static/(webseed|web-videos|streaming-playlists)/private/)|^/download {
203 location ~ ^/static/(thumbnails|avatars)/ {
204 if ($request_method = 'OPTIONS') {
205 add_header Access-Control-Allow-Origin '*';
206 add_header Access-Control-Allow-Methods 'GET, OPTIONS';
207 add_header Access-Control-Allow-Headers 'Range,DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';
208 add_header Access-Control-Max-Age 1728000; # Preflight request can be cached 20 days
209 add_header Content-Type 'text/plain charset=UTF-8';
210 add_header Content-Length 0;
211 return 204;
212 }
213
214 add_header Access-Control-Allow-Origin '*';
215 add_header Access-Control-Allow-Methods 'GET, OPTIONS';
216 add_header Access-Control-Allow-Headers 'Range,DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';
217 add_header Cache-Control "public, max-age=7200"; # Cache response 2 hours
218
219 rewrite ^/static/(.*)$ /$1 break;
220
221 try_files $uri @api;
222 }
223
224 location ~ ^(/static/(webseed|streaming-playlists)/private/)|^/download {
225 # We can't rate limit a try_files directive, so we need to duplicate @api 203 # We can't rate limit a try_files directive, so we need to duplicate @api
226 204
227 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 205 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -234,16 +212,10 @@ server {
234 } 212 }
235 213
236 # Bypass PeerTube for performance reasons. Optional. 214 # Bypass PeerTube for performance reasons. Optional.
237 location ~ ^/static/(webseed|redundancy|streaming-playlists)/ { 215 location ~ ^/static/(webseed|web-videos|redundancy|streaming-playlists)/ {
238 limit_rate_after 5M; 216 limit_rate_after 5M;
239 217
240 # Clients usually have 4 simultaneous webseed connections, so the real limit is 3MB/s per client 218 set $peertube_limit_rate 5M;
241 set $peertube_limit_rate 800k;
242
243 # Increase rate limit in HLS mode, because we don't have multiple simultaneous connections
244 if ($request_uri ~ -fragmented.mp4$) {
245 set $peertube_limit_rate 5M;
246 }
247 219
248 # Use this line with nginx >= 1.17.0 220 # Use this line with nginx >= 1.17.0
249 limit_rate $peertube_limit_rate; 221 limit_rate $peertube_limit_rate;
diff --git a/yarn.lock b/yarn.lock
index 62cb00592..2686c4d4a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -430,6 +430,16 @@
430 "@aws-sdk/util-hex-encoding" "3.310.0" 430 "@aws-sdk/util-hex-encoding" "3.310.0"
431 tslib "^2.5.0" 431 tslib "^2.5.0"
432 432
433"@aws-sdk/eventstream-codec@3.342.0":
434 version "3.342.0"
435 resolved "https://registry.yarnpkg.com/@aws-sdk/eventstream-codec/-/eventstream-codec-3.342.0.tgz#aef9ab3c5fdaa02c6da9836194eada9d35515fa1"
436 integrity sha512-IwtvSuplioMyiu/pQgpazKkGWDM5M5BOx85zmsB0uNxt6rmje8+WqPmGmuPdmJv4bLC5dJPLovcCp/fuH8XWhA==
437 dependencies:
438 "@aws-crypto/crc32" "3.0.0"
439 "@aws-sdk/types" "3.342.0"
440 "@aws-sdk/util-hex-encoding" "3.310.0"
441 tslib "^2.5.0"
442
433"@aws-sdk/eventstream-serde-browser@3.329.0": 443"@aws-sdk/eventstream-serde-browser@3.329.0":
434 version "3.329.0" 444 version "3.329.0"
435 resolved "https://registry.yarnpkg.com/@aws-sdk/eventstream-serde-browser/-/eventstream-serde-browser-3.329.0.tgz#3ba7866a691905e2af8a89c1f562f91fb3779ef9" 445 resolved "https://registry.yarnpkg.com/@aws-sdk/eventstream-serde-browser/-/eventstream-serde-browser-3.329.0.tgz#3ba7866a691905e2af8a89c1f562f91fb3779ef9"
@@ -571,6 +581,17 @@
571 "@aws-sdk/util-middleware" "3.329.0" 581 "@aws-sdk/util-middleware" "3.329.0"
572 tslib "^2.5.0" 582 tslib "^2.5.0"
573 583
584"@aws-sdk/middleware-endpoint@3.344.0":
585 version "3.344.0"
586 resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-endpoint/-/middleware-endpoint-3.344.0.tgz#3acd2815fcbd07b005fb8ffea09a0a109b5acb93"
587 integrity sha512-rg4ysfusGw5tm8XTqNpdWo0wP0K79hZs3z1xkkskeSsMrbYiDn78Bkkt4s3JELUJY64VanQktPaKo08dNFYNZw==
588 dependencies:
589 "@aws-sdk/middleware-serde" "3.342.0"
590 "@aws-sdk/types" "3.342.0"
591 "@aws-sdk/url-parser" "3.342.0"
592 "@aws-sdk/util-middleware" "3.342.0"
593 tslib "^2.5.0"
594
574"@aws-sdk/middleware-expect-continue@3.329.0": 595"@aws-sdk/middleware-expect-continue@3.329.0":
575 version "3.329.0" 596 version "3.329.0"
576 resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.329.0.tgz#2a69584020b9c93926b83735fbd9741de117a586" 597 resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.329.0.tgz#2a69584020b9c93926b83735fbd9741de117a586"
@@ -667,6 +688,14 @@
667 "@aws-sdk/types" "3.329.0" 688 "@aws-sdk/types" "3.329.0"
668 tslib "^2.5.0" 689 tslib "^2.5.0"
669 690
691"@aws-sdk/middleware-serde@3.342.0":
692 version "3.342.0"
693 resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-serde/-/middleware-serde-3.342.0.tgz#ed051e4e7dfc33e431aa27f260e065b9fbb5ee0f"
694 integrity sha512-WRD+Cyu6+h1ymfPnAw4fI2q3zXjihJ55HFe1uRF8VPN4uBbJNfN3IqL38y/SMEdZ0gH9zNlRNxZLhR0q6SNZEQ==
695 dependencies:
696 "@aws-sdk/types" "3.342.0"
697 tslib "^2.5.0"
698
670"@aws-sdk/middleware-signing@3.329.0": 699"@aws-sdk/middleware-signing@3.329.0":
671 version "3.329.0" 700 version "3.329.0"
672 resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-signing/-/middleware-signing-3.329.0.tgz#25011abb0911c1a23840d8d228676758f5b55926" 701 resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-signing/-/middleware-signing-3.329.0.tgz#25011abb0911c1a23840d8d228676758f5b55926"
@@ -694,6 +723,13 @@
694 dependencies: 723 dependencies:
695 tslib "^2.5.0" 724 tslib "^2.5.0"
696 725
726"@aws-sdk/middleware-stack@3.342.0":
727 version "3.342.0"
728 resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-stack/-/middleware-stack-3.342.0.tgz#e755815cb22a66f15a964db12e998211f736eda0"
729 integrity sha512-nDYtLAv9IZq8YFxtbyAiK/U1mtvtJS0DG6HiIPT5jpHcRpuWRHQ170EAW51zYts+21Ffj1VA6ZPkbup83+T6/w==
730 dependencies:
731 tslib "^2.5.0"
732
697"@aws-sdk/middleware-user-agent@3.332.0": 733"@aws-sdk/middleware-user-agent@3.332.0":
698 version "3.332.0" 734 version "3.332.0"
699 resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.332.0.tgz#6f2de9579b09dd7feeab27ef8a18c236694ad903" 735 resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.332.0.tgz#6f2de9579b09dd7feeab27ef8a18c236694ad903"
@@ -741,6 +777,14 @@
741 "@aws-sdk/types" "3.329.0" 777 "@aws-sdk/types" "3.329.0"
742 tslib "^2.5.0" 778 tslib "^2.5.0"
743 779
780"@aws-sdk/protocol-http@3.342.0":
781 version "3.342.0"
782 resolved "https://registry.yarnpkg.com/@aws-sdk/protocol-http/-/protocol-http-3.342.0.tgz#2f4852a1ff14491f8785ca094684e7fcd80db4e5"
783 integrity sha512-zuF2urcTJBZ1tltPdTBQzRasuGB7+4Yfs9i5l0F7lE0luK5Azy6G+2r3WWENUNxFTYuP94GrrqaOhVyj8XXLPQ==
784 dependencies:
785 "@aws-sdk/types" "3.342.0"
786 tslib "^2.5.0"
787
744"@aws-sdk/querystring-builder@3.329.0": 788"@aws-sdk/querystring-builder@3.329.0":
745 version "3.329.0" 789 version "3.329.0"
746 resolved "https://registry.yarnpkg.com/@aws-sdk/querystring-builder/-/querystring-builder-3.329.0.tgz#c6e6dd03dcd4378d1fbee576ce2a81dd94ac46a6" 790 resolved "https://registry.yarnpkg.com/@aws-sdk/querystring-builder/-/querystring-builder-3.329.0.tgz#c6e6dd03dcd4378d1fbee576ce2a81dd94ac46a6"
@@ -750,6 +794,15 @@
750 "@aws-sdk/util-uri-escape" "3.310.0" 794 "@aws-sdk/util-uri-escape" "3.310.0"
751 tslib "^2.5.0" 795 tslib "^2.5.0"
752 796
797"@aws-sdk/querystring-builder@3.342.0":
798 version "3.342.0"
799 resolved "https://registry.yarnpkg.com/@aws-sdk/querystring-builder/-/querystring-builder-3.342.0.tgz#1163c1b9ec901b1264911be504a42638113f1002"
800 integrity sha512-tb3FbtC36a7XBYeupdKm60LeM0etp73I6/7pDAkzAlw7zJdvY0aQIvj1c0U6nZlwZF8sSSxC7vlamR+wCspdMw==
801 dependencies:
802 "@aws-sdk/types" "3.342.0"
803 "@aws-sdk/util-uri-escape" "3.310.0"
804 tslib "^2.5.0"
805
753"@aws-sdk/querystring-parser@3.329.0": 806"@aws-sdk/querystring-parser@3.329.0":
754 version "3.329.0" 807 version "3.329.0"
755 resolved "https://registry.yarnpkg.com/@aws-sdk/querystring-parser/-/querystring-parser-3.329.0.tgz#dbbf2fd23ff0dfa2e4663fa414de1d5e60814896" 808 resolved "https://registry.yarnpkg.com/@aws-sdk/querystring-parser/-/querystring-parser-3.329.0.tgz#dbbf2fd23ff0dfa2e4663fa414de1d5e60814896"
@@ -758,6 +811,27 @@
758 "@aws-sdk/types" "3.329.0" 811 "@aws-sdk/types" "3.329.0"
759 tslib "^2.5.0" 812 tslib "^2.5.0"
760 813
814"@aws-sdk/querystring-parser@3.342.0":
815 version "3.342.0"
816 resolved "https://registry.yarnpkg.com/@aws-sdk/querystring-parser/-/querystring-parser-3.342.0.tgz#20b3e13cb727171045625c1fbb87e351f300bb20"
817 integrity sha512-6svvr/LZW1EPJaARnOpjf92FIiK25wuO7fRq05gLTcTRAfUMDvub+oDg3Ro9EjJERumrYQrYCem5Qi4X9w8K2g==
818 dependencies:
819 "@aws-sdk/types" "3.342.0"
820 tslib "^2.5.0"
821
822"@aws-sdk/s3-request-presigner@^3.345.0":
823 version "3.345.0"
824 resolved "https://registry.yarnpkg.com/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.345.0.tgz#3e1e82123b57eae816bc3132c23244b4272d327d"
825 integrity sha512-xtmYp0d5OzYoiXo2Vw4JtIyW40OvFU68keC4p4Ik9ttQVVQIQ9kgphxBGAYezgcXNBbxeZ/VJUZuP7SkbVlyWA==
826 dependencies:
827 "@aws-sdk/middleware-endpoint" "3.344.0"
828 "@aws-sdk/protocol-http" "3.342.0"
829 "@aws-sdk/signature-v4-multi-region" "3.344.0"
830 "@aws-sdk/smithy-client" "3.342.0"
831 "@aws-sdk/types" "3.342.0"
832 "@aws-sdk/util-format-url" "3.342.0"
833 tslib "^2.5.0"
834
761"@aws-sdk/service-error-classification@3.329.0": 835"@aws-sdk/service-error-classification@3.329.0":
762 version "3.329.0" 836 version "3.329.0"
763 resolved "https://registry.yarnpkg.com/@aws-sdk/service-error-classification/-/service-error-classification-3.329.0.tgz#32db59091ff28f14e526cee738bc14e32a6850f6" 837 resolved "https://registry.yarnpkg.com/@aws-sdk/service-error-classification/-/service-error-classification-3.329.0.tgz#32db59091ff28f14e526cee738bc14e32a6850f6"
@@ -781,6 +855,16 @@
781 "@aws-sdk/types" "3.329.0" 855 "@aws-sdk/types" "3.329.0"
782 tslib "^2.5.0" 856 tslib "^2.5.0"
783 857
858"@aws-sdk/signature-v4-multi-region@3.344.0":
859 version "3.344.0"
860 resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.344.0.tgz#38c2da1c75c13d93964ac4a3682b427eeb75253a"
861 integrity sha512-B5hN9b0Qa3UvpzsLjGIeCZ9AXE1qpwSXNXEeGcAdUIyf6lG3l+JMREKr+ZVaqAwAcZCOWmUyuuHIhkiK5YzClg==
862 dependencies:
863 "@aws-sdk/protocol-http" "3.342.0"
864 "@aws-sdk/signature-v4" "3.342.0"
865 "@aws-sdk/types" "3.342.0"
866 tslib "^2.5.0"
867
784"@aws-sdk/signature-v4@3.329.0": 868"@aws-sdk/signature-v4@3.329.0":
785 version "3.329.0" 869 version "3.329.0"
786 resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4/-/signature-v4-3.329.0.tgz#8d40683189678f49504169c923e8342247b1da70" 870 resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4/-/signature-v4-3.329.0.tgz#8d40683189678f49504169c923e8342247b1da70"
@@ -794,6 +878,20 @@
794 "@aws-sdk/util-utf8" "3.310.0" 878 "@aws-sdk/util-utf8" "3.310.0"
795 tslib "^2.5.0" 879 tslib "^2.5.0"
796 880
881"@aws-sdk/signature-v4@3.342.0":
882 version "3.342.0"
883 resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4/-/signature-v4-3.342.0.tgz#c2249594c53c76891986e3a54a077062a0b55b63"
884 integrity sha512-OWrGO2UOa1ENpy0kYd2shK4sklQygWUqvWLx9FotDbjIeUIEfAnqoPq/QqcXVrNyT/UvPi4iIrjHJEO8JCNRmA==
885 dependencies:
886 "@aws-sdk/eventstream-codec" "3.342.0"
887 "@aws-sdk/is-array-buffer" "3.310.0"
888 "@aws-sdk/types" "3.342.0"
889 "@aws-sdk/util-hex-encoding" "3.310.0"
890 "@aws-sdk/util-middleware" "3.342.0"
891 "@aws-sdk/util-uri-escape" "3.310.0"
892 "@aws-sdk/util-utf8" "3.310.0"
893 tslib "^2.5.0"
894
797"@aws-sdk/smithy-client@3.329.0": 895"@aws-sdk/smithy-client@3.329.0":
798 version "3.329.0" 896 version "3.329.0"
799 resolved "https://registry.yarnpkg.com/@aws-sdk/smithy-client/-/smithy-client-3.329.0.tgz#54705963939855c87ae6e6c88196d23e819d728e" 897 resolved "https://registry.yarnpkg.com/@aws-sdk/smithy-client/-/smithy-client-3.329.0.tgz#54705963939855c87ae6e6c88196d23e819d728e"
@@ -803,6 +901,15 @@
803 "@aws-sdk/types" "3.329.0" 901 "@aws-sdk/types" "3.329.0"
804 tslib "^2.5.0" 902 tslib "^2.5.0"
805 903
904"@aws-sdk/smithy-client@3.342.0":
905 version "3.342.0"
906 resolved "https://registry.yarnpkg.com/@aws-sdk/smithy-client/-/smithy-client-3.342.0.tgz#976ec7ca4e029145707c33d6300d60efcee53214"
907 integrity sha512-HQ4JejjHU2X7OAZPwixFG+EyPSjmoZqll7EvWjPSKyclWrM320haWWz1trVzjG/AgPfeDLfRkH/JoMr13lECew==
908 dependencies:
909 "@aws-sdk/middleware-stack" "3.342.0"
910 "@aws-sdk/types" "3.342.0"
911 tslib "^2.5.0"
912
806"@aws-sdk/token-providers@3.335.0": 913"@aws-sdk/token-providers@3.335.0":
807 version "3.335.0" 914 version "3.335.0"
808 resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.335.0.tgz#fcd7bdf62a17343c3bd6f57f58511e6eda7b81f9" 915 resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.335.0.tgz#fcd7bdf62a17343c3bd6f57f58511e6eda7b81f9"
@@ -821,6 +928,13 @@
821 dependencies: 928 dependencies:
822 tslib "^2.5.0" 929 tslib "^2.5.0"
823 930
931"@aws-sdk/types@3.342.0":
932 version "3.342.0"
933 resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.342.0.tgz#0bcba3b5966f28e0725122697a19ece8647afbec"
934 integrity sha512-5uyXVda/AgUpdZNJ9JPHxwyxr08miPiZ/CKSMcRdQVjcNnrdzY9m/iM9LvnQT44sQO+IEEkF2IoZIWvZcq199A==
935 dependencies:
936 tslib "^2.5.0"
937
824"@aws-sdk/url-parser@3.329.0": 938"@aws-sdk/url-parser@3.329.0":
825 version "3.329.0" 939 version "3.329.0"
826 resolved "https://registry.yarnpkg.com/@aws-sdk/url-parser/-/url-parser-3.329.0.tgz#a2862834a832ec1d379791f5233e378b75fc63ad" 940 resolved "https://registry.yarnpkg.com/@aws-sdk/url-parser/-/url-parser-3.329.0.tgz#a2862834a832ec1d379791f5233e378b75fc63ad"
@@ -830,6 +944,15 @@
830 "@aws-sdk/types" "3.329.0" 944 "@aws-sdk/types" "3.329.0"
831 tslib "^2.5.0" 945 tslib "^2.5.0"
832 946
947"@aws-sdk/url-parser@3.342.0":
948 version "3.342.0"
949 resolved "https://registry.yarnpkg.com/@aws-sdk/url-parser/-/url-parser-3.342.0.tgz#c0be80c1d88b0ff8a8224de0ff7de64ccd5ef186"
950 integrity sha512-r4s/FDK6iywl8l4TqEwIwtNvxWO0kZes03c/yCiRYqxlkjVmbXEOodn5IAAweAeS9yqC3sl/wKbsaoBiGFn45g==
951 dependencies:
952 "@aws-sdk/querystring-parser" "3.342.0"
953 "@aws-sdk/types" "3.342.0"
954 tslib "^2.5.0"
955
833"@aws-sdk/util-arn-parser@3.310.0": 956"@aws-sdk/util-arn-parser@3.310.0":
834 version "3.310.0" 957 version "3.310.0"
835 resolved "https://registry.yarnpkg.com/@aws-sdk/util-arn-parser/-/util-arn-parser-3.310.0.tgz#861ff8810851be52a320ec9e4786f15b5fc74fba" 958 resolved "https://registry.yarnpkg.com/@aws-sdk/util-arn-parser/-/util-arn-parser-3.310.0.tgz#861ff8810851be52a320ec9e4786f15b5fc74fba"
@@ -904,6 +1027,15 @@
904 "@aws-sdk/types" "3.329.0" 1027 "@aws-sdk/types" "3.329.0"
905 tslib "^2.5.0" 1028 tslib "^2.5.0"
906 1029
1030"@aws-sdk/util-format-url@3.342.0":
1031 version "3.342.0"
1032 resolved "https://registry.yarnpkg.com/@aws-sdk/util-format-url/-/util-format-url-3.342.0.tgz#c2f0e0fd831b2fadb1341bce7fdaad3da3e61cf4"
1033 integrity sha512-GXFxd7unAT3FkJmfTLABcbzDLMiLAtaWYcUlfV/6oHGxc+Pgv/IRq+0kWeBOlivqwRKxr8rAaCS0U8NcnSASDA==
1034 dependencies:
1035 "@aws-sdk/querystring-builder" "3.342.0"
1036 "@aws-sdk/types" "3.342.0"
1037 tslib "^2.5.0"
1038
907"@aws-sdk/util-hex-encoding@3.310.0": 1039"@aws-sdk/util-hex-encoding@3.310.0":
908 version "3.310.0" 1040 version "3.310.0"
909 resolved "https://registry.yarnpkg.com/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.310.0.tgz#19294c78986c90ae33f04491487863dc1d33bd87" 1041 resolved "https://registry.yarnpkg.com/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.310.0.tgz#19294c78986c90ae33f04491487863dc1d33bd87"
@@ -925,6 +1057,13 @@
925 dependencies: 1057 dependencies:
926 tslib "^2.5.0" 1058 tslib "^2.5.0"
927 1059
1060"@aws-sdk/util-middleware@3.342.0":
1061 version "3.342.0"
1062 resolved "https://registry.yarnpkg.com/@aws-sdk/util-middleware/-/util-middleware-3.342.0.tgz#db8f50136bcba3d480d5c8e5340aecaa1e1c3a6c"
1063 integrity sha512-P2LYyMP4JUFZBy9DcMvCDxWU34mlShCyrqBZ1ouuGW7UMgRb1PTEvpLAVndIWn9H+1KGDFjMqOWp1FZHr4YZOA==
1064 dependencies:
1065 tslib "^2.5.0"
1066
928"@aws-sdk/util-retry@3.329.0": 1067"@aws-sdk/util-retry@3.329.0":
929 version "3.329.0" 1068 version "3.329.0"
930 resolved "https://registry.yarnpkg.com/@aws-sdk/util-retry/-/util-retry-3.329.0.tgz#20b71504dd907e70a457cd56dcd131d08d6de39c" 1069 resolved "https://registry.yarnpkg.com/@aws-sdk/util-retry/-/util-retry-3.329.0.tgz#20b71504dd907e70a457cd56dcd131d08d6de39c"