aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/lib
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2023-07-31 14:34:36 +0200
committerChocobozzz <me@florianbigard.com>2023-08-11 15:02:33 +0200
commit3a4992633ee62d5edfbb484d9c6bcb3cf158489d (patch)
treee4510b39bdac9c318fdb4b47018d08f15368b8f0 /server/lib
parent04d1da5621d25d59bd5fa1543b725c497bf5d9a8 (diff)
downloadPeerTube-3a4992633ee62d5edfbb484d9c6bcb3cf158489d.tar.gz
PeerTube-3a4992633ee62d5edfbb484d9c6bcb3cf158489d.tar.zst
PeerTube-3a4992633ee62d5edfbb484d9c6bcb3cf158489d.zip
Migrate server to ESM
Sorry for the very big commit that may lead to git log issues and merge conflicts, but it's a major step forward: * Server can be faster at startup because imports() are async and we can easily lazy import big modules * Angular doesn't seem to support ES import (with .js extension), so we had to correctly organize peertube into a monorepo: * Use yarn workspace feature * Use typescript reference projects for dependencies * Shared projects have been moved into "packages", each one is now a node module (with a dedicated package.json/tsconfig.json) * server/tools have been moved into apps/ and is now a dedicated app bundled and published on NPM so users don't have to build peertube cli tools manually * server/tests have been moved into packages/ so we don't compile them every time we want to run the server * Use isolatedModule option: * Had to move from const enum to const (https://www.typescriptlang.org/docs/handbook/enums.html#objects-vs-enums) * Had to explictely specify "type" imports when used in decorators * Prefer tsx (that uses esbuild under the hood) instead of ts-node to load typescript files (tests with mocha or scripts): * To reduce test complexity as esbuild doesn't support decorator metadata, we only test server files that do not import server models * We still build tests files into js files for a faster CI * Remove unmaintained peertube CLI import script * Removed some barrels to speed up execution (less imports)
Diffstat (limited to 'server/lib')
-rw-r--r--server/lib/activitypub/activity.ts74
-rw-r--r--server/lib/activitypub/actors/get.ts143
-rw-r--r--server/lib/activitypub/actors/image.ts112
-rw-r--r--server/lib/activitypub/actors/index.ts6
-rw-r--r--server/lib/activitypub/actors/keys.ts16
-rw-r--r--server/lib/activitypub/actors/refresh.ts81
-rw-r--r--server/lib/activitypub/actors/shared/creator.ts149
-rw-r--r--server/lib/activitypub/actors/shared/index.ts3
-rw-r--r--server/lib/activitypub/actors/shared/object-to-model-attributes.ts84
-rw-r--r--server/lib/activitypub/actors/shared/url-to-object.ts56
-rw-r--r--server/lib/activitypub/actors/updater.ts91
-rw-r--r--server/lib/activitypub/actors/webfinger.ts67
-rw-r--r--server/lib/activitypub/audience.ts34
-rw-r--r--server/lib/activitypub/cache-file.ts82
-rw-r--r--server/lib/activitypub/collection.ts63
-rw-r--r--server/lib/activitypub/context.ts212
-rw-r--r--server/lib/activitypub/crawl.ts58
-rw-r--r--server/lib/activitypub/follow.ts51
-rw-r--r--server/lib/activitypub/inbox-manager.ts47
-rw-r--r--server/lib/activitypub/local-video-viewer.ts44
-rw-r--r--server/lib/activitypub/outbox.ts24
-rw-r--r--server/lib/activitypub/playlists/create-update.ts157
-rw-r--r--server/lib/activitypub/playlists/get.ts35
-rw-r--r--server/lib/activitypub/playlists/index.ts3
-rw-r--r--server/lib/activitypub/playlists/refresh.ts53
-rw-r--r--server/lib/activitypub/playlists/shared/index.ts2
-rw-r--r--server/lib/activitypub/playlists/shared/object-to-model-attributes.ts40
-rw-r--r--server/lib/activitypub/playlists/shared/url-to-object.ts47
-rw-r--r--server/lib/activitypub/process/index.ts1
-rw-r--r--server/lib/activitypub/process/process-accept.ts32
-rw-r--r--server/lib/activitypub/process/process-announce.ts75
-rw-r--r--server/lib/activitypub/process/process-create.ts170
-rw-r--r--server/lib/activitypub/process/process-delete.ts153
-rw-r--r--server/lib/activitypub/process/process-dislike.ts58
-rw-r--r--server/lib/activitypub/process/process-flag.ts103
-rw-r--r--server/lib/activitypub/process/process-follow.ts156
-rw-r--r--server/lib/activitypub/process/process-like.ts60
-rw-r--r--server/lib/activitypub/process/process-reject.ts33
-rw-r--r--server/lib/activitypub/process/process-undo.ts183
-rw-r--r--server/lib/activitypub/process/process-update.ts119
-rw-r--r--server/lib/activitypub/process/process-view.ts42
-rw-r--r--server/lib/activitypub/process/process.ts92
-rw-r--r--server/lib/activitypub/send/http.ts73
-rw-r--r--server/lib/activitypub/send/index.ts10
-rw-r--r--server/lib/activitypub/send/send-accept.ts47
-rw-r--r--server/lib/activitypub/send/send-announce.ts58
-rw-r--r--server/lib/activitypub/send/send-create.ts226
-rw-r--r--server/lib/activitypub/send/send-delete.ts158
-rw-r--r--server/lib/activitypub/send/send-dislike.ts40
-rw-r--r--server/lib/activitypub/send/send-flag.ts42
-rw-r--r--server/lib/activitypub/send/send-follow.ts37
-rw-r--r--server/lib/activitypub/send/send-like.ts40
-rw-r--r--server/lib/activitypub/send/send-reject.ts39
-rw-r--r--server/lib/activitypub/send/send-undo.ts172
-rw-r--r--server/lib/activitypub/send/send-update.ts157
-rw-r--r--server/lib/activitypub/send/send-view.ts62
-rw-r--r--server/lib/activitypub/send/shared/audience-utils.ts74
-rw-r--r--server/lib/activitypub/send/shared/index.ts2
-rw-r--r--server/lib/activitypub/send/shared/send-utils.ts291
-rw-r--r--server/lib/activitypub/share.ts120
-rw-r--r--server/lib/activitypub/url.ts177
-rw-r--r--server/lib/activitypub/video-comments.ts205
-rw-r--r--server/lib/activitypub/video-rates.ts59
-rw-r--r--server/lib/activitypub/videos/federate.ts29
-rw-r--r--server/lib/activitypub/videos/get.ts116
-rw-r--r--server/lib/activitypub/videos/index.ts4
-rw-r--r--server/lib/activitypub/videos/refresh.ts68
-rw-r--r--server/lib/activitypub/videos/shared/abstract-builder.ts190
-rw-r--r--server/lib/activitypub/videos/shared/creator.ts65
-rw-r--r--server/lib/activitypub/videos/shared/index.ts6
-rw-r--r--server/lib/activitypub/videos/shared/object-to-model-attributes.ts285
-rw-r--r--server/lib/activitypub/videos/shared/trackers.ts43
-rw-r--r--server/lib/activitypub/videos/shared/url-to-object.ts25
-rw-r--r--server/lib/activitypub/videos/shared/video-sync-attributes.ts107
-rw-r--r--server/lib/activitypub/videos/updater.ts180
-rw-r--r--server/lib/actor-follow-health-cache.ts86
-rw-r--r--server/lib/actor-image.ts14
-rw-r--r--server/lib/auth/external-auth.ts231
-rw-r--r--server/lib/auth/oauth-model.ts294
-rw-r--r--server/lib/auth/oauth.ts223
-rw-r--r--server/lib/auth/tokens-cache.ts52
-rw-r--r--server/lib/blocklist.ts62
-rw-r--r--server/lib/client-html.ts623
-rw-r--r--server/lib/emailer.ts284
-rw-r--r--server/lib/emails/abuse-new-message/html.pug11
-rw-r--r--server/lib/emails/abuse-state-change/html.pug9
-rw-r--r--server/lib/emails/account-abuse-new/html.pug14
-rw-r--r--server/lib/emails/common/base.pug258
-rw-r--r--server/lib/emails/common/greetings.pug11
-rw-r--r--server/lib/emails/common/html.pug4
-rw-r--r--server/lib/emails/common/mixins.pug7
-rw-r--r--server/lib/emails/contact-form/html.pug9
-rw-r--r--server/lib/emails/follower-on-channel/html.pug9
-rw-r--r--server/lib/emails/password-create/html.pug10
-rw-r--r--server/lib/emails/password-reset/html.pug12
-rw-r--r--server/lib/emails/peertube-version-new/html.pug9
-rw-r--r--server/lib/emails/plugin-version-new/html.pug9
-rw-r--r--server/lib/emails/user-registered/html.pug10
-rw-r--r--server/lib/emails/user-registration-request-accepted/html.pug10
-rw-r--r--server/lib/emails/user-registration-request-rejected/html.pug9
-rw-r--r--server/lib/emails/user-registration-request/html.pug9
-rw-r--r--server/lib/emails/verify-email/html.pug19
-rw-r--r--server/lib/emails/video-abuse-new/html.pug18
-rw-r--r--server/lib/emails/video-auto-blacklist-new/html.pug17
-rw-r--r--server/lib/emails/video-comment-abuse-new/html.pug16
-rw-r--r--server/lib/emails/video-comment-mention/html.pug11
-rw-r--r--server/lib/emails/video-comment-new/html.pug11
-rw-r--r--server/lib/files-cache/avatar-permanent-file-cache.ts27
-rw-r--r--server/lib/files-cache/index.ts6
-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.ts30
-rw-r--r--server/lib/files-cache/shared/index.ts2
-rw-r--r--server/lib/files-cache/video-captions-simple-file-cache.ts61
-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.ts58
-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.ts70
-rw-r--r--server/lib/hls.ts285
-rw-r--r--server/lib/internal-event-emitter.ts35
-rw-r--r--server/lib/job-queue/handlers/activitypub-cleaner.ts202
-rw-r--r--server/lib/job-queue/handlers/activitypub-follow.ts82
-rw-r--r--server/lib/job-queue/handlers/activitypub-http-broadcast.ts49
-rw-r--r--server/lib/job-queue/handlers/activitypub-http-fetcher.ts41
-rw-r--r--server/lib/job-queue/handlers/activitypub-http-unicast.ts38
-rw-r--r--server/lib/job-queue/handlers/activitypub-refresher.ts60
-rw-r--r--server/lib/job-queue/handlers/actor-keys.ts20
-rw-r--r--server/lib/job-queue/handlers/after-video-channel-import.ts37
-rw-r--r--server/lib/job-queue/handlers/email.ts17
-rw-r--r--server/lib/job-queue/handlers/federate-video.ts28
-rw-r--r--server/lib/job-queue/handlers/generate-storyboard.ts163
-rw-r--r--server/lib/job-queue/handlers/manage-video-torrent.ts110
-rw-r--r--server/lib/job-queue/handlers/move-to-object-storage.ts159
-rw-r--r--server/lib/job-queue/handlers/notify.ts27
-rw-r--r--server/lib/job-queue/handlers/transcoding-job-builder.ts48
-rw-r--r--server/lib/job-queue/handlers/video-channel-import.ts43
-rw-r--r--server/lib/job-queue/handlers/video-file-import.ts83
-rw-r--r--server/lib/job-queue/handlers/video-import.ts344
-rw-r--r--server/lib/job-queue/handlers/video-live-ending.ts279
-rw-r--r--server/lib/job-queue/handlers/video-redundancy.ts17
-rw-r--r--server/lib/job-queue/handlers/video-studio-edition.ts180
-rw-r--r--server/lib/job-queue/handlers/video-transcoding.ts150
-rw-r--r--server/lib/job-queue/handlers/video-views-stats.ts57
-rw-r--r--server/lib/job-queue/index.ts1
-rw-r--r--server/lib/job-queue/job-queue.ts537
-rw-r--r--server/lib/live/index.ts4
-rw-r--r--server/lib/live/live-manager.ts552
-rw-r--r--server/lib/live/live-quota-store.ts48
-rw-r--r--server/lib/live/live-segment-sha-store.ts95
-rw-r--r--server/lib/live/live-utils.ts99
-rw-r--r--server/lib/live/shared/index.ts1
-rw-r--r--server/lib/live/shared/muxing-session.ts518
-rw-r--r--server/lib/live/shared/transcoding-wrapper/abstract-transcoding-wrapper.ts110
-rw-r--r--server/lib/live/shared/transcoding-wrapper/ffmpeg-transcoding-wrapper.ts107
-rw-r--r--server/lib/live/shared/transcoding-wrapper/index.ts3
-rw-r--r--server/lib/live/shared/transcoding-wrapper/remote-transcoding-wrapper.ts21
-rw-r--r--server/lib/local-actor.ts102
-rw-r--r--server/lib/model-loaders/actor.ts17
-rw-r--r--server/lib/model-loaders/index.ts2
-rw-r--r--server/lib/model-loaders/video.ts66
-rw-r--r--server/lib/moderation.ts258
-rw-r--r--server/lib/notifier/index.ts1
-rw-r--r--server/lib/notifier/notifier.ts284
-rw-r--r--server/lib/notifier/shared/abuse/abstract-new-abuse-message.ts67
-rw-r--r--server/lib/notifier/shared/abuse/abuse-state-change-for-reporter.ts74
-rw-r--r--server/lib/notifier/shared/abuse/index.ts4
-rw-r--r--server/lib/notifier/shared/abuse/new-abuse-for-moderators.ts119
-rw-r--r--server/lib/notifier/shared/abuse/new-abuse-message-for-moderators.ts32
-rw-r--r--server/lib/notifier/shared/abuse/new-abuse-message-for-reporter.ts36
-rw-r--r--server/lib/notifier/shared/blacklist/index.ts3
-rw-r--r--server/lib/notifier/shared/blacklist/new-auto-blacklist-for-moderators.ts60
-rw-r--r--server/lib/notifier/shared/blacklist/new-blacklist-for-owner.ts58
-rw-r--r--server/lib/notifier/shared/blacklist/unblacklist-for-owner.ts55
-rw-r--r--server/lib/notifier/shared/comment/comment-mention.ts111
-rw-r--r--server/lib/notifier/shared/comment/index.ts2
-rw-r--r--server/lib/notifier/shared/comment/new-comment-for-video-owner.ts76
-rw-r--r--server/lib/notifier/shared/common/abstract-notification.ts23
-rw-r--r--server/lib/notifier/shared/common/index.ts1
-rw-r--r--server/lib/notifier/shared/follow/auto-follow-for-instance.ts51
-rw-r--r--server/lib/notifier/shared/follow/follow-for-instance.ts68
-rw-r--r--server/lib/notifier/shared/follow/follow-for-user.ts82
-rw-r--r--server/lib/notifier/shared/follow/index.ts3
-rw-r--r--server/lib/notifier/shared/index.ts7
-rw-r--r--server/lib/notifier/shared/instance/direct-registration-for-moderators.ts49
-rw-r--r--server/lib/notifier/shared/instance/index.ts4
-rw-r--r--server/lib/notifier/shared/instance/new-peertube-version-for-admins.ts54
-rw-r--r--server/lib/notifier/shared/instance/new-plugin-version-for-admins.ts58
-rw-r--r--server/lib/notifier/shared/instance/registration-request-for-moderators.ts48
-rw-r--r--server/lib/notifier/shared/video-publication/abstract-owned-video-publication.ts57
-rw-r--r--server/lib/notifier/shared/video-publication/import-finished-for-owner.ts97
-rw-r--r--server/lib/notifier/shared/video-publication/index.ts6
-rw-r--r--server/lib/notifier/shared/video-publication/new-video-for-subscribers.ts61
-rw-r--r--server/lib/notifier/shared/video-publication/owned-publication-after-auto-unblacklist.ts11
-rw-r--r--server/lib/notifier/shared/video-publication/owned-publication-after-schedule-update.ts10
-rw-r--r--server/lib/notifier/shared/video-publication/owned-publication-after-transcoding.ts9
-rw-r--r--server/lib/notifier/shared/video-publication/studio-edition-finished-for-owner.ts57
-rw-r--r--server/lib/object-storage/index.ts5
-rw-r--r--server/lib/object-storage/keys.ts20
-rw-r--r--server/lib/object-storage/pre-signed-urls.ts46
-rw-r--r--server/lib/object-storage/proxy.ts97
-rw-r--r--server/lib/object-storage/shared/client.ts71
-rw-r--r--server/lib/object-storage/shared/index.ts3
-rw-r--r--server/lib/object-storage/shared/logger.ts7
-rw-r--r--server/lib/object-storage/shared/object-storage-helpers.ts328
-rw-r--r--server/lib/object-storage/urls.ts63
-rw-r--r--server/lib/object-storage/videos.ts197
-rw-r--r--server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts51
-rw-r--r--server/lib/opentelemetry/metric-helpers/index.ts7
-rw-r--r--server/lib/opentelemetry/metric-helpers/job-queue-observers-builder.ts24
-rw-r--r--server/lib/opentelemetry/metric-helpers/lives-observers-builder.ts21
-rw-r--r--server/lib/opentelemetry/metric-helpers/nodejs-observers-builder.ts202
-rw-r--r--server/lib/opentelemetry/metric-helpers/playback-metrics.ts85
-rw-r--r--server/lib/opentelemetry/metric-helpers/stats-observers-builder.ts186
-rw-r--r--server/lib/opentelemetry/metric-helpers/viewers-observers-builder.ts24
-rw-r--r--server/lib/opentelemetry/metrics.ts123
-rw-r--r--server/lib/opentelemetry/tracing.ts94
-rw-r--r--server/lib/paths.ts92
-rw-r--r--server/lib/peertube-socket.ts129
-rw-r--r--server/lib/plugins/hooks.ts35
-rw-r--r--server/lib/plugins/plugin-helpers-builder.ts262
-rw-r--r--server/lib/plugins/plugin-index.ts85
-rw-r--r--server/lib/plugins/plugin-manager.ts665
-rw-r--r--server/lib/plugins/register-helpers.ts340
-rw-r--r--server/lib/plugins/theme-utils.ts24
-rw-r--r--server/lib/plugins/video-constant-manager-factory.ts139
-rw-r--r--server/lib/plugins/yarn.ts73
-rw-r--r--server/lib/redis.ts465
-rw-r--r--server/lib/redundancy.ts59
-rw-r--r--server/lib/runners/index.ts3
-rw-r--r--server/lib/runners/job-handlers/abstract-job-handler.ts269
-rw-r--r--server/lib/runners/job-handlers/abstract-vod-transcoding-job-handler.ts66
-rw-r--r--server/lib/runners/job-handlers/index.ts7
-rw-r--r--server/lib/runners/job-handlers/live-rtmp-hls-transcoding-job-handler.ts173
-rw-r--r--server/lib/runners/job-handlers/runner-job-handlers.ts20
-rw-r--r--server/lib/runners/job-handlers/shared/index.ts1
-rw-r--r--server/lib/runners/job-handlers/shared/vod-helpers.ts44
-rw-r--r--server/lib/runners/job-handlers/video-studio-transcoding-job-handler.ts157
-rw-r--r--server/lib/runners/job-handlers/vod-audio-merge-transcoding-job-handler.ts97
-rw-r--r--server/lib/runners/job-handlers/vod-hls-transcoding-job-handler.ts114
-rw-r--r--server/lib/runners/job-handlers/vod-web-video-transcoding-job-handler.ts84
-rw-r--r--server/lib/runners/runner-urls.ts13
-rw-r--r--server/lib/runners/runner.ts49
-rw-r--r--server/lib/schedulers/abstract-scheduler.ts35
-rw-r--r--server/lib/schedulers/actor-follow-scheduler.ts54
-rw-r--r--server/lib/schedulers/auto-follow-index-instances.ts75
-rw-r--r--server/lib/schedulers/geo-ip-update-scheduler.ts22
-rw-r--r--server/lib/schedulers/peertube-version-check-scheduler.ts55
-rw-r--r--server/lib/schedulers/plugins-check-scheduler.ts74
-rw-r--r--server/lib/schedulers/remove-dangling-resumable-uploads-scheduler.ts40
-rw-r--r--server/lib/schedulers/remove-old-history-scheduler.ts31
-rw-r--r--server/lib/schedulers/remove-old-views-scheduler.ts31
-rw-r--r--server/lib/schedulers/runner-job-watch-dog-scheduler.ts42
-rw-r--r--server/lib/schedulers/update-videos-scheduler.ts89
-rw-r--r--server/lib/schedulers/video-channel-sync-latest-scheduler.ts50
-rw-r--r--server/lib/schedulers/video-views-buffer-scheduler.ts52
-rw-r--r--server/lib/schedulers/videos-redundancy-scheduler.ts375
-rw-r--r--server/lib/schedulers/youtube-dl-update-scheduler.ts22
-rw-r--r--server/lib/search.ts49
-rw-r--r--server/lib/server-config-manager.ts384
-rw-r--r--server/lib/signup.ts75
-rw-r--r--server/lib/stat-manager.ts182
-rw-r--r--server/lib/sync-channel.ts111
-rw-r--r--server/lib/thumbnail.ts327
-rw-r--r--server/lib/timeserie.ts61
-rw-r--r--server/lib/transcoding/create-transcoding-job.ts37
-rw-r--r--server/lib/transcoding/default-transcoding-profiles.ts143
-rw-r--r--server/lib/transcoding/ended-transcoding.ts18
-rw-r--r--server/lib/transcoding/hls-transcoding.ts180
-rw-r--r--server/lib/transcoding/shared/ffmpeg-builder.ts18
-rw-r--r--server/lib/transcoding/shared/index.ts2
-rw-r--r--server/lib/transcoding/shared/job-builders/abstract-job-builder.ts21
-rw-r--r--server/lib/transcoding/shared/job-builders/index.ts2
-rw-r--r--server/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts322
-rw-r--r--server/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts196
-rw-r--r--server/lib/transcoding/transcoding-priority.ts24
-rw-r--r--server/lib/transcoding/transcoding-quick-transcode.ts12
-rw-r--r--server/lib/transcoding/transcoding-resolutions.ts73
-rw-r--r--server/lib/transcoding/web-transcoding.ts263
-rw-r--r--server/lib/uploadx.ts37
-rw-r--r--server/lib/user.ts301
-rw-r--r--server/lib/video-blacklist.ts145
-rw-r--r--server/lib/video-channel.ts50
-rw-r--r--server/lib/video-comment.ts116
-rw-r--r--server/lib/video-file.ts145
-rw-r--r--server/lib/video-path-manager.ts174
-rw-r--r--server/lib/video-playlist.ts30
-rw-r--r--server/lib/video-pre-import.ts323
-rw-r--r--server/lib/video-privacy.ts133
-rw-r--r--server/lib/video-state.ts154
-rw-r--r--server/lib/video-studio.ts130
-rw-r--r--server/lib/video-tokens-manager.ts78
-rw-r--r--server/lib/video-urls.ts31
-rw-r--r--server/lib/video.ts189
-rw-r--r--server/lib/views/shared/index.ts3
-rw-r--r--server/lib/views/shared/video-viewer-counters.ts198
-rw-r--r--server/lib/views/shared/video-viewer-stats.ts196
-rw-r--r--server/lib/views/shared/video-views.ts70
-rw-r--r--server/lib/views/video-views-manager.ts100
-rw-r--r--server/lib/worker/parent-process.ts77
-rw-r--r--server/lib/worker/workers/http-broadcast.ts32
-rw-r--r--server/lib/worker/workers/image-downloader.ts35
-rw-r--r--server/lib/worker/workers/image-processor.ts7
301 files changed, 0 insertions, 27409 deletions
diff --git a/server/lib/activitypub/activity.ts b/server/lib/activitypub/activity.ts
deleted file mode 100644
index 391bcd9c6..000000000
--- a/server/lib/activitypub/activity.ts
+++ /dev/null
@@ -1,74 +0,0 @@
1import { doJSONRequest, PeerTubeRequestOptions } from '@server/helpers/requests'
2import { CONFIG } from '@server/initializers/config'
3import { ActivityObject, ActivityPubActor, ActivityType, APObjectId } from '@shared/models'
4import { buildSignedRequestOptions } from './send'
5
6export function getAPId (object: string | { id: string }) {
7 if (typeof object === 'string') return object
8
9 return object.id
10}
11
12export function getActivityStreamDuration (duration: number) {
13 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
14 return 'PT' + duration + 'S'
15}
16
17export function getDurationFromActivityStream (duration: string) {
18 return parseInt(duration.replace(/[^\d]+/, ''))
19}
20
21// ---------------------------------------------------------------------------
22
23export function buildAvailableActivities (): ActivityType[] {
24 return [
25 'Create',
26 'Update',
27 'Delete',
28 'Follow',
29 'Accept',
30 'Announce',
31 'Undo',
32 'Like',
33 'Reject',
34 'View',
35 'Dislike',
36 'Flag'
37 ]
38}
39
40// ---------------------------------------------------------------------------
41
42export async function fetchAP <T> (url: string, moreOptions: PeerTubeRequestOptions = {}) {
43 const options = {
44 activityPub: true,
45
46 httpSignature: CONFIG.FEDERATION.SIGN_FEDERATED_FETCHES
47 ? await buildSignedRequestOptions({ hasPayload: false })
48 : undefined,
49
50 ...moreOptions
51 }
52
53 return doJSONRequest<T>(url, options)
54}
55
56export async function fetchAPObjectIfNeeded <T extends (ActivityObject | ActivityPubActor)> (object: APObjectId) {
57 if (typeof object === 'string') {
58 const { body } = await fetchAP<Exclude<T, string>>(object)
59
60 return body
61 }
62
63 return object as Exclude<T, string>
64}
65
66export async function findLatestAPRedirection (url: string, iteration = 1) {
67 if (iteration > 10) throw new Error('Too much iterations to find final URL ' + url)
68
69 const { headers } = await fetchAP(url, { followRedirect: false })
70
71 if (headers.location) return findLatestAPRedirection(headers.location, iteration + 1)
72
73 return url
74}
diff --git a/server/lib/activitypub/actors/get.ts b/server/lib/activitypub/actors/get.ts
deleted file mode 100644
index dd2bc9f03..000000000
--- a/server/lib/activitypub/actors/get.ts
+++ /dev/null
@@ -1,143 +0,0 @@
1import { retryTransactionWrapper } from '@server/helpers/database-utils'
2import { logger } from '@server/helpers/logger'
3import { JobQueue } from '@server/lib/job-queue'
4import { ActorLoadByUrlType, loadActorByUrl } from '@server/lib/model-loaders'
5import { MActor, MActorAccountChannelId, MActorAccountChannelIdActor, MActorAccountId, MActorFullActor } from '@server/types/models'
6import { arrayify } from '@shared/core-utils'
7import { ActivityPubActor, APObjectId } from '@shared/models'
8import { fetchAPObjectIfNeeded, getAPId } from '../activity'
9import { checkUrlsSameHost } from '../url'
10import { refreshActorIfNeeded } from './refresh'
11import { APActorCreator, fetchRemoteActor } from './shared'
12
13function getOrCreateAPActor (
14 activityActor: string | ActivityPubActor,
15 fetchType: 'all',
16 recurseIfNeeded?: boolean,
17 updateCollections?: boolean
18): Promise<MActorFullActor>
19
20function getOrCreateAPActor (
21 activityActor: string | ActivityPubActor,
22 fetchType?: 'association-ids',
23 recurseIfNeeded?: boolean,
24 updateCollections?: boolean
25): Promise<MActorAccountChannelId>
26
27async function getOrCreateAPActor (
28 activityActor: string | ActivityPubActor,
29 fetchType: ActorLoadByUrlType = 'association-ids',
30 recurseIfNeeded = true,
31 updateCollections = false
32): Promise<MActorFullActor | MActorAccountChannelId> {
33 const actorUrl = getAPId(activityActor)
34 let actor = await loadActorFromDB(actorUrl, fetchType)
35
36 let created = false
37 let accountPlaylistsUrl: string
38
39 // We don't have this actor in our database, fetch it on remote
40 if (!actor) {
41 const { actorObject } = await fetchRemoteActor(actorUrl)
42 if (actorObject === undefined) throw new Error('Cannot fetch remote actor ' + actorUrl)
43
44 // actorUrl is just an alias/redirection, so process object id instead
45 if (actorObject.id !== actorUrl) return getOrCreateAPActor(actorObject, 'all', recurseIfNeeded, updateCollections)
46
47 // Create the attributed to actor
48 // In PeerTube a video channel is owned by an account
49 let ownerActor: MActorFullActor
50 if (recurseIfNeeded === true && actorObject.type === 'Group') {
51 ownerActor = await getOrCreateAPOwner(actorObject, actorUrl)
52 }
53
54 const creator = new APActorCreator(actorObject, ownerActor)
55 actor = await retryTransactionWrapper(creator.create.bind(creator))
56 created = true
57 accountPlaylistsUrl = actorObject.playlists
58 }
59
60 if (actor.Account) (actor as MActorAccountChannelIdActor).Account.Actor = actor
61 if (actor.VideoChannel) (actor as MActorAccountChannelIdActor).VideoChannel.Actor = actor
62
63 const { actor: actorRefreshed, refreshed } = await refreshActorIfNeeded({ actor, fetchedType: fetchType })
64 if (!actorRefreshed) throw new Error('Actor ' + actor.url + ' does not exist anymore.')
65
66 await scheduleOutboxFetchIfNeeded(actor, created, refreshed, updateCollections)
67 await schedulePlaylistFetchIfNeeded(actor, created, accountPlaylistsUrl)
68
69 return actorRefreshed
70}
71
72async function getOrCreateAPOwner (actorObject: ActivityPubActor, actorUrl: string) {
73 const accountAttributedTo = await findOwner(actorUrl, actorObject.attributedTo, 'Person')
74 if (!accountAttributedTo) {
75 throw new Error(`Cannot find account attributed to video channel ${actorUrl}`)
76 }
77
78 try {
79 // Don't recurse another time
80 const recurseIfNeeded = false
81 return getOrCreateAPActor(accountAttributedTo, 'all', recurseIfNeeded)
82 } catch (err) {
83 logger.error('Cannot get or create account attributed to video channel ' + actorUrl)
84 throw new Error(err)
85 }
86}
87
88async function findOwner (rootUrl: string, attributedTo: APObjectId[] | APObjectId, type: 'Person' | 'Group') {
89 for (const actorToCheck of arrayify(attributedTo)) {
90 const actorObject = await fetchAPObjectIfNeeded<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
108// ---------------------------------------------------------------------------
109
110export {
111 getOrCreateAPOwner,
112 getOrCreateAPActor,
113 findOwner
114}
115
116// ---------------------------------------------------------------------------
117
118async function loadActorFromDB (actorUrl: string, fetchType: ActorLoadByUrlType) {
119 let actor = await loadActorByUrl(actorUrl, fetchType)
120
121 // Orphan actor (not associated to an account of channel) so recreate it
122 if (actor && (!actor.Account && !actor.VideoChannel)) {
123 await actor.destroy()
124 actor = null
125 }
126
127 return actor
128}
129
130async function scheduleOutboxFetchIfNeeded (actor: MActor, created: boolean, refreshed: boolean, updateCollections: boolean) {
131 if ((created === true || refreshed === true) && updateCollections === true) {
132 const payload = { uri: actor.outboxUrl, type: 'activity' as 'activity' }
133 await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
134 }
135}
136
137async function schedulePlaylistFetchIfNeeded (actor: MActorAccountId, created: boolean, accountPlaylistsUrl: string) {
138 // We created a new account: fetch the playlists
139 if (created === true && actor.Account && accountPlaylistsUrl) {
140 const payload = { uri: accountPlaylistsUrl, type: 'account-playlists' as 'account-playlists' }
141 await JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
142 }
143}
diff --git a/server/lib/activitypub/actors/image.ts b/server/lib/activitypub/actors/image.ts
deleted file mode 100644
index e1d29af5b..000000000
--- a/server/lib/activitypub/actors/image.ts
+++ /dev/null
@@ -1,112 +0,0 @@
1import { Transaction } from 'sequelize/types'
2import { logger } from '@server/helpers/logger'
3import { ActorImageModel } from '@server/models/actor/actor-image'
4import { MActorImage, MActorImages } from '@server/types/models'
5import { ActorImageType } from '@shared/models'
6
7type ImageInfo = {
8 name: string
9 fileUrl: string
10 height: number
11 width: number
12 onDisk?: boolean
13}
14
15async function updateActorImages (actor: MActorImages, type: ActorImageType, imagesInfo: ImageInfo[], t: Transaction) {
16 const getAvatarsOrBanners = () => {
17 const result = type === ActorImageType.AVATAR
18 ? actor.Avatars
19 : actor.Banners
20
21 return result || []
22 }
23
24 if (imagesInfo.length === 0) {
25 await deleteActorImages(actor, type, t)
26 }
27
28 // Cleanup old images that did not have a width
29 for (const oldImageModel of getAvatarsOrBanners()) {
30 if (oldImageModel.width) continue
31
32 await safeDeleteActorImage(actor, oldImageModel, type, t)
33 }
34
35 for (const imageInfo of imagesInfo) {
36 const oldImageModel = getAvatarsOrBanners().find(i => imageInfo.width && i.width === imageInfo.width)
37
38 if (oldImageModel) {
39 // Don't update the avatar if the file URL did not change
40 if (imageInfo.fileUrl && oldImageModel.fileUrl === imageInfo.fileUrl) {
41 continue
42 }
43
44 await safeDeleteActorImage(actor, oldImageModel, type, t)
45 }
46
47 const imageModel = await ActorImageModel.create({
48 filename: imageInfo.name,
49 onDisk: imageInfo.onDisk ?? false,
50 fileUrl: imageInfo.fileUrl,
51 height: imageInfo.height,
52 width: imageInfo.width,
53 type,
54 actorId: actor.id
55 }, { transaction: t })
56
57 addActorImage(actor, type, imageModel)
58 }
59
60 return actor
61}
62
63async function deleteActorImages (actor: MActorImages, type: ActorImageType, t: Transaction) {
64 try {
65 const association = buildAssociationName(type)
66
67 for (const image of actor[association]) {
68 await image.destroy({ transaction: t })
69 }
70
71 actor[association] = []
72 } catch (err) {
73 logger.error('Cannot remove old image of actor %s.', actor.url, { err })
74 }
75
76 return actor
77}
78
79async function safeDeleteActorImage (actor: MActorImages, toDelete: MActorImage, type: ActorImageType, t: Transaction) {
80 try {
81 await toDelete.destroy({ transaction: t })
82
83 const association = buildAssociationName(type)
84 actor[association] = actor[association].filter(image => image.id !== toDelete.id)
85 } catch (err) {
86 logger.error('Cannot remove old actor image of actor %s.', actor.url, { err })
87 }
88}
89
90// ---------------------------------------------------------------------------
91
92export {
93 ImageInfo,
94
95 updateActorImages,
96 deleteActorImages
97}
98
99// ---------------------------------------------------------------------------
100
101function addActorImage (actor: MActorImages, type: ActorImageType, imageModel: MActorImage) {
102 const association = buildAssociationName(type)
103 if (!actor[association]) actor[association] = []
104
105 actor[association].push(imageModel)
106}
107
108function buildAssociationName (type: ActorImageType) {
109 return type === ActorImageType.AVATAR
110 ? 'Avatars'
111 : 'Banners'
112}
diff --git a/server/lib/activitypub/actors/index.ts b/server/lib/activitypub/actors/index.ts
deleted file mode 100644
index 5ee2a6f1a..000000000
--- a/server/lib/activitypub/actors/index.ts
+++ /dev/null
@@ -1,6 +0,0 @@
1export * from './get'
2export * from './image'
3export * from './keys'
4export * from './refresh'
5export * from './updater'
6export * from './webfinger'
diff --git a/server/lib/activitypub/actors/keys.ts b/server/lib/activitypub/actors/keys.ts
deleted file mode 100644
index c3d18abd8..000000000
--- a/server/lib/activitypub/actors/keys.ts
+++ /dev/null
@@ -1,16 +0,0 @@
1import { createPrivateAndPublicKeys } from '@server/helpers/peertube-crypto'
2import { MActor } from '@server/types/models'
3
4// Set account keys, this could be long so process after the account creation and do not block the client
5async function generateAndSaveActorKeys <T extends MActor> (actor: T) {
6 const { publicKey, privateKey } = await createPrivateAndPublicKeys()
7
8 actor.publicKey = publicKey
9 actor.privateKey = privateKey
10
11 return actor.save()
12}
13
14export {
15 generateAndSaveActorKeys
16}
diff --git a/server/lib/activitypub/actors/refresh.ts b/server/lib/activitypub/actors/refresh.ts
deleted file mode 100644
index d15cb5e90..000000000
--- a/server/lib/activitypub/actors/refresh.ts
+++ /dev/null
@@ -1,81 +0,0 @@
1import { logger, loggerTagsFactory } from '@server/helpers/logger'
2import { CachePromiseFactory } from '@server/helpers/promise-cache'
3import { PeerTubeRequestError } from '@server/helpers/requests'
4import { ActorLoadByUrlType } from '@server/lib/model-loaders'
5import { ActorModel } from '@server/models/actor/actor'
6import { MActorAccountChannelId, MActorFull } from '@server/types/models'
7import { HttpStatusCode } from '@shared/models'
8import { fetchRemoteActor } from './shared'
9import { APActorUpdater } from './updater'
10import { getUrlFromWebfinger } from './webfinger'
11
12type RefreshResult <T> = Promise<{ actor: T | MActorFull, refreshed: boolean }>
13
14type RefreshOptions <T> = {
15 actor: T
16 fetchedType: ActorLoadByUrlType
17}
18
19const promiseCache = new CachePromiseFactory(doRefresh, (options: RefreshOptions<MActorFull | MActorAccountChannelId>) => options.actor.url)
20
21function refreshActorIfNeeded <T extends MActorFull | MActorAccountChannelId> (options: RefreshOptions<T>): RefreshResult <T> {
22 const actorArg = options.actor
23 if (!actorArg.isOutdated()) return Promise.resolve({ actor: actorArg, refreshed: false })
24
25 return promiseCache.run(options)
26}
27
28export {
29 refreshActorIfNeeded
30}
31
32// ---------------------------------------------------------------------------
33
34async function doRefresh <T extends MActorFull | MActorAccountChannelId> (options: RefreshOptions<T>): RefreshResult <MActorFull> {
35 const { actor: actorArg, fetchedType } = options
36
37 // We need more attributes
38 const actor = fetchedType === 'all'
39 ? actorArg as MActorFull
40 : await ActorModel.loadByUrlAndPopulateAccountAndChannel(actorArg.url)
41
42 const lTags = loggerTagsFactory('ap', 'actor', 'refresh', actor.url)
43
44 logger.info('Refreshing actor %s.', actor.url, lTags())
45
46 try {
47 const actorUrl = await getActorUrl(actor)
48 const { actorObject } = await fetchRemoteActor(actorUrl)
49
50 if (actorObject === undefined) {
51 logger.info('Cannot fetch remote actor %s in refresh actor.', actorUrl)
52 return { actor, refreshed: false }
53 }
54
55 const updater = new APActorUpdater(actorObject, actor)
56 await updater.update()
57
58 return { refreshed: true, actor }
59 } catch (err) {
60 if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) {
61 logger.info('Deleting actor %s because there is a 404 in refresh actor.', actor.url, lTags())
62
63 actor.Account
64 ? await actor.Account.destroy()
65 : await actor.VideoChannel.destroy()
66
67 return { actor: undefined, refreshed: false }
68 }
69
70 logger.info('Cannot refresh actor %s.', actor.url, { err, ...lTags() })
71 return { actor, refreshed: false }
72 }
73}
74
75function getActorUrl (actor: MActorFull) {
76 return getUrlFromWebfinger(actor.preferredUsername + '@' + actor.getHost())
77 .catch(err => {
78 logger.warn('Cannot get actor URL from webfinger, keeping the old one.', { err })
79 return actor.url
80 })
81}
diff --git a/server/lib/activitypub/actors/shared/creator.ts b/server/lib/activitypub/actors/shared/creator.ts
deleted file mode 100644
index 500bc9912..000000000
--- a/server/lib/activitypub/actors/shared/creator.ts
+++ /dev/null
@@ -1,149 +0,0 @@
1import { Op, Transaction } from 'sequelize'
2import { sequelizeTypescript } from '@server/initializers/database'
3import { AccountModel } from '@server/models/account/account'
4import { ActorModel } from '@server/models/actor/actor'
5import { ServerModel } from '@server/models/server/server'
6import { VideoChannelModel } from '@server/models/video/video-channel'
7import { MAccount, MAccountDefault, MActor, MActorFullActor, MActorId, MActorImages, MChannel, MServer } from '@server/types/models'
8import { ActivityPubActor, ActorImageType } from '@shared/models'
9import { updateActorImages } from '../image'
10import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImagesInfoFromObject } from './object-to-model-attributes'
11import { fetchActorFollowsCount } from './url-to-object'
12
13export class APActorCreator {
14
15 constructor (
16 private readonly actorObject: ActivityPubActor,
17 private readonly ownerActor?: MActorFullActor
18 ) {
19
20 }
21
22 async create (): Promise<MActorFullActor> {
23 const { followersCount, followingCount } = await fetchActorFollowsCount(this.actorObject)
24
25 const actorInstance = new ActorModel(getActorAttributesFromObject(this.actorObject, followersCount, followingCount))
26
27 return sequelizeTypescript.transaction(async t => {
28 const server = await this.setServer(actorInstance, t)
29
30 const { actorCreated, created } = await this.saveActor(actorInstance, t)
31
32 await this.setImageIfNeeded(actorCreated, ActorImageType.AVATAR, t)
33 await this.setImageIfNeeded(actorCreated, ActorImageType.BANNER, t)
34
35 await this.tryToFixActorUrlIfNeeded(actorCreated, actorInstance, created, t)
36
37 if (actorCreated.type === 'Person' || actorCreated.type === 'Application') { // Account or PeerTube instance
38 actorCreated.Account = await this.saveAccount(actorCreated, t) as MAccountDefault
39 actorCreated.Account.Actor = actorCreated
40 }
41
42 if (actorCreated.type === 'Group') { // Video channel
43 const channel = await this.saveVideoChannel(actorCreated, t)
44 actorCreated.VideoChannel = Object.assign(channel, { Actor: actorCreated, Account: this.ownerActor.Account })
45 }
46
47 actorCreated.Server = server
48
49 return actorCreated
50 })
51 }
52
53 private async setServer (actor: MActor, t: Transaction) {
54 const actorHost = new URL(actor.url).host
55
56 const serverOptions = {
57 where: {
58 host: actorHost
59 },
60 defaults: {
61 host: actorHost
62 },
63 transaction: t
64 }
65 const [ server ] = await ServerModel.findOrCreate(serverOptions)
66
67 // Save our new account in database
68 actor.serverId = server.id
69
70 return server as MServer
71 }
72
73 private async setImageIfNeeded (actor: MActor, type: ActorImageType, t: Transaction) {
74 const imagesInfo = getImagesInfoFromObject(this.actorObject, type)
75 if (imagesInfo.length === 0) return
76
77 return updateActorImages(actor as MActorImages, type, imagesInfo, t)
78 }
79
80 private async saveActor (actor: MActor, t: Transaction) {
81 // Force the actor creation using findOrCreate() instead of save()
82 // Sometimes Sequelize skips the save() when it thinks the instance already exists
83 // (which could be false in a retried query)
84 const [ actorCreated, created ] = await ActorModel.findOrCreate<MActorFullActor>({
85 defaults: actor.toJSON(),
86 where: {
87 [Op.or]: [
88 {
89 url: actor.url
90 },
91 {
92 serverId: actor.serverId,
93 preferredUsername: actor.preferredUsername
94 }
95 ]
96 },
97 transaction: t
98 })
99
100 return { actorCreated, created }
101 }
102
103 private async tryToFixActorUrlIfNeeded (actorCreated: MActor, newActor: MActor, created: boolean, t: Transaction) {
104 // Try to fix non HTTPS accounts of remote instances that fixed their URL afterwards
105 if (created !== true && actorCreated.url !== newActor.url) {
106 // Only fix http://example.com/account/djidane to https://example.com/account/djidane
107 if (actorCreated.url.replace(/^http:\/\//, '') !== newActor.url.replace(/^https:\/\//, '')) {
108 throw new Error(`Actor from DB with URL ${actorCreated.url} does not correspond to actor ${newActor.url}`)
109 }
110
111 actorCreated.url = newActor.url
112 await actorCreated.save({ transaction: t })
113 }
114 }
115
116 private async saveAccount (actor: MActorId, t: Transaction) {
117 const [ accountCreated ] = await AccountModel.findOrCreate({
118 defaults: {
119 name: getActorDisplayNameFromObject(this.actorObject),
120 description: this.actorObject.summary,
121 actorId: actor.id
122 },
123 where: {
124 actorId: actor.id
125 },
126 transaction: t
127 })
128
129 return accountCreated as MAccount
130 }
131
132 private async saveVideoChannel (actor: MActorId, t: Transaction) {
133 const [ videoChannelCreated ] = await VideoChannelModel.findOrCreate({
134 defaults: {
135 name: getActorDisplayNameFromObject(this.actorObject),
136 description: this.actorObject.summary,
137 support: this.actorObject.support,
138 actorId: actor.id,
139 accountId: this.ownerActor.Account.id
140 },
141 where: {
142 actorId: actor.id
143 },
144 transaction: t
145 })
146
147 return videoChannelCreated as MChannel
148 }
149}
diff --git a/server/lib/activitypub/actors/shared/index.ts b/server/lib/activitypub/actors/shared/index.ts
deleted file mode 100644
index 52af1a8e1..000000000
--- a/server/lib/activitypub/actors/shared/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
1export * from './creator'
2export * from './object-to-model-attributes'
3export * from './url-to-object'
diff --git a/server/lib/activitypub/actors/shared/object-to-model-attributes.ts b/server/lib/activitypub/actors/shared/object-to-model-attributes.ts
deleted file mode 100644
index 3ce332681..000000000
--- a/server/lib/activitypub/actors/shared/object-to-model-attributes.ts
+++ /dev/null
@@ -1,84 +0,0 @@
1import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc'
2import { MIMETYPES } from '@server/initializers/constants'
3import { ActorModel } from '@server/models/actor/actor'
4import { FilteredModelAttributes } from '@server/types'
5import { getLowercaseExtension } from '@shared/core-utils'
6import { buildUUID } from '@shared/extra-utils'
7import { ActivityIconObject, ActivityPubActor, ActorImageType } from '@shared/models'
8
9function getActorAttributesFromObject (
10 actorObject: ActivityPubActor,
11 followersCount: number,
12 followingCount: number
13): FilteredModelAttributes<ActorModel> {
14 return {
15 type: actorObject.type,
16 preferredUsername: actorObject.preferredUsername,
17 url: actorObject.id,
18 publicKey: actorObject.publicKey.publicKeyPem,
19 privateKey: null,
20 followersCount,
21 followingCount,
22 inboxUrl: actorObject.inbox,
23 outboxUrl: actorObject.outbox,
24 followersUrl: actorObject.followers,
25 followingUrl: actorObject.following,
26
27 sharedInboxUrl: actorObject.endpoints?.sharedInbox
28 ? actorObject.endpoints.sharedInbox
29 : null
30 }
31}
32
33function getImagesInfoFromObject (actorObject: ActivityPubActor, type: ActorImageType) {
34 const iconsOrImages = type === ActorImageType.AVATAR
35 ? actorObject.icon
36 : actorObject.image
37
38 return normalizeIconOrImage(iconsOrImages)
39 .map(iconOrImage => {
40 const mimetypes = MIMETYPES.IMAGE
41
42 if (iconOrImage.type !== 'Image' || !isActivityPubUrlValid(iconOrImage.url)) return undefined
43
44 let extension: string
45
46 if (iconOrImage.mediaType) {
47 extension = mimetypes.MIMETYPE_EXT[iconOrImage.mediaType]
48 } else {
49 const tmp = getLowercaseExtension(iconOrImage.url)
50
51 if (mimetypes.EXT_MIMETYPE[tmp] !== undefined) extension = tmp
52 }
53
54 if (!extension) return undefined
55
56 return {
57 name: buildUUID() + extension,
58 fileUrl: iconOrImage.url,
59 height: iconOrImage.height,
60 width: iconOrImage.width,
61 type
62 }
63 })
64 .filter(i => !!i)
65}
66
67function getActorDisplayNameFromObject (actorObject: ActivityPubActor) {
68 return actorObject.name || actorObject.preferredUsername
69}
70
71export {
72 getActorAttributesFromObject,
73 getImagesInfoFromObject,
74 getActorDisplayNameFromObject
75}
76
77// ---------------------------------------------------------------------------
78
79function normalizeIconOrImage (icon: ActivityIconObject | ActivityIconObject[]): ActivityIconObject[] {
80 if (Array.isArray(icon)) return icon
81 if (icon) return [ icon ]
82
83 return []
84}
diff --git a/server/lib/activitypub/actors/shared/url-to-object.ts b/server/lib/activitypub/actors/shared/url-to-object.ts
deleted file mode 100644
index 73766bd50..000000000
--- a/server/lib/activitypub/actors/shared/url-to-object.ts
+++ /dev/null
@@ -1,56 +0,0 @@
1import { sanitizeAndCheckActorObject } from '@server/helpers/custom-validators/activitypub/actor'
2import { logger } from '@server/helpers/logger'
3import { ActivityPubActor, ActivityPubOrderedCollection } from '@shared/models'
4import { fetchAP } from '../../activity'
5import { checkUrlsSameHost } from '../../url'
6
7async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode: number, actorObject: ActivityPubActor }> {
8 logger.info('Fetching remote actor %s.', actorUrl)
9
10 const { body, statusCode } = await fetchAP<ActivityPubActor>(actorUrl)
11
12 if (sanitizeAndCheckActorObject(body) === false) {
13 logger.debug('Remote actor JSON is not valid.', { actorJSON: body })
14 return { actorObject: undefined, statusCode }
15 }
16
17 if (checkUrlsSameHost(body.id, actorUrl) !== true) {
18 logger.warn('Actor url %s has not the same host than its AP id %s', actorUrl, body.id)
19 return { actorObject: undefined, statusCode }
20 }
21
22 return {
23 statusCode,
24
25 actorObject: body
26 }
27}
28
29async function fetchActorFollowsCount (actorObject: ActivityPubActor) {
30 let followersCount = 0
31 let followingCount = 0
32
33 if (actorObject.followers) followersCount = await fetchActorTotalItems(actorObject.followers)
34 if (actorObject.following) followingCount = await fetchActorTotalItems(actorObject.following)
35
36 return { followersCount, followingCount }
37}
38
39// ---------------------------------------------------------------------------
40export {
41 fetchActorFollowsCount,
42 fetchRemoteActor
43}
44
45// ---------------------------------------------------------------------------
46
47async function fetchActorTotalItems (url: string) {
48 try {
49 const { body } = await fetchAP<ActivityPubOrderedCollection<unknown>>(url)
50
51 return body.totalItems || 0
52 } catch (err) {
53 logger.info('Cannot fetch remote actor count %s.', url, { err })
54 return 0
55 }
56}
diff --git a/server/lib/activitypub/actors/updater.ts b/server/lib/activitypub/actors/updater.ts
deleted file mode 100644
index 5a92e7a22..000000000
--- a/server/lib/activitypub/actors/updater.ts
+++ /dev/null
@@ -1,91 +0,0 @@
1import { resetSequelizeInstance, runInReadCommittedTransaction } from '@server/helpers/database-utils'
2import { logger } from '@server/helpers/logger'
3import { AccountModel } from '@server/models/account/account'
4import { VideoChannelModel } from '@server/models/video/video-channel'
5import { MAccount, MActor, MActorFull, MChannel } from '@server/types/models'
6import { ActivityPubActor, ActorImageType } from '@shared/models'
7import { getOrCreateAPOwner } from './get'
8import { updateActorImages } from './image'
9import { fetchActorFollowsCount } from './shared'
10import { getImagesInfoFromObject } from './shared/object-to-model-attributes'
11
12export class APActorUpdater {
13
14 private readonly accountOrChannel: MAccount | MChannel
15
16 constructor (
17 private readonly actorObject: ActivityPubActor,
18 private readonly actor: MActorFull
19 ) {
20 if (this.actorObject.type === 'Group') this.accountOrChannel = this.actor.VideoChannel
21 else this.accountOrChannel = this.actor.Account
22 }
23
24 async update () {
25 const avatarsInfo = getImagesInfoFromObject(this.actorObject, ActorImageType.AVATAR)
26 const bannersInfo = getImagesInfoFromObject(this.actorObject, ActorImageType.BANNER)
27
28 try {
29 await this.updateActorInstance(this.actor, this.actorObject)
30
31 this.accountOrChannel.name = this.actorObject.name || this.actorObject.preferredUsername
32 this.accountOrChannel.description = this.actorObject.summary
33
34 if (this.accountOrChannel instanceof VideoChannelModel) {
35 const owner = await getOrCreateAPOwner(this.actorObject, this.actorObject.url)
36 this.accountOrChannel.accountId = owner.Account.id
37 this.accountOrChannel.Account = owner.Account as AccountModel
38
39 this.accountOrChannel.support = this.actorObject.support
40 }
41
42 await runInReadCommittedTransaction(async t => {
43 await updateActorImages(this.actor, ActorImageType.BANNER, bannersInfo, t)
44 await updateActorImages(this.actor, ActorImageType.AVATAR, avatarsInfo, t)
45 })
46
47 await runInReadCommittedTransaction(async t => {
48 await this.actor.save({ transaction: t })
49 await this.accountOrChannel.save({ transaction: t })
50 })
51
52 logger.info('Remote account %s updated', this.actorObject.url)
53 } catch (err) {
54 if (this.actor !== undefined) {
55 await resetSequelizeInstance(this.actor)
56 }
57
58 if (this.accountOrChannel !== undefined) {
59 await resetSequelizeInstance(this.accountOrChannel)
60 }
61
62 // This is just a debug because we will retry the insert
63 logger.debug('Cannot update the remote account.', { err })
64 throw err
65 }
66 }
67
68 private async updateActorInstance (actorInstance: MActor, actorObject: ActivityPubActor) {
69 const { followersCount, followingCount } = await fetchActorFollowsCount(actorObject)
70
71 actorInstance.type = actorObject.type
72 actorInstance.preferredUsername = actorObject.preferredUsername
73 actorInstance.url = actorObject.id
74 actorInstance.publicKey = actorObject.publicKey.publicKeyPem
75 actorInstance.followersCount = followersCount
76 actorInstance.followingCount = followingCount
77 actorInstance.inboxUrl = actorObject.inbox
78 actorInstance.outboxUrl = actorObject.outbox
79 actorInstance.followersUrl = actorObject.followers
80 actorInstance.followingUrl = actorObject.following
81
82 if (actorObject.published) actorInstance.remoteCreatedAt = new Date(actorObject.published)
83
84 if (actorObject.endpoints?.sharedInbox) {
85 actorInstance.sharedInboxUrl = actorObject.endpoints.sharedInbox
86 }
87
88 // Force actor update
89 actorInstance.changed('updatedAt', true)
90 }
91}
diff --git a/server/lib/activitypub/actors/webfinger.ts b/server/lib/activitypub/actors/webfinger.ts
deleted file mode 100644
index b20a724da..000000000
--- a/server/lib/activitypub/actors/webfinger.ts
+++ /dev/null
@@ -1,67 +0,0 @@
1import WebFinger from 'webfinger.js'
2import { isProdInstance } from '@server/helpers/core-utils'
3import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc'
4import { REQUEST_TIMEOUTS, WEBSERVER } from '@server/initializers/constants'
5import { ActorModel } from '@server/models/actor/actor'
6import { MActorFull } from '@server/types/models'
7import { WebFingerData } from '@shared/models'
8
9const webfinger = new WebFinger({
10 webfist_fallback: false,
11 tls_only: isProdInstance(),
12 uri_fallback: false,
13 request_timeout: REQUEST_TIMEOUTS.DEFAULT
14})
15
16async function loadActorUrlOrGetFromWebfinger (uriArg: string) {
17 // Handle strings like @toto@example.com
18 const uri = uriArg.startsWith('@') ? uriArg.slice(1) : uriArg
19
20 const [ name, host ] = uri.split('@')
21 let actor: MActorFull
22
23 if (!host || host === WEBSERVER.HOST) {
24 actor = await ActorModel.loadLocalByName(name)
25 } else {
26 actor = await ActorModel.loadByNameAndHost(name, host)
27 }
28
29 if (actor) return actor.url
30
31 return getUrlFromWebfinger(uri)
32}
33
34async function getUrlFromWebfinger (uri: string) {
35 const webfingerData: WebFingerData = await webfingerLookup(uri)
36 return getLinkOrThrow(webfingerData)
37}
38
39// ---------------------------------------------------------------------------
40
41export {
42 getUrlFromWebfinger,
43 loadActorUrlOrGetFromWebfinger
44}
45
46// ---------------------------------------------------------------------------
47
48function getLinkOrThrow (webfingerData: WebFingerData) {
49 if (Array.isArray(webfingerData.links) === false) throw new Error('WebFinger links is not an array.')
50
51 const selfLink = webfingerData.links.find(l => l.rel === 'self')
52 if (selfLink === undefined || isActivityPubUrlValid(selfLink.href) === false) {
53 throw new Error('Cannot find self link or href is not a valid URL.')
54 }
55
56 return selfLink.href
57}
58
59function webfingerLookup (nameWithHost: string) {
60 return new Promise<WebFingerData>((res, rej) => {
61 webfinger.lookup(nameWithHost, (err, p) => {
62 if (err) return rej(err)
63
64 return res(p.object)
65 })
66 })
67}
diff --git a/server/lib/activitypub/audience.ts b/server/lib/activitypub/audience.ts
deleted file mode 100644
index 6f5491387..000000000
--- a/server/lib/activitypub/audience.ts
+++ /dev/null
@@ -1,34 +0,0 @@
1import { ActivityAudience } from '../../../shared/models/activitypub'
2import { ACTIVITY_PUB } from '../../initializers/constants'
3import { MActorFollowersUrl } from '../../types/models'
4
5function getAudience (actorSender: MActorFollowersUrl, isPublic = true) {
6 return buildAudience([ actorSender.followersUrl ], isPublic)
7}
8
9function buildAudience (followerUrls: string[], isPublic = true) {
10 let to: string[] = []
11 let cc: string[] = []
12
13 if (isPublic) {
14 to = [ ACTIVITY_PUB.PUBLIC ]
15 cc = followerUrls
16 } else { // Unlisted
17 to = []
18 cc = []
19 }
20
21 return { to, cc }
22}
23
24function audiencify<T> (object: T, audience: ActivityAudience) {
25 return { ...audience, ...object }
26}
27
28// ---------------------------------------------------------------------------
29
30export {
31 buildAudience,
32 getAudience,
33 audiencify
34}
diff --git a/server/lib/activitypub/cache-file.ts b/server/lib/activitypub/cache-file.ts
deleted file mode 100644
index c3acd7112..000000000
--- a/server/lib/activitypub/cache-file.ts
+++ /dev/null
@@ -1,82 +0,0 @@
1import { Transaction } from 'sequelize'
2import { MActorId, MVideoRedundancy, MVideoWithAllFiles } from '@server/types/models'
3import { CacheFileObject, VideoStreamingPlaylistType } from '@shared/models'
4import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
5
6async function createOrUpdateCacheFile (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId, t: Transaction) {
7 const redundancyModel = await VideoRedundancyModel.loadByUrl(cacheFileObject.id, t)
8
9 if (redundancyModel) {
10 return updateCacheFile(cacheFileObject, redundancyModel, video, byActor, t)
11 }
12
13 return createCacheFile(cacheFileObject, video, byActor, t)
14}
15
16// ---------------------------------------------------------------------------
17
18export {
19 createOrUpdateCacheFile
20}
21
22// ---------------------------------------------------------------------------
23
24function createCacheFile (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId, t: Transaction) {
25 const attributes = cacheFileActivityObjectToDBAttributes(cacheFileObject, video, byActor)
26
27 return VideoRedundancyModel.create(attributes, { transaction: t })
28}
29
30function updateCacheFile (
31 cacheFileObject: CacheFileObject,
32 redundancyModel: MVideoRedundancy,
33 video: MVideoWithAllFiles,
34 byActor: MActorId,
35 t: Transaction
36) {
37 if (redundancyModel.actorId !== byActor.id) {
38 throw new Error('Cannot update redundancy ' + redundancyModel.url + ' of another actor.')
39 }
40
41 const attributes = cacheFileActivityObjectToDBAttributes(cacheFileObject, video, byActor)
42
43 redundancyModel.expiresOn = attributes.expiresOn
44 redundancyModel.fileUrl = attributes.fileUrl
45
46 return redundancyModel.save({ transaction: t })
47}
48
49function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId) {
50
51 if (cacheFileObject.url.mediaType === 'application/x-mpegURL') {
52 const url = cacheFileObject.url
53
54 const playlist = video.VideoStreamingPlaylists.find(t => t.type === VideoStreamingPlaylistType.HLS)
55 if (!playlist) throw new Error('Cannot find HLS playlist of video ' + video.url)
56
57 return {
58 expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null,
59 url: cacheFileObject.id,
60 fileUrl: url.href,
61 strategy: null,
62 videoStreamingPlaylistId: playlist.id,
63 actorId: byActor.id
64 }
65 }
66
67 const url = cacheFileObject.url
68 const videoFile = video.VideoFiles.find(f => {
69 return f.resolution === url.height && f.fps === url.fps
70 })
71
72 if (!videoFile) throw new Error(`Cannot find video file ${url.height} ${url.fps} of video ${video.url}`)
73
74 return {
75 expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null,
76 url: cacheFileObject.id,
77 fileUrl: url.href,
78 strategy: null,
79 videoFileId: videoFile.id,
80 actorId: byActor.id
81 }
82}
diff --git a/server/lib/activitypub/collection.ts b/server/lib/activitypub/collection.ts
deleted file mode 100644
index a176cab51..000000000
--- a/server/lib/activitypub/collection.ts
+++ /dev/null
@@ -1,63 +0,0 @@
1import Bluebird from 'bluebird'
2import validator from 'validator'
3import { pageToStartAndCount } from '@server/helpers/core-utils'
4import { ACTIVITY_PUB } from '@server/initializers/constants'
5import { ResultList } from '@shared/models'
6import { forceNumber } from '@shared/core-utils'
7
8type ActivityPubCollectionPaginationHandler = (start: number, count: number) => Bluebird<ResultList<any>> | Promise<ResultList<any>>
9
10async function activityPubCollectionPagination (
11 baseUrl: string,
12 handler: ActivityPubCollectionPaginationHandler,
13 page?: any,
14 size = ACTIVITY_PUB.COLLECTION_ITEMS_PER_PAGE
15) {
16 if (!page || !validator.isInt(page)) {
17 // We just display the first page URL, we only need the total items
18 const result = await handler(0, 1)
19
20 return {
21 id: baseUrl,
22 type: 'OrderedCollection',
23 totalItems: result.total,
24 first: result.data.length === 0
25 ? undefined
26 : baseUrl + '?page=1'
27 }
28 }
29
30 const { start, count } = pageToStartAndCount(page, size)
31 const result = await handler(start, count)
32
33 let next: string | undefined
34 let prev: string | undefined
35
36 // Assert page is a number
37 page = forceNumber(page)
38
39 // There are more results
40 if (result.total > page * size) {
41 next = baseUrl + '?page=' + (page + 1)
42 }
43
44 if (page > 1) {
45 prev = baseUrl + '?page=' + (page - 1)
46 }
47
48 return {
49 id: baseUrl + '?page=' + page,
50 type: 'OrderedCollectionPage',
51 prev,
52 next,
53 partOf: baseUrl,
54 orderedItems: result.data,
55 totalItems: result.total
56 }
57}
58
59// ---------------------------------------------------------------------------
60
61export {
62 activityPubCollectionPagination
63}
diff --git a/server/lib/activitypub/context.ts b/server/lib/activitypub/context.ts
deleted file mode 100644
index 87eb498a3..000000000
--- a/server/lib/activitypub/context.ts
+++ /dev/null
@@ -1,212 +0,0 @@
1import { ContextType } from '@shared/models'
2import { Hooks } from '../plugins/hooks'
3
4async function activityPubContextify <T> (data: T, type: ContextType) {
5 return { ...await getContextData(type), ...data }
6}
7
8// ---------------------------------------------------------------------------
9
10export {
11 getContextData,
12 activityPubContextify
13}
14
15// ---------------------------------------------------------------------------
16
17type ContextValue = { [ id: string ]: (string | { '@type': string, '@id': string }) }
18
19const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string })[] } = {
20 Video: buildContext({
21 Hashtag: 'as:Hashtag',
22 uuid: 'sc:identifier',
23 category: 'sc:category',
24 licence: 'sc:license',
25 subtitleLanguage: 'sc:subtitleLanguage',
26 sensitive: 'as:sensitive',
27 language: 'sc:inLanguage',
28 identifier: 'sc:identifier',
29
30 isLiveBroadcast: 'sc:isLiveBroadcast',
31 liveSaveReplay: {
32 '@type': 'sc:Boolean',
33 '@id': 'pt:liveSaveReplay'
34 },
35 permanentLive: {
36 '@type': 'sc:Boolean',
37 '@id': 'pt:permanentLive'
38 },
39 latencyMode: {
40 '@type': 'sc:Number',
41 '@id': 'pt:latencyMode'
42 },
43
44 Infohash: 'pt:Infohash',
45
46 tileWidth: {
47 '@type': 'sc:Number',
48 '@id': 'pt:tileWidth'
49 },
50 tileHeight: {
51 '@type': 'sc:Number',
52 '@id': 'pt:tileHeight'
53 },
54 tileDuration: {
55 '@type': 'sc:Number',
56 '@id': 'pt:tileDuration'
57 },
58
59 originallyPublishedAt: 'sc:datePublished',
60
61 uploadDate: 'sc:uploadDate',
62
63 views: {
64 '@type': 'sc:Number',
65 '@id': 'pt:views'
66 },
67 state: {
68 '@type': 'sc:Number',
69 '@id': 'pt:state'
70 },
71 size: {
72 '@type': 'sc:Number',
73 '@id': 'pt:size'
74 },
75 fps: {
76 '@type': 'sc:Number',
77 '@id': 'pt:fps'
78 },
79 commentsEnabled: {
80 '@type': 'sc:Boolean',
81 '@id': 'pt:commentsEnabled'
82 },
83 downloadEnabled: {
84 '@type': 'sc:Boolean',
85 '@id': 'pt:downloadEnabled'
86 },
87 waitTranscoding: {
88 '@type': 'sc:Boolean',
89 '@id': 'pt:waitTranscoding'
90 },
91 support: {
92 '@type': 'sc:Text',
93 '@id': 'pt:support'
94 },
95 likes: {
96 '@id': 'as:likes',
97 '@type': '@id'
98 },
99 dislikes: {
100 '@id': 'as:dislikes',
101 '@type': '@id'
102 },
103 shares: {
104 '@id': 'as:shares',
105 '@type': '@id'
106 },
107 comments: {
108 '@id': 'as:comments',
109 '@type': '@id'
110 }
111 }),
112
113 Playlist: buildContext({
114 Playlist: 'pt:Playlist',
115 PlaylistElement: 'pt:PlaylistElement',
116 position: {
117 '@type': 'sc:Number',
118 '@id': 'pt:position'
119 },
120 startTimestamp: {
121 '@type': 'sc:Number',
122 '@id': 'pt:startTimestamp'
123 },
124 stopTimestamp: {
125 '@type': 'sc:Number',
126 '@id': 'pt:stopTimestamp'
127 },
128 uuid: 'sc:identifier'
129 }),
130
131 CacheFile: buildContext({
132 expires: 'sc:expires',
133 CacheFile: 'pt:CacheFile'
134 }),
135
136 Flag: buildContext({
137 Hashtag: 'as:Hashtag'
138 }),
139
140 Actor: buildContext({
141 playlists: {
142 '@id': 'pt:playlists',
143 '@type': '@id'
144 },
145 support: {
146 '@type': 'sc:Text',
147 '@id': 'pt:support'
148 },
149
150 // TODO: remove in a few versions, introduced in 4.2
151 icons: 'as:icon'
152 }),
153
154 WatchAction: buildContext({
155 WatchAction: 'sc:WatchAction',
156 startTimestamp: {
157 '@type': 'sc:Number',
158 '@id': 'pt:startTimestamp'
159 },
160 stopTimestamp: {
161 '@type': 'sc:Number',
162 '@id': 'pt:stopTimestamp'
163 },
164 watchSection: {
165 '@type': 'sc:Number',
166 '@id': 'pt:stopTimestamp'
167 },
168 uuid: 'sc:identifier'
169 }),
170
171 Collection: buildContext(),
172 Follow: buildContext(),
173 Reject: buildContext(),
174 Accept: buildContext(),
175 View: buildContext(),
176 Announce: buildContext(),
177 Comment: buildContext(),
178 Delete: buildContext(),
179 Rate: buildContext()
180}
181
182async function getContextData (type: ContextType) {
183 const contextData = await Hooks.wrapObject(
184 contextStore[type],
185 'filter:activity-pub.activity.context.build.result'
186 )
187
188 return { '@context': contextData }
189}
190
191function buildContext (contextValue?: ContextValue) {
192 const baseContext = [
193 'https://www.w3.org/ns/activitystreams',
194 'https://w3id.org/security/v1',
195 {
196 RsaSignature2017: 'https://w3id.org/security#RsaSignature2017'
197 }
198 ]
199
200 if (!contextValue) return baseContext
201
202 return [
203 ...baseContext,
204
205 {
206 pt: 'https://joinpeertube.org/ns#',
207 sc: 'http://schema.org/',
208
209 ...contextValue
210 }
211 ]
212}
diff --git a/server/lib/activitypub/crawl.ts b/server/lib/activitypub/crawl.ts
deleted file mode 100644
index b8348e8cf..000000000
--- a/server/lib/activitypub/crawl.ts
+++ /dev/null
@@ -1,58 +0,0 @@
1import Bluebird from 'bluebird'
2import { URL } from 'url'
3import { retryTransactionWrapper } from '@server/helpers/database-utils'
4import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub'
5import { logger } from '../../helpers/logger'
6import { ACTIVITY_PUB, WEBSERVER } from '../../initializers/constants'
7import { fetchAP } from './activity'
8
9type HandlerFunction<T> = (items: T[]) => (Promise<any> | Bluebird<any>)
10type CleanerFunction = (startedDate: Date) => Promise<any>
11
12async function crawlCollectionPage <T> (argUrl: string, handler: HandlerFunction<T>, cleaner?: CleanerFunction) {
13 let url = argUrl
14
15 logger.info('Crawling ActivityPub data on %s.', url)
16
17 const startDate = new Date()
18
19 const response = await fetchAP<ActivityPubOrderedCollection<T>>(url)
20 const firstBody = response.body
21
22 const limit = ACTIVITY_PUB.FETCH_PAGE_LIMIT
23 let i = 0
24 let nextLink = firstBody.first
25 while (nextLink && i < limit) {
26 let body: any
27
28 if (typeof nextLink === 'string') {
29 // Don't crawl ourselves
30 const remoteHost = new URL(nextLink).host
31 if (remoteHost === WEBSERVER.HOST) continue
32
33 url = nextLink
34
35 const res = await fetchAP<ActivityPubOrderedCollection<T>>(url)
36 body = res.body
37 } else {
38 // nextLink is already the object we want
39 body = nextLink
40 }
41
42 nextLink = body.next
43 i++
44
45 if (Array.isArray(body.orderedItems)) {
46 const items = body.orderedItems
47 logger.info('Processing %i ActivityPub items for %s.', items.length, url)
48
49 await handler(items)
50 }
51 }
52
53 if (cleaner) await retryTransactionWrapper(cleaner, startDate)
54}
55
56export {
57 crawlCollectionPage
58}
diff --git a/server/lib/activitypub/follow.ts b/server/lib/activitypub/follow.ts
deleted file mode 100644
index f6e2a48fd..000000000
--- a/server/lib/activitypub/follow.ts
+++ /dev/null
@@ -1,51 +0,0 @@
1import { Transaction } from 'sequelize'
2import { getServerActor } from '@server/models/application/application'
3import { logger } from '../../helpers/logger'
4import { CONFIG } from '../../initializers/config'
5import { SERVER_ACTOR_NAME } from '../../initializers/constants'
6import { ServerModel } from '../../models/server/server'
7import { MActorFollowActors } from '../../types/models'
8import { JobQueue } from '../job-queue'
9
10async function autoFollowBackIfNeeded (actorFollow: MActorFollowActors, transaction?: Transaction) {
11 if (!CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_BACK.ENABLED) return
12
13 const follower = actorFollow.ActorFollower
14
15 if (follower.type === 'Application' && follower.preferredUsername === SERVER_ACTOR_NAME) {
16 logger.info('Auto follow back %s.', follower.url)
17
18 const me = await getServerActor()
19
20 const server = await ServerModel.load(follower.serverId, transaction)
21 const host = server.host
22
23 const payload = {
24 host,
25 name: SERVER_ACTOR_NAME,
26 followerActorId: me.id,
27 isAutoFollow: true
28 }
29
30 JobQueue.Instance.createJobAsync({ type: 'activitypub-follow', payload })
31 }
32}
33
34// If we only have an host, use a default account handle
35function getRemoteNameAndHost (handleOrHost: string) {
36 let name = SERVER_ACTOR_NAME
37 let host = handleOrHost
38
39 const splitted = handleOrHost.split('@')
40 if (splitted.length === 2) {
41 name = splitted[0]
42 host = splitted[1]
43 }
44
45 return { name, host }
46}
47
48export {
49 autoFollowBackIfNeeded,
50 getRemoteNameAndHost
51}
diff --git a/server/lib/activitypub/inbox-manager.ts b/server/lib/activitypub/inbox-manager.ts
deleted file mode 100644
index 27778cc9d..000000000
--- a/server/lib/activitypub/inbox-manager.ts
+++ /dev/null
@@ -1,47 +0,0 @@
1import PQueue from 'p-queue'
2import { logger } from '@server/helpers/logger'
3import { SCHEDULER_INTERVALS_MS } from '@server/initializers/constants'
4import { MActorDefault, MActorSignature } from '@server/types/models'
5import { Activity } from '@shared/models'
6import { StatsManager } from '../stat-manager'
7import { processActivities } from './process'
8
9class InboxManager {
10
11 private static instance: InboxManager
12 private readonly inboxQueue: PQueue
13
14 private constructor () {
15 this.inboxQueue = new PQueue({ concurrency: 1 })
16
17 setInterval(() => {
18 StatsManager.Instance.updateInboxWaiting(this.getActivityPubMessagesWaiting())
19 }, SCHEDULER_INTERVALS_MS.UPDATE_INBOX_STATS)
20 }
21
22 addInboxMessage (param: {
23 activities: Activity[]
24 signatureActor?: MActorSignature
25 inboxActor?: MActorDefault
26 }) {
27 this.inboxQueue.add(() => {
28 const options = { signatureActor: param.signatureActor, inboxActor: param.inboxActor }
29
30 return processActivities(param.activities, options)
31 }).catch(err => logger.error('Error with inbox queue.', { err }))
32 }
33
34 getActivityPubMessagesWaiting () {
35 return this.inboxQueue.size + this.inboxQueue.pending
36 }
37
38 static get Instance () {
39 return this.instance || (this.instance = new this())
40 }
41}
42
43// ---------------------------------------------------------------------------
44
45export {
46 InboxManager
47}
diff --git a/server/lib/activitypub/local-video-viewer.ts b/server/lib/activitypub/local-video-viewer.ts
deleted file mode 100644
index bdd746791..000000000
--- a/server/lib/activitypub/local-video-viewer.ts
+++ /dev/null
@@ -1,44 +0,0 @@
1import { Transaction } from 'sequelize'
2import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer'
3import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section'
4import { MVideo } from '@server/types/models'
5import { WatchActionObject } from '@shared/models'
6import { getDurationFromActivityStream } from './activity'
7
8async function createOrUpdateLocalVideoViewer (watchAction: WatchActionObject, video: MVideo, t: Transaction) {
9 const stats = await LocalVideoViewerModel.loadByUrl(watchAction.id)
10 if (stats) await stats.destroy({ transaction: t })
11
12 const localVideoViewer = await LocalVideoViewerModel.create({
13 url: watchAction.id,
14 uuid: watchAction.uuid,
15
16 watchTime: getDurationFromActivityStream(watchAction.duration),
17
18 startDate: new Date(watchAction.startTime),
19 endDate: new Date(watchAction.endTime),
20
21 country: watchAction.location
22 ? watchAction.location.addressCountry
23 : null,
24
25 videoId: video.id
26 }, { transaction: t })
27
28 await LocalVideoViewerWatchSectionModel.bulkCreateSections({
29 localVideoViewerId: localVideoViewer.id,
30
31 watchSections: watchAction.watchSections.map(s => ({
32 start: s.startTimestamp,
33 end: s.endTimestamp
34 })),
35
36 transaction: t
37 })
38}
39
40// ---------------------------------------------------------------------------
41
42export {
43 createOrUpdateLocalVideoViewer
44}
diff --git a/server/lib/activitypub/outbox.ts b/server/lib/activitypub/outbox.ts
deleted file mode 100644
index 5eef76871..000000000
--- a/server/lib/activitypub/outbox.ts
+++ /dev/null
@@ -1,24 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { ActorModel } from '@server/models/actor/actor'
3import { getServerActor } from '@server/models/application/application'
4import { JobQueue } from '../job-queue'
5
6async function addFetchOutboxJob (actor: Pick<ActorModel, 'id' | 'outboxUrl'>) {
7 // Don't fetch ourselves
8 const serverActor = await getServerActor()
9 if (serverActor.id === actor.id) {
10 logger.error('Cannot fetch our own outbox!')
11 return undefined
12 }
13
14 const payload = {
15 uri: actor.outboxUrl,
16 type: 'activity' as 'activity'
17 }
18
19 return JobQueue.Instance.createJobAsync({ type: 'activitypub-http-fetcher', payload })
20}
21
22export {
23 addFetchOutboxJob
24}
diff --git a/server/lib/activitypub/playlists/create-update.ts b/server/lib/activitypub/playlists/create-update.ts
deleted file mode 100644
index b24299f29..000000000
--- a/server/lib/activitypub/playlists/create-update.ts
+++ /dev/null
@@ -1,157 +0,0 @@
1import { map } from 'bluebird'
2import { isArray } from '@server/helpers/custom-validators/misc'
3import { retryTransactionWrapper } from '@server/helpers/database-utils'
4import { logger, loggerTagsFactory } from '@server/helpers/logger'
5import { CRAWL_REQUEST_CONCURRENCY } from '@server/initializers/constants'
6import { sequelizeTypescript } from '@server/initializers/database'
7import { updateRemotePlaylistMiniatureFromUrl } from '@server/lib/thumbnail'
8import { VideoPlaylistModel } from '@server/models/video/video-playlist'
9import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element'
10import { FilteredModelAttributes } from '@server/types'
11import { MThumbnail, MVideoPlaylist, MVideoPlaylistFull, MVideoPlaylistVideosLength } from '@server/types/models'
12import { PlaylistObject } from '@shared/models'
13import { AttributesOnly } from '@shared/typescript-utils'
14import { getAPId } from '../activity'
15import { getOrCreateAPActor } from '../actors'
16import { crawlCollectionPage } from '../crawl'
17import { getOrCreateAPVideo } from '../videos'
18import {
19 fetchRemotePlaylistElement,
20 fetchRemoteVideoPlaylist,
21 playlistElementObjectToDBAttributes,
22 playlistObjectToDBAttributes
23} from './shared'
24
25const lTags = loggerTagsFactory('ap', 'video-playlist')
26
27async function createAccountPlaylists (playlistUrls: string[]) {
28 await map(playlistUrls, async playlistUrl => {
29 try {
30 const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl)
31 if (exists === true) return
32
33 const { playlistObject } = await fetchRemoteVideoPlaylist(playlistUrl)
34
35 if (playlistObject === undefined) {
36 throw new Error(`Cannot refresh remote playlist ${playlistUrl}: invalid body.`)
37 }
38
39 return createOrUpdateVideoPlaylist(playlistObject)
40 } catch (err) {
41 logger.warn('Cannot add playlist element %s.', playlistUrl, { err, ...lTags(playlistUrl) })
42 }
43 }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
44}
45
46async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, to?: string[]) {
47 const playlistAttributes = playlistObjectToDBAttributes(playlistObject, to || playlistObject.to)
48
49 await setVideoChannel(playlistObject, playlistAttributes)
50
51 const [ upsertPlaylist ] = await VideoPlaylistModel.upsert<MVideoPlaylistVideosLength>(playlistAttributes, { returning: true })
52
53 const playlistElementUrls = await fetchElementUrls(playlistObject)
54
55 // Refetch playlist from DB since elements fetching could be long in time
56 const playlist = await VideoPlaylistModel.loadWithAccountAndChannel(upsertPlaylist.id, null)
57
58 await updatePlaylistThumbnail(playlistObject, playlist)
59
60 const elementsLength = await rebuildVideoPlaylistElements(playlistElementUrls, playlist)
61 playlist.setVideosLength(elementsLength)
62
63 return playlist
64}
65
66// ---------------------------------------------------------------------------
67
68export {
69 createAccountPlaylists,
70 createOrUpdateVideoPlaylist
71}
72
73// ---------------------------------------------------------------------------
74
75async function setVideoChannel (playlistObject: PlaylistObject, playlistAttributes: AttributesOnly<VideoPlaylistModel>) {
76 if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) {
77 throw new Error('Not attributed to for playlist object ' + getAPId(playlistObject))
78 }
79
80 const actor = await getOrCreateAPActor(getAPId(playlistObject.attributedTo[0]), 'all')
81
82 if (!actor.VideoChannel) {
83 logger.warn('Playlist "attributedTo" %s is not a video channel.', playlistObject.id, { playlistObject, ...lTags(playlistObject.id) })
84 return
85 }
86
87 playlistAttributes.videoChannelId = actor.VideoChannel.id
88 playlistAttributes.ownerAccountId = actor.VideoChannel.Account.id
89}
90
91async function fetchElementUrls (playlistObject: PlaylistObject) {
92 let accItems: string[] = []
93 await crawlCollectionPage<string>(playlistObject.id, items => {
94 accItems = accItems.concat(items)
95
96 return Promise.resolve()
97 })
98
99 return accItems
100}
101
102async function updatePlaylistThumbnail (playlistObject: PlaylistObject, playlist: MVideoPlaylistFull) {
103 if (playlistObject.icon) {
104 let thumbnailModel: MThumbnail
105
106 try {
107 thumbnailModel = await updateRemotePlaylistMiniatureFromUrl({ downloadUrl: playlistObject.icon.url, playlist })
108 await playlist.setAndSaveThumbnail(thumbnailModel, undefined)
109 } catch (err) {
110 logger.warn('Cannot set thumbnail of %s.', playlistObject.id, { err, ...lTags(playlistObject.id, playlist.uuid, playlist.url) })
111
112 if (thumbnailModel) await thumbnailModel.removeThumbnail()
113 }
114
115 return
116 }
117
118 // Playlist does not have an icon, destroy existing one
119 if (playlist.hasThumbnail()) {
120 await playlist.Thumbnail.destroy()
121 playlist.Thumbnail = null
122 }
123}
124
125async function rebuildVideoPlaylistElements (elementUrls: string[], playlist: MVideoPlaylist) {
126 const elementsToCreate = await buildElementsDBAttributes(elementUrls, playlist)
127
128 await retryTransactionWrapper(() => sequelizeTypescript.transaction(async t => {
129 await VideoPlaylistElementModel.deleteAllOf(playlist.id, t)
130
131 for (const element of elementsToCreate) {
132 await VideoPlaylistElementModel.create(element, { transaction: t })
133 }
134 }))
135
136 logger.info('Rebuilt playlist %s with %s elements.', playlist.url, elementsToCreate.length, lTags(playlist.uuid, playlist.url))
137
138 return elementsToCreate.length
139}
140
141async function buildElementsDBAttributes (elementUrls: string[], playlist: MVideoPlaylist) {
142 const elementsToCreate: FilteredModelAttributes<VideoPlaylistElementModel>[] = []
143
144 await map(elementUrls, async elementUrl => {
145 try {
146 const { elementObject } = await fetchRemotePlaylistElement(elementUrl)
147
148 const { video } = await getOrCreateAPVideo({ videoObject: { id: elementObject.url }, fetchType: 'only-video' })
149
150 elementsToCreate.push(playlistElementObjectToDBAttributes(elementObject, playlist, video))
151 } catch (err) {
152 logger.warn('Cannot add playlist element %s.', elementUrl, { err, ...lTags(playlist.uuid, playlist.url) })
153 }
154 }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
155
156 return elementsToCreate
157}
diff --git a/server/lib/activitypub/playlists/get.ts b/server/lib/activitypub/playlists/get.ts
deleted file mode 100644
index c34554d69..000000000
--- a/server/lib/activitypub/playlists/get.ts
+++ /dev/null
@@ -1,35 +0,0 @@
1import { VideoPlaylistModel } from '@server/models/video/video-playlist'
2import { MVideoPlaylistFullSummary } from '@server/types/models'
3import { APObjectId } from '@shared/models'
4import { getAPId } from '../activity'
5import { createOrUpdateVideoPlaylist } from './create-update'
6import { scheduleRefreshIfNeeded } from './refresh'
7import { fetchRemoteVideoPlaylist } from './shared'
8
9async function getOrCreateAPVideoPlaylist (playlistObjectArg: APObjectId): Promise<MVideoPlaylistFullSummary> {
10 const playlistUrl = getAPId(playlistObjectArg)
11
12 const playlistFromDatabase = await VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(playlistUrl)
13
14 if (playlistFromDatabase) {
15 scheduleRefreshIfNeeded(playlistFromDatabase)
16
17 return playlistFromDatabase
18 }
19
20 const { playlistObject } = await fetchRemoteVideoPlaylist(playlistUrl)
21 if (!playlistObject) throw new Error('Cannot fetch remote playlist with url: ' + playlistUrl)
22
23 // playlistUrl is just an alias/redirection, so process object id instead
24 if (playlistObject.id !== playlistUrl) return getOrCreateAPVideoPlaylist(playlistObject)
25
26 const playlistCreated = await createOrUpdateVideoPlaylist(playlistObject)
27
28 return playlistCreated
29}
30
31// ---------------------------------------------------------------------------
32
33export {
34 getOrCreateAPVideoPlaylist
35}
diff --git a/server/lib/activitypub/playlists/index.ts b/server/lib/activitypub/playlists/index.ts
deleted file mode 100644
index e2470a674..000000000
--- a/server/lib/activitypub/playlists/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
1export * from './get'
2export * from './create-update'
3export * from './refresh'
diff --git a/server/lib/activitypub/playlists/refresh.ts b/server/lib/activitypub/playlists/refresh.ts
deleted file mode 100644
index 33260ea02..000000000
--- a/server/lib/activitypub/playlists/refresh.ts
+++ /dev/null
@@ -1,53 +0,0 @@
1import { logger, loggerTagsFactory } from '@server/helpers/logger'
2import { PeerTubeRequestError } from '@server/helpers/requests'
3import { JobQueue } from '@server/lib/job-queue'
4import { MVideoPlaylist, MVideoPlaylistOwner } from '@server/types/models'
5import { HttpStatusCode } from '@shared/models'
6import { createOrUpdateVideoPlaylist } from './create-update'
7import { fetchRemoteVideoPlaylist } from './shared'
8
9function scheduleRefreshIfNeeded (playlist: MVideoPlaylist) {
10 if (!playlist.isOutdated()) return
11
12 JobQueue.Instance.createJobAsync({ type: 'activitypub-refresher', payload: { type: 'video-playlist', url: playlist.url } })
13}
14
15async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner): Promise<MVideoPlaylistOwner> {
16 if (!videoPlaylist.isOutdated()) return videoPlaylist
17
18 const lTags = loggerTagsFactory('ap', 'video-playlist', 'refresh', videoPlaylist.uuid, videoPlaylist.url)
19
20 logger.info('Refreshing playlist %s.', videoPlaylist.url, lTags())
21
22 try {
23 const { playlistObject } = await fetchRemoteVideoPlaylist(videoPlaylist.url)
24
25 if (playlistObject === undefined) {
26 logger.warn('Cannot refresh remote playlist %s: invalid body.', videoPlaylist.url, lTags())
27
28 await videoPlaylist.setAsRefreshed()
29 return videoPlaylist
30 }
31
32 await createOrUpdateVideoPlaylist(playlistObject)
33
34 return videoPlaylist
35 } catch (err) {
36 if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) {
37 logger.info('Cannot refresh not existing playlist %s. Deleting it.', videoPlaylist.url, lTags())
38
39 await videoPlaylist.destroy()
40 return undefined
41 }
42
43 logger.warn('Cannot refresh video playlist %s.', videoPlaylist.url, { err, ...lTags() })
44
45 await videoPlaylist.setAsRefreshed()
46 return videoPlaylist
47 }
48}
49
50export {
51 scheduleRefreshIfNeeded,
52 refreshVideoPlaylistIfNeeded
53}
diff --git a/server/lib/activitypub/playlists/shared/index.ts b/server/lib/activitypub/playlists/shared/index.ts
deleted file mode 100644
index a217f2291..000000000
--- a/server/lib/activitypub/playlists/shared/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
1export * from './object-to-model-attributes'
2export * from './url-to-object'
diff --git a/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts b/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts
deleted file mode 100644
index 753b5e660..000000000
--- a/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts
+++ /dev/null
@@ -1,40 +0,0 @@
1import { ACTIVITY_PUB } from '@server/initializers/constants'
2import { VideoPlaylistModel } from '@server/models/video/video-playlist'
3import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element'
4import { MVideoId, MVideoPlaylistId } from '@server/types/models'
5import { AttributesOnly } from '@shared/typescript-utils'
6import { PlaylistElementObject, PlaylistObject, VideoPlaylistPrivacy } from '@shared/models'
7
8function playlistObjectToDBAttributes (playlistObject: PlaylistObject, to: string[]) {
9 const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
10 ? VideoPlaylistPrivacy.PUBLIC
11 : VideoPlaylistPrivacy.UNLISTED
12
13 return {
14 name: playlistObject.name,
15 description: playlistObject.content,
16 privacy,
17 url: playlistObject.id,
18 uuid: playlistObject.uuid,
19 ownerAccountId: null,
20 videoChannelId: null,
21 createdAt: new Date(playlistObject.published),
22 updatedAt: new Date(playlistObject.updated)
23 } as AttributesOnly<VideoPlaylistModel>
24}
25
26function playlistElementObjectToDBAttributes (elementObject: PlaylistElementObject, videoPlaylist: MVideoPlaylistId, video: MVideoId) {
27 return {
28 position: elementObject.position,
29 url: elementObject.id,
30 startTimestamp: elementObject.startTimestamp || null,
31 stopTimestamp: elementObject.stopTimestamp || null,
32 videoPlaylistId: videoPlaylist.id,
33 videoId: video.id
34 } as AttributesOnly<VideoPlaylistElementModel>
35}
36
37export {
38 playlistObjectToDBAttributes,
39 playlistElementObjectToDBAttributes
40}
diff --git a/server/lib/activitypub/playlists/shared/url-to-object.ts b/server/lib/activitypub/playlists/shared/url-to-object.ts
deleted file mode 100644
index fd9fe5558..000000000
--- a/server/lib/activitypub/playlists/shared/url-to-object.ts
+++ /dev/null
@@ -1,47 +0,0 @@
1import { isPlaylistElementObjectValid, isPlaylistObjectValid } from '@server/helpers/custom-validators/activitypub/playlist'
2import { isArray } from '@server/helpers/custom-validators/misc'
3import { logger, loggerTagsFactory } from '@server/helpers/logger'
4import { PlaylistElementObject, PlaylistObject } from '@shared/models'
5import { fetchAP } from '../../activity'
6import { checkUrlsSameHost } from '../../url'
7
8async function fetchRemoteVideoPlaylist (playlistUrl: string): Promise<{ statusCode: number, playlistObject: PlaylistObject }> {
9 const lTags = loggerTagsFactory('ap', 'video-playlist', playlistUrl)
10
11 logger.info('Fetching remote playlist %s.', playlistUrl, lTags())
12
13 const { body, statusCode } = await fetchAP<any>(playlistUrl)
14
15 if (isPlaylistObjectValid(body) === false || checkUrlsSameHost(body.id, playlistUrl) !== true) {
16 logger.debug('Remote video playlist JSON is not valid.', { body, ...lTags() })
17 return { statusCode, playlistObject: undefined }
18 }
19
20 if (!isArray(body.to)) {
21 logger.debug('Remote video playlist JSON does not have a valid audience.', { body, ...lTags() })
22 return { statusCode, playlistObject: undefined }
23 }
24
25 return { statusCode, playlistObject: body }
26}
27
28async function fetchRemotePlaylistElement (elementUrl: string): Promise<{ statusCode: number, elementObject: PlaylistElementObject }> {
29 const lTags = loggerTagsFactory('ap', 'video-playlist', 'element', elementUrl)
30
31 logger.debug('Fetching remote playlist element %s.', elementUrl, lTags())
32
33 const { body, statusCode } = await fetchAP<PlaylistElementObject>(elementUrl)
34
35 if (!isPlaylistElementObjectValid(body)) throw new Error(`Invalid body in fetch playlist element ${elementUrl}`)
36
37 if (checkUrlsSameHost(body.id, elementUrl) !== true) {
38 throw new Error(`Playlist element url ${elementUrl} host is different from the AP object id ${body.id}`)
39 }
40
41 return { statusCode, elementObject: body }
42}
43
44export {
45 fetchRemoteVideoPlaylist,
46 fetchRemotePlaylistElement
47}
diff --git a/server/lib/activitypub/process/index.ts b/server/lib/activitypub/process/index.ts
deleted file mode 100644
index 5466739c1..000000000
--- a/server/lib/activitypub/process/index.ts
+++ /dev/null
@@ -1 +0,0 @@
1export * from './process'
diff --git a/server/lib/activitypub/process/process-accept.ts b/server/lib/activitypub/process/process-accept.ts
deleted file mode 100644
index 077b01eda..000000000
--- a/server/lib/activitypub/process/process-accept.ts
+++ /dev/null
@@ -1,32 +0,0 @@
1import { ActivityAccept } from '../../../../shared/models/activitypub'
2import { ActorFollowModel } from '../../../models/actor/actor-follow'
3import { APProcessorOptions } from '../../../types/activitypub-processor.model'
4import { MActorDefault, MActorSignature } from '../../../types/models'
5import { addFetchOutboxJob } from '../outbox'
6
7async function processAcceptActivity (options: APProcessorOptions<ActivityAccept>) {
8 const { byActor: targetActor, inboxActor } = options
9 if (inboxActor === undefined) throw new Error('Need to accept on explicit inbox.')
10
11 return processAccept(inboxActor, targetActor)
12}
13
14// ---------------------------------------------------------------------------
15
16export {
17 processAcceptActivity
18}
19
20// ---------------------------------------------------------------------------
21
22async function processAccept (actor: MActorDefault, targetActor: MActorSignature) {
23 const follow = await ActorFollowModel.loadByActorAndTarget(actor.id, targetActor.id)
24 if (!follow) throw new Error('Cannot find associated follow.')
25
26 if (follow.state !== 'accepted') {
27 follow.state = 'accepted'
28 await follow.save()
29
30 await addFetchOutboxJob(targetActor)
31 }
32}
diff --git a/server/lib/activitypub/process/process-announce.ts b/server/lib/activitypub/process/process-announce.ts
deleted file mode 100644
index 9cc87ee27..000000000
--- a/server/lib/activitypub/process/process-announce.ts
+++ /dev/null
@@ -1,75 +0,0 @@
1import { getAPId } from '@server/lib/activitypub/activity'
2import { ActivityAnnounce } from '../../../../shared/models/activitypub'
3import { retryTransactionWrapper } from '../../../helpers/database-utils'
4import { logger } from '../../../helpers/logger'
5import { sequelizeTypescript } from '../../../initializers/database'
6import { VideoShareModel } from '../../../models/video/video-share'
7import { APProcessorOptions } from '../../../types/activitypub-processor.model'
8import { MActorSignature, MVideoAccountLightBlacklistAllFiles } from '../../../types/models'
9import { Notifier } from '../../notifier'
10import { forwardVideoRelatedActivity } from '../send/shared/send-utils'
11import { getOrCreateAPVideo } from '../videos'
12
13async function processAnnounceActivity (options: APProcessorOptions<ActivityAnnounce>) {
14 const { activity, byActor: actorAnnouncer } = options
15 // Only notify if it is not from a fetcher job
16 const notify = options.fromFetch !== true
17
18 // Announces on accounts are not supported
19 if (actorAnnouncer.type !== 'Application' && actorAnnouncer.type !== 'Group') return
20
21 return retryTransactionWrapper(processVideoShare, actorAnnouncer, activity, notify)
22}
23
24// ---------------------------------------------------------------------------
25
26export {
27 processAnnounceActivity
28}
29
30// ---------------------------------------------------------------------------
31
32async function processVideoShare (actorAnnouncer: MActorSignature, activity: ActivityAnnounce, notify: boolean) {
33 const objectUri = getAPId(activity.object)
34
35 let video: MVideoAccountLightBlacklistAllFiles
36 let videoCreated: boolean
37
38 try {
39 const result = await getOrCreateAPVideo({ videoObject: objectUri })
40 video = result.video
41 videoCreated = result.created
42 } catch (err) {
43 logger.debug('Cannot process share of %s. Maybe this is not a video object, so just skipping.', objectUri, { err })
44 return
45 }
46
47 await sequelizeTypescript.transaction(async t => {
48 // Add share entry
49
50 const share = {
51 actorId: actorAnnouncer.id,
52 videoId: video.id,
53 url: activity.id
54 }
55
56 const [ , created ] = await VideoShareModel.findOrCreate({
57 where: {
58 url: activity.id
59 },
60 defaults: share,
61 transaction: t
62 })
63
64 if (video.isOwned() && created === true) {
65 // Don't resend the activity to the sender
66 const exceptions = [ actorAnnouncer ]
67
68 await forwardVideoRelatedActivity(activity, t, exceptions, video)
69 }
70
71 return undefined
72 })
73
74 if (videoCreated && notify) Notifier.Instance.notifyOnNewVideoIfNeeded(video)
75}
diff --git a/server/lib/activitypub/process/process-create.ts b/server/lib/activitypub/process/process-create.ts
deleted file mode 100644
index 5f980de65..000000000
--- a/server/lib/activitypub/process/process-create.ts
+++ /dev/null
@@ -1,170 +0,0 @@
1import { isBlockedByServerOrAccount } from '@server/lib/blocklist'
2import { isRedundancyAccepted } from '@server/lib/redundancy'
3import { VideoModel } from '@server/models/video/video'
4import {
5 AbuseObject,
6 ActivityCreate,
7 ActivityCreateObject,
8 ActivityObject,
9 CacheFileObject,
10 PlaylistObject,
11 VideoCommentObject,
12 VideoObject,
13 WatchActionObject
14} from '@shared/models'
15import { retryTransactionWrapper } from '../../../helpers/database-utils'
16import { logger } from '../../../helpers/logger'
17import { sequelizeTypescript } from '../../../initializers/database'
18import { APProcessorOptions } from '../../../types/activitypub-processor.model'
19import { MActorSignature, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../../types/models'
20import { Notifier } from '../../notifier'
21import { fetchAPObjectIfNeeded } from '../activity'
22import { createOrUpdateCacheFile } from '../cache-file'
23import { createOrUpdateLocalVideoViewer } from '../local-video-viewer'
24import { createOrUpdateVideoPlaylist } from '../playlists'
25import { forwardVideoRelatedActivity } from '../send/shared/send-utils'
26import { resolveThread } from '../video-comments'
27import { getOrCreateAPVideo } from '../videos'
28
29async function processCreateActivity (options: APProcessorOptions<ActivityCreate<ActivityCreateObject>>) {
30 const { activity, byActor } = options
31
32 // Only notify if it is not from a fetcher job
33 const notify = options.fromFetch !== true
34 const activityObject = await fetchAPObjectIfNeeded<Exclude<ActivityObject, AbuseObject>>(activity.object)
35 const activityType = activityObject.type
36
37 if (activityType === 'Video') {
38 return processCreateVideo(activityObject, notify)
39 }
40
41 if (activityType === 'Note') {
42 // Comments will be fetched from videos
43 if (options.fromFetch) return
44
45 return retryTransactionWrapper(processCreateVideoComment, activity, activityObject, byActor, notify)
46 }
47
48 if (activityType === 'WatchAction') {
49 return retryTransactionWrapper(processCreateWatchAction, activityObject)
50 }
51
52 if (activityType === 'CacheFile') {
53 return retryTransactionWrapper(processCreateCacheFile, activity, activityObject, byActor)
54 }
55
56 if (activityType === 'Playlist') {
57 return retryTransactionWrapper(processCreatePlaylist, activity, activityObject, byActor)
58 }
59
60 logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id })
61 return Promise.resolve(undefined)
62}
63
64// ---------------------------------------------------------------------------
65
66export {
67 processCreateActivity
68}
69
70// ---------------------------------------------------------------------------
71
72async function processCreateVideo (videoToCreateData: VideoObject, notify: boolean) {
73 const syncParam = { rates: false, shares: false, comments: false, refreshVideo: false }
74 const { video, created } = await getOrCreateAPVideo({ videoObject: videoToCreateData, syncParam })
75
76 if (created && notify) Notifier.Instance.notifyOnNewVideoIfNeeded(video)
77
78 return video
79}
80
81async function processCreateCacheFile (
82 activity: ActivityCreate<CacheFileObject | string>,
83 cacheFile: CacheFileObject,
84 byActor: MActorSignature
85) {
86 if (await isRedundancyAccepted(activity, byActor) !== true) return
87
88 const { video } = await getOrCreateAPVideo({ videoObject: cacheFile.object })
89
90 await sequelizeTypescript.transaction(async t => {
91 return createOrUpdateCacheFile(cacheFile, video, byActor, t)
92 })
93
94 if (video.isOwned()) {
95 // Don't resend the activity to the sender
96 const exceptions = [ byActor ]
97 await forwardVideoRelatedActivity(activity, undefined, exceptions, video)
98 }
99}
100
101async function processCreateWatchAction (watchAction: WatchActionObject) {
102 if (watchAction.actionStatus !== 'CompletedActionStatus') return
103
104 const video = await VideoModel.loadByUrl(watchAction.object)
105 if (video.remote) return
106
107 await sequelizeTypescript.transaction(async t => {
108 return createOrUpdateLocalVideoViewer(watchAction, video, t)
109 })
110}
111
112async function processCreateVideoComment (
113 activity: ActivityCreate<VideoCommentObject | string>,
114 commentObject: VideoCommentObject,
115 byActor: MActorSignature,
116 notify: boolean
117) {
118 const byAccount = byActor.Account
119
120 if (!byAccount) throw new Error('Cannot create video comment with the non account actor ' + byActor.url)
121
122 let video: MVideoAccountLightBlacklistAllFiles
123 let created: boolean
124 let comment: MCommentOwnerVideo
125
126 try {
127 const resolveThreadResult = await resolveThread({ url: commentObject.id, isVideo: false })
128 if (!resolveThreadResult) return // Comment not accepted
129
130 video = resolveThreadResult.video
131 created = resolveThreadResult.commentCreated
132 comment = resolveThreadResult.comment
133 } catch (err) {
134 logger.debug(
135 'Cannot process video comment because we could not resolve thread %s. Maybe it was not a video thread, so skip it.',
136 commentObject.inReplyTo,
137 { err }
138 )
139 return
140 }
141
142 // Try to not forward unwanted comments on our videos
143 if (video.isOwned()) {
144 if (await isBlockedByServerOrAccount(comment.Account, video.VideoChannel.Account)) {
145 logger.info('Skip comment forward from blocked account or server %s.', comment.Account.Actor.url)
146 return
147 }
148
149 if (created === true) {
150 // Don't resend the activity to the sender
151 const exceptions = [ byActor ]
152
153 await forwardVideoRelatedActivity(activity, undefined, exceptions, video)
154 }
155 }
156
157 if (created && notify) Notifier.Instance.notifyOnNewComment(comment)
158}
159
160async function processCreatePlaylist (
161 activity: ActivityCreate<PlaylistObject | string>,
162 playlistObject: PlaylistObject,
163 byActor: MActorSignature
164) {
165 const byAccount = byActor.Account
166
167 if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url)
168
169 await createOrUpdateVideoPlaylist(playlistObject, activity.to)
170}
diff --git a/server/lib/activitypub/process/process-delete.ts b/server/lib/activitypub/process/process-delete.ts
deleted file mode 100644
index ac0e7e235..000000000
--- a/server/lib/activitypub/process/process-delete.ts
+++ /dev/null
@@ -1,153 +0,0 @@
1import { ActivityDelete } from '../../../../shared/models/activitypub'
2import { retryTransactionWrapper } from '../../../helpers/database-utils'
3import { logger } from '../../../helpers/logger'
4import { sequelizeTypescript } from '../../../initializers/database'
5import { ActorModel } from '../../../models/actor/actor'
6import { VideoModel } from '../../../models/video/video'
7import { VideoCommentModel } from '../../../models/video/video-comment'
8import { VideoPlaylistModel } from '../../../models/video/video-playlist'
9import { APProcessorOptions } from '../../../types/activitypub-processor.model'
10import {
11 MAccountActor,
12 MActor,
13 MActorFull,
14 MActorSignature,
15 MChannelAccountActor,
16 MChannelActor,
17 MCommentOwnerVideo
18} from '../../../types/models'
19import { forwardVideoRelatedActivity } from '../send/shared/send-utils'
20
21async function processDeleteActivity (options: APProcessorOptions<ActivityDelete>) {
22 const { activity, byActor } = options
23
24 const objectUrl = typeof activity.object === 'string' ? activity.object : activity.object.id
25
26 if (activity.actor === objectUrl) {
27 // We need more attributes (all the account and channel)
28 const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
29
30 if (byActorFull.type === 'Person') {
31 if (!byActorFull.Account) throw new Error('Actor ' + byActorFull.url + ' is a person but we cannot find it in database.')
32
33 const accountToDelete = byActorFull.Account as MAccountActor
34 accountToDelete.Actor = byActorFull
35
36 return retryTransactionWrapper(processDeleteAccount, accountToDelete)
37 } else if (byActorFull.type === 'Group') {
38 if (!byActorFull.VideoChannel) throw new Error('Actor ' + byActorFull.url + ' is a group but we cannot find it in database.')
39
40 const channelToDelete = byActorFull.VideoChannel as MChannelAccountActor & { Actor: MActorFull }
41 channelToDelete.Actor = byActorFull
42 return retryTransactionWrapper(processDeleteVideoChannel, channelToDelete)
43 }
44 }
45
46 {
47 const videoCommentInstance = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(objectUrl)
48 if (videoCommentInstance) {
49 return retryTransactionWrapper(processDeleteVideoComment, byActor, videoCommentInstance, activity)
50 }
51 }
52
53 {
54 const videoInstance = await VideoModel.loadByUrlAndPopulateAccount(objectUrl)
55 if (videoInstance) {
56 if (videoInstance.isOwned()) throw new Error(`Remote instance cannot delete owned video ${videoInstance.url}.`)
57
58 return retryTransactionWrapper(processDeleteVideo, byActor, videoInstance)
59 }
60 }
61
62 {
63 const videoPlaylist = await VideoPlaylistModel.loadByUrlAndPopulateAccount(objectUrl)
64 if (videoPlaylist) {
65 if (videoPlaylist.isOwned()) throw new Error(`Remote instance cannot delete owned playlist ${videoPlaylist.url}.`)
66
67 return retryTransactionWrapper(processDeleteVideoPlaylist, byActor, videoPlaylist)
68 }
69 }
70
71 return undefined
72}
73
74// ---------------------------------------------------------------------------
75
76export {
77 processDeleteActivity
78}
79
80// ---------------------------------------------------------------------------
81
82async function processDeleteVideo (actor: MActor, videoToDelete: VideoModel) {
83 logger.debug('Removing remote video "%s".', videoToDelete.uuid)
84
85 await sequelizeTypescript.transaction(async t => {
86 if (videoToDelete.VideoChannel.Account.Actor.id !== actor.id) {
87 throw new Error('Account ' + actor.url + ' does not own video channel ' + videoToDelete.VideoChannel.Actor.url)
88 }
89
90 await videoToDelete.destroy({ transaction: t })
91 })
92
93 logger.info('Remote video with uuid %s removed.', videoToDelete.uuid)
94}
95
96async function processDeleteVideoPlaylist (actor: MActor, playlistToDelete: VideoPlaylistModel) {
97 logger.debug('Removing remote video playlist "%s".', playlistToDelete.uuid)
98
99 await sequelizeTypescript.transaction(async t => {
100 if (playlistToDelete.OwnerAccount.Actor.id !== actor.id) {
101 throw new Error('Account ' + actor.url + ' does not own video playlist ' + playlistToDelete.url)
102 }
103
104 await playlistToDelete.destroy({ transaction: t })
105 })
106
107 logger.info('Remote video playlist with uuid %s removed.', playlistToDelete.uuid)
108}
109
110async function processDeleteAccount (accountToRemove: MAccountActor) {
111 logger.debug('Removing remote account "%s".', accountToRemove.Actor.url)
112
113 await sequelizeTypescript.transaction(async t => {
114 await accountToRemove.destroy({ transaction: t })
115 })
116
117 logger.info('Remote account %s removed.', accountToRemove.Actor.url)
118}
119
120async function processDeleteVideoChannel (videoChannelToRemove: MChannelActor) {
121 logger.debug('Removing remote video channel "%s".', videoChannelToRemove.Actor.url)
122
123 await sequelizeTypescript.transaction(async t => {
124 await videoChannelToRemove.destroy({ transaction: t })
125 })
126
127 logger.info('Remote video channel %s removed.', videoChannelToRemove.Actor.url)
128}
129
130function processDeleteVideoComment (byActor: MActorSignature, videoComment: MCommentOwnerVideo, activity: ActivityDelete) {
131 // Already deleted
132 if (videoComment.isDeleted()) return Promise.resolve()
133
134 logger.debug('Removing remote video comment "%s".', videoComment.url)
135
136 return sequelizeTypescript.transaction(async t => {
137 if (byActor.Account.id !== videoComment.Account.id && byActor.Account.id !== videoComment.Video.VideoChannel.accountId) {
138 throw new Error(`Account ${byActor.url} does not own video comment ${videoComment.url} or video ${videoComment.Video.url}`)
139 }
140
141 videoComment.markAsDeleted()
142
143 await videoComment.save({ transaction: t })
144
145 if (videoComment.Video.isOwned()) {
146 // Don't resend the activity to the sender
147 const exceptions = [ byActor ]
148 await forwardVideoRelatedActivity(activity, t, exceptions, videoComment.Video)
149 }
150
151 logger.info('Remote video comment %s removed.', videoComment.url)
152 })
153}
diff --git a/server/lib/activitypub/process/process-dislike.ts b/server/lib/activitypub/process/process-dislike.ts
deleted file mode 100644
index 4e270f917..000000000
--- a/server/lib/activitypub/process/process-dislike.ts
+++ /dev/null
@@ -1,58 +0,0 @@
1import { VideoModel } from '@server/models/video/video'
2import { ActivityDislike } from '@shared/models'
3import { retryTransactionWrapper } from '../../../helpers/database-utils'
4import { sequelizeTypescript } from '../../../initializers/database'
5import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
6import { APProcessorOptions } from '../../../types/activitypub-processor.model'
7import { MActorSignature } from '../../../types/models'
8import { federateVideoIfNeeded, getOrCreateAPVideo } from '../videos'
9
10async function processDislikeActivity (options: APProcessorOptions<ActivityDislike>) {
11 const { activity, byActor } = options
12 return retryTransactionWrapper(processDislike, activity, byActor)
13}
14
15// ---------------------------------------------------------------------------
16
17export {
18 processDislikeActivity
19}
20
21// ---------------------------------------------------------------------------
22
23async function processDislike (activity: ActivityDislike, byActor: MActorSignature) {
24 const dislikeObject = activity.object
25 const byAccount = byActor.Account
26
27 if (!byAccount) throw new Error('Cannot create dislike with the non account actor ' + byActor.url)
28
29 const { video: onlyVideo } = await getOrCreateAPVideo({ videoObject: dislikeObject, fetchType: 'only-video' })
30
31 // We don't care about dislikes of remote videos
32 if (!onlyVideo.isOwned()) return
33
34 return sequelizeTypescript.transaction(async t => {
35 const video = await VideoModel.loadFull(onlyVideo.id, t)
36
37 const existingRate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byAccount.id, video.id, activity.id, t)
38 if (existingRate && existingRate.type === 'dislike') return
39
40 await video.increment('dislikes', { transaction: t })
41 video.dislikes++
42
43 if (existingRate && existingRate.type === 'like') {
44 await video.decrement('likes', { transaction: t })
45 video.likes--
46 }
47
48 const rate = existingRate || new AccountVideoRateModel()
49 rate.type = 'dislike'
50 rate.videoId = video.id
51 rate.accountId = byAccount.id
52 rate.url = activity.id
53
54 await rate.save({ transaction: t })
55
56 await federateVideoIfNeeded(video, false, t)
57 })
58}
diff --git a/server/lib/activitypub/process/process-flag.ts b/server/lib/activitypub/process/process-flag.ts
deleted file mode 100644
index bea285670..000000000
--- a/server/lib/activitypub/process/process-flag.ts
+++ /dev/null
@@ -1,103 +0,0 @@
1import { createAccountAbuse, createVideoAbuse, createVideoCommentAbuse } from '@server/lib/moderation'
2import { AccountModel } from '@server/models/account/account'
3import { VideoModel } from '@server/models/video/video'
4import { VideoCommentModel } from '@server/models/video/video-comment'
5import { abusePredefinedReasonsMap } from '@shared/core-utils/abuse'
6import { AbuseState, ActivityFlag } from '@shared/models'
7import { retryTransactionWrapper } from '../../../helpers/database-utils'
8import { logger } from '../../../helpers/logger'
9import { sequelizeTypescript } from '../../../initializers/database'
10import { getAPId } from '../../../lib/activitypub/activity'
11import { APProcessorOptions } from '../../../types/activitypub-processor.model'
12import { MAccountDefault, MActorSignature, MCommentOwnerVideo } from '../../../types/models'
13
14async function processFlagActivity (options: APProcessorOptions<ActivityFlag>) {
15 const { activity, byActor } = options
16
17 return retryTransactionWrapper(processCreateAbuse, activity, byActor)
18}
19
20// ---------------------------------------------------------------------------
21
22export {
23 processFlagActivity
24}
25
26// ---------------------------------------------------------------------------
27
28async function processCreateAbuse (flag: ActivityFlag, byActor: MActorSignature) {
29 const account = byActor.Account
30 if (!account) throw new Error('Cannot create abuse with the non account actor ' + byActor.url)
31
32 const reporterAccount = await AccountModel.load(account.id)
33
34 const objects = Array.isArray(flag.object) ? flag.object : [ flag.object ]
35
36 const tags = Array.isArray(flag.tag) ? flag.tag : []
37 const predefinedReasons = tags.map(tag => abusePredefinedReasonsMap[tag.name])
38 .filter(v => !isNaN(v))
39
40 const startAt = flag.startAt
41 const endAt = flag.endAt
42
43 for (const object of objects) {
44 try {
45 const uri = getAPId(object)
46
47 logger.debug('Reporting remote abuse for object %s.', uri)
48
49 await sequelizeTypescript.transaction(async t => {
50 const video = await VideoModel.loadByUrlAndPopulateAccount(uri, t)
51 let videoComment: MCommentOwnerVideo
52 let flaggedAccount: MAccountDefault
53
54 if (!video) videoComment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(uri, t)
55 if (!videoComment) flaggedAccount = await AccountModel.loadByUrl(uri, t)
56
57 if (!video && !videoComment && !flaggedAccount) {
58 logger.warn('Cannot flag unknown entity %s.', object)
59 return
60 }
61
62 const baseAbuse = {
63 reporterAccountId: reporterAccount.id,
64 reason: flag.content,
65 state: AbuseState.PENDING,
66 predefinedReasons
67 }
68
69 if (video) {
70 return createVideoAbuse({
71 baseAbuse,
72 startAt,
73 endAt,
74 reporterAccount,
75 transaction: t,
76 videoInstance: video,
77 skipNotification: false
78 })
79 }
80
81 if (videoComment) {
82 return createVideoCommentAbuse({
83 baseAbuse,
84 reporterAccount,
85 transaction: t,
86 commentInstance: videoComment,
87 skipNotification: false
88 })
89 }
90
91 return await createAccountAbuse({
92 baseAbuse,
93 reporterAccount,
94 transaction: t,
95 accountInstance: flaggedAccount,
96 skipNotification: false
97 })
98 })
99 } catch (err) {
100 logger.debug('Cannot process report of %s', getAPId(object), { err })
101 }
102 }
103}
diff --git a/server/lib/activitypub/process/process-follow.ts b/server/lib/activitypub/process/process-follow.ts
deleted file mode 100644
index 7def753d5..000000000
--- a/server/lib/activitypub/process/process-follow.ts
+++ /dev/null
@@ -1,156 +0,0 @@
1import { Transaction } from 'sequelize/types'
2import { isBlockedByServerOrAccount } from '@server/lib/blocklist'
3import { AccountModel } from '@server/models/account/account'
4import { getServerActor } from '@server/models/application/application'
5import { ActivityFollow } from '../../../../shared/models/activitypub'
6import { retryTransactionWrapper } from '../../../helpers/database-utils'
7import { logger } from '../../../helpers/logger'
8import { CONFIG } from '../../../initializers/config'
9import { sequelizeTypescript } from '../../../initializers/database'
10import { getAPId } from '../../../lib/activitypub/activity'
11import { ActorModel } from '../../../models/actor/actor'
12import { ActorFollowModel } from '../../../models/actor/actor-follow'
13import { APProcessorOptions } from '../../../types/activitypub-processor.model'
14import { MActorFollow, MActorFull, MActorId, MActorSignature } from '../../../types/models'
15import { Notifier } from '../../notifier'
16import { autoFollowBackIfNeeded } from '../follow'
17import { sendAccept, sendReject } from '../send'
18
19async function processFollowActivity (options: APProcessorOptions<ActivityFollow>) {
20 const { activity, byActor } = options
21
22 const activityId = activity.id
23 const objectId = getAPId(activity.object)
24
25 return retryTransactionWrapper(processFollow, byActor, activityId, objectId)
26}
27
28// ---------------------------------------------------------------------------
29
30export {
31 processFollowActivity
32}
33
34// ---------------------------------------------------------------------------
35
36async function processFollow (byActor: MActorSignature, activityId: string, targetActorURL: string) {
37 const { actorFollow, created, targetActor } = await sequelizeTypescript.transaction(async t => {
38 const targetActor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(targetActorURL, t)
39
40 if (!targetActor) throw new Error('Unknown actor')
41 if (targetActor.isOwned() === false) throw new Error('This is not a local actor.')
42
43 if (await rejectIfInstanceFollowDisabled(byActor, activityId, targetActor)) return { actorFollow: undefined }
44 if (await rejectIfMuted(byActor, activityId, targetActor)) return { actorFollow: undefined }
45
46 const [ actorFollow, created ] = await ActorFollowModel.findOrCreateCustom({
47 byActor,
48 targetActor,
49 activityId,
50 state: await isFollowingInstance(targetActor) && CONFIG.FOLLOWERS.INSTANCE.MANUAL_APPROVAL
51 ? 'pending'
52 : 'accepted',
53 transaction: t
54 })
55
56 if (rejectIfAlreadyRejected(actorFollow, byActor, activityId, targetActor)) return { actorFollow: undefined }
57
58 await acceptIfNeeded(actorFollow, targetActor, t)
59
60 await fixFollowURLIfNeeded(actorFollow, activityId, t)
61
62 actorFollow.ActorFollower = byActor
63 actorFollow.ActorFollowing = targetActor
64
65 // Target sends to actor he accepted the follow request
66 if (actorFollow.state === 'accepted') {
67 sendAccept(actorFollow)
68
69 await autoFollowBackIfNeeded(actorFollow, t)
70 }
71
72 return { actorFollow, created, targetActor }
73 })
74
75 // Rejected
76 if (!actorFollow) return
77
78 if (created) {
79 const follower = await ActorModel.loadFull(byActor.id)
80 const actorFollowFull = Object.assign(actorFollow, { ActorFollowing: targetActor, ActorFollower: follower })
81
82 if (await isFollowingInstance(targetActor)) {
83 Notifier.Instance.notifyOfNewInstanceFollow(actorFollowFull)
84 } else {
85 Notifier.Instance.notifyOfNewUserFollow(actorFollowFull)
86 }
87 }
88
89 logger.info('Actor %s is followed by actor %s.', targetActorURL, byActor.url)
90}
91
92async function rejectIfInstanceFollowDisabled (byActor: MActorSignature, activityId: string, targetActor: MActorFull) {
93 if (await isFollowingInstance(targetActor) && CONFIG.FOLLOWERS.INSTANCE.ENABLED === false) {
94 logger.info('Rejecting %s because instance followers are disabled.', targetActor.url)
95
96 sendReject(activityId, byActor, targetActor)
97
98 return true
99 }
100
101 return false
102}
103
104async function rejectIfMuted (byActor: MActorSignature, activityId: string, targetActor: MActorFull) {
105 const followerAccount = await AccountModel.load(byActor.Account.id)
106 const followingAccountId = targetActor.Account
107
108 if (followerAccount && await isBlockedByServerOrAccount(followerAccount, followingAccountId)) {
109 logger.info('Rejecting %s because follower is muted.', byActor.url)
110
111 sendReject(activityId, byActor, targetActor)
112
113 return true
114 }
115
116 return false
117}
118
119function rejectIfAlreadyRejected (actorFollow: MActorFollow, byActor: MActorSignature, activityId: string, targetActor: MActorFull) {
120 // Already rejected
121 if (actorFollow.state === 'rejected') {
122 logger.info('Rejecting %s because follow is already rejected.', byActor.url)
123
124 sendReject(activityId, byActor, targetActor)
125
126 return true
127 }
128
129 return false
130}
131
132async function acceptIfNeeded (actorFollow: MActorFollow, targetActor: MActorFull, transaction: Transaction) {
133 // Set the follow as accepted if the remote actor follows a channel or account
134 // Or if the instance automatically accepts followers
135 if (actorFollow.state === 'accepted') return
136 if (!await isFollowingInstance(targetActor)) return
137 if (CONFIG.FOLLOWERS.INSTANCE.MANUAL_APPROVAL === true && await isFollowingInstance(targetActor)) return
138
139 actorFollow.state = 'accepted'
140
141 await actorFollow.save({ transaction })
142}
143
144async function fixFollowURLIfNeeded (actorFollow: MActorFollow, activityId: string, transaction: Transaction) {
145 // Before PeerTube V3 we did not save the follow ID. Try to fix these old follows
146 if (!actorFollow.url) {
147 actorFollow.url = activityId
148 await actorFollow.save({ transaction })
149 }
150}
151
152async function isFollowingInstance (targetActor: MActorId) {
153 const serverActor = await getServerActor()
154
155 return targetActor.id === serverActor.id
156}
diff --git a/server/lib/activitypub/process/process-like.ts b/server/lib/activitypub/process/process-like.ts
deleted file mode 100644
index 580a05bcd..000000000
--- a/server/lib/activitypub/process/process-like.ts
+++ /dev/null
@@ -1,60 +0,0 @@
1import { VideoModel } from '@server/models/video/video'
2import { ActivityLike } from '../../../../shared/models/activitypub'
3import { retryTransactionWrapper } from '../../../helpers/database-utils'
4import { sequelizeTypescript } from '../../../initializers/database'
5import { getAPId } from '../../../lib/activitypub/activity'
6import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
7import { APProcessorOptions } from '../../../types/activitypub-processor.model'
8import { MActorSignature } from '../../../types/models'
9import { federateVideoIfNeeded, getOrCreateAPVideo } from '../videos'
10
11async function processLikeActivity (options: APProcessorOptions<ActivityLike>) {
12 const { activity, byActor } = options
13
14 return retryTransactionWrapper(processLikeVideo, byActor, activity)
15}
16
17// ---------------------------------------------------------------------------
18
19export {
20 processLikeActivity
21}
22
23// ---------------------------------------------------------------------------
24
25async function processLikeVideo (byActor: MActorSignature, activity: ActivityLike) {
26 const videoUrl = getAPId(activity.object)
27
28 const byAccount = byActor.Account
29 if (!byAccount) throw new Error('Cannot create like with the non account actor ' + byActor.url)
30
31 const { video: onlyVideo } = await getOrCreateAPVideo({ videoObject: videoUrl, fetchType: 'only-video' })
32
33 // We don't care about likes of remote videos
34 if (!onlyVideo.isOwned()) return
35
36 return sequelizeTypescript.transaction(async t => {
37 const video = await VideoModel.loadFull(onlyVideo.id, t)
38
39 const existingRate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byAccount.id, video.id, activity.id, t)
40 if (existingRate && existingRate.type === 'like') return
41
42 if (existingRate && existingRate.type === 'dislike') {
43 await video.decrement('dislikes', { transaction: t })
44 video.dislikes--
45 }
46
47 await video.increment('likes', { transaction: t })
48 video.likes++
49
50 const rate = existingRate || new AccountVideoRateModel()
51 rate.type = 'like'
52 rate.videoId = video.id
53 rate.accountId = byAccount.id
54 rate.url = activity.id
55
56 await rate.save({ transaction: t })
57
58 await federateVideoIfNeeded(video, false, t)
59 })
60}
diff --git a/server/lib/activitypub/process/process-reject.ts b/server/lib/activitypub/process/process-reject.ts
deleted file mode 100644
index db7ff24d8..000000000
--- a/server/lib/activitypub/process/process-reject.ts
+++ /dev/null
@@ -1,33 +0,0 @@
1import { ActivityReject } from '../../../../shared/models/activitypub/activity'
2import { sequelizeTypescript } from '../../../initializers/database'
3import { ActorFollowModel } from '../../../models/actor/actor-follow'
4import { APProcessorOptions } from '../../../types/activitypub-processor.model'
5import { MActor } from '../../../types/models'
6
7async function processRejectActivity (options: APProcessorOptions<ActivityReject>) {
8 const { byActor: targetActor, inboxActor } = options
9 if (inboxActor === undefined) throw new Error('Need to reject on explicit inbox.')
10
11 return processReject(inboxActor, targetActor)
12}
13
14// ---------------------------------------------------------------------------
15
16export {
17 processRejectActivity
18}
19
20// ---------------------------------------------------------------------------
21
22async function processReject (follower: MActor, targetActor: MActor) {
23 return sequelizeTypescript.transaction(async t => {
24 const actorFollow = await ActorFollowModel.loadByActorAndTarget(follower.id, targetActor.id, t)
25
26 if (!actorFollow) throw new Error(`'Unknown actor follow ${follower.id} -> ${targetActor.id}.`)
27
28 actorFollow.state = 'rejected'
29 await actorFollow.save({ transaction: t })
30
31 return undefined
32 })
33}
diff --git a/server/lib/activitypub/process/process-undo.ts b/server/lib/activitypub/process/process-undo.ts
deleted file mode 100644
index a9d8199de..000000000
--- a/server/lib/activitypub/process/process-undo.ts
+++ /dev/null
@@ -1,183 +0,0 @@
1import { VideoModel } from '@server/models/video/video'
2import {
3 ActivityAnnounce,
4 ActivityCreate,
5 ActivityDislike,
6 ActivityFollow,
7 ActivityLike,
8 ActivityUndo,
9 ActivityUndoObject,
10 CacheFileObject
11} from '../../../../shared/models/activitypub'
12import { retryTransactionWrapper } from '../../../helpers/database-utils'
13import { logger } from '../../../helpers/logger'
14import { sequelizeTypescript } from '../../../initializers/database'
15import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
16import { ActorModel } from '../../../models/actor/actor'
17import { ActorFollowModel } from '../../../models/actor/actor-follow'
18import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
19import { VideoShareModel } from '../../../models/video/video-share'
20import { APProcessorOptions } from '../../../types/activitypub-processor.model'
21import { MActorSignature } from '../../../types/models'
22import { fetchAPObjectIfNeeded } from '../activity'
23import { forwardVideoRelatedActivity } from '../send/shared/send-utils'
24import { federateVideoIfNeeded, getOrCreateAPVideo } from '../videos'
25
26async function processUndoActivity (options: APProcessorOptions<ActivityUndo<ActivityUndoObject>>) {
27 const { activity, byActor } = options
28 const activityToUndo = activity.object
29
30 if (activityToUndo.type === 'Like') {
31 return retryTransactionWrapper(processUndoLike, byActor, activity)
32 }
33
34 if (activityToUndo.type === 'Create') {
35 const objectToUndo = await fetchAPObjectIfNeeded<CacheFileObject>(activityToUndo.object)
36
37 if (objectToUndo.type === 'CacheFile') {
38 return retryTransactionWrapper(processUndoCacheFile, byActor, activity, objectToUndo)
39 }
40 }
41
42 if (activityToUndo.type === 'Dislike') {
43 return retryTransactionWrapper(processUndoDislike, byActor, activity)
44 }
45
46 if (activityToUndo.type === 'Follow') {
47 return retryTransactionWrapper(processUndoFollow, byActor, activityToUndo)
48 }
49
50 if (activityToUndo.type === 'Announce') {
51 return retryTransactionWrapper(processUndoAnnounce, byActor, activityToUndo)
52 }
53
54 logger.warn('Unknown activity object type %s -> %s when undo activity.', activityToUndo.type, { activity: activity.id })
55
56 return undefined
57}
58
59// ---------------------------------------------------------------------------
60
61export {
62 processUndoActivity
63}
64
65// ---------------------------------------------------------------------------
66
67async function processUndoLike (byActor: MActorSignature, activity: ActivityUndo<ActivityLike>) {
68 const likeActivity = activity.object
69
70 const { video: onlyVideo } = await getOrCreateAPVideo({ videoObject: likeActivity.object })
71 // We don't care about likes of remote videos
72 if (!onlyVideo.isOwned()) return
73
74 return sequelizeTypescript.transaction(async t => {
75 if (!byActor.Account) throw new Error('Unknown account ' + byActor.url)
76
77 const video = await VideoModel.loadFull(onlyVideo.id, t)
78 const rate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byActor.Account.id, video.id, likeActivity.id, t)
79 if (!rate || rate.type !== 'like') {
80 logger.warn('Unknown like by account %d for video %d.', byActor.Account.id, video.id)
81 return
82 }
83
84 await rate.destroy({ transaction: t })
85 await video.decrement('likes', { transaction: t })
86
87 video.likes--
88 await federateVideoIfNeeded(video, false, t)
89 })
90}
91
92async function processUndoDislike (byActor: MActorSignature, activity: ActivityUndo<ActivityDislike>) {
93 const dislikeActivity = activity.object
94
95 const { video: onlyVideo } = await getOrCreateAPVideo({ videoObject: dislikeActivity.object })
96 // We don't care about likes of remote videos
97 if (!onlyVideo.isOwned()) return
98
99 return sequelizeTypescript.transaction(async t => {
100 if (!byActor.Account) throw new Error('Unknown account ' + byActor.url)
101
102 const video = await VideoModel.loadFull(onlyVideo.id, t)
103 const rate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byActor.Account.id, video.id, dislikeActivity.id, t)
104 if (!rate || rate.type !== 'dislike') {
105 logger.warn(`Unknown dislike by account %d for video %d.`, byActor.Account.id, video.id)
106 return
107 }
108
109 await rate.destroy({ transaction: t })
110 await video.decrement('dislikes', { transaction: t })
111 video.dislikes--
112
113 await federateVideoIfNeeded(video, false, t)
114 })
115}
116
117// ---------------------------------------------------------------------------
118
119async function processUndoCacheFile (
120 byActor: MActorSignature,
121 activity: ActivityUndo<ActivityCreate<CacheFileObject>>,
122 cacheFileObject: CacheFileObject
123) {
124 const { video } = await getOrCreateAPVideo({ videoObject: cacheFileObject.object })
125
126 return sequelizeTypescript.transaction(async t => {
127 const cacheFile = await VideoRedundancyModel.loadByUrl(cacheFileObject.id, t)
128 if (!cacheFile) {
129 logger.debug('Cannot undo unknown video cache %s.', cacheFileObject.id)
130 return
131 }
132
133 if (cacheFile.actorId !== byActor.id) throw new Error('Cannot delete redundancy ' + cacheFile.url + ' of another actor.')
134
135 await cacheFile.destroy({ transaction: t })
136
137 if (video.isOwned()) {
138 // Don't resend the activity to the sender
139 const exceptions = [ byActor ]
140
141 await forwardVideoRelatedActivity(activity, t, exceptions, video)
142 }
143 })
144}
145
146function processUndoAnnounce (byActor: MActorSignature, announceActivity: ActivityAnnounce) {
147 return sequelizeTypescript.transaction(async t => {
148 const share = await VideoShareModel.loadByUrl(announceActivity.id, t)
149 if (!share) {
150 logger.warn('Unknown video share %d', announceActivity.id)
151 return
152 }
153
154 if (share.actorId !== byActor.id) throw new Error(`${share.url} is not shared by ${byActor.url}.`)
155
156 await share.destroy({ transaction: t })
157
158 if (share.Video.isOwned()) {
159 // Don't resend the activity to the sender
160 const exceptions = [ byActor ]
161
162 await forwardVideoRelatedActivity(announceActivity, t, exceptions, share.Video)
163 }
164 })
165}
166
167// ---------------------------------------------------------------------------
168
169function processUndoFollow (follower: MActorSignature, followActivity: ActivityFollow) {
170 return sequelizeTypescript.transaction(async t => {
171 const following = await ActorModel.loadByUrlAndPopulateAccountAndChannel(followActivity.object, t)
172 const actorFollow = await ActorFollowModel.loadByActorAndTarget(follower.id, following.id, t)
173
174 if (!actorFollow) {
175 logger.warn('Unknown actor follow %d -> %d.', follower.id, following.id)
176 return
177 }
178
179 await actorFollow.destroy({ transaction: t })
180
181 return undefined
182 })
183}
diff --git a/server/lib/activitypub/process/process-update.ts b/server/lib/activitypub/process/process-update.ts
deleted file mode 100644
index 304ed9de6..000000000
--- a/server/lib/activitypub/process/process-update.ts
+++ /dev/null
@@ -1,119 +0,0 @@
1import { isRedundancyAccepted } from '@server/lib/redundancy'
2import { ActivityUpdate, ActivityUpdateObject, CacheFileObject, VideoObject } from '../../../../shared/models/activitypub'
3import { ActivityPubActor } from '../../../../shared/models/activitypub/activitypub-actor'
4import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
5import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file'
6import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos'
7import { retryTransactionWrapper } from '../../../helpers/database-utils'
8import { logger } from '../../../helpers/logger'
9import { sequelizeTypescript } from '../../../initializers/database'
10import { ActorModel } from '../../../models/actor/actor'
11import { APProcessorOptions } from '../../../types/activitypub-processor.model'
12import { MActorFull, MActorSignature } from '../../../types/models'
13import { fetchAPObjectIfNeeded } from '../activity'
14import { APActorUpdater } from '../actors/updater'
15import { createOrUpdateCacheFile } from '../cache-file'
16import { createOrUpdateVideoPlaylist } from '../playlists'
17import { forwardVideoRelatedActivity } from '../send/shared/send-utils'
18import { APVideoUpdater, getOrCreateAPVideo } from '../videos'
19
20async function processUpdateActivity (options: APProcessorOptions<ActivityUpdate<ActivityUpdateObject>>) {
21 const { activity, byActor } = options
22
23 const object = await fetchAPObjectIfNeeded(activity.object)
24 const objectType = object.type
25
26 if (objectType === 'Video') {
27 return retryTransactionWrapper(processUpdateVideo, activity)
28 }
29
30 if (objectType === 'Person' || objectType === 'Application' || objectType === 'Group') {
31 // We need more attributes
32 const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
33 return retryTransactionWrapper(processUpdateActor, byActorFull, object)
34 }
35
36 if (objectType === 'CacheFile') {
37 // We need more attributes
38 const byActorFull = await ActorModel.loadByUrlAndPopulateAccountAndChannel(byActor.url)
39 return retryTransactionWrapper(processUpdateCacheFile, byActorFull, activity, object)
40 }
41
42 if (objectType === 'Playlist') {
43 return retryTransactionWrapper(processUpdatePlaylist, byActor, activity, object)
44 }
45
46 return undefined
47}
48
49// ---------------------------------------------------------------------------
50
51export {
52 processUpdateActivity
53}
54
55// ---------------------------------------------------------------------------
56
57async function processUpdateVideo (activity: ActivityUpdate<VideoObject | string>) {
58 const videoObject = activity.object as VideoObject
59
60 if (sanitizeAndCheckVideoTorrentObject(videoObject) === false) {
61 logger.debug('Video sent by update is not valid.', { videoObject })
62 return undefined
63 }
64
65 const { video, created } = await getOrCreateAPVideo({
66 videoObject: videoObject.id,
67 allowRefresh: false,
68 fetchType: 'all'
69 })
70 // We did not have this video, it has been created so no need to update
71 if (created) return
72
73 const updater = new APVideoUpdater(videoObject, video)
74 return updater.update(activity.to)
75}
76
77async function processUpdateCacheFile (
78 byActor: MActorSignature,
79 activity: ActivityUpdate<CacheFileObject | string>,
80 cacheFileObject: CacheFileObject
81) {
82 if (await isRedundancyAccepted(activity, byActor) !== true) return
83
84 if (!isCacheFileObjectValid(cacheFileObject)) {
85 logger.debug('Cache file object sent by update is not valid.', { cacheFileObject })
86 return undefined
87 }
88
89 const { video } = await getOrCreateAPVideo({ videoObject: cacheFileObject.object })
90
91 await sequelizeTypescript.transaction(async t => {
92 await createOrUpdateCacheFile(cacheFileObject, video, byActor, t)
93 })
94
95 if (video.isOwned()) {
96 // Don't resend the activity to the sender
97 const exceptions = [ byActor ]
98
99 await forwardVideoRelatedActivity(activity, undefined, exceptions, video)
100 }
101}
102
103async function processUpdateActor (actor: MActorFull, actorObject: ActivityPubActor) {
104 logger.debug('Updating remote account "%s".', actorObject.url)
105
106 const updater = new APActorUpdater(actorObject, actor)
107 return updater.update()
108}
109
110async function processUpdatePlaylist (
111 byActor: MActorSignature,
112 activity: ActivityUpdate<PlaylistObject | string>,
113 playlistObject: PlaylistObject
114) {
115 const byAccount = byActor.Account
116 if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url)
117
118 await createOrUpdateVideoPlaylist(playlistObject, activity.to)
119}
diff --git a/server/lib/activitypub/process/process-view.ts b/server/lib/activitypub/process/process-view.ts
deleted file mode 100644
index e49506d82..000000000
--- a/server/lib/activitypub/process/process-view.ts
+++ /dev/null
@@ -1,42 +0,0 @@
1import { VideoViewsManager } from '@server/lib/views/video-views-manager'
2import { ActivityView } from '../../../../shared/models/activitypub'
3import { APProcessorOptions } from '../../../types/activitypub-processor.model'
4import { MActorSignature } from '../../../types/models'
5import { forwardVideoRelatedActivity } from '../send/shared/send-utils'
6import { getOrCreateAPVideo } from '../videos'
7
8async function processViewActivity (options: APProcessorOptions<ActivityView>) {
9 const { activity, byActor } = options
10
11 return processCreateView(activity, byActor)
12}
13
14// ---------------------------------------------------------------------------
15
16export {
17 processViewActivity
18}
19
20// ---------------------------------------------------------------------------
21
22async function processCreateView (activity: ActivityView, byActor: MActorSignature) {
23 const videoObject = activity.object
24
25 const { video } = await getOrCreateAPVideo({
26 videoObject,
27 fetchType: 'only-video',
28 allowRefresh: false
29 })
30
31 const viewerExpires = activity.expires
32 ? new Date(activity.expires)
33 : undefined
34
35 await VideoViewsManager.Instance.processRemoteView({ video, viewerId: activity.id, viewerExpires })
36
37 if (video.isOwned()) {
38 // Forward the view but don't resend the activity to the sender
39 const exceptions = [ byActor ]
40 await forwardVideoRelatedActivity(activity, undefined, exceptions, video)
41 }
42}
diff --git a/server/lib/activitypub/process/process.ts b/server/lib/activitypub/process/process.ts
deleted file mode 100644
index 2bc3dce03..000000000
--- a/server/lib/activitypub/process/process.ts
+++ /dev/null
@@ -1,92 +0,0 @@
1import { StatsManager } from '@server/lib/stat-manager'
2import { Activity, ActivityType } from '../../../../shared/models/activitypub'
3import { logger } from '../../../helpers/logger'
4import { APProcessorOptions } from '../../../types/activitypub-processor.model'
5import { MActorDefault, MActorSignature } from '../../../types/models'
6import { getAPId } from '../activity'
7import { getOrCreateAPActor } from '../actors'
8import { checkUrlsSameHost } from '../url'
9import { processAcceptActivity } from './process-accept'
10import { processAnnounceActivity } from './process-announce'
11import { processCreateActivity } from './process-create'
12import { processDeleteActivity } from './process-delete'
13import { processDislikeActivity } from './process-dislike'
14import { processFlagActivity } from './process-flag'
15import { processFollowActivity } from './process-follow'
16import { processLikeActivity } from './process-like'
17import { processRejectActivity } from './process-reject'
18import { processUndoActivity } from './process-undo'
19import { processUpdateActivity } from './process-update'
20import { processViewActivity } from './process-view'
21
22const processActivity: { [ P in ActivityType ]: (options: APProcessorOptions<Activity>) => Promise<any> } = {
23 Create: processCreateActivity,
24 Update: processUpdateActivity,
25 Delete: processDeleteActivity,
26 Follow: processFollowActivity,
27 Accept: processAcceptActivity,
28 Reject: processRejectActivity,
29 Announce: processAnnounceActivity,
30 Undo: processUndoActivity,
31 Like: processLikeActivity,
32 Dislike: processDislikeActivity,
33 Flag: processFlagActivity,
34 View: processViewActivity
35}
36
37async function processActivities (
38 activities: Activity[],
39 options: {
40 signatureActor?: MActorSignature
41 inboxActor?: MActorDefault
42 outboxUrl?: string
43 fromFetch?: boolean
44 } = {}
45) {
46 const { outboxUrl, signatureActor, inboxActor, fromFetch = false } = options
47
48 const actorsCache: { [ url: string ]: MActorSignature } = {}
49
50 for (const activity of activities) {
51 if (!signatureActor && [ 'Create', 'Announce', 'Like' ].includes(activity.type) === false) {
52 logger.error('Cannot process activity %s (type: %s) without the actor signature.', activity.id, activity.type)
53 continue
54 }
55
56 const actorUrl = getAPId(activity.actor)
57
58 // When we fetch remote data, we don't have signature
59 if (signatureActor && actorUrl !== signatureActor.url) {
60 logger.warn('Signature mismatch between %s and %s, skipping.', actorUrl, signatureActor.url)
61 continue
62 }
63
64 if (outboxUrl && checkUrlsSameHost(outboxUrl, actorUrl) !== true) {
65 logger.warn('Host mismatch between outbox URL %s and actor URL %s, skipping.', outboxUrl, actorUrl)
66 continue
67 }
68
69 const byActor = signatureActor || actorsCache[actorUrl] || await getOrCreateAPActor(actorUrl)
70 actorsCache[actorUrl] = byActor
71
72 const activityProcessor = processActivity[activity.type]
73 if (activityProcessor === undefined) {
74 logger.warn('Unknown activity type %s.', activity.type, { activityId: activity.id })
75 continue
76 }
77
78 try {
79 await activityProcessor({ activity, byActor, inboxActor, fromFetch })
80
81 StatsManager.Instance.addInboxProcessedSuccess(activity.type)
82 } catch (err) {
83 logger.warn('Cannot process activity %s.', activity.type, { err })
84
85 StatsManager.Instance.addInboxProcessedError(activity.type)
86 }
87 }
88}
89
90export {
91 processActivities
92}
diff --git a/server/lib/activitypub/send/http.ts b/server/lib/activitypub/send/http.ts
deleted file mode 100644
index b461aa55d..000000000
--- a/server/lib/activitypub/send/http.ts
+++ /dev/null
@@ -1,73 +0,0 @@
1import { buildDigest, signJsonLDObject } from '@server/helpers/peertube-crypto'
2import { ACTIVITY_PUB, HTTP_SIGNATURE } from '@server/initializers/constants'
3import { ActorModel } from '@server/models/actor/actor'
4import { getServerActor } from '@server/models/application/application'
5import { MActor } from '@server/types/models'
6import { ContextType } from '@shared/models/activitypub/context'
7import { activityPubContextify } from '../context'
8
9type Payload <T> = { body: T, contextType: ContextType, signatureActorId?: number }
10
11async function computeBody <T> (
12 payload: Payload<T>
13): Promise<T | T & { type: 'RsaSignature2017', creator: string, created: string }> {
14 let body = payload.body
15
16 if (payload.signatureActorId) {
17 const actorSignature = await ActorModel.load(payload.signatureActorId)
18 if (!actorSignature) throw new Error('Unknown signature actor id.')
19
20 body = await signAndContextify(actorSignature, payload.body, payload.contextType)
21 }
22
23 return body
24}
25
26async function buildSignedRequestOptions (options: {
27 signatureActorId?: number
28 hasPayload: boolean
29}) {
30 let actor: MActor | null
31
32 if (options.signatureActorId) {
33 actor = await ActorModel.load(options.signatureActorId)
34 if (!actor) throw new Error('Unknown signature actor id.')
35 } else {
36 // We need to sign the request, so use the server
37 actor = await getServerActor()
38 }
39
40 const keyId = actor.url
41 return {
42 algorithm: HTTP_SIGNATURE.ALGORITHM,
43 authorizationHeaderName: HTTP_SIGNATURE.HEADER_NAME,
44 keyId,
45 key: actor.privateKey,
46 headers: options.hasPayload
47 ? HTTP_SIGNATURE.HEADERS_TO_SIGN_WITH_PAYLOAD
48 : HTTP_SIGNATURE.HEADERS_TO_SIGN_WITHOUT_PAYLOAD
49 }
50}
51
52function buildGlobalHeaders (body: any) {
53 return {
54 'digest': buildDigest(body),
55 'content-type': 'application/activity+json',
56 'accept': ACTIVITY_PUB.ACCEPT_HEADER
57 }
58}
59
60async function signAndContextify <T> (byActor: MActor, data: T, contextType: ContextType | null) {
61 const activity = contextType
62 ? await activityPubContextify(data, contextType)
63 : data
64
65 return signJsonLDObject(byActor, activity)
66}
67
68export {
69 buildGlobalHeaders,
70 computeBody,
71 buildSignedRequestOptions,
72 signAndContextify
73}
diff --git a/server/lib/activitypub/send/index.ts b/server/lib/activitypub/send/index.ts
deleted file mode 100644
index 852ea2e74..000000000
--- a/server/lib/activitypub/send/index.ts
+++ /dev/null
@@ -1,10 +0,0 @@
1export * from './http'
2export * from './send-accept'
3export * from './send-announce'
4export * from './send-create'
5export * from './send-delete'
6export * from './send-follow'
7export * from './send-like'
8export * from './send-reject'
9export * from './send-undo'
10export * from './send-update'
diff --git a/server/lib/activitypub/send/send-accept.ts b/server/lib/activitypub/send/send-accept.ts
deleted file mode 100644
index 4c9bcbb0b..000000000
--- a/server/lib/activitypub/send/send-accept.ts
+++ /dev/null
@@ -1,47 +0,0 @@
1import { ActivityAccept, ActivityFollow } from '@shared/models'
2import { logger } from '../../../helpers/logger'
3import { MActor, MActorFollowActors } from '../../../types/models'
4import { getLocalActorFollowAcceptActivityPubUrl } from '../url'
5import { buildFollowActivity } from './send-follow'
6import { unicastTo } from './shared/send-utils'
7
8function sendAccept (actorFollow: MActorFollowActors) {
9 const follower = actorFollow.ActorFollower
10 const me = actorFollow.ActorFollowing
11
12 if (!follower.serverId) { // This should never happen
13 logger.warn('Do not sending accept to local follower.')
14 return
15 }
16
17 logger.info('Creating job to accept follower %s.', follower.url)
18
19 const followData = buildFollowActivity(actorFollow.url, follower, me)
20
21 const url = getLocalActorFollowAcceptActivityPubUrl(actorFollow)
22 const data = buildAcceptActivity(url, me, followData)
23
24 return unicastTo({
25 data,
26 byActor: me,
27 toActorUrl: follower.inboxUrl,
28 contextType: 'Accept'
29 })
30}
31
32// ---------------------------------------------------------------------------
33
34export {
35 sendAccept
36}
37
38// ---------------------------------------------------------------------------
39
40function buildAcceptActivity (url: string, byActor: MActor, followActivityData: ActivityFollow): ActivityAccept {
41 return {
42 type: 'Accept',
43 id: url,
44 actor: byActor.url,
45 object: followActivityData
46 }
47}
diff --git a/server/lib/activitypub/send/send-announce.ts b/server/lib/activitypub/send/send-announce.ts
deleted file mode 100644
index 6c078b047..000000000
--- a/server/lib/activitypub/send/send-announce.ts
+++ /dev/null
@@ -1,58 +0,0 @@
1import { Transaction } from 'sequelize'
2import { ActivityAnnounce, ActivityAudience } from '@shared/models'
3import { logger } from '../../../helpers/logger'
4import { MActorLight, MVideo } from '../../../types/models'
5import { MVideoShare } from '../../../types/models/video'
6import { audiencify, getAudience } from '../audience'
7import { getActorsInvolvedInVideo, getAudienceFromFollowersOf } from './shared'
8import { broadcastToFollowers } from './shared/send-utils'
9
10async function buildAnnounceWithVideoAudience (
11 byActor: MActorLight,
12 videoShare: MVideoShare,
13 video: MVideo,
14 t: Transaction
15) {
16 const announcedObject = video.url
17
18 const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, t)
19 const audience = getAudienceFromFollowersOf(actorsInvolvedInVideo)
20
21 const activity = buildAnnounceActivity(videoShare.url, byActor, announcedObject, audience)
22
23 return { activity, actorsInvolvedInVideo }
24}
25
26async function sendVideoAnnounce (byActor: MActorLight, videoShare: MVideoShare, video: MVideo, transaction: Transaction) {
27 const { activity, actorsInvolvedInVideo } = await buildAnnounceWithVideoAudience(byActor, videoShare, video, transaction)
28
29 logger.info('Creating job to send announce %s.', videoShare.url)
30
31 return broadcastToFollowers({
32 data: activity,
33 byActor,
34 toFollowersOf: actorsInvolvedInVideo,
35 transaction,
36 actorsException: [ byActor ],
37 contextType: 'Announce'
38 })
39}
40
41function buildAnnounceActivity (url: string, byActor: MActorLight, object: string, audience?: ActivityAudience): ActivityAnnounce {
42 if (!audience) audience = getAudience(byActor)
43
44 return audiencify({
45 type: 'Announce' as 'Announce',
46 id: url,
47 actor: byActor.url,
48 object
49 }, audience)
50}
51
52// ---------------------------------------------------------------------------
53
54export {
55 sendVideoAnnounce,
56 buildAnnounceActivity,
57 buildAnnounceWithVideoAudience
58}
diff --git a/server/lib/activitypub/send/send-create.ts b/server/lib/activitypub/send/send-create.ts
deleted file mode 100644
index 2cd4db14d..000000000
--- a/server/lib/activitypub/send/send-create.ts
+++ /dev/null
@@ -1,226 +0,0 @@
1import { Transaction } from 'sequelize'
2import { getServerActor } from '@server/models/application/application'
3import {
4 ActivityAudience,
5 ActivityCreate,
6 ActivityCreateObject,
7 ContextType,
8 VideoCommentObject,
9 VideoPlaylistPrivacy,
10 VideoPrivacy
11} from '@shared/models'
12import { logger, loggerTagsFactory } from '../../../helpers/logger'
13import { VideoCommentModel } from '../../../models/video/video-comment'
14import {
15 MActorLight,
16 MCommentOwnerVideo,
17 MLocalVideoViewerWithWatchSections,
18 MVideoAccountLight,
19 MVideoAP,
20 MVideoPlaylistFull,
21 MVideoRedundancyFileVideo,
22 MVideoRedundancyStreamingPlaylistVideo
23} from '../../../types/models'
24import { audiencify, getAudience } from '../audience'
25import {
26 broadcastToActors,
27 broadcastToFollowers,
28 getActorsInvolvedInVideo,
29 getAudienceFromFollowersOf,
30 getVideoCommentAudience,
31 sendVideoActivityToOrigin,
32 sendVideoRelatedActivity,
33 unicastTo
34} from './shared'
35
36const lTags = loggerTagsFactory('ap', 'create')
37
38async function sendCreateVideo (video: MVideoAP, transaction: Transaction) {
39 if (!video.hasPrivacyForFederation()) return undefined
40
41 logger.info('Creating job to send video creation of %s.', video.url, lTags(video.uuid))
42
43 const byActor = video.VideoChannel.Account.Actor
44 const videoObject = await video.toActivityPubObject()
45
46 const audience = getAudience(byActor, video.privacy === VideoPrivacy.PUBLIC)
47 const createActivity = buildCreateActivity(video.url, byActor, videoObject, audience)
48
49 return broadcastToFollowers({
50 data: createActivity,
51 byActor,
52 toFollowersOf: [ byActor ],
53 transaction,
54 contextType: 'Video'
55 })
56}
57
58async function sendCreateCacheFile (
59 byActor: MActorLight,
60 video: MVideoAccountLight,
61 fileRedundancy: MVideoRedundancyStreamingPlaylistVideo | MVideoRedundancyFileVideo
62) {
63 logger.info('Creating job to send file cache of %s.', fileRedundancy.url, lTags(video.uuid))
64
65 return sendVideoRelatedCreateActivity({
66 byActor,
67 video,
68 url: fileRedundancy.url,
69 object: fileRedundancy.toActivityPubObject(),
70 contextType: 'CacheFile'
71 })
72}
73
74async function sendCreateWatchAction (stats: MLocalVideoViewerWithWatchSections, transaction: Transaction) {
75 logger.info('Creating job to send create watch action %s.', stats.url, lTags(stats.uuid))
76
77 const byActor = await getServerActor()
78
79 const activityBuilder = (audience: ActivityAudience) => {
80 return buildCreateActivity(stats.url, byActor, stats.toActivityPubObject(), audience)
81 }
82
83 return sendVideoActivityToOrigin(activityBuilder, { byActor, video: stats.Video, transaction, contextType: 'WatchAction' })
84}
85
86async function sendCreateVideoPlaylist (playlist: MVideoPlaylistFull, transaction: Transaction) {
87 if (playlist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined
88
89 logger.info('Creating job to send create video playlist of %s.', playlist.url, lTags(playlist.uuid))
90
91 const byActor = playlist.OwnerAccount.Actor
92 const audience = getAudience(byActor, playlist.privacy === VideoPlaylistPrivacy.PUBLIC)
93
94 const object = await playlist.toActivityPubObject(null, transaction)
95 const createActivity = buildCreateActivity(playlist.url, byActor, object, audience)
96
97 const serverActor = await getServerActor()
98 const toFollowersOf = [ byActor, serverActor ]
99
100 if (playlist.VideoChannel) toFollowersOf.push(playlist.VideoChannel.Actor)
101
102 return broadcastToFollowers({
103 data: createActivity,
104 byActor,
105 toFollowersOf,
106 transaction,
107 contextType: 'Playlist'
108 })
109}
110
111async function sendCreateVideoComment (comment: MCommentOwnerVideo, transaction: Transaction) {
112 logger.info('Creating job to send comment %s.', comment.url)
113
114 const isOrigin = comment.Video.isOwned()
115
116 const byActor = comment.Account.Actor
117 const threadParentComments = await VideoCommentModel.listThreadParentComments(comment, transaction)
118 const commentObject = comment.toActivityPubObject(threadParentComments) as VideoCommentObject
119
120 const actorsInvolvedInComment = await getActorsInvolvedInVideo(comment.Video, transaction)
121 // Add the actor that commented too
122 actorsInvolvedInComment.push(byActor)
123
124 const parentsCommentActors = threadParentComments.filter(c => !c.isDeleted())
125 .map(c => c.Account.Actor)
126
127 let audience: ActivityAudience
128 if (isOrigin) {
129 audience = getVideoCommentAudience(comment, threadParentComments, actorsInvolvedInComment, isOrigin)
130 } else {
131 audience = getAudienceFromFollowersOf(actorsInvolvedInComment.concat(parentsCommentActors))
132 }
133
134 const createActivity = buildCreateActivity(comment.url, byActor, commentObject, audience)
135
136 // This was a reply, send it to the parent actors
137 const actorsException = [ byActor ]
138 await broadcastToActors({
139 data: createActivity,
140 byActor,
141 toActors: parentsCommentActors,
142 transaction,
143 actorsException,
144 contextType: 'Comment'
145 })
146
147 // Broadcast to our followers
148 await broadcastToFollowers({
149 data: createActivity,
150 byActor,
151 toFollowersOf: [ byActor ],
152 transaction,
153 contextType: 'Comment'
154 })
155
156 // Send to actors involved in the comment
157 if (isOrigin) {
158 return broadcastToFollowers({
159 data: createActivity,
160 byActor,
161 toFollowersOf: actorsInvolvedInComment,
162 transaction,
163 actorsException,
164 contextType: 'Comment'
165 })
166 }
167
168 // Send to origin
169 return transaction.afterCommit(() => {
170 return unicastTo({
171 data: createActivity,
172 byActor,
173 toActorUrl: comment.Video.VideoChannel.Account.Actor.getSharedInbox(),
174 contextType: 'Comment'
175 })
176 })
177}
178
179function buildCreateActivity <T extends ActivityCreateObject> (
180 url: string,
181 byActor: MActorLight,
182 object: T,
183 audience?: ActivityAudience
184): ActivityCreate<T> {
185 if (!audience) audience = getAudience(byActor)
186
187 return audiencify(
188 {
189 type: 'Create' as 'Create',
190 id: url + '/activity',
191 actor: byActor.url,
192 object: typeof object === 'string'
193 ? object
194 : audiencify(object, audience)
195 },
196 audience
197 )
198}
199
200// ---------------------------------------------------------------------------
201
202export {
203 sendCreateVideo,
204 buildCreateActivity,
205 sendCreateVideoComment,
206 sendCreateVideoPlaylist,
207 sendCreateCacheFile,
208 sendCreateWatchAction
209}
210
211// ---------------------------------------------------------------------------
212
213async function sendVideoRelatedCreateActivity (options: {
214 byActor: MActorLight
215 video: MVideoAccountLight
216 url: string
217 object: any
218 contextType: ContextType
219 transaction?: Transaction
220}) {
221 const activityBuilder = (audience: ActivityAudience) => {
222 return buildCreateActivity(options.url, options.byActor, options.object, audience)
223 }
224
225 return sendVideoRelatedActivity(activityBuilder, options)
226}
diff --git a/server/lib/activitypub/send/send-delete.ts b/server/lib/activitypub/send/send-delete.ts
deleted file mode 100644
index 0d85d9001..000000000
--- a/server/lib/activitypub/send/send-delete.ts
+++ /dev/null
@@ -1,158 +0,0 @@
1import { Transaction } from 'sequelize'
2import { getServerActor } from '@server/models/application/application'
3import { ActivityAudience, ActivityDelete } from '@shared/models'
4import { logger } from '../../../helpers/logger'
5import { ActorModel } from '../../../models/actor/actor'
6import { VideoCommentModel } from '../../../models/video/video-comment'
7import { VideoShareModel } from '../../../models/video/video-share'
8import { MActorUrl } from '../../../types/models'
9import { MCommentOwnerVideo, MVideoAccountLight, MVideoPlaylistFullSummary } from '../../../types/models/video'
10import { audiencify } from '../audience'
11import { getDeleteActivityPubUrl } from '../url'
12import { getActorsInvolvedInVideo, getVideoCommentAudience } from './shared'
13import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './shared/send-utils'
14
15async function sendDeleteVideo (video: MVideoAccountLight, transaction: Transaction) {
16 logger.info('Creating job to broadcast delete of video %s.', video.url)
17
18 const byActor = video.VideoChannel.Account.Actor
19
20 const activityBuilder = (audience: ActivityAudience) => {
21 const url = getDeleteActivityPubUrl(video.url)
22
23 return buildDeleteActivity(url, video.url, byActor, audience)
24 }
25
26 return sendVideoRelatedActivity(activityBuilder, { byActor, video, contextType: 'Delete', transaction })
27}
28
29async function sendDeleteActor (byActor: ActorModel, transaction: Transaction) {
30 logger.info('Creating job to broadcast delete of actor %s.', byActor.url)
31
32 const url = getDeleteActivityPubUrl(byActor.url)
33 const activity = buildDeleteActivity(url, byActor.url, byActor)
34
35 const actorsInvolved = await VideoShareModel.loadActorsWhoSharedVideosOf(byActor.id, transaction)
36
37 // In case the actor did not have any videos
38 const serverActor = await getServerActor()
39 actorsInvolved.push(serverActor)
40
41 actorsInvolved.push(byActor)
42
43 return broadcastToFollowers({
44 data: activity,
45 byActor,
46 toFollowersOf: actorsInvolved,
47 contextType: 'Delete',
48 transaction
49 })
50}
51
52async function sendDeleteVideoComment (videoComment: MCommentOwnerVideo, transaction: Transaction) {
53 logger.info('Creating job to send delete of comment %s.', videoComment.url)
54
55 const isVideoOrigin = videoComment.Video.isOwned()
56
57 const url = getDeleteActivityPubUrl(videoComment.url)
58 const byActor = videoComment.isOwned()
59 ? videoComment.Account.Actor
60 : videoComment.Video.VideoChannel.Account.Actor
61
62 const threadParentComments = await VideoCommentModel.listThreadParentComments(videoComment, transaction)
63 const threadParentCommentsFiltered = threadParentComments.filter(c => !c.isDeleted())
64
65 const actorsInvolvedInComment = await getActorsInvolvedInVideo(videoComment.Video, transaction)
66 actorsInvolvedInComment.push(byActor) // Add the actor that commented the video
67
68 const audience = getVideoCommentAudience(videoComment, threadParentCommentsFiltered, actorsInvolvedInComment, isVideoOrigin)
69 const activity = buildDeleteActivity(url, videoComment.url, byActor, audience)
70
71 // This was a reply, send it to the parent actors
72 const actorsException = [ byActor ]
73 await broadcastToActors({
74 data: activity,
75 byActor,
76 toActors: threadParentCommentsFiltered.map(c => c.Account.Actor),
77 transaction,
78 contextType: 'Delete',
79 actorsException
80 })
81
82 // Broadcast to our followers
83 await broadcastToFollowers({
84 data: activity,
85 byActor,
86 toFollowersOf: [ byActor ],
87 contextType: 'Delete',
88 transaction
89 })
90
91 // Send to actors involved in the comment
92 if (isVideoOrigin) {
93 return broadcastToFollowers({
94 data: activity,
95 byActor,
96 toFollowersOf: actorsInvolvedInComment,
97 transaction,
98 contextType: 'Delete',
99 actorsException
100 })
101 }
102
103 // Send to origin
104 return transaction.afterCommit(() => {
105 return unicastTo({
106 data: activity,
107 byActor,
108 toActorUrl: videoComment.Video.VideoChannel.Account.Actor.getSharedInbox(),
109 contextType: 'Delete'
110 })
111 })
112}
113
114async function sendDeleteVideoPlaylist (videoPlaylist: MVideoPlaylistFullSummary, transaction: Transaction) {
115 logger.info('Creating job to send delete of playlist %s.', videoPlaylist.url)
116
117 const byActor = videoPlaylist.OwnerAccount.Actor
118
119 const url = getDeleteActivityPubUrl(videoPlaylist.url)
120 const activity = buildDeleteActivity(url, videoPlaylist.url, byActor)
121
122 const serverActor = await getServerActor()
123 const toFollowersOf = [ byActor, serverActor ]
124
125 if (videoPlaylist.VideoChannel) toFollowersOf.push(videoPlaylist.VideoChannel.Actor)
126
127 return broadcastToFollowers({
128 data: activity,
129 byActor,
130 toFollowersOf,
131 contextType: 'Delete',
132 transaction
133 })
134}
135
136// ---------------------------------------------------------------------------
137
138export {
139 sendDeleteVideo,
140 sendDeleteActor,
141 sendDeleteVideoComment,
142 sendDeleteVideoPlaylist
143}
144
145// ---------------------------------------------------------------------------
146
147function buildDeleteActivity (url: string, object: string, byActor: MActorUrl, audience?: ActivityAudience): ActivityDelete {
148 const activity = {
149 type: 'Delete' as 'Delete',
150 id: url,
151 actor: byActor.url,
152 object
153 }
154
155 if (audience) return audiencify(activity, audience)
156
157 return activity
158}
diff --git a/server/lib/activitypub/send/send-dislike.ts b/server/lib/activitypub/send/send-dislike.ts
deleted file mode 100644
index 959e74823..000000000
--- a/server/lib/activitypub/send/send-dislike.ts
+++ /dev/null
@@ -1,40 +0,0 @@
1import { Transaction } from 'sequelize'
2import { ActivityAudience, ActivityDislike } from '@shared/models'
3import { logger } from '../../../helpers/logger'
4import { MActor, MActorAudience, MVideoAccountLight, MVideoUrl } from '../../../types/models'
5import { audiencify, getAudience } from '../audience'
6import { getVideoDislikeActivityPubUrlByLocalActor } from '../url'
7import { sendVideoActivityToOrigin } from './shared/send-utils'
8
9function sendDislike (byActor: MActor, video: MVideoAccountLight, transaction: Transaction) {
10 logger.info('Creating job to dislike %s.', video.url)
11
12 const activityBuilder = (audience: ActivityAudience) => {
13 const url = getVideoDislikeActivityPubUrlByLocalActor(byActor, video)
14
15 return buildDislikeActivity(url, byActor, video, audience)
16 }
17
18 return sendVideoActivityToOrigin(activityBuilder, { byActor, video, transaction, contextType: 'Rate' })
19}
20
21function buildDislikeActivity (url: string, byActor: MActorAudience, video: MVideoUrl, audience?: ActivityAudience): ActivityDislike {
22 if (!audience) audience = getAudience(byActor)
23
24 return audiencify(
25 {
26 id: url,
27 type: 'Dislike' as 'Dislike',
28 actor: byActor.url,
29 object: video.url
30 },
31 audience
32 )
33}
34
35// ---------------------------------------------------------------------------
36
37export {
38 sendDislike,
39 buildDislikeActivity
40}
diff --git a/server/lib/activitypub/send/send-flag.ts b/server/lib/activitypub/send/send-flag.ts
deleted file mode 100644
index 138eb5adc..000000000
--- a/server/lib/activitypub/send/send-flag.ts
+++ /dev/null
@@ -1,42 +0,0 @@
1import { Transaction } from 'sequelize'
2import { ActivityAudience, ActivityFlag } from '@shared/models'
3import { logger } from '../../../helpers/logger'
4import { MAbuseAP, MAccountLight, MActor } from '../../../types/models'
5import { audiencify, getAudience } from '../audience'
6import { getLocalAbuseActivityPubUrl } from '../url'
7import { unicastTo } from './shared/send-utils'
8
9function sendAbuse (byActor: MActor, abuse: MAbuseAP, flaggedAccount: MAccountLight, t: Transaction) {
10 if (!flaggedAccount.Actor.serverId) return // Local user
11
12 const url = getLocalAbuseActivityPubUrl(abuse)
13
14 logger.info('Creating job to send abuse %s.', url)
15
16 // Custom audience, we only send the abuse to the origin instance
17 const audience = { to: [ flaggedAccount.Actor.url ], cc: [] }
18 const flagActivity = buildFlagActivity(url, byActor, abuse, audience)
19
20 return t.afterCommit(() => {
21 return unicastTo({
22 data: flagActivity,
23 byActor,
24 toActorUrl: flaggedAccount.Actor.getSharedInbox(),
25 contextType: 'Flag'
26 })
27 })
28}
29
30function buildFlagActivity (url: string, byActor: MActor, abuse: MAbuseAP, audience: ActivityAudience): ActivityFlag {
31 if (!audience) audience = getAudience(byActor)
32
33 const activity = { id: url, actor: byActor.url, ...abuse.toActivityPubObject() }
34
35 return audiencify(activity, audience)
36}
37
38// ---------------------------------------------------------------------------
39
40export {
41 sendAbuse
42}
diff --git a/server/lib/activitypub/send/send-follow.ts b/server/lib/activitypub/send/send-follow.ts
deleted file mode 100644
index 57501dadb..000000000
--- a/server/lib/activitypub/send/send-follow.ts
+++ /dev/null
@@ -1,37 +0,0 @@
1import { Transaction } from 'sequelize'
2import { ActivityFollow } from '@shared/models'
3import { logger } from '../../../helpers/logger'
4import { MActor, MActorFollowActors } from '../../../types/models'
5import { unicastTo } from './shared/send-utils'
6
7function sendFollow (actorFollow: MActorFollowActors, t: Transaction) {
8 const me = actorFollow.ActorFollower
9 const following = actorFollow.ActorFollowing
10
11 // Same server as ours
12 if (!following.serverId) return
13
14 logger.info('Creating job to send follow request to %s.', following.url)
15
16 const data = buildFollowActivity(actorFollow.url, me, following)
17
18 return t.afterCommit(() => {
19 return unicastTo({ data, byActor: me, toActorUrl: following.inboxUrl, contextType: 'Follow' })
20 })
21}
22
23function buildFollowActivity (url: string, byActor: MActor, targetActor: MActor): ActivityFollow {
24 return {
25 type: 'Follow',
26 id: url,
27 actor: byActor.url,
28 object: targetActor.url
29 }
30}
31
32// ---------------------------------------------------------------------------
33
34export {
35 sendFollow,
36 buildFollowActivity
37}
diff --git a/server/lib/activitypub/send/send-like.ts b/server/lib/activitypub/send/send-like.ts
deleted file mode 100644
index 46c9fdec9..000000000
--- a/server/lib/activitypub/send/send-like.ts
+++ /dev/null
@@ -1,40 +0,0 @@
1import { Transaction } from 'sequelize'
2import { ActivityAudience, ActivityLike } from '@shared/models'
3import { logger } from '../../../helpers/logger'
4import { MActor, MActorAudience, MVideoAccountLight, MVideoUrl } from '../../../types/models'
5import { audiencify, getAudience } from '../audience'
6import { getVideoLikeActivityPubUrlByLocalActor } from '../url'
7import { sendVideoActivityToOrigin } from './shared/send-utils'
8
9function sendLike (byActor: MActor, video: MVideoAccountLight, transaction: Transaction) {
10 logger.info('Creating job to like %s.', video.url)
11
12 const activityBuilder = (audience: ActivityAudience) => {
13 const url = getVideoLikeActivityPubUrlByLocalActor(byActor, video)
14
15 return buildLikeActivity(url, byActor, video, audience)
16 }
17
18 return sendVideoActivityToOrigin(activityBuilder, { byActor, video, transaction, contextType: 'Rate' })
19}
20
21function buildLikeActivity (url: string, byActor: MActorAudience, video: MVideoUrl, audience?: ActivityAudience): ActivityLike {
22 if (!audience) audience = getAudience(byActor)
23
24 return audiencify(
25 {
26 id: url,
27 type: 'Like' as 'Like',
28 actor: byActor.url,
29 object: video.url
30 },
31 audience
32 )
33}
34
35// ---------------------------------------------------------------------------
36
37export {
38 sendLike,
39 buildLikeActivity
40}
diff --git a/server/lib/activitypub/send/send-reject.ts b/server/lib/activitypub/send/send-reject.ts
deleted file mode 100644
index a5f8c2ecf..000000000
--- a/server/lib/activitypub/send/send-reject.ts
+++ /dev/null
@@ -1,39 +0,0 @@
1import { ActivityFollow, ActivityReject } from '@shared/models'
2import { logger } from '../../../helpers/logger'
3import { MActor } from '../../../types/models'
4import { getLocalActorFollowRejectActivityPubUrl } from '../url'
5import { buildFollowActivity } from './send-follow'
6import { unicastTo } from './shared/send-utils'
7
8function sendReject (followUrl: string, follower: MActor, following: MActor) {
9 if (!follower.serverId) { // This should never happen
10 logger.warn('Do not sending reject to local follower.')
11 return
12 }
13
14 logger.info('Creating job to reject follower %s.', follower.url)
15
16 const followData = buildFollowActivity(followUrl, follower, following)
17
18 const url = getLocalActorFollowRejectActivityPubUrl()
19 const data = buildRejectActivity(url, following, followData)
20
21 return unicastTo({ data, byActor: following, toActorUrl: follower.inboxUrl, contextType: 'Reject' })
22}
23
24// ---------------------------------------------------------------------------
25
26export {
27 sendReject
28}
29
30// ---------------------------------------------------------------------------
31
32function buildRejectActivity (url: string, byActor: MActor, followActivityData: ActivityFollow): ActivityReject {
33 return {
34 type: 'Reject',
35 id: url,
36 actor: byActor.url,
37 object: followActivityData
38 }
39}
diff --git a/server/lib/activitypub/send/send-undo.ts b/server/lib/activitypub/send/send-undo.ts
deleted file mode 100644
index b0b48c9c4..000000000
--- a/server/lib/activitypub/send/send-undo.ts
+++ /dev/null
@@ -1,172 +0,0 @@
1import { Transaction } from 'sequelize'
2import { ActivityAudience, ActivityDislike, ActivityLike, ActivityUndo, ActivityUndoObject, ContextType } from '@shared/models'
3import { logger } from '../../../helpers/logger'
4import { VideoModel } from '../../../models/video/video'
5import {
6 MActor,
7 MActorAudience,
8 MActorFollowActors,
9 MActorLight,
10 MVideo,
11 MVideoAccountLight,
12 MVideoRedundancyVideo,
13 MVideoShare
14} from '../../../types/models'
15import { audiencify, getAudience } from '../audience'
16import { getUndoActivityPubUrl, getVideoDislikeActivityPubUrlByLocalActor, getVideoLikeActivityPubUrlByLocalActor } from '../url'
17import { buildAnnounceWithVideoAudience } from './send-announce'
18import { buildCreateActivity } from './send-create'
19import { buildDislikeActivity } from './send-dislike'
20import { buildFollowActivity } from './send-follow'
21import { buildLikeActivity } from './send-like'
22import { broadcastToFollowers, sendVideoActivityToOrigin, sendVideoRelatedActivity, unicastTo } from './shared/send-utils'
23
24function sendUndoFollow (actorFollow: MActorFollowActors, t: Transaction) {
25 const me = actorFollow.ActorFollower
26 const following = actorFollow.ActorFollowing
27
28 // Same server as ours
29 if (!following.serverId) return
30
31 logger.info('Creating job to send an unfollow request to %s.', following.url)
32
33 const undoUrl = getUndoActivityPubUrl(actorFollow.url)
34
35 const followActivity = buildFollowActivity(actorFollow.url, me, following)
36 const undoActivity = undoActivityData(undoUrl, me, followActivity)
37
38 t.afterCommit(() => {
39 return unicastTo({
40 data: undoActivity,
41 byActor: me,
42 toActorUrl: following.inboxUrl,
43 contextType: 'Follow'
44 })
45 })
46}
47
48// ---------------------------------------------------------------------------
49
50async function sendUndoAnnounce (byActor: MActorLight, videoShare: MVideoShare, video: MVideo, transaction: Transaction) {
51 logger.info('Creating job to undo announce %s.', videoShare.url)
52
53 const undoUrl = getUndoActivityPubUrl(videoShare.url)
54
55 const { activity: announce, actorsInvolvedInVideo } = await buildAnnounceWithVideoAudience(byActor, videoShare, video, transaction)
56 const undoActivity = undoActivityData(undoUrl, byActor, announce)
57
58 return broadcastToFollowers({
59 data: undoActivity,
60 byActor,
61 toFollowersOf: actorsInvolvedInVideo,
62 transaction,
63 actorsException: [ byActor ],
64 contextType: 'Announce'
65 })
66}
67
68async function sendUndoCacheFile (byActor: MActor, redundancyModel: MVideoRedundancyVideo, transaction: Transaction) {
69 logger.info('Creating job to undo cache file %s.', redundancyModel.url)
70
71 const associatedVideo = redundancyModel.getVideo()
72 if (!associatedVideo) {
73 logger.warn('Cannot send undo activity for redundancy %s: no video files associated.', redundancyModel.url)
74 return
75 }
76
77 const video = await VideoModel.loadFull(associatedVideo.id)
78 const createActivity = buildCreateActivity(redundancyModel.url, byActor, redundancyModel.toActivityPubObject())
79
80 return sendUndoVideoRelatedActivity({
81 byActor,
82 video,
83 url: redundancyModel.url,
84 activity: createActivity,
85 contextType: 'CacheFile',
86 transaction
87 })
88}
89
90// ---------------------------------------------------------------------------
91
92async function sendUndoLike (byActor: MActor, video: MVideoAccountLight, t: Transaction) {
93 logger.info('Creating job to undo a like of video %s.', video.url)
94
95 const likeUrl = getVideoLikeActivityPubUrlByLocalActor(byActor, video)
96 const likeActivity = buildLikeActivity(likeUrl, byActor, video)
97
98 return sendUndoVideoRateToOriginActivity({ byActor, video, url: likeUrl, activity: likeActivity, transaction: t })
99}
100
101async function sendUndoDislike (byActor: MActor, video: MVideoAccountLight, t: Transaction) {
102 logger.info('Creating job to undo a dislike of video %s.', video.url)
103
104 const dislikeUrl = getVideoDislikeActivityPubUrlByLocalActor(byActor, video)
105 const dislikeActivity = buildDislikeActivity(dislikeUrl, byActor, video)
106
107 return sendUndoVideoRateToOriginActivity({ byActor, video, url: dislikeUrl, activity: dislikeActivity, transaction: t })
108}
109
110// ---------------------------------------------------------------------------
111
112export {
113 sendUndoFollow,
114 sendUndoLike,
115 sendUndoDislike,
116 sendUndoAnnounce,
117 sendUndoCacheFile
118}
119
120// ---------------------------------------------------------------------------
121
122function undoActivityData <T extends ActivityUndoObject> (
123 url: string,
124 byActor: MActorAudience,
125 object: T,
126 audience?: ActivityAudience
127): ActivityUndo<T> {
128 if (!audience) audience = getAudience(byActor)
129
130 return audiencify(
131 {
132 type: 'Undo' as 'Undo',
133 id: url,
134 actor: byActor.url,
135 object
136 },
137 audience
138 )
139}
140
141async function sendUndoVideoRelatedActivity (options: {
142 byActor: MActor
143 video: MVideoAccountLight
144 url: string
145 activity: ActivityUndoObject
146 contextType: ContextType
147 transaction: Transaction
148}) {
149 const activityBuilder = (audience: ActivityAudience) => {
150 const undoUrl = getUndoActivityPubUrl(options.url)
151
152 return undoActivityData(undoUrl, options.byActor, options.activity, audience)
153 }
154
155 return sendVideoRelatedActivity(activityBuilder, options)
156}
157
158async function sendUndoVideoRateToOriginActivity (options: {
159 byActor: MActor
160 video: MVideoAccountLight
161 url: string
162 activity: ActivityLike | ActivityDislike
163 transaction: Transaction
164}) {
165 const activityBuilder = (audience: ActivityAudience) => {
166 const undoUrl = getUndoActivityPubUrl(options.url)
167
168 return undoActivityData(undoUrl, options.byActor, options.activity, audience)
169 }
170
171 return sendVideoActivityToOrigin(activityBuilder, { ...options, contextType: 'Rate' })
172}
diff --git a/server/lib/activitypub/send/send-update.ts b/server/lib/activitypub/send/send-update.ts
deleted file mode 100644
index f3fb741c6..000000000
--- a/server/lib/activitypub/send/send-update.ts
+++ /dev/null
@@ -1,157 +0,0 @@
1import { Transaction } from 'sequelize'
2import { getServerActor } from '@server/models/application/application'
3import { ActivityAudience, ActivityUpdate, ActivityUpdateObject, VideoPlaylistPrivacy, VideoPrivacy } from '@shared/models'
4import { logger } from '../../../helpers/logger'
5import { AccountModel } from '../../../models/account/account'
6import { VideoModel } from '../../../models/video/video'
7import { VideoShareModel } from '../../../models/video/video-share'
8import {
9 MAccountDefault,
10 MActor,
11 MActorLight,
12 MChannelDefault,
13 MVideoAPLight,
14 MVideoPlaylistFull,
15 MVideoRedundancyVideo
16} from '../../../types/models'
17import { audiencify, getAudience } from '../audience'
18import { getUpdateActivityPubUrl } from '../url'
19import { getActorsInvolvedInVideo } from './shared'
20import { broadcastToFollowers, sendVideoRelatedActivity } from './shared/send-utils'
21
22async function sendUpdateVideo (videoArg: MVideoAPLight, transaction: Transaction, overriddenByActor?: MActor) {
23 if (!videoArg.hasPrivacyForFederation()) return undefined
24
25 const video = await videoArg.lightAPToFullAP(transaction)
26
27 logger.info('Creating job to update video %s.', video.url)
28
29 const byActor = overriddenByActor || video.VideoChannel.Account.Actor
30
31 const url = getUpdateActivityPubUrl(video.url, video.updatedAt.toISOString())
32
33 const videoObject = await video.toActivityPubObject()
34 const audience = getAudience(byActor, video.privacy === VideoPrivacy.PUBLIC)
35
36 const updateActivity = buildUpdateActivity(url, byActor, videoObject, audience)
37
38 const actorsInvolved = await getActorsInvolvedInVideo(video, transaction)
39 if (overriddenByActor) actorsInvolved.push(overriddenByActor)
40
41 return broadcastToFollowers({
42 data: updateActivity,
43 byActor,
44 toFollowersOf: actorsInvolved,
45 contextType: 'Video',
46 transaction
47 })
48}
49
50async function sendUpdateActor (accountOrChannel: MChannelDefault | MAccountDefault, transaction: Transaction) {
51 const byActor = accountOrChannel.Actor
52
53 logger.info('Creating job to update actor %s.', byActor.url)
54
55 const url = getUpdateActivityPubUrl(byActor.url, byActor.updatedAt.toISOString())
56 const accountOrChannelObject = await (accountOrChannel as any).toActivityPubObject() // FIXME: typescript bug?
57 const audience = getAudience(byActor)
58 const updateActivity = buildUpdateActivity(url, byActor, accountOrChannelObject, audience)
59
60 let actorsInvolved: MActor[]
61 if (accountOrChannel instanceof AccountModel) {
62 // Actors that shared my videos are involved too
63 actorsInvolved = await VideoShareModel.loadActorsWhoSharedVideosOf(byActor.id, transaction)
64 } else {
65 // Actors that shared videos of my channel are involved too
66 actorsInvolved = await VideoShareModel.loadActorsByVideoChannel(accountOrChannel.id, transaction)
67 }
68
69 actorsInvolved.push(byActor)
70
71 return broadcastToFollowers({
72 data: updateActivity,
73 byActor,
74 toFollowersOf: actorsInvolved,
75 transaction,
76 contextType: 'Actor'
77 })
78}
79
80async function sendUpdateCacheFile (byActor: MActorLight, redundancyModel: MVideoRedundancyVideo) {
81 logger.info('Creating job to update cache file %s.', redundancyModel.url)
82
83 const associatedVideo = redundancyModel.getVideo()
84 if (!associatedVideo) {
85 logger.warn('Cannot send update activity for redundancy %s: no video files associated.', redundancyModel.url)
86 return
87 }
88
89 const video = await VideoModel.loadFull(associatedVideo.id)
90
91 const activityBuilder = (audience: ActivityAudience) => {
92 const redundancyObject = redundancyModel.toActivityPubObject()
93 const url = getUpdateActivityPubUrl(redundancyModel.url, redundancyModel.updatedAt.toISOString())
94
95 return buildUpdateActivity(url, byActor, redundancyObject, audience)
96 }
97
98 return sendVideoRelatedActivity(activityBuilder, { byActor, video, contextType: 'CacheFile' })
99}
100
101async function sendUpdateVideoPlaylist (videoPlaylist: MVideoPlaylistFull, transaction: Transaction) {
102 if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined
103
104 const byActor = videoPlaylist.OwnerAccount.Actor
105
106 logger.info('Creating job to update video playlist %s.', videoPlaylist.url)
107
108 const url = getUpdateActivityPubUrl(videoPlaylist.url, videoPlaylist.updatedAt.toISOString())
109
110 const object = await videoPlaylist.toActivityPubObject(null, transaction)
111 const audience = getAudience(byActor, videoPlaylist.privacy === VideoPlaylistPrivacy.PUBLIC)
112
113 const updateActivity = buildUpdateActivity(url, byActor, object, audience)
114
115 const serverActor = await getServerActor()
116 const toFollowersOf = [ byActor, serverActor ]
117
118 if (videoPlaylist.VideoChannel) toFollowersOf.push(videoPlaylist.VideoChannel.Actor)
119
120 return broadcastToFollowers({
121 data: updateActivity,
122 byActor,
123 toFollowersOf,
124 transaction,
125 contextType: 'Playlist'
126 })
127}
128
129// ---------------------------------------------------------------------------
130
131export {
132 sendUpdateActor,
133 sendUpdateVideo,
134 sendUpdateCacheFile,
135 sendUpdateVideoPlaylist
136}
137
138// ---------------------------------------------------------------------------
139
140function buildUpdateActivity (
141 url: string,
142 byActor: MActorLight,
143 object: ActivityUpdateObject,
144 audience?: ActivityAudience
145): ActivityUpdate<ActivityUpdateObject> {
146 if (!audience) audience = getAudience(byActor)
147
148 return audiencify(
149 {
150 type: 'Update' as 'Update',
151 id: url,
152 actor: byActor.url,
153 object: audiencify(object, audience)
154 },
155 audience
156 )
157}
diff --git a/server/lib/activitypub/send/send-view.ts b/server/lib/activitypub/send/send-view.ts
deleted file mode 100644
index bf3451603..000000000
--- a/server/lib/activitypub/send/send-view.ts
+++ /dev/null
@@ -1,62 +0,0 @@
1import { Transaction } from 'sequelize'
2import { VideoViewsManager } from '@server/lib/views/video-views-manager'
3import { MActorAudience, MActorLight, MVideoImmutable, MVideoUrl } from '@server/types/models'
4import { ActivityAudience, ActivityView } from '@shared/models'
5import { logger } from '../../../helpers/logger'
6import { audiencify, getAudience } from '../audience'
7import { getLocalVideoViewActivityPubUrl } from '../url'
8import { sendVideoRelatedActivity } from './shared/send-utils'
9
10type ViewType = 'view' | 'viewer'
11
12async function sendView (options: {
13 byActor: MActorLight
14 type: ViewType
15 video: MVideoImmutable
16 viewerIdentifier: string
17 transaction?: Transaction
18}) {
19 const { byActor, type, video, viewerIdentifier, transaction } = options
20
21 logger.info('Creating job to send %s of %s.', type, video.url)
22
23 const activityBuilder = (audience: ActivityAudience) => {
24 const url = getLocalVideoViewActivityPubUrl(byActor, video, viewerIdentifier)
25
26 return buildViewActivity({ url, byActor, video, audience, type })
27 }
28
29 return sendVideoRelatedActivity(activityBuilder, { byActor, video, transaction, contextType: 'View', parallelizable: true })
30}
31
32// ---------------------------------------------------------------------------
33
34export {
35 sendView
36}
37
38// ---------------------------------------------------------------------------
39
40function buildViewActivity (options: {
41 url: string
42 byActor: MActorAudience
43 video: MVideoUrl
44 type: ViewType
45 audience?: ActivityAudience
46}): ActivityView {
47 const { url, byActor, type, video, audience = getAudience(byActor) } = options
48
49 return audiencify(
50 {
51 id: url,
52 type: 'View' as 'View',
53 actor: byActor.url,
54 object: video.url,
55
56 expires: type === 'viewer'
57 ? new Date(VideoViewsManager.Instance.buildViewerExpireTime()).toISOString()
58 : undefined
59 },
60 audience
61 )
62}
diff --git a/server/lib/activitypub/send/shared/audience-utils.ts b/server/lib/activitypub/send/shared/audience-utils.ts
deleted file mode 100644
index 2f6b0741d..000000000
--- a/server/lib/activitypub/send/shared/audience-utils.ts
+++ /dev/null
@@ -1,74 +0,0 @@
1import { Transaction } from 'sequelize'
2import { ACTIVITY_PUB } from '@server/initializers/constants'
3import { ActorModel } from '@server/models/actor/actor'
4import { VideoModel } from '@server/models/video/video'
5import { VideoShareModel } from '@server/models/video/video-share'
6import { MActorFollowersUrl, MActorUrl, MCommentOwner, MCommentOwnerVideo, MVideoId } from '@server/types/models'
7import { ActivityAudience } from '@shared/models'
8
9function getOriginVideoAudience (accountActor: MActorUrl, actorsInvolvedInVideo: MActorFollowersUrl[] = []): ActivityAudience {
10 return {
11 to: [ accountActor.url ],
12 cc: actorsInvolvedInVideo.map(a => a.followersUrl)
13 }
14}
15
16function getVideoCommentAudience (
17 videoComment: MCommentOwnerVideo,
18 threadParentComments: MCommentOwner[],
19 actorsInvolvedInVideo: MActorFollowersUrl[],
20 isOrigin = false
21): ActivityAudience {
22 const to = [ ACTIVITY_PUB.PUBLIC ]
23 const cc: string[] = []
24
25 // Owner of the video we comment
26 if (isOrigin === false) {
27 cc.push(videoComment.Video.VideoChannel.Account.Actor.url)
28 }
29
30 // Followers of the poster
31 cc.push(videoComment.Account.Actor.followersUrl)
32
33 // Send to actors we reply to
34 for (const parentComment of threadParentComments) {
35 if (parentComment.isDeleted()) continue
36
37 cc.push(parentComment.Account.Actor.url)
38 }
39
40 return {
41 to,
42 cc: cc.concat(actorsInvolvedInVideo.map(a => a.followersUrl))
43 }
44}
45
46function getAudienceFromFollowersOf (actorsInvolvedInObject: MActorFollowersUrl[]): ActivityAudience {
47 return {
48 to: [ ACTIVITY_PUB.PUBLIC ].concat(actorsInvolvedInObject.map(a => a.followersUrl)),
49 cc: []
50 }
51}
52
53async function getActorsInvolvedInVideo (video: MVideoId, t: Transaction) {
54 const actors = await VideoShareModel.listActorIdsAndFollowerUrlsByShare(video.id, t)
55
56 const videoAll = video as VideoModel
57
58 const videoActor = videoAll.VideoChannel?.Account
59 ? videoAll.VideoChannel.Account.Actor
60 : await ActorModel.loadAccountActorFollowerUrlByVideoId(video.id, t)
61
62 actors.push(videoActor)
63
64 return actors
65}
66
67// ---------------------------------------------------------------------------
68
69export {
70 getOriginVideoAudience,
71 getActorsInvolvedInVideo,
72 getAudienceFromFollowersOf,
73 getVideoCommentAudience
74}
diff --git a/server/lib/activitypub/send/shared/index.ts b/server/lib/activitypub/send/shared/index.ts
deleted file mode 100644
index bda579115..000000000
--- a/server/lib/activitypub/send/shared/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
1export * from './audience-utils'
2export * from './send-utils'
diff --git a/server/lib/activitypub/send/shared/send-utils.ts b/server/lib/activitypub/send/shared/send-utils.ts
deleted file mode 100644
index 2bc1ef8f5..000000000
--- a/server/lib/activitypub/send/shared/send-utils.ts
+++ /dev/null
@@ -1,291 +0,0 @@
1import { Transaction } from 'sequelize'
2import { ActorFollowHealthCache } from '@server/lib/actor-follow-health-cache'
3import { getServerActor } from '@server/models/application/application'
4import { Activity, ActivityAudience, ActivitypubHttpBroadcastPayload } from '@shared/models'
5import { ContextType } from '@shared/models/activitypub/context'
6import { afterCommitIfTransaction } from '../../../../helpers/database-utils'
7import { logger } from '../../../../helpers/logger'
8import { ActorModel } from '../../../../models/actor/actor'
9import { ActorFollowModel } from '../../../../models/actor/actor-follow'
10import { MActor, MActorId, MActorLight, MActorWithInboxes, MVideoAccountLight, MVideoId, MVideoImmutable } from '../../../../types/models'
11import { JobQueue } from '../../../job-queue'
12import { getActorsInvolvedInVideo, getAudienceFromFollowersOf, getOriginVideoAudience } from './audience-utils'
13
14async function sendVideoRelatedActivity (activityBuilder: (audience: ActivityAudience) => Activity, options: {
15 byActor: MActorLight
16 video: MVideoImmutable | MVideoAccountLight
17 contextType: ContextType
18 parallelizable?: boolean
19 transaction?: Transaction
20}) {
21 const { byActor, video, transaction, contextType, parallelizable } = options
22
23 // Send to origin
24 if (video.isOwned() === false) {
25 return sendVideoActivityToOrigin(activityBuilder, options)
26 }
27
28 const actorsInvolvedInVideo = await getActorsInvolvedInVideo(video, transaction)
29
30 // Send to followers
31 const audience = getAudienceFromFollowersOf(actorsInvolvedInVideo)
32 const activity = activityBuilder(audience)
33
34 const actorsException = [ byActor ]
35
36 return broadcastToFollowers({
37 data: activity,
38 byActor,
39 toFollowersOf: actorsInvolvedInVideo,
40 transaction,
41 actorsException,
42 parallelizable,
43 contextType
44 })
45}
46
47async function sendVideoActivityToOrigin (activityBuilder: (audience: ActivityAudience) => Activity, options: {
48 byActor: MActorLight
49 video: MVideoImmutable | MVideoAccountLight
50 contextType: ContextType
51
52 actorsInvolvedInVideo?: MActorLight[]
53 transaction?: Transaction
54}) {
55 const { byActor, video, actorsInvolvedInVideo, transaction, contextType } = options
56
57 if (video.isOwned()) throw new Error('Cannot send activity to owned video origin ' + video.url)
58
59 let accountActor: MActorLight = (video as MVideoAccountLight).VideoChannel?.Account?.Actor
60 if (!accountActor) accountActor = await ActorModel.loadAccountActorByVideoId(video.id, transaction)
61
62 const audience = getOriginVideoAudience(accountActor, actorsInvolvedInVideo)
63 const activity = activityBuilder(audience)
64
65 return afterCommitIfTransaction(transaction, () => {
66 return unicastTo({
67 data: activity,
68 byActor,
69 toActorUrl: accountActor.getSharedInbox(),
70 contextType
71 })
72 })
73}
74
75// ---------------------------------------------------------------------------
76
77async function forwardVideoRelatedActivity (
78 activity: Activity,
79 t: Transaction,
80 followersException: MActorWithInboxes[],
81 video: MVideoId
82) {
83 // Mastodon does not add our announces in audience, so we forward to them manually
84 const additionalActors = await getActorsInvolvedInVideo(video, t)
85 const additionalFollowerUrls = additionalActors.map(a => a.followersUrl)
86
87 return forwardActivity(activity, t, followersException, additionalFollowerUrls)
88}
89
90async function forwardActivity (
91 activity: Activity,
92 t: Transaction,
93 followersException: MActorWithInboxes[] = [],
94 additionalFollowerUrls: string[] = []
95) {
96 logger.info('Forwarding activity %s.', activity.id)
97
98 const to = activity.to || []
99 const cc = activity.cc || []
100
101 const followersUrls = additionalFollowerUrls
102 for (const dest of to.concat(cc)) {
103 if (dest.endsWith('/followers')) {
104 followersUrls.push(dest)
105 }
106 }
107
108 const toActorFollowers = await ActorModel.listByFollowersUrls(followersUrls, t)
109 const uris = await computeFollowerUris(toActorFollowers, followersException, t)
110
111 if (uris.length === 0) {
112 logger.info('0 followers for %s, no forwarding.', toActorFollowers.map(a => a.id).join(', '))
113 return undefined
114 }
115
116 logger.debug('Creating forwarding job.', { uris })
117
118 const payload: ActivitypubHttpBroadcastPayload = {
119 uris,
120 body: activity,
121 contextType: null
122 }
123 return afterCommitIfTransaction(t, () => JobQueue.Instance.createJobAsync({ type: 'activitypub-http-broadcast', payload }))
124}
125
126// ---------------------------------------------------------------------------
127
128async function broadcastToFollowers (options: {
129 data: any
130 byActor: MActorId
131 toFollowersOf: MActorId[]
132 transaction: Transaction
133 contextType: ContextType
134
135 parallelizable?: boolean
136 actorsException?: MActorWithInboxes[]
137}) {
138 const { data, byActor, toFollowersOf, transaction, contextType, actorsException = [], parallelizable } = options
139
140 const uris = await computeFollowerUris(toFollowersOf, actorsException, transaction)
141
142 return afterCommitIfTransaction(transaction, () => {
143 return broadcastTo({
144 uris,
145 data,
146 byActor,
147 parallelizable,
148 contextType
149 })
150 })
151}
152
153async function broadcastToActors (options: {
154 data: any
155 byActor: MActorId
156 toActors: MActor[]
157 transaction: Transaction
158 contextType: ContextType
159 actorsException?: MActorWithInboxes[]
160}) {
161 const { data, byActor, toActors, transaction, contextType, actorsException = [] } = options
162
163 const uris = await computeUris(toActors, actorsException)
164
165 return afterCommitIfTransaction(transaction, () => {
166 return broadcastTo({
167 uris,
168 data,
169 byActor,
170 contextType
171 })
172 })
173}
174
175function broadcastTo (options: {
176 uris: string[]
177 data: any
178 byActor: MActorId
179 contextType: ContextType
180 parallelizable?: boolean // default to false
181}) {
182 const { uris, data, byActor, contextType, parallelizable } = options
183
184 if (uris.length === 0) return undefined
185
186 const broadcastUris: string[] = []
187 const unicastUris: string[] = []
188
189 // Bad URIs could be slow to respond, prefer to process them in a dedicated queue
190 for (const uri of uris) {
191 if (ActorFollowHealthCache.Instance.isBadInbox(uri)) {
192 unicastUris.push(uri)
193 } else {
194 broadcastUris.push(uri)
195 }
196 }
197
198 logger.debug('Creating broadcast job.', { broadcastUris, unicastUris })
199
200 if (broadcastUris.length !== 0) {
201 const payload = {
202 uris: broadcastUris,
203 signatureActorId: byActor.id,
204 body: data,
205 contextType
206 }
207
208 JobQueue.Instance.createJobAsync({
209 type: parallelizable
210 ? 'activitypub-http-broadcast-parallel'
211 : 'activitypub-http-broadcast',
212
213 payload
214 })
215 }
216
217 for (const unicastUri of unicastUris) {
218 const payload = {
219 uri: unicastUri,
220 signatureActorId: byActor.id,
221 body: data,
222 contextType
223 }
224
225 JobQueue.Instance.createJobAsync({ type: 'activitypub-http-unicast', payload })
226 }
227}
228
229function unicastTo (options: {
230 data: any
231 byActor: MActorId
232 toActorUrl: string
233 contextType: ContextType
234}) {
235 const { data, byActor, toActorUrl, contextType } = options
236
237 logger.debug('Creating unicast job.', { uri: toActorUrl })
238
239 const payload = {
240 uri: toActorUrl,
241 signatureActorId: byActor.id,
242 body: data,
243 contextType
244 }
245
246 JobQueue.Instance.createJobAsync({ type: 'activitypub-http-unicast', payload })
247}
248
249// ---------------------------------------------------------------------------
250
251export {
252 broadcastToFollowers,
253 unicastTo,
254 forwardActivity,
255 broadcastToActors,
256 sendVideoActivityToOrigin,
257 forwardVideoRelatedActivity,
258 sendVideoRelatedActivity
259}
260
261// ---------------------------------------------------------------------------
262
263async function computeFollowerUris (toFollowersOf: MActorId[], actorsException: MActorWithInboxes[], t: Transaction) {
264 const toActorFollowerIds = toFollowersOf.map(a => a.id)
265
266 const result = await ActorFollowModel.listAcceptedFollowerSharedInboxUrls(toActorFollowerIds, t)
267 const sharedInboxesException = await buildSharedInboxesException(actorsException)
268
269 return result.data.filter(sharedInbox => sharedInboxesException.includes(sharedInbox) === false)
270}
271
272async function computeUris (toActors: MActor[], actorsException: MActorWithInboxes[] = []) {
273 const serverActor = await getServerActor()
274 const targetUrls = toActors
275 .filter(a => a.id !== serverActor.id) // Don't send to ourselves
276 .map(a => a.getSharedInbox())
277
278 const toActorSharedInboxesSet = new Set(targetUrls)
279
280 const sharedInboxesException = await buildSharedInboxesException(actorsException)
281 return Array.from(toActorSharedInboxesSet)
282 .filter(sharedInbox => sharedInboxesException.includes(sharedInbox) === false)
283}
284
285async function buildSharedInboxesException (actorsException: MActorWithInboxes[]) {
286 const serverActor = await getServerActor()
287
288 return actorsException
289 .map(f => f.getSharedInbox())
290 .concat([ serverActor.sharedInboxUrl ])
291}
diff --git a/server/lib/activitypub/share.ts b/server/lib/activitypub/share.ts
deleted file mode 100644
index 792a73f2a..000000000
--- a/server/lib/activitypub/share.ts
+++ /dev/null
@@ -1,120 +0,0 @@
1import { map } from 'bluebird'
2import { Transaction } from 'sequelize'
3import { getServerActor } from '@server/models/application/application'
4import { logger, loggerTagsFactory } from '../../helpers/logger'
5import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants'
6import { VideoShareModel } from '../../models/video/video-share'
7import { MChannelActorLight, MVideo, MVideoAccountLight, MVideoId } from '../../types/models/video'
8import { fetchAP, getAPId } from './activity'
9import { getOrCreateAPActor } from './actors'
10import { sendUndoAnnounce, sendVideoAnnounce } from './send'
11import { checkUrlsSameHost, getLocalVideoAnnounceActivityPubUrl } from './url'
12
13const lTags = loggerTagsFactory('share')
14
15async function shareVideoByServerAndChannel (video: MVideoAccountLight, t: Transaction) {
16 if (!video.hasPrivacyForFederation()) return undefined
17
18 return Promise.all([
19 shareByServer(video, t),
20 shareByVideoChannel(video, t)
21 ])
22}
23
24async function changeVideoChannelShare (
25 video: MVideoAccountLight,
26 oldVideoChannel: MChannelActorLight,
27 t: Transaction
28) {
29 logger.info(
30 'Updating video channel of video %s: %s -> %s.', video.uuid, oldVideoChannel.name, video.VideoChannel.name,
31 lTags(video.uuid)
32 )
33
34 await undoShareByVideoChannel(video, oldVideoChannel, t)
35
36 await shareByVideoChannel(video, t)
37}
38
39async function addVideoShares (shareUrls: string[], video: MVideoId) {
40 await map(shareUrls, async shareUrl => {
41 try {
42 await addVideoShare(shareUrl, video)
43 } catch (err) {
44 logger.warn('Cannot add share %s.', shareUrl, { err })
45 }
46 }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
47}
48
49export {
50 changeVideoChannelShare,
51 addVideoShares,
52 shareVideoByServerAndChannel
53}
54
55// ---------------------------------------------------------------------------
56
57async function addVideoShare (shareUrl: string, video: MVideoId) {
58 const { body } = await fetchAP<any>(shareUrl)
59 if (!body?.actor) throw new Error('Body or body actor is invalid')
60
61 const actorUrl = getAPId(body.actor)
62 if (checkUrlsSameHost(shareUrl, actorUrl) !== true) {
63 throw new Error(`Actor url ${actorUrl} has not the same host than the share url ${shareUrl}`)
64 }
65
66 const actor = await getOrCreateAPActor(actorUrl)
67
68 const entry = {
69 actorId: actor.id,
70 videoId: video.id,
71 url: shareUrl
72 }
73
74 await VideoShareModel.upsert(entry)
75}
76
77async function shareByServer (video: MVideo, t: Transaction) {
78 const serverActor = await getServerActor()
79
80 const serverShareUrl = getLocalVideoAnnounceActivityPubUrl(serverActor, video)
81 const [ serverShare ] = await VideoShareModel.findOrCreate({
82 defaults: {
83 actorId: serverActor.id,
84 videoId: video.id,
85 url: serverShareUrl
86 },
87 where: {
88 url: serverShareUrl
89 },
90 transaction: t
91 })
92
93 return sendVideoAnnounce(serverActor, serverShare, video, t)
94}
95
96async function shareByVideoChannel (video: MVideoAccountLight, t: Transaction) {
97 const videoChannelShareUrl = getLocalVideoAnnounceActivityPubUrl(video.VideoChannel.Actor, video)
98 const [ videoChannelShare ] = await VideoShareModel.findOrCreate({
99 defaults: {
100 actorId: video.VideoChannel.actorId,
101 videoId: video.id,
102 url: videoChannelShareUrl
103 },
104 where: {
105 url: videoChannelShareUrl
106 },
107 transaction: t
108 })
109
110 return sendVideoAnnounce(video.VideoChannel.Actor, videoChannelShare, video, t)
111}
112
113async function undoShareByVideoChannel (video: MVideo, oldVideoChannel: MChannelActorLight, t: Transaction) {
114 // Load old share
115 const oldShare = await VideoShareModel.load(oldVideoChannel.actorId, video.id, t)
116 if (!oldShare) return new Error('Cannot find old video channel share ' + oldVideoChannel.actorId + ' for video ' + video.id)
117
118 await sendUndoAnnounce(oldVideoChannel.Actor, oldShare, video, t)
119 await oldShare.destroy({ transaction: t })
120}
diff --git a/server/lib/activitypub/url.ts b/server/lib/activitypub/url.ts
deleted file mode 100644
index 5cdac71bf..000000000
--- a/server/lib/activitypub/url.ts
+++ /dev/null
@@ -1,177 +0,0 @@
1import { REMOTE_SCHEME, WEBSERVER } from '../../initializers/constants'
2import {
3 MAbuseFull,
4 MAbuseId,
5 MActor,
6 MActorFollow,
7 MActorId,
8 MActorUrl,
9 MCommentId,
10 MLocalVideoViewer,
11 MVideoId,
12 MVideoPlaylistElement,
13 MVideoUrl,
14 MVideoUUID,
15 MVideoWithHost
16} from '../../types/models'
17import { MVideoFileVideoUUID } from '../../types/models/video/video-file'
18import { MVideoPlaylist, MVideoPlaylistUUID } from '../../types/models/video/video-playlist'
19import { MStreamingPlaylist } from '../../types/models/video/video-streaming-playlist'
20
21function getLocalVideoActivityPubUrl (video: MVideoUUID) {
22 return WEBSERVER.URL + '/videos/watch/' + video.uuid
23}
24
25function getLocalVideoPlaylistActivityPubUrl (videoPlaylist: MVideoPlaylist) {
26 return WEBSERVER.URL + '/video-playlists/' + videoPlaylist.uuid
27}
28
29function getLocalVideoPlaylistElementActivityPubUrl (videoPlaylist: MVideoPlaylistUUID, videoPlaylistElement: MVideoPlaylistElement) {
30 return WEBSERVER.URL + '/video-playlists/' + videoPlaylist.uuid + '/videos/' + videoPlaylistElement.id
31}
32
33function getLocalVideoCacheFileActivityPubUrl (videoFile: MVideoFileVideoUUID) {
34 const suffixFPS = videoFile.fps && videoFile.fps !== -1 ? '-' + videoFile.fps : ''
35
36 return `${WEBSERVER.URL}/redundancy/videos/${videoFile.Video.uuid}/${videoFile.resolution}${suffixFPS}`
37}
38
39function getLocalVideoCacheStreamingPlaylistActivityPubUrl (video: MVideoUUID, playlist: MStreamingPlaylist) {
40 return `${WEBSERVER.URL}/redundancy/streaming-playlists/${playlist.getStringType()}/${video.uuid}`
41}
42
43function getLocalVideoCommentActivityPubUrl (video: MVideoUUID, videoComment: MCommentId) {
44 return WEBSERVER.URL + '/videos/watch/' + video.uuid + '/comments/' + videoComment.id
45}
46
47function getLocalVideoChannelActivityPubUrl (videoChannelName: string) {
48 return WEBSERVER.URL + '/video-channels/' + videoChannelName
49}
50
51function getLocalAccountActivityPubUrl (accountName: string) {
52 return WEBSERVER.URL + '/accounts/' + accountName
53}
54
55function getLocalAbuseActivityPubUrl (abuse: MAbuseId) {
56 return WEBSERVER.URL + '/admin/abuses/' + abuse.id
57}
58
59function getLocalVideoViewActivityPubUrl (byActor: MActorUrl, video: MVideoId, viewerIdentifier: string) {
60 return byActor.url + '/views/videos/' + video.id + '/' + viewerIdentifier
61}
62
63function getLocalVideoViewerActivityPubUrl (stats: MLocalVideoViewer) {
64 return WEBSERVER.URL + '/videos/local-viewer/' + stats.uuid
65}
66
67function getVideoLikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) {
68 return byActor.url + '/likes/' + video.id
69}
70
71function getVideoDislikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) {
72 return byActor.url + '/dislikes/' + video.id
73}
74
75function getLocalVideoSharesActivityPubUrl (video: MVideoUrl) {
76 return video.url + '/announces'
77}
78
79function getLocalVideoCommentsActivityPubUrl (video: MVideoUrl) {
80 return video.url + '/comments'
81}
82
83function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) {
84 return video.url + '/likes'
85}
86
87function getLocalVideoDislikesActivityPubUrl (video: MVideoUrl) {
88 return video.url + '/dislikes'
89}
90
91function getLocalActorFollowActivityPubUrl (follower: MActor, following: MActorId) {
92 return follower.url + '/follows/' + following.id
93}
94
95function getLocalActorFollowAcceptActivityPubUrl (actorFollow: MActorFollow) {
96 return WEBSERVER.URL + '/accepts/follows/' + actorFollow.id
97}
98
99function getLocalActorFollowRejectActivityPubUrl () {
100 return WEBSERVER.URL + '/rejects/follows/' + new Date().toISOString()
101}
102
103function getLocalVideoAnnounceActivityPubUrl (byActor: MActorId, video: MVideoUrl) {
104 return video.url + '/announces/' + byActor.id
105}
106
107function getDeleteActivityPubUrl (originalUrl: string) {
108 return originalUrl + '/delete'
109}
110
111function getUpdateActivityPubUrl (originalUrl: string, updatedAt: string) {
112 return originalUrl + '/updates/' + updatedAt
113}
114
115function getUndoActivityPubUrl (originalUrl: string) {
116 return originalUrl + '/undo'
117}
118
119// ---------------------------------------------------------------------------
120
121function getAbuseTargetUrl (abuse: MAbuseFull) {
122 return abuse.VideoAbuse?.Video?.url ||
123 abuse.VideoCommentAbuse?.VideoComment?.url ||
124 abuse.FlaggedAccount.Actor.url
125}
126
127// ---------------------------------------------------------------------------
128
129function buildRemoteVideoBaseUrl (video: MVideoWithHost, path: string, scheme?: string) {
130 if (!scheme) scheme = REMOTE_SCHEME.HTTP
131
132 const host = video.VideoChannel.Actor.Server.host
133
134 return scheme + '://' + host + path
135}
136
137// ---------------------------------------------------------------------------
138
139function checkUrlsSameHost (url1: string, url2: string) {
140 const idHost = new URL(url1).host
141 const actorHost = new URL(url2).host
142
143 return idHost && actorHost && idHost.toLowerCase() === actorHost.toLowerCase()
144}
145
146// ---------------------------------------------------------------------------
147
148export {
149 getLocalVideoActivityPubUrl,
150 getLocalVideoPlaylistActivityPubUrl,
151 getLocalVideoPlaylistElementActivityPubUrl,
152 getLocalVideoCacheFileActivityPubUrl,
153 getLocalVideoCacheStreamingPlaylistActivityPubUrl,
154 getLocalVideoCommentActivityPubUrl,
155 getLocalVideoChannelActivityPubUrl,
156 getLocalAccountActivityPubUrl,
157 getLocalAbuseActivityPubUrl,
158 getLocalActorFollowActivityPubUrl,
159 getLocalActorFollowAcceptActivityPubUrl,
160 getLocalVideoAnnounceActivityPubUrl,
161 getUpdateActivityPubUrl,
162 getUndoActivityPubUrl,
163 getVideoLikeActivityPubUrlByLocalActor,
164 getLocalVideoViewActivityPubUrl,
165 getVideoDislikeActivityPubUrlByLocalActor,
166 getLocalActorFollowRejectActivityPubUrl,
167 getDeleteActivityPubUrl,
168 getLocalVideoSharesActivityPubUrl,
169 getLocalVideoCommentsActivityPubUrl,
170 getLocalVideoLikesActivityPubUrl,
171 getLocalVideoDislikesActivityPubUrl,
172 getLocalVideoViewerActivityPubUrl,
173
174 getAbuseTargetUrl,
175 checkUrlsSameHost,
176 buildRemoteVideoBaseUrl
177}
diff --git a/server/lib/activitypub/video-comments.ts b/server/lib/activitypub/video-comments.ts
deleted file mode 100644
index b861be5bd..000000000
--- a/server/lib/activitypub/video-comments.ts
+++ /dev/null
@@ -1,205 +0,0 @@
1import { map } from 'bluebird'
2
3import { sanitizeAndCheckVideoCommentObject } from '../../helpers/custom-validators/activitypub/video-comments'
4import { logger } from '../../helpers/logger'
5import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants'
6import { VideoCommentModel } from '../../models/video/video-comment'
7import { MComment, MCommentOwner, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../types/models/video'
8import { isRemoteVideoCommentAccepted } from '../moderation'
9import { Hooks } from '../plugins/hooks'
10import { fetchAP } from './activity'
11import { getOrCreateAPActor } from './actors'
12import { checkUrlsSameHost } from './url'
13import { getOrCreateAPVideo } from './videos'
14
15type ResolveThreadParams = {
16 url: string
17 comments?: MCommentOwner[]
18 isVideo?: boolean
19 commentCreated?: boolean
20}
21type ResolveThreadResult = Promise<{ video: MVideoAccountLightBlacklistAllFiles, comment: MCommentOwnerVideo, commentCreated: boolean }>
22
23async function addVideoComments (commentUrls: string[]) {
24 return map(commentUrls, async commentUrl => {
25 try {
26 await resolveThread({ url: commentUrl, isVideo: false })
27 } catch (err) {
28 logger.warn('Cannot resolve thread %s.', commentUrl, { err })
29 }
30 }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
31}
32
33async function resolveThread (params: ResolveThreadParams): ResolveThreadResult {
34 const { url, isVideo } = params
35
36 if (params.commentCreated === undefined) params.commentCreated = false
37 if (params.comments === undefined) params.comments = []
38
39 // If it is not a video, or if we don't know if it's a video, try to get the thread from DB
40 if (isVideo === false || isVideo === undefined) {
41 const result = await resolveCommentFromDB(params)
42 if (result) return result
43 }
44
45 try {
46 // If it is a video, or if we don't know if it's a video
47 if (isVideo === true || isVideo === undefined) {
48 // Keep await so we catch the exception
49 return await tryToResolveThreadFromVideo(params)
50 }
51 } catch (err) {
52 logger.debug('Cannot resolve thread from video %s, maybe because it was not a video', url, { err })
53 }
54
55 return resolveRemoteParentComment(params)
56}
57
58export {
59 addVideoComments,
60 resolveThread
61}
62
63// ---------------------------------------------------------------------------
64
65async function resolveCommentFromDB (params: ResolveThreadParams) {
66 const { url, comments, commentCreated } = params
67
68 const commentFromDatabase = await VideoCommentModel.loadByUrlAndPopulateReplyAndVideoUrlAndAccount(url)
69 if (!commentFromDatabase) return undefined
70
71 let parentComments = comments.concat([ commentFromDatabase ])
72
73 // Speed up things and resolve directly the thread
74 if (commentFromDatabase.InReplyToVideoComment) {
75 const data = await VideoCommentModel.listThreadParentComments(commentFromDatabase, undefined, 'DESC')
76
77 parentComments = parentComments.concat(data)
78 }
79
80 return resolveThread({
81 url: commentFromDatabase.Video.url,
82 comments: parentComments,
83 isVideo: true,
84 commentCreated
85 })
86}
87
88async function tryToResolveThreadFromVideo (params: ResolveThreadParams) {
89 const { url, comments, commentCreated } = params
90
91 // Maybe it's a reply to a video?
92 // If yes, it's done: we resolved all the thread
93 const syncParam = { rates: true, shares: true, comments: false, refreshVideo: false }
94 const { video } = await getOrCreateAPVideo({ videoObject: url, syncParam })
95
96 if (video.isOwned() && !video.hasPrivacyForFederation()) {
97 throw new Error('Cannot resolve thread of video with privacy that is not compatible with federation')
98 }
99
100 let resultComment: MCommentOwnerVideo
101 if (comments.length !== 0) {
102 const firstReply = comments[comments.length - 1] as MCommentOwnerVideo
103 firstReply.inReplyToCommentId = null
104 firstReply.originCommentId = null
105 firstReply.videoId = video.id
106 firstReply.changed('updatedAt', true)
107 firstReply.Video = video
108
109 if (await isRemoteCommentAccepted(firstReply) !== true) {
110 return undefined
111 }
112
113 comments[comments.length - 1] = await firstReply.save()
114
115 for (let i = comments.length - 2; i >= 0; i--) {
116 const comment = comments[i] as MCommentOwnerVideo
117 comment.originCommentId = firstReply.id
118 comment.inReplyToCommentId = comments[i + 1].id
119 comment.videoId = video.id
120 comment.changed('updatedAt', true)
121 comment.Video = video
122
123 if (await isRemoteCommentAccepted(comment) !== true) {
124 return undefined
125 }
126
127 comments[i] = await comment.save()
128 }
129
130 resultComment = comments[0] as MCommentOwnerVideo
131 }
132
133 return { video, comment: resultComment, commentCreated }
134}
135
136async function resolveRemoteParentComment (params: ResolveThreadParams) {
137 const { url, comments } = params
138
139 if (comments.length > ACTIVITY_PUB.MAX_RECURSION_COMMENTS) {
140 throw new Error('Recursion limit reached when resolving a thread')
141 }
142
143 const { body } = await fetchAP<any>(url)
144
145 if (sanitizeAndCheckVideoCommentObject(body) === false) {
146 throw new Error(`Remote video comment JSON ${url} is not valid:` + JSON.stringify(body))
147 }
148
149 const actorUrl = body.attributedTo
150 if (!actorUrl && body.type !== 'Tombstone') throw new Error('Miss attributed to in comment')
151
152 if (actorUrl && checkUrlsSameHost(url, actorUrl) !== true) {
153 throw new Error(`Actor url ${actorUrl} has not the same host than the comment url ${url}`)
154 }
155
156 if (checkUrlsSameHost(body.id, url) !== true) {
157 throw new Error(`Comment url ${url} host is different from the AP object id ${body.id}`)
158 }
159
160 const actor = actorUrl
161 ? await getOrCreateAPActor(actorUrl, 'all')
162 : null
163
164 const comment = new VideoCommentModel({
165 url: body.id,
166 text: body.content ? body.content : '',
167 videoId: null,
168 accountId: actor ? actor.Account.id : null,
169 inReplyToCommentId: null,
170 originCommentId: null,
171 createdAt: new Date(body.published),
172 updatedAt: new Date(body.updated),
173 deletedAt: body.deleted ? new Date(body.deleted) : null
174 }) as MCommentOwner
175 comment.Account = actor ? actor.Account : null
176
177 return resolveThread({
178 url: body.inReplyTo,
179 comments: comments.concat([ comment ]),
180 commentCreated: true
181 })
182}
183
184async function isRemoteCommentAccepted (comment: MComment) {
185 // Already created
186 if (comment.id) return true
187
188 const acceptParameters = {
189 comment
190 }
191
192 const acceptedResult = await Hooks.wrapFun(
193 isRemoteVideoCommentAccepted,
194 acceptParameters,
195 'filter:activity-pub.remote-video-comment.create.accept.result'
196 )
197
198 if (!acceptedResult || acceptedResult.accepted !== true) {
199 logger.info('Refused to create a remote comment.', { acceptedResult, acceptParameters })
200
201 return false
202 }
203
204 return true
205}
diff --git a/server/lib/activitypub/video-rates.ts b/server/lib/activitypub/video-rates.ts
deleted file mode 100644
index 2e7920f4e..000000000
--- a/server/lib/activitypub/video-rates.ts
+++ /dev/null
@@ -1,59 +0,0 @@
1import { Transaction } from 'sequelize'
2import { VideoRateType } from '../../../shared/models/videos'
3import { MAccountActor, MActorUrl, MVideoAccountLight, MVideoFullLight, MVideoId } from '../../types/models'
4import { sendLike, sendUndoDislike, sendUndoLike } from './send'
5import { sendDislike } from './send/send-dislike'
6import { getVideoDislikeActivityPubUrlByLocalActor, getVideoLikeActivityPubUrlByLocalActor } from './url'
7import { federateVideoIfNeeded } from './videos'
8
9async function sendVideoRateChange (
10 account: MAccountActor,
11 video: MVideoFullLight,
12 likes: number,
13 dislikes: number,
14 t: Transaction
15) {
16 if (video.isOwned()) return federateVideoIfNeeded(video, false, t)
17
18 return sendVideoRateChangeToOrigin(account, video, likes, dislikes, t)
19}
20
21function getLocalRateUrl (rateType: VideoRateType, actor: MActorUrl, video: MVideoId) {
22 return rateType === 'like'
23 ? getVideoLikeActivityPubUrlByLocalActor(actor, video)
24 : getVideoDislikeActivityPubUrlByLocalActor(actor, video)
25}
26
27// ---------------------------------------------------------------------------
28
29export {
30 getLocalRateUrl,
31 sendVideoRateChange
32}
33
34// ---------------------------------------------------------------------------
35
36async function sendVideoRateChangeToOrigin (
37 account: MAccountActor,
38 video: MVideoAccountLight,
39 likes: number,
40 dislikes: number,
41 t: Transaction
42) {
43 // Local video, we don't need to send like
44 if (video.isOwned()) return
45
46 const actor = account.Actor
47
48 // Keep the order: first we undo and then we create
49
50 // Undo Like
51 if (likes < 0) await sendUndoLike(actor, video, t)
52 // Undo Dislike
53 if (dislikes < 0) await sendUndoDislike(actor, video, t)
54
55 // Like
56 if (likes > 0) await sendLike(actor, video, t)
57 // Dislike
58 if (dislikes > 0) await sendDislike(actor, video, t)
59}
diff --git a/server/lib/activitypub/videos/federate.ts b/server/lib/activitypub/videos/federate.ts
deleted file mode 100644
index d7e251153..000000000
--- a/server/lib/activitypub/videos/federate.ts
+++ /dev/null
@@ -1,29 +0,0 @@
1import { Transaction } from 'sequelize/types'
2import { MVideoAP, MVideoAPLight } from '@server/types/models'
3import { sendCreateVideo, sendUpdateVideo } from '../send'
4import { shareVideoByServerAndChannel } from '../share'
5
6async function federateVideoIfNeeded (videoArg: MVideoAPLight, isNewVideo: boolean, transaction?: Transaction) {
7 const video = videoArg as MVideoAP
8
9 if (
10 // Check this is not a blacklisted video, or unfederated blacklisted video
11 (video.isBlacklisted() === false || (isNewVideo === false && video.VideoBlacklist.unfederated === false)) &&
12 // Check the video is public/unlisted and published
13 video.hasPrivacyForFederation() && video.hasStateForFederation()
14 ) {
15 const video = await videoArg.lightAPToFullAP(transaction)
16
17 if (isNewVideo) {
18 // Now we'll add the video's meta data to our followers
19 await sendCreateVideo(video, transaction)
20 await shareVideoByServerAndChannel(video, transaction)
21 } else {
22 await sendUpdateVideo(video, transaction)
23 }
24 }
25}
26
27export {
28 federateVideoIfNeeded
29}
diff --git a/server/lib/activitypub/videos/get.ts b/server/lib/activitypub/videos/get.ts
deleted file mode 100644
index 288c506ee..000000000
--- a/server/lib/activitypub/videos/get.ts
+++ /dev/null
@@ -1,116 +0,0 @@
1import { retryTransactionWrapper } from '@server/helpers/database-utils'
2import { logger } from '@server/helpers/logger'
3import { JobQueue } from '@server/lib/job-queue'
4import { loadVideoByUrl, VideoLoadByUrlType } from '@server/lib/model-loaders'
5import { MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from '@server/types/models'
6import { APObjectId } from '@shared/models'
7import { getAPId } from '../activity'
8import { refreshVideoIfNeeded } from './refresh'
9import { APVideoCreator, fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared'
10
11type GetVideoResult <T> = Promise<{
12 video: T
13 created: boolean
14 autoBlacklisted?: boolean
15}>
16
17type GetVideoParamAll = {
18 videoObject: APObjectId
19 syncParam?: SyncParam
20 fetchType?: 'all'
21 allowRefresh?: boolean
22}
23
24type GetVideoParamImmutable = {
25 videoObject: APObjectId
26 syncParam?: SyncParam
27 fetchType: 'only-immutable-attributes'
28 allowRefresh: false
29}
30
31type GetVideoParamOther = {
32 videoObject: APObjectId
33 syncParam?: SyncParam
34 fetchType?: 'all' | 'only-video'
35 allowRefresh?: boolean
36}
37
38function getOrCreateAPVideo (options: GetVideoParamAll): GetVideoResult<MVideoAccountLightBlacklistAllFiles>
39function getOrCreateAPVideo (options: GetVideoParamImmutable): GetVideoResult<MVideoImmutable>
40function getOrCreateAPVideo (options: GetVideoParamOther): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail>
41
42async function getOrCreateAPVideo (
43 options: GetVideoParamAll | GetVideoParamImmutable | GetVideoParamOther
44): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail | MVideoImmutable> {
45 // Default params
46 const syncParam = options.syncParam || { rates: true, shares: true, comments: true, refreshVideo: false }
47 const fetchType = options.fetchType || 'all'
48 const allowRefresh = options.allowRefresh !== false
49
50 // Get video url
51 const videoUrl = getAPId(options.videoObject)
52 let videoFromDatabase = await loadVideoByUrl(videoUrl, fetchType)
53
54 if (videoFromDatabase) {
55 if (allowRefresh === true) {
56 // Typings ensure allowRefresh === false in only-immutable-attributes fetch type
57 videoFromDatabase = await scheduleRefresh(videoFromDatabase as MVideoThumbnail, fetchType, syncParam)
58 }
59
60 return { video: videoFromDatabase, created: false }
61 }
62
63 const { videoObject } = await fetchRemoteVideo(videoUrl)
64 if (!videoObject) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
65
66 // videoUrl is just an alias/rediraction, so process object id instead
67 if (videoObject.id !== videoUrl) return getOrCreateAPVideo({ ...options, fetchType: 'all', videoObject })
68
69 try {
70 const creator = new APVideoCreator(videoObject)
71 const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(creator.create.bind(creator))
72
73 await syncVideoExternalAttributes(videoCreated, videoObject, syncParam)
74
75 return { video: videoCreated, created: true, autoBlacklisted }
76 } catch (err) {
77 // Maybe a concurrent getOrCreateAPVideo call created this video
78 if (err.name === 'SequelizeUniqueConstraintError') {
79 const alreadyCreatedVideo = await loadVideoByUrl(videoUrl, fetchType)
80 if (alreadyCreatedVideo) return { video: alreadyCreatedVideo, created: false }
81
82 logger.error('Cannot create video %s because of SequelizeUniqueConstraintError error, but cannot find it in database.', videoUrl)
83 }
84
85 throw err
86 }
87}
88
89// ---------------------------------------------------------------------------
90
91export {
92 getOrCreateAPVideo
93}
94
95// ---------------------------------------------------------------------------
96
97async function scheduleRefresh (video: MVideoThumbnail, fetchType: VideoLoadByUrlType, syncParam: SyncParam) {
98 if (!video.isOutdated()) return video
99
100 const refreshOptions = {
101 video,
102 fetchedType: fetchType,
103 syncParam
104 }
105
106 if (syncParam.refreshVideo === true) {
107 return refreshVideoIfNeeded(refreshOptions)
108 }
109
110 await JobQueue.Instance.createJob({
111 type: 'activitypub-refresher',
112 payload: { type: 'video', url: video.url }
113 })
114
115 return video
116}
diff --git a/server/lib/activitypub/videos/index.ts b/server/lib/activitypub/videos/index.ts
deleted file mode 100644
index b22062598..000000000
--- a/server/lib/activitypub/videos/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
1export * from './federate'
2export * from './get'
3export * from './refresh'
4export * from './updater'
diff --git a/server/lib/activitypub/videos/refresh.ts b/server/lib/activitypub/videos/refresh.ts
deleted file mode 100644
index 9f952a218..000000000
--- a/server/lib/activitypub/videos/refresh.ts
+++ /dev/null
@@ -1,68 +0,0 @@
1import { logger, loggerTagsFactory } from '@server/helpers/logger'
2import { PeerTubeRequestError } from '@server/helpers/requests'
3import { VideoLoadByUrlType } from '@server/lib/model-loaders'
4import { VideoModel } from '@server/models/video/video'
5import { MVideoAccountLightBlacklistAllFiles, MVideoThumbnail } from '@server/types/models'
6import { HttpStatusCode } from '@shared/models'
7import { ActorFollowHealthCache } from '../../actor-follow-health-cache'
8import { fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared'
9import { APVideoUpdater } from './updater'
10
11async function refreshVideoIfNeeded (options: {
12 video: MVideoThumbnail
13 fetchedType: VideoLoadByUrlType
14 syncParam: SyncParam
15}): Promise<MVideoThumbnail> {
16 if (!options.video.isOutdated()) return options.video
17
18 // We need more attributes if the argument video was fetched with not enough joints
19 const video = options.fetchedType === 'all'
20 ? options.video as MVideoAccountLightBlacklistAllFiles
21 : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
22
23 const lTags = loggerTagsFactory('ap', 'video', 'refresh', video.uuid, video.url)
24
25 logger.info('Refreshing video %s.', video.url, lTags())
26
27 try {
28 const { videoObject } = await fetchRemoteVideo(video.url)
29
30 if (videoObject === undefined) {
31 logger.warn('Cannot refresh remote video %s: invalid body.', video.url, lTags())
32
33 await video.setAsRefreshed()
34 return video
35 }
36
37 const videoUpdater = new APVideoUpdater(videoObject, video)
38 await videoUpdater.update()
39
40 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
41
42 ActorFollowHealthCache.Instance.addGoodServerId(video.VideoChannel.Actor.serverId)
43
44 return video
45 } catch (err) {
46 if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) {
47 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url, lTags())
48
49 // Video does not exist anymore
50 await video.destroy()
51 return undefined
52 }
53
54 logger.warn('Cannot refresh video %s.', options.video.url, { err, ...lTags() })
55
56 ActorFollowHealthCache.Instance.addBadServerId(video.VideoChannel.Actor.serverId)
57
58 // Don't refresh in loop
59 await video.setAsRefreshed()
60 return video
61 }
62}
63
64// ---------------------------------------------------------------------------
65
66export {
67 refreshVideoIfNeeded
68}
diff --git a/server/lib/activitypub/videos/shared/abstract-builder.ts b/server/lib/activitypub/videos/shared/abstract-builder.ts
deleted file mode 100644
index 98c2f58eb..000000000
--- a/server/lib/activitypub/videos/shared/abstract-builder.ts
+++ /dev/null
@@ -1,190 +0,0 @@
1import { CreationAttributes, Transaction } from 'sequelize/types'
2import { deleteAllModels, filterNonExistingModels } from '@server/helpers/database-utils'
3import { logger, LoggerTagsFn } from '@server/helpers/logger'
4import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail'
5import { setVideoTags } from '@server/lib/video'
6import { StoryboardModel } from '@server/models/video/storyboard'
7import { VideoCaptionModel } from '@server/models/video/video-caption'
8import { VideoFileModel } from '@server/models/video/video-file'
9import { VideoLiveModel } from '@server/models/video/video-live'
10import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
11import {
12 MStreamingPlaylistFiles,
13 MStreamingPlaylistFilesVideo,
14 MVideoCaption,
15 MVideoFile,
16 MVideoFullLight,
17 MVideoThumbnail
18} from '@server/types/models'
19import { ActivityTagObject, ThumbnailType, VideoObject, VideoStreamingPlaylistType } from '@shared/models'
20import { findOwner, getOrCreateAPActor } from '../../actors'
21import {
22 getCaptionAttributesFromObject,
23 getFileAttributesFromUrl,
24 getLiveAttributesFromObject,
25 getPreviewFromIcons,
26 getStoryboardAttributeFromObject,
27 getStreamingPlaylistAttributesFromObject,
28 getTagsFromObject,
29 getThumbnailFromIcons
30} from './object-to-model-attributes'
31import { getTrackerUrls, setVideoTrackers } from './trackers'
32
33export abstract class APVideoAbstractBuilder {
34 protected abstract videoObject: VideoObject
35 protected abstract lTags: LoggerTagsFn
36
37 protected async getOrCreateVideoChannelFromVideoObject () {
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)
40
41 return getOrCreateAPActor(channel.id, 'all')
42 }
43
44 protected async setThumbnail (video: MVideoThumbnail, t?: Transaction) {
45 const miniatureIcon = getThumbnailFromIcons(this.videoObject)
46 if (!miniatureIcon) {
47 logger.warn('Cannot find thumbnail in video object', { object: this.videoObject })
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 })
58
59 await video.addAndSaveThumbnail(miniatureModel, t)
60 }
61
62 protected async setPreview (video: MVideoFullLight, t?: Transaction) {
63 const previewIcon = getPreviewFromIcons(this.videoObject)
64 if (!previewIcon) return
65
66 const previewModel = updateRemoteVideoThumbnail({
67 fileUrl: previewIcon.url,
68 video,
69 type: ThumbnailType.PREVIEW,
70 size: previewIcon,
71 onDisk: false // Lazy download remote previews
72 })
73
74 await video.addAndSaveThumbnail(previewModel, t)
75 }
76
77 protected async setTags (video: MVideoFullLight, t: Transaction) {
78 const tags = getTagsFromObject(this.videoObject)
79 await setVideoTags({ video, tags, transaction: t })
80 }
81
82 protected async setTrackers (video: MVideoFullLight, t: Transaction) {
83 const trackers = getTrackerUrls(this.videoObject, video)
84 await setVideoTrackers({ video, trackers, transaction: t })
85 }
86
87 protected async insertOrReplaceCaptions (video: MVideoFullLight, t: Transaction) {
88 const existingCaptions = await VideoCaptionModel.listVideoCaptions(video.id, t)
89
90 let captionsToCreate = getCaptionAttributesFromObject(video, this.videoObject)
91 .map(a => new VideoCaptionModel(a) as MVideoCaption)
92
93 for (const existingCaption of existingCaptions) {
94 // Only keep captions that do not already exist
95 const filtered = captionsToCreate.filter(c => !c.isEqual(existingCaption))
96
97 // This caption already exists, we don't need to destroy and create it
98 if (filtered.length !== captionsToCreate.length) {
99 captionsToCreate = filtered
100 continue
101 }
102
103 // Destroy this caption that does not exist anymore
104 await existingCaption.destroy({ transaction: t })
105 }
106
107 for (const captionToCreate of captionsToCreate) {
108 await captionToCreate.save({ transaction: t })
109 }
110 }
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
122 protected async insertOrReplaceLive (video: MVideoFullLight, transaction: Transaction) {
123 const attributes = getLiveAttributesFromObject(video, this.videoObject)
124 const [ videoLive ] = await VideoLiveModel.upsert(attributes, { transaction, returning: true })
125
126 video.VideoLive = videoLive
127 }
128
129 protected async setWebVideoFiles (video: MVideoFullLight, t: Transaction) {
130 const videoFileAttributes = getFileAttributesFromUrl(video, this.videoObject.url)
131 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
132
133 // Remove video files that do not exist anymore
134 await deleteAllModels(filterNonExistingModels(video.VideoFiles || [], newVideoFiles), t)
135
136 // Update or add other one
137 const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'video', t))
138 video.VideoFiles = await Promise.all(upsertTasks)
139 }
140
141 protected async setStreamingPlaylists (video: MVideoFullLight, t: Transaction) {
142 const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject)
143 const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
144
145 // Remove video playlists that do not exist anymore
146 await deleteAllModels(filterNonExistingModels(video.VideoStreamingPlaylists || [], newStreamingPlaylists), t)
147
148 const oldPlaylists = video.VideoStreamingPlaylists
149 video.VideoStreamingPlaylists = []
150
151 for (const playlistAttributes of streamingPlaylistAttributes) {
152 const streamingPlaylistModel = await this.insertOrReplaceStreamingPlaylist(playlistAttributes, t)
153 streamingPlaylistModel.Video = video
154
155 await this.setStreamingPlaylistFiles(oldPlaylists, streamingPlaylistModel, playlistAttributes.tagAPObject, t)
156
157 video.VideoStreamingPlaylists.push(streamingPlaylistModel)
158 }
159 }
160
161 private async insertOrReplaceStreamingPlaylist (attributes: CreationAttributes<VideoStreamingPlaylistModel>, t: Transaction) {
162 const [ streamingPlaylist ] = await VideoStreamingPlaylistModel.upsert(attributes, { returning: true, transaction: t })
163
164 return streamingPlaylist as MStreamingPlaylistFilesVideo
165 }
166
167 private getStreamingPlaylistFiles (oldPlaylists: MStreamingPlaylistFiles[], type: VideoStreamingPlaylistType) {
168 const playlist = oldPlaylists.find(s => s.type === type)
169 if (!playlist) return []
170
171 return playlist.VideoFiles
172 }
173
174 private async setStreamingPlaylistFiles (
175 oldPlaylists: MStreamingPlaylistFiles[],
176 playlistModel: MStreamingPlaylistFilesVideo,
177 tagObjects: ActivityTagObject[],
178 t: Transaction
179 ) {
180 const oldStreamingPlaylistFiles = this.getStreamingPlaylistFiles(oldPlaylists || [], playlistModel.type)
181
182 const newVideoFiles: MVideoFile[] = getFileAttributesFromUrl(playlistModel, tagObjects).map(a => new VideoFileModel(a))
183
184 await deleteAllModels(filterNonExistingModels(oldStreamingPlaylistFiles, newVideoFiles), t)
185
186 // Update or add other one
187 const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'streaming-playlist', t))
188 playlistModel.VideoFiles = await Promise.all(upsertTasks)
189 }
190}
diff --git a/server/lib/activitypub/videos/shared/creator.ts b/server/lib/activitypub/videos/shared/creator.ts
deleted file mode 100644
index e44fd0d52..000000000
--- a/server/lib/activitypub/videos/shared/creator.ts
+++ /dev/null
@@ -1,65 +0,0 @@
1
2import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger'
3import { sequelizeTypescript } from '@server/initializers/database'
4import { Hooks } from '@server/lib/plugins/hooks'
5import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist'
6import { VideoModel } from '@server/models/video/video'
7import { MVideoFullLight, MVideoThumbnail } from '@server/types/models'
8import { VideoObject } from '@shared/models'
9import { APVideoAbstractBuilder } from './abstract-builder'
10import { getVideoAttributesFromObject } from './object-to-model-attributes'
11
12export class APVideoCreator extends APVideoAbstractBuilder {
13 protected lTags: LoggerTagsFn
14
15 constructor (protected readonly videoObject: VideoObject) {
16 super()
17
18 this.lTags = loggerTagsFactory('ap', 'video', 'create', this.videoObject.uuid, this.videoObject.id)
19 }
20
21 async create () {
22 logger.debug('Adding remote video %s.', this.videoObject.id, this.lTags())
23
24 const channelActor = await this.getOrCreateVideoChannelFromVideoObject()
25 const channel = channelActor.VideoChannel
26
27 const videoData = getVideoAttributesFromObject(channel, this.videoObject, this.videoObject.to)
28 const video = VideoModel.build({ ...videoData, likes: 0, dislikes: 0 }) as MVideoThumbnail
29
30 const { autoBlacklisted, videoCreated } = await sequelizeTypescript.transaction(async t => {
31 const videoCreated = await video.save({ transaction: t }) as MVideoFullLight
32 videoCreated.VideoChannel = channel
33
34 await this.setThumbnail(videoCreated, t)
35 await this.setPreview(videoCreated, t)
36 await this.setWebVideoFiles(videoCreated, t)
37 await this.setStreamingPlaylists(videoCreated, t)
38 await this.setTags(videoCreated, t)
39 await this.setTrackers(videoCreated, t)
40 await this.insertOrReplaceCaptions(videoCreated, t)
41 await this.insertOrReplaceLive(videoCreated, t)
42 await this.insertOrReplaceStoryboard(videoCreated, t)
43
44 // We added a video in this channel, set it as updated
45 await channel.setAsUpdated(t)
46
47 const autoBlacklisted = await autoBlacklistVideoIfNeeded({
48 video: videoCreated,
49 user: undefined,
50 isRemote: true,
51 isNew: true,
52 isNewFile: true,
53 transaction: t
54 })
55
56 logger.info('Remote video with uuid %s inserted.', this.videoObject.uuid, this.lTags())
57
58 Hooks.runAction('action:activity-pub.remote-video.created', { video: videoCreated, videoAPObject: this.videoObject })
59
60 return { autoBlacklisted, videoCreated }
61 })
62
63 return { autoBlacklisted, videoCreated }
64 }
65}
diff --git a/server/lib/activitypub/videos/shared/index.ts b/server/lib/activitypub/videos/shared/index.ts
deleted file mode 100644
index 951403493..000000000
--- a/server/lib/activitypub/videos/shared/index.ts
+++ /dev/null
@@ -1,6 +0,0 @@
1export * from './abstract-builder'
2export * from './creator'
3export * from './object-to-model-attributes'
4export * from './trackers'
5export * from './url-to-object'
6export * from './video-sync-attributes'
diff --git a/server/lib/activitypub/videos/shared/object-to-model-attributes.ts b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
deleted file mode 100644
index 6cbe72e27..000000000
--- a/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
+++ /dev/null
@@ -1,285 +0,0 @@
1import { maxBy, minBy } from 'lodash'
2import { decode as magnetUriDecode } from 'magnet-uri'
3import { basename, extname } from 'path'
4import { isAPVideoFileUrlMetadataObject } from '@server/helpers/custom-validators/activitypub/videos'
5import { isVideoFileInfoHashValid } from '@server/helpers/custom-validators/videos'
6import { logger } from '@server/helpers/logger'
7import { getExtFromMimetype } from '@server/helpers/video'
8import { ACTIVITY_PUB, MIMETYPES, P2P_MEDIA_LOADER_PEER_VERSION, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '@server/initializers/constants'
9import { generateTorrentFileName } from '@server/lib/paths'
10import { VideoCaptionModel } from '@server/models/video/video-caption'
11import { VideoFileModel } from '@server/models/video/video-file'
12import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
13import { FilteredModelAttributes } from '@server/types'
14import { isStreamingPlaylist, MChannelId, MStreamingPlaylistVideo, MVideo, MVideoId } from '@server/types/models'
15import {
16 ActivityHashTagObject,
17 ActivityMagnetUrlObject,
18 ActivityPlaylistSegmentHashesObject,
19 ActivityPlaylistUrlObject,
20 ActivityTagObject,
21 ActivityUrlObject,
22 ActivityVideoUrlObject,
23 VideoObject,
24 VideoPrivacy,
25 VideoStreamingPlaylistType
26} from '@shared/models'
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'
31
32function getThumbnailFromIcons (videoObject: VideoObject) {
33 let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth)
34 // Fallback if there are not valid icons
35 if (validIcons.length === 0) validIcons = videoObject.icon
36
37 return minBy(validIcons, 'width')
38}
39
40function getPreviewFromIcons (videoObject: VideoObject) {
41 const validIcons = videoObject.icon.filter(i => i.width > PREVIEWS_SIZE.minWidth)
42
43 return maxBy(validIcons, 'width')
44}
45
46function getTagsFromObject (videoObject: VideoObject) {
47 return videoObject.tag
48 .filter(isAPHashTagObject)
49 .map(t => t.name)
50}
51
52function getFileAttributesFromUrl (
53 videoOrPlaylist: MVideo | MStreamingPlaylistVideo,
54 urls: (ActivityTagObject | ActivityUrlObject)[]
55) {
56 const fileUrls = urls.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
57
58 if (fileUrls.length === 0) return []
59
60 const attributes: FilteredModelAttributes<VideoFileModel>[] = []
61 for (const fileUrl of fileUrls) {
62 // Fetch associated magnet uri
63 const magnet = urls.filter(isAPMagnetUrlObject)
64 .find(u => u.height === fileUrl.height)
65
66 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
67
68 const parsed = magnetUriDecode(magnet.href)
69 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
70 throw new Error('Cannot parse magnet URI ' + magnet.href)
71 }
72
73 const torrentUrl = Array.isArray(parsed.xs)
74 ? parsed.xs[0]
75 : parsed.xs
76
77 // Fetch associated metadata url, if any
78 const metadata = urls.filter(isAPVideoFileUrlMetadataObject)
79 .find(u => {
80 return u.height === fileUrl.height &&
81 u.fps === fileUrl.fps &&
82 u.rel.includes(fileUrl.mediaType)
83 })
84
85 const extname = getExtFromMimetype(MIMETYPES.VIDEO.MIMETYPE_EXT, fileUrl.mediaType)
86 const resolution = fileUrl.height
87 const videoId = isStreamingPlaylist(videoOrPlaylist) ? null : videoOrPlaylist.id
88 const videoStreamingPlaylistId = isStreamingPlaylist(videoOrPlaylist) ? videoOrPlaylist.id : null
89
90 const attribute = {
91 extname,
92 infoHash: parsed.infoHash,
93 resolution,
94 size: fileUrl.size,
95 fps: fileUrl.fps || -1,
96 metadataUrl: metadata?.href,
97
98 // Use the name of the remote file because we don't proxify video file requests
99 filename: basename(fileUrl.href),
100 fileUrl: fileUrl.href,
101
102 torrentUrl,
103 // Use our own torrent name since we proxify torrent requests
104 torrentFilename: generateTorrentFileName(videoOrPlaylist, resolution),
105
106 // This is a video file owned by a video or by a streaming playlist
107 videoId,
108 videoStreamingPlaylistId
109 }
110
111 attributes.push(attribute)
112 }
113
114 return attributes
115}
116
117function getStreamingPlaylistAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
118 const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[]
119 if (playlistUrls.length === 0) return []
120
121 const attributes: (FilteredModelAttributes<VideoStreamingPlaylistModel> & { tagAPObject?: ActivityTagObject[] })[] = []
122 for (const playlistUrlObject of playlistUrls) {
123 const segmentsSha256UrlObject = playlistUrlObject.tag.find(isAPPlaylistSegmentHashesUrlObject)
124
125 const files: unknown[] = playlistUrlObject.tag.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
126
127 if (!segmentsSha256UrlObject) {
128 logger.warn('No segment sha256 URL found in AP playlist object.', { playlistUrl: playlistUrlObject })
129 continue
130 }
131
132 const attribute = {
133 type: VideoStreamingPlaylistType.HLS,
134
135 playlistFilename: basename(playlistUrlObject.href),
136 playlistUrl: playlistUrlObject.href,
137
138 segmentsSha256Filename: basename(segmentsSha256UrlObject.href),
139 segmentsSha256Url: segmentsSha256UrlObject.href,
140
141 p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrlObject.href, files),
142 p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
143 videoId: video.id,
144
145 tagAPObject: playlistUrlObject.tag
146 }
147
148 attributes.push(attribute)
149 }
150
151 return attributes
152}
153
154function getLiveAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
155 return {
156 saveReplay: videoObject.liveSaveReplay,
157 permanentLive: videoObject.permanentLive,
158 latencyMode: videoObject.latencyMode,
159 videoId: video.id
160 }
161}
162
163function getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
164 return videoObject.subtitleLanguage.map(c => ({
165 videoId: video.id,
166 filename: VideoCaptionModel.generateCaptionName(c.identifier),
167 language: c.identifier,
168 fileUrl: c.url
169 }))
170}
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
192function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) {
193 const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
194 ? VideoPrivacy.PUBLIC
195 : VideoPrivacy.UNLISTED
196
197 const language = videoObject.language?.identifier
198
199 const category = videoObject.category
200 ? parseInt(videoObject.category.identifier, 10)
201 : undefined
202
203 const licence = videoObject.licence
204 ? parseInt(videoObject.licence.identifier, 10)
205 : undefined
206
207 const description = videoObject.content || null
208 const support = videoObject.support || null
209
210 return {
211 name: videoObject.name,
212 uuid: videoObject.uuid,
213 url: videoObject.id,
214 category,
215 licence,
216 language,
217 description,
218 support,
219 nsfw: videoObject.sensitive,
220 commentsEnabled: videoObject.commentsEnabled,
221 downloadEnabled: videoObject.downloadEnabled,
222 waitTranscoding: videoObject.waitTranscoding,
223 isLive: videoObject.isLiveBroadcast,
224 state: videoObject.state,
225 channelId: videoChannel.id,
226 duration: getDurationFromActivityStream(videoObject.duration),
227 createdAt: new Date(videoObject.published),
228 publishedAt: new Date(videoObject.published),
229
230 originallyPublishedAt: videoObject.originallyPublishedAt
231 ? new Date(videoObject.originallyPublishedAt)
232 : null,
233
234 inputFileUpdatedAt: videoObject.uploadDate
235 ? new Date(videoObject.uploadDate)
236 : null,
237
238 updatedAt: new Date(videoObject.updated),
239 views: videoObject.views,
240 remote: true,
241 privacy
242 }
243}
244
245// ---------------------------------------------------------------------------
246
247export {
248 getThumbnailFromIcons,
249 getPreviewFromIcons,
250
251 getTagsFromObject,
252
253 getFileAttributesFromUrl,
254 getStreamingPlaylistAttributesFromObject,
255
256 getLiveAttributesFromObject,
257 getCaptionAttributesFromObject,
258 getStoryboardAttributeFromObject,
259
260 getVideoAttributesFromObject
261}
262
263// ---------------------------------------------------------------------------
264
265function isAPVideoUrlObject (url: any): url is ActivityVideoUrlObject {
266 const urlMediaType = url.mediaType
267
268 return MIMETYPES.VIDEO.MIMETYPE_EXT[urlMediaType] && urlMediaType.startsWith('video/')
269}
270
271function isAPStreamingPlaylistUrlObject (url: any): url is ActivityPlaylistUrlObject {
272 return url && url.mediaType === 'application/x-mpegURL'
273}
274
275function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject {
276 return tag && tag.name === 'sha256' && tag.type === 'Link' && tag.mediaType === 'application/json'
277}
278
279function isAPMagnetUrlObject (url: any): url is ActivityMagnetUrlObject {
280 return url && url.mediaType === 'application/x-bittorrent;x-scheme-handler/magnet'
281}
282
283function isAPHashTagObject (url: any): url is ActivityHashTagObject {
284 return url && url.type === 'Hashtag'
285}
diff --git a/server/lib/activitypub/videos/shared/trackers.ts b/server/lib/activitypub/videos/shared/trackers.ts
deleted file mode 100644
index 2418f45c2..000000000
--- a/server/lib/activitypub/videos/shared/trackers.ts
+++ /dev/null
@@ -1,43 +0,0 @@
1import { Transaction } from 'sequelize/types'
2import { isAPVideoTrackerUrlObject } from '@server/helpers/custom-validators/activitypub/videos'
3import { isArray } from '@server/helpers/custom-validators/misc'
4import { REMOTE_SCHEME } from '@server/initializers/constants'
5import { TrackerModel } from '@server/models/server/tracker'
6import { MVideo, MVideoWithHost } from '@server/types/models'
7import { ActivityTrackerUrlObject, VideoObject } from '@shared/models'
8import { buildRemoteVideoBaseUrl } from '../../url'
9
10function getTrackerUrls (object: VideoObject, video: MVideoWithHost) {
11 let wsFound = false
12
13 const trackers = object.url.filter(u => isAPVideoTrackerUrlObject(u))
14 .map((u: ActivityTrackerUrlObject) => {
15 if (isArray(u.rel) && u.rel.includes('websocket')) wsFound = true
16
17 return u.href
18 })
19
20 if (wsFound) return trackers
21
22 return [
23 buildRemoteVideoBaseUrl(video, '/tracker/socket', REMOTE_SCHEME.WS),
24 buildRemoteVideoBaseUrl(video, '/tracker/announce')
25 ]
26}
27
28async function setVideoTrackers (options: {
29 video: MVideo
30 trackers: string[]
31 transaction: Transaction
32}) {
33 const { video, trackers, transaction } = options
34
35 const trackerInstances = await TrackerModel.findOrCreateTrackers(trackers, transaction)
36
37 await video.$set('Trackers', trackerInstances, { transaction })
38}
39
40export {
41 getTrackerUrls,
42 setVideoTrackers
43}
diff --git a/server/lib/activitypub/videos/shared/url-to-object.ts b/server/lib/activitypub/videos/shared/url-to-object.ts
deleted file mode 100644
index 7fe008419..000000000
--- a/server/lib/activitypub/videos/shared/url-to-object.ts
+++ /dev/null
@@ -1,25 +0,0 @@
1import { sanitizeAndCheckVideoTorrentObject } from '@server/helpers/custom-validators/activitypub/videos'
2import { logger, loggerTagsFactory } from '@server/helpers/logger'
3import { VideoObject } from '@shared/models'
4import { fetchAP } from '../../activity'
5import { checkUrlsSameHost } from '../../url'
6
7const lTags = loggerTagsFactory('ap', 'video')
8
9async function fetchRemoteVideo (videoUrl: string): Promise<{ statusCode: number, videoObject: VideoObject }> {
10 logger.info('Fetching remote video %s.', videoUrl, lTags(videoUrl))
11
12 const { statusCode, body } = await fetchAP<any>(videoUrl)
13
14 if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) {
15 logger.debug('Remote video JSON is not valid.', { body, ...lTags(videoUrl) })
16
17 return { statusCode, videoObject: undefined }
18 }
19
20 return { statusCode, videoObject: body }
21}
22
23export {
24 fetchRemoteVideo
25}
diff --git a/server/lib/activitypub/videos/shared/video-sync-attributes.ts b/server/lib/activitypub/videos/shared/video-sync-attributes.ts
deleted file mode 100644
index 7fb933559..000000000
--- a/server/lib/activitypub/videos/shared/video-sync-attributes.ts
+++ /dev/null
@@ -1,107 +0,0 @@
1import { runInReadCommittedTransaction } from '@server/helpers/database-utils'
2import { logger, loggerTagsFactory } from '@server/helpers/logger'
3import { JobQueue } from '@server/lib/job-queue'
4import { VideoModel } from '@server/models/video/video'
5import { VideoCommentModel } from '@server/models/video/video-comment'
6import { VideoShareModel } from '@server/models/video/video-share'
7import { MVideo } from '@server/types/models'
8import { ActivitypubHttpFetcherPayload, ActivityPubOrderedCollection, VideoObject } from '@shared/models'
9import { fetchAP } from '../../activity'
10import { crawlCollectionPage } from '../../crawl'
11import { addVideoShares } from '../../share'
12import { addVideoComments } from '../../video-comments'
13
14const lTags = loggerTagsFactory('ap', 'video')
15
16type SyncParam = {
17 rates: boolean
18 shares: boolean
19 comments: boolean
20 refreshVideo?: boolean
21}
22
23async function syncVideoExternalAttributes (
24 video: MVideo,
25 fetchedVideo: VideoObject,
26 syncParam: Pick<SyncParam, 'rates' | 'shares' | 'comments'>
27) {
28 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
29
30 const ratePromise = updateVideoRates(video, fetchedVideo)
31 if (syncParam.rates) await ratePromise
32
33 await syncShares(video, fetchedVideo, syncParam.shares)
34
35 await syncComments(video, fetchedVideo, syncParam.comments)
36}
37
38async function updateVideoRates (video: MVideo, fetchedVideo: VideoObject) {
39 const [ likes, dislikes ] = await Promise.all([
40 getRatesCount('like', video, fetchedVideo),
41 getRatesCount('dislike', video, fetchedVideo)
42 ])
43
44 return runInReadCommittedTransaction(async t => {
45 await VideoModel.updateRatesOf(video.id, 'like', likes, t)
46 await VideoModel.updateRatesOf(video.id, 'dislike', dislikes, t)
47 })
48}
49
50// ---------------------------------------------------------------------------
51
52export {
53 SyncParam,
54 syncVideoExternalAttributes,
55 updateVideoRates
56}
57
58// ---------------------------------------------------------------------------
59
60async function getRatesCount (type: 'like' | 'dislike', video: MVideo, fetchedVideo: VideoObject) {
61 const uri = type === 'like'
62 ? fetchedVideo.likes
63 : fetchedVideo.dislikes
64
65 logger.info('Sync %s of video %s', type, video.url)
66
67 const { body } = await fetchAP<ActivityPubOrderedCollection<any>>(uri)
68
69 if (isNaN(body.totalItems)) {
70 logger.error('Cannot sync %s of video %s, totalItems is not a number', type, video.url, { body })
71 return
72 }
73
74 return body.totalItems
75}
76
77function syncShares (video: MVideo, fetchedVideo: VideoObject, isSync: boolean) {
78 const uri = fetchedVideo.shares
79
80 if (!isSync) {
81 return createJob({ uri, videoId: video.id, type: 'video-shares' })
82 }
83
84 const handler = items => addVideoShares(items, video)
85 const cleaner = crawlStartDate => VideoShareModel.cleanOldSharesOf(video.id, crawlStartDate)
86
87 return crawlCollectionPage<string>(uri, handler, cleaner)
88 .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err, rootUrl: uri, ...lTags(video.uuid, video.url) }))
89}
90
91function syncComments (video: MVideo, fetchedVideo: VideoObject, isSync: boolean) {
92 const uri = fetchedVideo.comments
93
94 if (!isSync) {
95 return createJob({ uri, videoId: video.id, type: 'video-comments' })
96 }
97
98 const handler = items => addVideoComments(items)
99 const cleaner = crawlStartDate => VideoCommentModel.cleanOldCommentsOf(video.id, crawlStartDate)
100
101 return crawlCollectionPage<string>(uri, handler, cleaner)
102 .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err, rootUrl: uri, ...lTags(video.uuid, video.url) }))
103}
104
105function createJob (payload: ActivitypubHttpFetcherPayload) {
106 return JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
107}
diff --git a/server/lib/activitypub/videos/updater.ts b/server/lib/activitypub/videos/updater.ts
deleted file mode 100644
index acb087895..000000000
--- a/server/lib/activitypub/videos/updater.ts
+++ /dev/null
@@ -1,180 +0,0 @@
1import { Transaction } from 'sequelize/types'
2import { resetSequelizeInstance, runInReadCommittedTransaction } from '@server/helpers/database-utils'
3import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger'
4import { Notifier } from '@server/lib/notifier'
5import { PeerTubeSocket } from '@server/lib/peertube-socket'
6import { Hooks } from '@server/lib/plugins/hooks'
7import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist'
8import { VideoLiveModel } from '@server/models/video/video-live'
9import { MActor, MChannelAccountLight, MChannelId, MVideoAccountLightBlacklistAllFiles, MVideoFullLight } from '@server/types/models'
10import { VideoObject, VideoPrivacy } from '@shared/models'
11import { APVideoAbstractBuilder, getVideoAttributesFromObject, updateVideoRates } from './shared'
12
13export class APVideoUpdater extends APVideoAbstractBuilder {
14 private readonly wasPrivateVideo: boolean
15 private readonly wasUnlistedVideo: boolean
16
17 private readonly oldVideoChannel: MChannelAccountLight
18
19 protected lTags: LoggerTagsFn
20
21 constructor (
22 protected readonly videoObject: VideoObject,
23 private readonly video: MVideoAccountLightBlacklistAllFiles
24 ) {
25 super()
26
27 this.wasPrivateVideo = this.video.privacy === VideoPrivacy.PRIVATE
28 this.wasUnlistedVideo = this.video.privacy === VideoPrivacy.UNLISTED
29
30 this.oldVideoChannel = this.video.VideoChannel
31
32 this.lTags = loggerTagsFactory('ap', 'video', 'update', video.uuid, video.url)
33 }
34
35 async update (overrideTo?: string[]) {
36 logger.debug(
37 'Updating remote video "%s".', this.videoObject.uuid,
38 { videoObject: this.videoObject, ...this.lTags() }
39 )
40
41 const oldInputFileUpdatedAt = this.video.inputFileUpdatedAt
42
43 try {
44 const channelActor = await this.getOrCreateVideoChannelFromVideoObject()
45
46 const thumbnailModel = await this.setThumbnail(this.video)
47
48 this.checkChannelUpdateOrThrow(channelActor)
49
50 const videoUpdated = await this.updateVideo(channelActor.VideoChannel, undefined, overrideTo)
51
52 if (thumbnailModel) await videoUpdated.addAndSaveThumbnail(thumbnailModel)
53
54 await runInReadCommittedTransaction(async t => {
55 await this.setWebVideoFiles(videoUpdated, t)
56 await this.setStreamingPlaylists(videoUpdated, t)
57 })
58
59 await Promise.all([
60 runInReadCommittedTransaction(t => this.setTags(videoUpdated, t)),
61 runInReadCommittedTransaction(t => this.setTrackers(videoUpdated, t)),
62 runInReadCommittedTransaction(t => this.setStoryboard(videoUpdated, t)),
63 runInReadCommittedTransaction(t => {
64 return Promise.all([
65 this.setPreview(videoUpdated, t),
66 this.setThumbnail(videoUpdated, t)
67 ])
68 }),
69 this.setOrDeleteLive(videoUpdated)
70 ])
71
72 await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t))
73
74 await autoBlacklistVideoIfNeeded({
75 video: videoUpdated,
76 user: undefined,
77 isRemote: true,
78 isNew: false,
79 isNewFile: oldInputFileUpdatedAt !== videoUpdated.inputFileUpdatedAt,
80 transaction: undefined
81 })
82
83 await updateVideoRates(videoUpdated, this.videoObject)
84
85 // Notify our users?
86 if (this.wasPrivateVideo || this.wasUnlistedVideo) {
87 Notifier.Instance.notifyOnNewVideoIfNeeded(videoUpdated)
88 }
89
90 if (videoUpdated.isLive) {
91 PeerTubeSocket.Instance.sendVideoLiveNewState(videoUpdated)
92 }
93
94 Hooks.runAction('action:activity-pub.remote-video.updated', { video: videoUpdated, videoAPObject: this.videoObject })
95
96 logger.info('Remote video with uuid %s updated', this.videoObject.uuid, this.lTags())
97
98 return videoUpdated
99 } catch (err) {
100 await this.catchUpdateError(err)
101 }
102 }
103
104 // Check we can update the channel: we trust the remote server
105 private checkChannelUpdateOrThrow (newChannelActor: MActor) {
106 if (!this.oldVideoChannel.Actor.serverId || !newChannelActor.serverId) {
107 throw new Error('Cannot check old channel/new channel validity because `serverId` is null')
108 }
109
110 if (this.oldVideoChannel.Actor.serverId !== newChannelActor.serverId) {
111 throw new Error(`New channel ${newChannelActor.url} is not on the same server than new channel ${this.oldVideoChannel.Actor.url}`)
112 }
113 }
114
115 private updateVideo (channel: MChannelId, transaction?: Transaction, overrideTo?: string[]) {
116 const to = overrideTo || this.videoObject.to
117 const videoData = getVideoAttributesFromObject(channel, this.videoObject, to)
118 this.video.name = videoData.name
119 this.video.uuid = videoData.uuid
120 this.video.url = videoData.url
121 this.video.category = videoData.category
122 this.video.licence = videoData.licence
123 this.video.language = videoData.language
124 this.video.description = videoData.description
125 this.video.support = videoData.support
126 this.video.nsfw = videoData.nsfw
127 this.video.commentsEnabled = videoData.commentsEnabled
128 this.video.downloadEnabled = videoData.downloadEnabled
129 this.video.waitTranscoding = videoData.waitTranscoding
130 this.video.state = videoData.state
131 this.video.duration = videoData.duration
132 this.video.createdAt = videoData.createdAt
133 this.video.publishedAt = videoData.publishedAt
134 this.video.originallyPublishedAt = videoData.originallyPublishedAt
135 this.video.inputFileUpdatedAt = videoData.inputFileUpdatedAt
136 this.video.privacy = videoData.privacy
137 this.video.channelId = videoData.channelId
138 this.video.views = videoData.views
139 this.video.isLive = videoData.isLive
140
141 // Ensures we update the updatedAt attribute, even if main attributes did not change
142 this.video.changed('updatedAt', true)
143
144 return this.video.save({ transaction }) as Promise<MVideoFullLight>
145 }
146
147 private async setCaptions (videoUpdated: MVideoFullLight, t: Transaction) {
148 await this.insertOrReplaceCaptions(videoUpdated, t)
149 }
150
151 private async setStoryboard (videoUpdated: MVideoFullLight, t: Transaction) {
152 await this.insertOrReplaceStoryboard(videoUpdated, t)
153 }
154
155 private async setOrDeleteLive (videoUpdated: MVideoFullLight, transaction?: Transaction) {
156 if (!this.video.isLive) return
157
158 if (this.video.isLive) return this.insertOrReplaceLive(videoUpdated, transaction)
159
160 // Delete existing live if it exists
161 await VideoLiveModel.destroy({
162 where: {
163 videoId: this.video.id
164 },
165 transaction
166 })
167
168 videoUpdated.VideoLive = null
169 }
170
171 private async catchUpdateError (err: Error) {
172 if (this.video !== undefined) {
173 await resetSequelizeInstance(this.video)
174 }
175
176 // This is just a debug because we will retry the insert
177 logger.debug('Cannot update the remote video.', { err, ...this.lTags() })
178 throw err
179 }
180}
diff --git a/server/lib/actor-follow-health-cache.ts b/server/lib/actor-follow-health-cache.ts
deleted file mode 100644
index 34357a97a..000000000
--- a/server/lib/actor-follow-health-cache.ts
+++ /dev/null
@@ -1,86 +0,0 @@
1import { ACTOR_FOLLOW_SCORE } from '../initializers/constants'
2import { logger } from '../helpers/logger'
3
4// Cache follows scores, instead of writing them too often in database
5// Keep data in memory, we don't really need Redis here as we don't really care to loose some scores
6class ActorFollowHealthCache {
7
8 private static instance: ActorFollowHealthCache
9
10 private pendingFollowsScore: { [ url: string ]: number } = {}
11
12 private pendingBadServer = new Set<number>()
13 private pendingGoodServer = new Set<number>()
14
15 private readonly badInboxes = new Set<string>()
16
17 private constructor () {}
18
19 static get Instance () {
20 return this.instance || (this.instance = new this())
21 }
22
23 updateActorFollowsHealth (goodInboxes: string[], badInboxes: string[]) {
24 this.badInboxes.clear()
25
26 if (goodInboxes.length === 0 && badInboxes.length === 0) return
27
28 logger.info(
29 'Updating %d good actor follows and %d bad actor follows scores in cache.',
30 goodInboxes.length, badInboxes.length, { badInboxes }
31 )
32
33 for (const goodInbox of goodInboxes) {
34 if (this.pendingFollowsScore[goodInbox] === undefined) this.pendingFollowsScore[goodInbox] = 0
35
36 this.pendingFollowsScore[goodInbox] += ACTOR_FOLLOW_SCORE.BONUS
37 }
38
39 for (const badInbox of badInboxes) {
40 if (this.pendingFollowsScore[badInbox] === undefined) this.pendingFollowsScore[badInbox] = 0
41
42 this.pendingFollowsScore[badInbox] += ACTOR_FOLLOW_SCORE.PENALTY
43 this.badInboxes.add(badInbox)
44 }
45 }
46
47 isBadInbox (inboxUrl: string) {
48 return this.badInboxes.has(inboxUrl)
49 }
50
51 addBadServerId (serverId: number) {
52 this.pendingBadServer.add(serverId)
53 }
54
55 getBadFollowingServerIds () {
56 return Array.from(this.pendingBadServer)
57 }
58
59 clearBadFollowingServerIds () {
60 this.pendingBadServer = new Set<number>()
61 }
62
63 addGoodServerId (serverId: number) {
64 this.pendingGoodServer.add(serverId)
65 }
66
67 getGoodFollowingServerIds () {
68 return Array.from(this.pendingGoodServer)
69 }
70
71 clearGoodFollowingServerIds () {
72 this.pendingGoodServer = new Set<number>()
73 }
74
75 getPendingFollowsScore () {
76 return this.pendingFollowsScore
77 }
78
79 clearPendingFollowsScore () {
80 this.pendingFollowsScore = {}
81 }
82}
83
84export {
85 ActorFollowHealthCache
86}
diff --git a/server/lib/actor-image.ts b/server/lib/actor-image.ts
deleted file mode 100644
index e9bd148f6..000000000
--- a/server/lib/actor-image.ts
+++ /dev/null
@@ -1,14 +0,0 @@
1import maxBy from 'lodash/maxBy'
2
3function getBiggestActorImage <T extends { width: number }> (images: T[]) {
4 const image = maxBy(images, 'width')
5
6 // If width is null, maxBy won't return a value
7 if (!image) return images[0]
8
9 return image
10}
11
12export {
13 getBiggestActorImage
14}
diff --git a/server/lib/auth/external-auth.ts b/server/lib/auth/external-auth.ts
deleted file mode 100644
index bc5b74257..000000000
--- a/server/lib/auth/external-auth.ts
+++ /dev/null
@@ -1,231 +0,0 @@
1
2import {
3 isUserAdminFlagsValid,
4 isUserDisplayNameValid,
5 isUserRoleValid,
6 isUserUsernameValid,
7 isUserVideoQuotaDailyValid,
8 isUserVideoQuotaValid
9} from '@server/helpers/custom-validators/users'
10import { logger } from '@server/helpers/logger'
11import { generateRandomString } from '@server/helpers/utils'
12import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants'
13import { PluginManager } from '@server/lib/plugins/plugin-manager'
14import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
15import { MUser } from '@server/types/models'
16import {
17 RegisterServerAuthenticatedResult,
18 RegisterServerAuthPassOptions,
19 RegisterServerExternalAuthenticatedResult
20} from '@server/types/plugins/register-server-auth.model'
21import { UserAdminFlag, UserRole } from '@shared/models'
22import { BypassLogin } from './oauth-model'
23
24export type ExternalUser =
25 Pick<MUser, 'username' | 'email' | 'role' | 'adminFlags' | 'videoQuotaDaily' | 'videoQuota'> &
26 { displayName: string }
27
28// Token is the key, expiration date is the value
29const authBypassTokens = new Map<string, {
30 expires: Date
31 user: ExternalUser
32 userUpdater: RegisterServerAuthenticatedResult['userUpdater']
33 authName: string
34 npmName: string
35}>()
36
37async function onExternalUserAuthenticated (options: {
38 npmName: string
39 authName: string
40 authResult: RegisterServerExternalAuthenticatedResult
41}) {
42 const { npmName, authName, authResult } = options
43
44 if (!authResult.req || !authResult.res) {
45 logger.error('Cannot authenticate external user for auth %s of plugin %s: no req or res are provided.', authName, npmName)
46 return
47 }
48
49 const { res } = authResult
50
51 if (!isAuthResultValid(npmName, authName, authResult)) {
52 res.redirect('/login?externalAuthError=true')
53 return
54 }
55
56 logger.info('Generating auth bypass token for %s in auth %s of plugin %s.', authResult.username, authName, npmName)
57
58 const bypassToken = await generateRandomString(32)
59
60 const expires = new Date()
61 expires.setTime(expires.getTime() + PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME)
62
63 const user = buildUserResult(authResult)
64 authBypassTokens.set(bypassToken, {
65 expires,
66 user,
67 npmName,
68 authName,
69 userUpdater: authResult.userUpdater
70 })
71
72 // Cleanup expired tokens
73 const now = new Date()
74 for (const [ key, value ] of authBypassTokens) {
75 if (value.expires.getTime() < now.getTime()) {
76 authBypassTokens.delete(key)
77 }
78 }
79
80 res.redirect(`/login?externalAuthToken=${bypassToken}&username=${user.username}`)
81}
82
83async function getAuthNameFromRefreshGrant (refreshToken?: string) {
84 if (!refreshToken) return undefined
85
86 const tokenModel = await OAuthTokenModel.loadByRefreshToken(refreshToken)
87
88 return tokenModel?.authName
89}
90
91async function getBypassFromPasswordGrant (username: string, password: string): Promise<BypassLogin> {
92 const plugins = PluginManager.Instance.getIdAndPassAuths()
93 const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = []
94
95 for (const plugin of plugins) {
96 const auths = plugin.idAndPassAuths
97
98 for (const auth of auths) {
99 pluginAuths.push({
100 npmName: plugin.npmName,
101 registerAuthOptions: auth
102 })
103 }
104 }
105
106 pluginAuths.sort((a, b) => {
107 const aWeight = a.registerAuthOptions.getWeight()
108 const bWeight = b.registerAuthOptions.getWeight()
109
110 // DESC weight order
111 if (aWeight === bWeight) return 0
112 if (aWeight < bWeight) return 1
113 return -1
114 })
115
116 const loginOptions = {
117 id: username,
118 password
119 }
120
121 for (const pluginAuth of pluginAuths) {
122 const authOptions = pluginAuth.registerAuthOptions
123 const authName = authOptions.authName
124 const npmName = pluginAuth.npmName
125
126 logger.debug(
127 'Using auth method %s of plugin %s to login %s with weight %d.',
128 authName, npmName, loginOptions.id, authOptions.getWeight()
129 )
130
131 try {
132 const loginResult = await authOptions.login(loginOptions)
133
134 if (!loginResult) continue
135 if (!isAuthResultValid(pluginAuth.npmName, authOptions.authName, loginResult)) continue
136
137 logger.info(
138 'Login success with auth method %s of plugin %s for %s.',
139 authName, npmName, loginOptions.id
140 )
141
142 return {
143 bypass: true,
144 pluginName: pluginAuth.npmName,
145 authName: authOptions.authName,
146 user: buildUserResult(loginResult),
147 userUpdater: loginResult.userUpdater
148 }
149 } catch (err) {
150 logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err })
151 }
152 }
153
154 return undefined
155}
156
157function getBypassFromExternalAuth (username: string, externalAuthToken: string): BypassLogin {
158 const obj = authBypassTokens.get(externalAuthToken)
159 if (!obj) throw new Error('Cannot authenticate user with unknown bypass token')
160
161 const { expires, user, authName, npmName } = obj
162
163 const now = new Date()
164 if (now.getTime() > expires.getTime()) {
165 throw new Error('Cannot authenticate user with an expired external auth token')
166 }
167
168 if (user.username !== username) {
169 throw new Error(`Cannot authenticate user ${user.username} with invalid username ${username}`)
170 }
171
172 logger.info(
173 'Auth success with external auth method %s of plugin %s for %s.',
174 authName, npmName, user.email
175 )
176
177 return {
178 bypass: true,
179 pluginName: npmName,
180 authName,
181 userUpdater: obj.userUpdater,
182 user
183 }
184}
185
186function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) {
187 const returnError = (field: string) => {
188 logger.error('Auth method %s of plugin %s did not provide a valid %s.', authName, npmName, field, { [field]: result[field] })
189 return false
190 }
191
192 if (!isUserUsernameValid(result.username)) return returnError('username')
193 if (!result.email) return returnError('email')
194
195 // Following fields are optional
196 if (result.role && !isUserRoleValid(result.role)) return returnError('role')
197 if (result.displayName && !isUserDisplayNameValid(result.displayName)) return returnError('displayName')
198 if (result.adminFlags && !isUserAdminFlagsValid(result.adminFlags)) return returnError('adminFlags')
199 if (result.videoQuota && !isUserVideoQuotaValid(result.videoQuota + '')) return returnError('videoQuota')
200 if (result.videoQuotaDaily && !isUserVideoQuotaDailyValid(result.videoQuotaDaily + '')) return returnError('videoQuotaDaily')
201
202 if (result.userUpdater && typeof result.userUpdater !== 'function') {
203 logger.error('Auth method %s of plugin %s did not provide a valid user updater function.', authName, npmName)
204 return false
205 }
206
207 return true
208}
209
210function buildUserResult (pluginResult: RegisterServerAuthenticatedResult) {
211 return {
212 username: pluginResult.username,
213 email: pluginResult.email,
214 role: pluginResult.role ?? UserRole.USER,
215 displayName: pluginResult.displayName || pluginResult.username,
216
217 adminFlags: pluginResult.adminFlags ?? UserAdminFlag.NONE,
218
219 videoQuota: pluginResult.videoQuota,
220 videoQuotaDaily: pluginResult.videoQuotaDaily
221 }
222}
223
224// ---------------------------------------------------------------------------
225
226export {
227 onExternalUserAuthenticated,
228 getBypassFromExternalAuth,
229 getAuthNameFromRefreshGrant,
230 getBypassFromPasswordGrant
231}
diff --git a/server/lib/auth/oauth-model.ts b/server/lib/auth/oauth-model.ts
deleted file mode 100644
index d3a5eccd5..000000000
--- a/server/lib/auth/oauth-model.ts
+++ /dev/null
@@ -1,294 +0,0 @@
1import express from 'express'
2import { AccessDeniedError } from '@node-oauth/oauth2-server'
3import { PluginManager } from '@server/lib/plugins/plugin-manager'
4import { AccountModel } from '@server/models/account/account'
5import { AuthenticatedResultUpdaterFieldName, RegisterServerAuthenticatedResult } from '@server/types'
6import { MOAuthClient } from '@server/types/models'
7import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
8import { MUser, MUserDefault } from '@server/types/models/user/user'
9import { pick } from '@shared/core-utils'
10import { AttributesOnly } from '@shared/typescript-utils'
11import { logger } from '../../helpers/logger'
12import { CONFIG } from '../../initializers/config'
13import { OAuthClientModel } from '../../models/oauth/oauth-client'
14import { OAuthTokenModel } from '../../models/oauth/oauth-token'
15import { UserModel } from '../../models/user/user'
16import { findAvailableLocalActorName } from '../local-actor'
17import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user'
18import { ExternalUser } from './external-auth'
19import { TokensCache } from './tokens-cache'
20
21type TokenInfo = {
22 accessToken: string
23 refreshToken: string
24 accessTokenExpiresAt: Date
25 refreshTokenExpiresAt: Date
26}
27
28export type BypassLogin = {
29 bypass: boolean
30 pluginName: string
31 authName?: string
32 user: ExternalUser
33 userUpdater: RegisterServerAuthenticatedResult['userUpdater']
34}
35
36async function getAccessToken (bearerToken: string) {
37 logger.debug('Getting access token.')
38
39 if (!bearerToken) return undefined
40
41 let tokenModel: MOAuthTokenUser
42
43 if (TokensCache.Instance.hasToken(bearerToken)) {
44 tokenModel = TokensCache.Instance.getByToken(bearerToken)
45 } else {
46 tokenModel = await OAuthTokenModel.getByTokenAndPopulateUser(bearerToken)
47
48 if (tokenModel) TokensCache.Instance.setToken(tokenModel)
49 }
50
51 if (!tokenModel) return undefined
52
53 if (tokenModel.User.pluginAuth) {
54 const valid = await PluginManager.Instance.isTokenValid(tokenModel, 'access')
55
56 if (valid !== true) return undefined
57 }
58
59 return tokenModel
60}
61
62function getClient (clientId: string, clientSecret: string) {
63 logger.debug('Getting Client (clientId: ' + clientId + ', clientSecret: ' + clientSecret + ').')
64
65 return OAuthClientModel.getByIdAndSecret(clientId, clientSecret)
66}
67
68async function getRefreshToken (refreshToken: string) {
69 logger.debug('Getting RefreshToken (refreshToken: ' + refreshToken + ').')
70
71 const tokenInfo = await OAuthTokenModel.getByRefreshTokenAndPopulateClient(refreshToken)
72 if (!tokenInfo) return undefined
73
74 const tokenModel = tokenInfo.token
75
76 if (tokenModel.User.pluginAuth) {
77 const valid = await PluginManager.Instance.isTokenValid(tokenModel, 'refresh')
78
79 if (valid !== true) return undefined
80 }
81
82 return tokenInfo
83}
84
85async function getUser (usernameOrEmail?: string, password?: string, bypassLogin?: BypassLogin) {
86 // Special treatment coming from a plugin
87 if (bypassLogin && bypassLogin.bypass === true) {
88 logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName)
89
90 let user = await UserModel.loadByEmail(bypassLogin.user.email)
91
92 if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user)
93 else user = await updateUserFromExternal(user, bypassLogin.user, bypassLogin.userUpdater)
94
95 // Cannot create a user
96 if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.')
97
98 // If the user does not belongs to a plugin, it was created before its installation
99 // Then we just go through a regular login process
100 if (user.pluginAuth !== null) {
101 // This user does not belong to this plugin, skip it
102 if (user.pluginAuth !== bypassLogin.pluginName) {
103 logger.info(
104 'Cannot bypass oauth login by plugin %s because %s has another plugin auth method (%s).',
105 bypassLogin.pluginName, bypassLogin.user.email, user.pluginAuth
106 )
107
108 return null
109 }
110
111 checkUserValidityOrThrow(user)
112
113 return user
114 }
115 }
116
117 logger.debug('Getting User (username/email: ' + usernameOrEmail + ', password: ******).')
118
119 const user = await UserModel.loadByUsernameOrEmail(usernameOrEmail)
120
121 // If we don't find the user, or if the user belongs to a plugin
122 if (!user || user.pluginAuth !== null || !password) return null
123
124 const passwordMatch = await user.isPasswordMatch(password)
125 if (passwordMatch !== true) return null
126
127 checkUserValidityOrThrow(user)
128
129 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION && user.emailVerified === false) {
130 throw new AccessDeniedError('User email is not verified.')
131 }
132
133 return user
134}
135
136async function revokeToken (
137 tokenInfo: { refreshToken: string },
138 options: {
139 req?: express.Request
140 explicitLogout?: boolean
141 } = {}
142): Promise<{ success: boolean, redirectUrl?: string }> {
143 const { req, explicitLogout } = options
144
145 const token = await OAuthTokenModel.getByRefreshTokenAndPopulateUser(tokenInfo.refreshToken)
146
147 if (token) {
148 let redirectUrl: string
149
150 if (explicitLogout === true && token.User.pluginAuth && token.authName) {
151 redirectUrl = await PluginManager.Instance.onLogout(token.User.pluginAuth, token.authName, token.User, req)
152 }
153
154 TokensCache.Instance.clearCacheByToken(token.accessToken)
155
156 token.destroy()
157 .catch(err => logger.error('Cannot destroy token when revoking token.', { err }))
158
159 return { success: true, redirectUrl }
160 }
161
162 return { success: false }
163}
164
165async function saveToken (
166 token: TokenInfo,
167 client: MOAuthClient,
168 user: MUser,
169 options: {
170 refreshTokenAuthName?: string
171 bypassLogin?: BypassLogin
172 } = {}
173) {
174 const { refreshTokenAuthName, bypassLogin } = options
175 let authName: string = null
176
177 if (bypassLogin?.bypass === true) {
178 authName = bypassLogin.authName
179 } else if (refreshTokenAuthName) {
180 authName = refreshTokenAuthName
181 }
182
183 logger.debug('Saving token ' + token.accessToken + ' for client ' + client.id + ' and user ' + user.id + '.')
184
185 const tokenToCreate = {
186 accessToken: token.accessToken,
187 accessTokenExpiresAt: token.accessTokenExpiresAt,
188 refreshToken: token.refreshToken,
189 refreshTokenExpiresAt: token.refreshTokenExpiresAt,
190 authName,
191 oAuthClientId: client.id,
192 userId: user.id
193 }
194
195 const tokenCreated = await OAuthTokenModel.create(tokenToCreate)
196
197 user.lastLoginDate = new Date()
198 await user.save()
199
200 return {
201 accessToken: tokenCreated.accessToken,
202 accessTokenExpiresAt: tokenCreated.accessTokenExpiresAt,
203 refreshToken: tokenCreated.refreshToken,
204 refreshTokenExpiresAt: tokenCreated.refreshTokenExpiresAt,
205 client,
206 user,
207 accessTokenExpiresIn: buildExpiresIn(tokenCreated.accessTokenExpiresAt),
208 refreshTokenExpiresIn: buildExpiresIn(tokenCreated.refreshTokenExpiresAt)
209 }
210}
211
212export {
213 getAccessToken,
214 getClient,
215 getRefreshToken,
216 getUser,
217 revokeToken,
218 saveToken
219}
220
221// ---------------------------------------------------------------------------
222
223async function createUserFromExternal (pluginAuth: string, userOptions: ExternalUser) {
224 const username = await findAvailableLocalActorName(userOptions.username)
225
226 const userToCreate = buildUser({
227 ...pick(userOptions, [ 'email', 'role', 'adminFlags', 'videoQuota', 'videoQuotaDaily' ]),
228
229 username,
230 emailVerified: null,
231 password: null,
232 pluginAuth
233 })
234
235 const { user } = await createUserAccountAndChannelAndPlaylist({
236 userToCreate,
237 userDisplayName: userOptions.displayName
238 })
239
240 return user
241}
242
243async function updateUserFromExternal (
244 user: MUserDefault,
245 userOptions: ExternalUser,
246 userUpdater: RegisterServerAuthenticatedResult['userUpdater']
247) {
248 if (!userUpdater) return user
249
250 {
251 type UserAttributeKeys = keyof AttributesOnly<UserModel>
252 const mappingKeys: { [ id in UserAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
253 role: 'role',
254 adminFlags: 'adminFlags',
255 videoQuota: 'videoQuota',
256 videoQuotaDaily: 'videoQuotaDaily'
257 }
258
259 for (const modelKey of Object.keys(mappingKeys)) {
260 const pluginOptionKey = mappingKeys[modelKey]
261
262 const newValue = userUpdater({ fieldName: pluginOptionKey, currentValue: user[modelKey], newValue: userOptions[pluginOptionKey] })
263 user.set(modelKey, newValue)
264 }
265 }
266
267 {
268 type AccountAttributeKeys = keyof Partial<AttributesOnly<AccountModel>>
269 const mappingKeys: { [ id in AccountAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
270 name: 'displayName'
271 }
272
273 for (const modelKey of Object.keys(mappingKeys)) {
274 const optionKey = mappingKeys[modelKey]
275
276 const newValue = userUpdater({ fieldName: optionKey, currentValue: user.Account[modelKey], newValue: userOptions[optionKey] })
277 user.Account.set(modelKey, newValue)
278 }
279 }
280
281 logger.debug('Updated user %s with plugin userUpdated function.', user.email, { user, userOptions })
282
283 user.Account = await user.Account.save()
284
285 return user.save()
286}
287
288function checkUserValidityOrThrow (user: MUser) {
289 if (user.blocked) throw new AccessDeniedError('User is blocked.')
290}
291
292function buildExpiresIn (expiresAt: Date) {
293 return Math.floor((expiresAt.getTime() - new Date().getTime()) / 1000)
294}
diff --git a/server/lib/auth/oauth.ts b/server/lib/auth/oauth.ts
deleted file mode 100644
index 887c4f7c9..000000000
--- a/server/lib/auth/oauth.ts
+++ /dev/null
@@ -1,223 +0,0 @@
1import express from 'express'
2import OAuth2Server, {
3 InvalidClientError,
4 InvalidGrantError,
5 InvalidRequestError,
6 Request,
7 Response,
8 UnauthorizedClientError,
9 UnsupportedGrantTypeError
10} from '@node-oauth/oauth2-server'
11import { randomBytesPromise } from '@server/helpers/core-utils'
12import { isOTPValid } from '@server/helpers/otp'
13import { CONFIG } from '@server/initializers/config'
14import { UserRegistrationModel } from '@server/models/user/user-registration'
15import { MOAuthClient } from '@server/types/models'
16import { sha1 } from '@shared/extra-utils'
17import { HttpStatusCode, ServerErrorCode, UserRegistrationState } from '@shared/models'
18import { OTP } from '../../initializers/constants'
19import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model'
20
21class MissingTwoFactorError extends Error {
22 code = HttpStatusCode.UNAUTHORIZED_401
23 name = ServerErrorCode.MISSING_TWO_FACTOR
24}
25
26class InvalidTwoFactorError extends Error {
27 code = HttpStatusCode.BAD_REQUEST_400
28 name = ServerErrorCode.INVALID_TWO_FACTOR
29}
30
31class RegistrationWaitingForApproval extends Error {
32 code = HttpStatusCode.BAD_REQUEST_400
33 name = ServerErrorCode.ACCOUNT_WAITING_FOR_APPROVAL
34}
35
36class RegistrationApprovalRejected extends Error {
37 code = HttpStatusCode.BAD_REQUEST_400
38 name = ServerErrorCode.ACCOUNT_APPROVAL_REJECTED
39}
40
41/**
42 *
43 * Reimplement some functions of OAuth2Server to inject external auth methods
44 *
45 */
46const oAuthServer = new OAuth2Server({
47 // Wants seconds
48 accessTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN / 1000,
49 refreshTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN / 1000,
50
51 // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications
52 model: require('./oauth-model')
53})
54
55// ---------------------------------------------------------------------------
56
57async function handleOAuthToken (req: express.Request, options: { refreshTokenAuthName?: string, bypassLogin?: BypassLogin }) {
58 const request = new Request(req)
59 const { refreshTokenAuthName, bypassLogin } = options
60
61 if (request.method !== 'POST') {
62 throw new InvalidRequestError('Invalid request: method must be POST')
63 }
64
65 if (!request.is([ 'application/x-www-form-urlencoded' ])) {
66 throw new InvalidRequestError('Invalid request: content must be application/x-www-form-urlencoded')
67 }
68
69 const clientId = request.body.client_id
70 const clientSecret = request.body.client_secret
71
72 if (!clientId || !clientSecret) {
73 throw new InvalidClientError('Invalid client: cannot retrieve client credentials')
74 }
75
76 const client = await getClient(clientId, clientSecret)
77 if (!client) {
78 throw new InvalidClientError('Invalid client: client is invalid')
79 }
80
81 const grantType = request.body.grant_type
82 if (!grantType) {
83 throw new InvalidRequestError('Missing parameter: `grant_type`')
84 }
85
86 if (![ 'password', 'refresh_token' ].includes(grantType)) {
87 throw new UnsupportedGrantTypeError('Unsupported grant type: `grant_type` is invalid')
88 }
89
90 if (!client.grants.includes(grantType)) {
91 throw new UnauthorizedClientError('Unauthorized client: `grant_type` is invalid')
92 }
93
94 if (grantType === 'password') {
95 return handlePasswordGrant({
96 request,
97 client,
98 bypassLogin
99 })
100 }
101
102 return handleRefreshGrant({
103 request,
104 client,
105 refreshTokenAuthName
106 })
107}
108
109function handleOAuthAuthenticate (
110 req: express.Request,
111 res: express.Response
112) {
113 return oAuthServer.authenticate(new Request(req), new Response(res))
114}
115
116export {
117 MissingTwoFactorError,
118 InvalidTwoFactorError,
119
120 handleOAuthToken,
121 handleOAuthAuthenticate
122}
123
124// ---------------------------------------------------------------------------
125
126async function handlePasswordGrant (options: {
127 request: Request
128 client: MOAuthClient
129 bypassLogin?: BypassLogin
130}) {
131 const { request, client, bypassLogin } = options
132
133 if (!request.body.username) {
134 throw new InvalidRequestError('Missing parameter: `username`')
135 }
136
137 if (!bypassLogin && !request.body.password) {
138 throw new InvalidRequestError('Missing parameter: `password`')
139 }
140
141 const user = await getUser(request.body.username, request.body.password, bypassLogin)
142 if (!user) {
143 const registration = await UserRegistrationModel.loadByEmailOrUsername(request.body.username)
144
145 if (registration?.state === UserRegistrationState.REJECTED) {
146 throw new RegistrationApprovalRejected('Registration approval for this account has been rejected')
147 } else if (registration?.state === UserRegistrationState.PENDING) {
148 throw new RegistrationWaitingForApproval('Registration for this account is awaiting approval')
149 }
150
151 throw new InvalidGrantError('Invalid grant: user credentials are invalid')
152 }
153
154 if (user.otpSecret) {
155 if (!request.headers[OTP.HEADER_NAME]) {
156 throw new MissingTwoFactorError('Missing two factor header')
157 }
158
159 if (await isOTPValid({ encryptedSecret: user.otpSecret, token: request.headers[OTP.HEADER_NAME] }) !== true) {
160 throw new InvalidTwoFactorError('Invalid two factor header')
161 }
162 }
163
164 const token = await buildToken()
165
166 return saveToken(token, client, user, { bypassLogin })
167}
168
169async function handleRefreshGrant (options: {
170 request: Request
171 client: MOAuthClient
172 refreshTokenAuthName: string
173}) {
174 const { request, client, refreshTokenAuthName } = options
175
176 if (!request.body.refresh_token) {
177 throw new InvalidRequestError('Missing parameter: `refresh_token`')
178 }
179
180 const refreshToken = await getRefreshToken(request.body.refresh_token)
181
182 if (!refreshToken) {
183 throw new InvalidGrantError('Invalid grant: refresh token is invalid')
184 }
185
186 if (refreshToken.client.id !== client.id) {
187 throw new InvalidGrantError('Invalid grant: refresh token is invalid')
188 }
189
190 if (refreshToken.refreshTokenExpiresAt && refreshToken.refreshTokenExpiresAt < new Date()) {
191 throw new InvalidGrantError('Invalid grant: refresh token has expired')
192 }
193
194 await revokeToken({ refreshToken: refreshToken.refreshToken })
195
196 const token = await buildToken()
197
198 return saveToken(token, client, refreshToken.user, { refreshTokenAuthName })
199}
200
201function generateRandomToken () {
202 return randomBytesPromise(256)
203 .then(buffer => sha1(buffer))
204}
205
206function getTokenExpiresAt (type: 'access' | 'refresh') {
207 const lifetime = type === 'access'
208 ? CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN
209 : CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN
210
211 return new Date(Date.now() + lifetime)
212}
213
214async function buildToken () {
215 const [ accessToken, refreshToken ] = await Promise.all([ generateRandomToken(), generateRandomToken() ])
216
217 return {
218 accessToken,
219 refreshToken,
220 accessTokenExpiresAt: getTokenExpiresAt('access'),
221 refreshTokenExpiresAt: getTokenExpiresAt('refresh')
222 }
223}
diff --git a/server/lib/auth/tokens-cache.ts b/server/lib/auth/tokens-cache.ts
deleted file mode 100644
index e7b12159b..000000000
--- a/server/lib/auth/tokens-cache.ts
+++ /dev/null
@@ -1,52 +0,0 @@
1import { LRUCache } from 'lru-cache'
2import { MOAuthTokenUser } from '@server/types/models'
3import { LRU_CACHE } from '../../initializers/constants'
4
5export class TokensCache {
6
7 private static instance: TokensCache
8
9 private readonly accessTokenCache = new LRUCache<string, MOAuthTokenUser>({ max: LRU_CACHE.USER_TOKENS.MAX_SIZE })
10 private readonly userHavingToken = new LRUCache<number, string>({ max: LRU_CACHE.USER_TOKENS.MAX_SIZE })
11
12 private constructor () { }
13
14 static get Instance () {
15 return this.instance || (this.instance = new this())
16 }
17
18 hasToken (token: string) {
19 return this.accessTokenCache.has(token)
20 }
21
22 getByToken (token: string) {
23 return this.accessTokenCache.get(token)
24 }
25
26 setToken (token: MOAuthTokenUser) {
27 this.accessTokenCache.set(token.accessToken, token)
28 this.userHavingToken.set(token.userId, token.accessToken)
29 }
30
31 deleteUserToken (userId: number) {
32 this.clearCacheByUserId(userId)
33 }
34
35 clearCacheByUserId (userId: number) {
36 const token = this.userHavingToken.get(userId)
37
38 if (token !== undefined) {
39 this.accessTokenCache.delete(token)
40 this.userHavingToken.delete(userId)
41 }
42 }
43
44 clearCacheByToken (token: string) {
45 const tokenModel = this.accessTokenCache.get(token)
46
47 if (tokenModel !== undefined) {
48 this.userHavingToken.delete(tokenModel.userId)
49 this.accessTokenCache.delete(token)
50 }
51 }
52}
diff --git a/server/lib/blocklist.ts b/server/lib/blocklist.ts
deleted file mode 100644
index 009e229ce..000000000
--- a/server/lib/blocklist.ts
+++ /dev/null
@@ -1,62 +0,0 @@
1import { sequelizeTypescript } from '@server/initializers/database'
2import { getServerActor } from '@server/models/application/application'
3import { MAccountBlocklist, MAccountId, MAccountHost, MServerBlocklist } from '@server/types/models'
4import { AccountBlocklistModel } from '../models/account/account-blocklist'
5import { ServerBlocklistModel } from '../models/server/server-blocklist'
6
7function addAccountInBlocklist (byAccountId: number, targetAccountId: number) {
8 return sequelizeTypescript.transaction(async t => {
9 return AccountBlocklistModel.upsert({
10 accountId: byAccountId,
11 targetAccountId
12 }, { transaction: t })
13 })
14}
15
16function addServerInBlocklist (byAccountId: number, targetServerId: number) {
17 return sequelizeTypescript.transaction(async t => {
18 return ServerBlocklistModel.upsert({
19 accountId: byAccountId,
20 targetServerId
21 }, { transaction: t })
22 })
23}
24
25function removeAccountFromBlocklist (accountBlock: MAccountBlocklist) {
26 return sequelizeTypescript.transaction(async t => {
27 return accountBlock.destroy({ transaction: t })
28 })
29}
30
31function removeServerFromBlocklist (serverBlock: MServerBlocklist) {
32 return sequelizeTypescript.transaction(async t => {
33 return serverBlock.destroy({ transaction: t })
34 })
35}
36
37async function isBlockedByServerOrAccount (targetAccount: MAccountHost, userAccount?: MAccountId) {
38 const serverAccountId = (await getServerActor()).Account.id
39 const sourceAccounts = [ serverAccountId ]
40
41 if (userAccount) sourceAccounts.push(userAccount.id)
42
43 const accountMutedHash = await AccountBlocklistModel.isAccountMutedByAccounts(sourceAccounts, targetAccount.id)
44 if (accountMutedHash[serverAccountId] || (userAccount && accountMutedHash[userAccount.id])) {
45 return true
46 }
47
48 const instanceMutedHash = await ServerBlocklistModel.isServerMutedByAccounts(sourceAccounts, targetAccount.Actor.serverId)
49 if (instanceMutedHash[serverAccountId] || (userAccount && instanceMutedHash[userAccount.id])) {
50 return true
51 }
52
53 return false
54}
55
56export {
57 addAccountInBlocklist,
58 addServerInBlocklist,
59 removeAccountFromBlocklist,
60 removeServerFromBlocklist,
61 isBlockedByServerOrAccount
62}
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts
deleted file mode 100644
index 8e0c9e328..000000000
--- a/server/lib/client-html.ts
+++ /dev/null
@@ -1,623 +0,0 @@
1import express from 'express'
2import { pathExists, readFile } from 'fs-extra'
3import { truncate } from 'lodash'
4import { join } from 'path'
5import validator from 'validator'
6import { isTestOrDevInstance } from '@server/helpers/core-utils'
7import { toCompleteUUID } from '@server/helpers/custom-validators/misc'
8import { mdToOneLinePlainText } from '@server/helpers/markdown'
9import { ActorImageModel } from '@server/models/actor/actor-image'
10import { root } from '@shared/core-utils'
11import { escapeHTML } from '@shared/core-utils/renderer'
12import { sha256 } from '@shared/extra-utils'
13import { HTMLServerConfig } from '@shared/models'
14import { buildFileLocale, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '../../shared/core-utils/i18n/i18n'
15import { HttpStatusCode } from '../../shared/models/http/http-error-codes'
16import { VideoPlaylistPrivacy, VideoPrivacy } from '../../shared/models/videos'
17import { logger } from '../helpers/logger'
18import { CONFIG } from '../initializers/config'
19import {
20 ACCEPT_HEADERS,
21 CUSTOM_HTML_TAG_COMMENTS,
22 EMBED_SIZE,
23 FILES_CONTENT_HASH,
24 PLUGIN_GLOBAL_CSS_PATH,
25 WEBSERVER
26} from '../initializers/constants'
27import { AccountModel } from '../models/account/account'
28import { VideoModel } from '../models/video/video'
29import { VideoChannelModel } from '../models/video/video-channel'
30import { VideoPlaylistModel } from '../models/video/video-playlist'
31import { MAccountHost, MChannelHost, MVideo, MVideoPlaylist } from '../types/models'
32import { getActivityStreamDuration } from './activitypub/activity'
33import { getBiggestActorImage } from './actor-image'
34import { Hooks } from './plugins/hooks'
35import { ServerConfigManager } from './server-config-manager'
36import { isVideoInPrivateDirectory } from './video-privacy'
37
38type Tags = {
39 ogType: string
40 twitterCard: 'player' | 'summary' | 'summary_large_image'
41 schemaType: string
42
43 list?: {
44 numberOfItems: number
45 }
46
47 escapedSiteName: string
48 escapedTitle: string
49 escapedTruncatedDescription: string
50
51 url: string
52 originUrl: string
53
54 disallowIndexation?: boolean
55
56 embed?: {
57 url: string
58 createdAt: string
59 duration?: string
60 views?: number
61 }
62
63 image: {
64 url: string
65 width?: number
66 height?: number
67 }
68}
69
70type HookContext = {
71 video?: MVideo
72 playlist?: MVideoPlaylist
73}
74
75class ClientHtml {
76
77 private static htmlCache: { [path: string]: string } = {}
78
79 static invalidCache () {
80 logger.info('Cleaning HTML cache.')
81
82 ClientHtml.htmlCache = {}
83 }
84
85 static async getDefaultHTMLPage (req: express.Request, res: express.Response, paramLang?: string) {
86 const html = paramLang
87 ? await ClientHtml.getIndexHTML(req, res, paramLang)
88 : await ClientHtml.getIndexHTML(req, res)
89
90 let customHtml = ClientHtml.addTitleTag(html)
91 customHtml = ClientHtml.addDescriptionTag(customHtml)
92
93 return customHtml
94 }
95
96 static async getWatchHTMLPage (videoIdArg: string, req: express.Request, res: express.Response) {
97 const videoId = toCompleteUUID(videoIdArg)
98
99 // Let Angular application handle errors
100 if (!validator.isInt(videoId) && !validator.isUUID(videoId, 4)) {
101 res.status(HttpStatusCode.NOT_FOUND_404)
102 return ClientHtml.getIndexHTML(req, res)
103 }
104
105 const [ html, video ] = await Promise.all([
106 ClientHtml.getIndexHTML(req, res),
107 VideoModel.loadWithBlacklist(videoId)
108 ])
109
110 // Let Angular application handle errors
111 if (!video || isVideoInPrivateDirectory(video.privacy) || video.VideoBlacklist) {
112 res.status(HttpStatusCode.NOT_FOUND_404)
113 return html
114 }
115 const escapedTruncatedDescription = buildEscapedTruncatedDescription(video.description)
116
117 let customHtml = ClientHtml.addTitleTag(html, video.name)
118 customHtml = ClientHtml.addDescriptionTag(customHtml, escapedTruncatedDescription)
119
120 const url = WEBSERVER.URL + video.getWatchStaticPath()
121 const originUrl = video.url
122 const title = video.name
123 const siteName = CONFIG.INSTANCE.NAME
124
125 const image = {
126 url: WEBSERVER.URL + video.getPreviewStaticPath()
127 }
128
129 const embed = {
130 url: WEBSERVER.URL + video.getEmbedStaticPath(),
131 createdAt: video.createdAt.toISOString(),
132 duration: getActivityStreamDuration(video.duration),
133 views: video.views
134 }
135
136 const ogType = 'video'
137 const twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary_large_image'
138 const schemaType = 'VideoObject'
139
140 customHtml = await ClientHtml.addTags(customHtml, {
141 url,
142 originUrl,
143 escapedSiteName: escapeHTML(siteName),
144 escapedTitle: escapeHTML(title),
145 escapedTruncatedDescription,
146 disallowIndexation: video.privacy !== VideoPrivacy.PUBLIC,
147 image,
148 embed,
149 ogType,
150 twitterCard,
151 schemaType
152 }, { video })
153
154 return customHtml
155 }
156
157 static async getWatchPlaylistHTMLPage (videoPlaylistIdArg: string, req: express.Request, res: express.Response) {
158 const videoPlaylistId = toCompleteUUID(videoPlaylistIdArg)
159
160 // Let Angular application handle errors
161 if (!validator.isInt(videoPlaylistId) && !validator.isUUID(videoPlaylistId, 4)) {
162 res.status(HttpStatusCode.NOT_FOUND_404)
163 return ClientHtml.getIndexHTML(req, res)
164 }
165
166 const [ html, videoPlaylist ] = await Promise.all([
167 ClientHtml.getIndexHTML(req, res),
168 VideoPlaylistModel.loadWithAccountAndChannel(videoPlaylistId, null)
169 ])
170
171 // Let Angular application handle errors
172 if (!videoPlaylist || videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
173 res.status(HttpStatusCode.NOT_FOUND_404)
174 return html
175 }
176
177 const escapedTruncatedDescription = buildEscapedTruncatedDescription(videoPlaylist.description)
178
179 let customHtml = ClientHtml.addTitleTag(html, videoPlaylist.name)
180 customHtml = ClientHtml.addDescriptionTag(customHtml, escapedTruncatedDescription)
181
182 const url = WEBSERVER.URL + videoPlaylist.getWatchStaticPath()
183 const originUrl = videoPlaylist.url
184 const title = videoPlaylist.name
185 const siteName = CONFIG.INSTANCE.NAME
186
187 const image = {
188 url: videoPlaylist.getThumbnailUrl()
189 }
190
191 const embed = {
192 url: WEBSERVER.URL + videoPlaylist.getEmbedStaticPath(),
193 createdAt: videoPlaylist.createdAt.toISOString()
194 }
195
196 const list = {
197 numberOfItems: videoPlaylist.get('videosLength') as number
198 }
199
200 const ogType = 'video'
201 const twitterCard = CONFIG.SERVICES.TWITTER.WHITELISTED ? 'player' : 'summary'
202 const schemaType = 'ItemList'
203
204 customHtml = await ClientHtml.addTags(customHtml, {
205 url,
206 originUrl,
207 escapedSiteName: escapeHTML(siteName),
208 escapedTitle: escapeHTML(title),
209 escapedTruncatedDescription,
210 disallowIndexation: videoPlaylist.privacy !== VideoPlaylistPrivacy.PUBLIC,
211 embed,
212 image,
213 list,
214 ogType,
215 twitterCard,
216 schemaType
217 }, { playlist: videoPlaylist })
218
219 return customHtml
220 }
221
222 static async getAccountHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
223 const accountModelPromise = AccountModel.loadByNameWithHost(nameWithHost)
224 return this.getAccountOrChannelHTMLPage(() => accountModelPromise, req, res)
225 }
226
227 static async getVideoChannelHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
228 const videoChannelModelPromise = VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost)
229 return this.getAccountOrChannelHTMLPage(() => videoChannelModelPromise, req, res)
230 }
231
232 static async getActorHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
233 const [ account, channel ] = await Promise.all([
234 AccountModel.loadByNameWithHost(nameWithHost),
235 VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost)
236 ])
237
238 return this.getAccountOrChannelHTMLPage(() => Promise.resolve(account || channel), req, res)
239 }
240
241 static async getEmbedHTML () {
242 const path = ClientHtml.getEmbedPath()
243
244 // Disable HTML cache in dev mode because webpack can regenerate JS files
245 if (!isTestOrDevInstance() && ClientHtml.htmlCache[path]) {
246 return ClientHtml.htmlCache[path]
247 }
248
249 const buffer = await readFile(path)
250 const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
251
252 let html = buffer.toString()
253 html = await ClientHtml.addAsyncPluginCSS(html)
254 html = ClientHtml.addCustomCSS(html)
255 html = ClientHtml.addTitleTag(html)
256 html = ClientHtml.addDescriptionTag(html)
257 html = ClientHtml.addServerConfig(html, serverConfig)
258
259 ClientHtml.htmlCache[path] = html
260
261 return html
262 }
263
264 private static async getAccountOrChannelHTMLPage (
265 loader: () => Promise<MAccountHost | MChannelHost>,
266 req: express.Request,
267 res: express.Response
268 ) {
269 const [ html, entity ] = await Promise.all([
270 ClientHtml.getIndexHTML(req, res),
271 loader()
272 ])
273
274 // Let Angular application handle errors
275 if (!entity) {
276 res.status(HttpStatusCode.NOT_FOUND_404)
277 return ClientHtml.getIndexHTML(req, res)
278 }
279
280 const escapedTruncatedDescription = buildEscapedTruncatedDescription(entity.description)
281
282 let customHtml = ClientHtml.addTitleTag(html, entity.getDisplayName())
283 customHtml = ClientHtml.addDescriptionTag(customHtml, escapedTruncatedDescription)
284
285 const url = entity.getClientUrl()
286 const originUrl = entity.Actor.url
287 const siteName = CONFIG.INSTANCE.NAME
288 const title = entity.getDisplayName()
289
290 const avatar = getBiggestActorImage(entity.Actor.Avatars)
291 const image = {
292 url: ActorImageModel.getImageUrl(avatar),
293 width: avatar?.width,
294 height: avatar?.height
295 }
296
297 const ogType = 'website'
298 const twitterCard = 'summary'
299 const schemaType = 'ProfilePage'
300
301 customHtml = await ClientHtml.addTags(customHtml, {
302 url,
303 originUrl,
304 escapedTitle: escapeHTML(title),
305 escapedSiteName: escapeHTML(siteName),
306 escapedTruncatedDescription,
307 image,
308 ogType,
309 twitterCard,
310 schemaType,
311 disallowIndexation: !entity.Actor.isOwned()
312 }, {})
313
314 return customHtml
315 }
316
317 private static async getIndexHTML (req: express.Request, res: express.Response, paramLang?: string) {
318 const path = ClientHtml.getIndexPath(req, res, paramLang)
319 if (ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
320
321 const buffer = await readFile(path)
322 const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
323
324 let html = buffer.toString()
325
326 html = ClientHtml.addManifestContentHash(html)
327 html = ClientHtml.addFaviconContentHash(html)
328 html = ClientHtml.addLogoContentHash(html)
329 html = ClientHtml.addCustomCSS(html)
330 html = ClientHtml.addServerConfig(html, serverConfig)
331 html = await ClientHtml.addAsyncPluginCSS(html)
332
333 ClientHtml.htmlCache[path] = html
334
335 return html
336 }
337
338 private static getIndexPath (req: express.Request, res: express.Response, paramLang: string) {
339 let lang: string
340
341 // Check param lang validity
342 if (paramLang && is18nLocale(paramLang)) {
343 lang = paramLang
344
345 // Save locale in cookies
346 res.cookie('clientLanguage', lang, {
347 secure: WEBSERVER.SCHEME === 'https',
348 sameSite: 'none',
349 maxAge: 1000 * 3600 * 24 * 90 // 3 months
350 })
351
352 } else if (req.cookies.clientLanguage && is18nLocale(req.cookies.clientLanguage)) {
353 lang = req.cookies.clientLanguage
354 } else {
355 lang = req.acceptsLanguages(POSSIBLE_LOCALES) || getDefaultLocale()
356 }
357
358 logger.debug(
359 'Serving %s HTML language', buildFileLocale(lang),
360 { cookie: req.cookies?.clientLanguage, paramLang, acceptLanguage: req.headers['accept-language'] }
361 )
362
363 return join(root(), 'client', 'dist', buildFileLocale(lang), 'index.html')
364 }
365
366 private static getEmbedPath () {
367 return join(root(), 'client', 'dist', 'standalone', 'videos', 'embed.html')
368 }
369
370 private static addManifestContentHash (htmlStringPage: string) {
371 return htmlStringPage.replace('[manifestContentHash]', FILES_CONTENT_HASH.MANIFEST)
372 }
373
374 private static addFaviconContentHash (htmlStringPage: string) {
375 return htmlStringPage.replace('[faviconContentHash]', FILES_CONTENT_HASH.FAVICON)
376 }
377
378 private static addLogoContentHash (htmlStringPage: string) {
379 return htmlStringPage.replace('[logoContentHash]', FILES_CONTENT_HASH.LOGO)
380 }
381
382 private static addTitleTag (htmlStringPage: string, title?: string) {
383 let text = title || CONFIG.INSTANCE.NAME
384 if (title) text += ` - ${CONFIG.INSTANCE.NAME}`
385
386 const titleTag = `<title>${escapeHTML(text)}</title>`
387
388 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.TITLE, titleTag)
389 }
390
391 private static addDescriptionTag (htmlStringPage: string, escapedTruncatedDescription?: string) {
392 const content = escapedTruncatedDescription || escapeHTML(CONFIG.INSTANCE.SHORT_DESCRIPTION)
393 const descriptionTag = `<meta name="description" content="${content}" />`
394
395 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.DESCRIPTION, descriptionTag)
396 }
397
398 private static addCustomCSS (htmlStringPage: string) {
399 const styleTag = `<style class="custom-css-style">${CONFIG.INSTANCE.CUSTOMIZATIONS.CSS}</style>`
400
401 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.CUSTOM_CSS, styleTag)
402 }
403
404 private static addServerConfig (htmlStringPage: string, serverConfig: HTMLServerConfig) {
405 // Stringify the JSON object, and then stringify the string object so we can inject it into the HTML
406 const serverConfigString = JSON.stringify(JSON.stringify(serverConfig))
407 const configScriptTag = `<script type="application/javascript">window.PeerTubeServerConfig = ${serverConfigString}</script>`
408
409 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.SERVER_CONFIG, configScriptTag)
410 }
411
412 private static async addAsyncPluginCSS (htmlStringPage: string) {
413 if (!pathExists(PLUGIN_GLOBAL_CSS_PATH)) {
414 logger.info('Plugin Global CSS file is not available (generation may still be in progress), ignoring it.')
415 return htmlStringPage
416 }
417
418 let globalCSSContent: Buffer
419
420 try {
421 globalCSSContent = await readFile(PLUGIN_GLOBAL_CSS_PATH)
422 } catch (err) {
423 logger.error('Error retrieving the Plugin Global CSS file, ignoring it.', { err })
424 return htmlStringPage
425 }
426
427 if (globalCSSContent.byteLength === 0) return htmlStringPage
428
429 const fileHash = sha256(globalCSSContent)
430 const linkTag = `<link rel="stylesheet" href="/plugins/global.css?hash=${fileHash}" />`
431
432 return htmlStringPage.replace('</head>', linkTag + '</head>')
433 }
434
435 private static generateOpenGraphMetaTags (tags: Tags) {
436 const metaTags = {
437 'og:type': tags.ogType,
438 'og:site_name': tags.escapedSiteName,
439 'og:title': tags.escapedTitle,
440 'og:image': tags.image.url
441 }
442
443 if (tags.image.width && tags.image.height) {
444 metaTags['og:image:width'] = tags.image.width
445 metaTags['og:image:height'] = tags.image.height
446 }
447
448 metaTags['og:url'] = tags.url
449 metaTags['og:description'] = tags.escapedTruncatedDescription
450
451 if (tags.embed) {
452 metaTags['og:video:url'] = tags.embed.url
453 metaTags['og:video:secure_url'] = tags.embed.url
454 metaTags['og:video:type'] = 'text/html'
455 metaTags['og:video:width'] = EMBED_SIZE.width
456 metaTags['og:video:height'] = EMBED_SIZE.height
457 }
458
459 return metaTags
460 }
461
462 private static generateStandardMetaTags (tags: Tags) {
463 return {
464 name: tags.escapedTitle,
465 description: tags.escapedTruncatedDescription,
466 image: tags.image.url
467 }
468 }
469
470 private static generateTwitterCardMetaTags (tags: Tags) {
471 const metaTags = {
472 'twitter:card': tags.twitterCard,
473 'twitter:site': CONFIG.SERVICES.TWITTER.USERNAME,
474 'twitter:title': tags.escapedTitle,
475 'twitter:description': tags.escapedTruncatedDescription,
476 'twitter:image': tags.image.url
477 }
478
479 if (tags.image.width && tags.image.height) {
480 metaTags['twitter:image:width'] = tags.image.width
481 metaTags['twitter:image:height'] = tags.image.height
482 }
483
484 if (tags.twitterCard === 'player') {
485 metaTags['twitter:player'] = tags.embed.url
486 metaTags['twitter:player:width'] = EMBED_SIZE.width
487 metaTags['twitter:player:height'] = EMBED_SIZE.height
488 }
489
490 return metaTags
491 }
492
493 private static async generateSchemaTags (tags: Tags, context: HookContext) {
494 const schema = {
495 '@context': 'http://schema.org',
496 '@type': tags.schemaType,
497 'name': tags.escapedTitle,
498 'description': tags.escapedTruncatedDescription,
499 'image': tags.image.url,
500 'url': tags.url
501 }
502
503 if (tags.list) {
504 schema['numberOfItems'] = tags.list.numberOfItems
505 schema['thumbnailUrl'] = tags.image.url
506 }
507
508 if (tags.embed) {
509 schema['embedUrl'] = tags.embed.url
510 schema['uploadDate'] = tags.embed.createdAt
511
512 if (tags.embed.duration) schema['duration'] = tags.embed.duration
513
514 schema['thumbnailUrl'] = tags.image.url
515 schema['contentUrl'] = tags.url
516 }
517
518 return Hooks.wrapObject(schema, 'filter:html.client.json-ld.result', context)
519 }
520
521 private static async addTags (htmlStringPage: string, tagsValues: Tags, context: HookContext) {
522 const openGraphMetaTags = this.generateOpenGraphMetaTags(tagsValues)
523 const standardMetaTags = this.generateStandardMetaTags(tagsValues)
524 const twitterCardMetaTags = this.generateTwitterCardMetaTags(tagsValues)
525 const schemaTags = await this.generateSchemaTags(tagsValues, context)
526
527 const { url, escapedTitle, embed, originUrl, disallowIndexation } = tagsValues
528
529 const oembedLinkTags: { type: string, href: string, escapedTitle: string }[] = []
530
531 if (embed) {
532 oembedLinkTags.push({
533 type: 'application/json+oembed',
534 href: WEBSERVER.URL + '/services/oembed?url=' + encodeURIComponent(url),
535 escapedTitle
536 })
537 }
538
539 let tagsStr = ''
540
541 // Opengraph
542 Object.keys(openGraphMetaTags).forEach(tagName => {
543 const tagValue = openGraphMetaTags[tagName]
544
545 tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
546 })
547
548 // Standard
549 Object.keys(standardMetaTags).forEach(tagName => {
550 const tagValue = standardMetaTags[tagName]
551
552 tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
553 })
554
555 // Twitter card
556 Object.keys(twitterCardMetaTags).forEach(tagName => {
557 const tagValue = twitterCardMetaTags[tagName]
558
559 tagsStr += `<meta property="${tagName}" content="${tagValue}" />`
560 })
561
562 // OEmbed
563 for (const oembedLinkTag of oembedLinkTags) {
564 tagsStr += `<link rel="alternate" type="${oembedLinkTag.type}" href="${oembedLinkTag.href}" title="${oembedLinkTag.escapedTitle}" />`
565 }
566
567 // Schema.org
568 if (schemaTags) {
569 tagsStr += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>`
570 }
571
572 // SEO, use origin URL
573 tagsStr += `<link rel="canonical" href="${originUrl}" />`
574
575 if (disallowIndexation) {
576 tagsStr += `<meta name="robots" content="noindex" />`
577 }
578
579 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.META_TAGS, tagsStr)
580 }
581}
582
583function sendHTML (html: string, res: express.Response, localizedHTML: boolean = false) {
584 res.set('Content-Type', 'text/html; charset=UTF-8')
585
586 if (localizedHTML) {
587 res.set('Vary', 'Accept-Language')
588 }
589
590 return res.send(html)
591}
592
593async function serveIndexHTML (req: express.Request, res: express.Response) {
594 if (req.accepts(ACCEPT_HEADERS) === 'html' || !req.headers.accept) {
595 try {
596 await generateHTMLPage(req, res, req.params.language)
597 return
598 } catch (err) {
599 logger.error('Cannot generate HTML page.', { err })
600 return res.status(HttpStatusCode.INTERNAL_SERVER_ERROR_500).end()
601 }
602 }
603
604 return res.status(HttpStatusCode.NOT_ACCEPTABLE_406).end()
605}
606
607// ---------------------------------------------------------------------------
608
609export {
610 ClientHtml,
611 sendHTML,
612 serveIndexHTML
613}
614
615async function generateHTMLPage (req: express.Request, res: express.Response, paramLang?: string) {
616 const html = await ClientHtml.getDefaultHTMLPage(req, res, paramLang)
617
618 return sendHTML(html, res, true)
619}
620
621function buildEscapedTruncatedDescription (description: string) {
622 return truncate(mdToOneLinePlainText(description), { length: 200 })
623}
diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts
deleted file mode 100644
index f5c3e4745..000000000
--- a/server/lib/emailer.ts
+++ /dev/null
@@ -1,284 +0,0 @@
1import { readFileSync } from 'fs-extra'
2import { merge } from 'lodash'
3import { createTransport, Transporter } from 'nodemailer'
4import { join } from 'path'
5import { arrayify, root } from '@shared/core-utils'
6import { EmailPayload, UserRegistrationState } from '@shared/models'
7import { SendEmailDefaultOptions } from '../../shared/models/server/emailer.model'
8import { isTestOrDevInstance } from '../helpers/core-utils'
9import { bunyanLogger, logger } from '../helpers/logger'
10import { CONFIG, isEmailEnabled } from '../initializers/config'
11import { WEBSERVER } from '../initializers/constants'
12import { MRegistration, MUser } from '../types/models'
13import { JobQueue } from './job-queue'
14
15const Email = require('email-templates')
16
17class Emailer {
18
19 private static instance: Emailer
20 private initialized = false
21 private transporter: Transporter
22
23 private constructor () {
24 }
25
26 init () {
27 // Already initialized
28 if (this.initialized === true) return
29 this.initialized = true
30
31 if (!isEmailEnabled()) {
32 if (!isTestOrDevInstance()) {
33 logger.error('Cannot use SMTP server because of lack of configuration. PeerTube will not be able to send mails!')
34 }
35
36 return
37 }
38
39 if (CONFIG.SMTP.TRANSPORT === 'smtp') this.initSMTPTransport()
40 else if (CONFIG.SMTP.TRANSPORT === 'sendmail') this.initSendmailTransport()
41 }
42
43 async checkConnection () {
44 if (!this.transporter || CONFIG.SMTP.TRANSPORT !== 'smtp') return
45
46 logger.info('Testing SMTP server...')
47
48 try {
49 const success = await this.transporter.verify()
50 if (success !== true) this.warnOnConnectionFailure()
51
52 logger.info('Successfully connected to SMTP server.')
53 } catch (err) {
54 this.warnOnConnectionFailure(err)
55 }
56 }
57
58 addPasswordResetEmailJob (username: string, to: string, resetPasswordUrl: string) {
59 const emailPayload: EmailPayload = {
60 template: 'password-reset',
61 to: [ to ],
62 subject: 'Reset your account password',
63 locals: {
64 username,
65 resetPasswordUrl,
66
67 hideNotificationPreferencesLink: true
68 }
69 }
70
71 return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
72 }
73
74 addPasswordCreateEmailJob (username: string, to: string, createPasswordUrl: string) {
75 const emailPayload: EmailPayload = {
76 template: 'password-create',
77 to: [ to ],
78 subject: 'Create your account password',
79 locals: {
80 username,
81 createPasswordUrl,
82
83 hideNotificationPreferencesLink: true
84 }
85 }
86
87 return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
88 }
89
90 addVerifyEmailJob (options: {
91 username: string
92 isRegistrationRequest: boolean
93 to: string
94 verifyEmailUrl: string
95 }) {
96 const { username, isRegistrationRequest, to, verifyEmailUrl } = options
97
98 const emailPayload: EmailPayload = {
99 template: 'verify-email',
100 to: [ to ],
101 subject: `Verify your email on ${CONFIG.INSTANCE.NAME}`,
102 locals: {
103 username,
104 verifyEmailUrl,
105 isRegistrationRequest,
106
107 hideNotificationPreferencesLink: true
108 }
109 }
110
111 return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
112 }
113
114 addUserBlockJob (user: MUser, blocked: boolean, reason?: string) {
115 const reasonString = reason ? ` for the following reason: ${reason}` : ''
116 const blockedWord = blocked ? 'blocked' : 'unblocked'
117
118 const to = user.email
119 const emailPayload: EmailPayload = {
120 to: [ to ],
121 subject: 'Account ' + blockedWord,
122 text: `Your account ${user.username} on ${CONFIG.INSTANCE.NAME} has been ${blockedWord}${reasonString}.`
123 }
124
125 return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
126 }
127
128 addContactFormJob (fromEmail: string, fromName: string, subject: string, body: string) {
129 const emailPayload: EmailPayload = {
130 template: 'contact-form',
131 to: [ CONFIG.ADMIN.EMAIL ],
132 replyTo: `"${fromName}" <${fromEmail}>`,
133 subject: `(contact form) ${subject}`,
134 locals: {
135 fromName,
136 fromEmail,
137 body,
138
139 // There are not notification preferences for the contact form
140 hideNotificationPreferencesLink: true
141 }
142 }
143
144 return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
145 }
146
147 addUserRegistrationRequestProcessedJob (registration: MRegistration) {
148 let template: string
149 let subject: string
150 if (registration.state === UserRegistrationState.ACCEPTED) {
151 template = 'user-registration-request-accepted'
152 subject = `Your registration request for ${registration.username} has been accepted`
153 } else {
154 template = 'user-registration-request-rejected'
155 subject = `Your registration request for ${registration.username} has been rejected`
156 }
157
158 const to = registration.email
159 const emailPayload: EmailPayload = {
160 to: [ to ],
161 template,
162 subject,
163 locals: {
164 username: registration.username,
165 moderationResponse: registration.moderationResponse,
166 loginLink: WEBSERVER.URL + '/login'
167 }
168 }
169
170 return JobQueue.Instance.createJobAsync({ type: 'email', payload: emailPayload })
171 }
172
173 async sendMail (options: EmailPayload) {
174 if (!isEmailEnabled()) {
175 logger.info('Cannot send mail because SMTP is not configured.')
176 return
177 }
178
179 const fromDisplayName = options.from
180 ? options.from
181 : CONFIG.INSTANCE.NAME
182
183 const email = new Email({
184 send: true,
185 htmlToText: {
186 selectors: [
187 { selector: 'img', format: 'skip' },
188 { selector: 'a', options: { hideLinkHrefIfSameAsText: true } }
189 ]
190 },
191 message: {
192 from: `"${fromDisplayName}" <${CONFIG.SMTP.FROM_ADDRESS}>`
193 },
194 transport: this.transporter,
195 views: {
196 root: join(root(), 'dist', 'server', 'lib', 'emails')
197 },
198 subjectPrefix: CONFIG.EMAIL.SUBJECT.PREFIX
199 })
200
201 const toEmails = arrayify(options.to)
202
203 for (const to of toEmails) {
204 const baseOptions: SendEmailDefaultOptions = {
205 template: 'common',
206 message: {
207 to,
208 from: options.from,
209 subject: options.subject,
210 replyTo: options.replyTo
211 },
212 locals: { // default variables available in all templates
213 WEBSERVER,
214 EMAIL: CONFIG.EMAIL,
215 instanceName: CONFIG.INSTANCE.NAME,
216 text: options.text,
217 subject: options.subject
218 }
219 }
220
221 // overridden/new variables given for a specific template in the payload
222 const sendOptions = merge(baseOptions, options)
223
224 await email.send(sendOptions)
225 .then(res => logger.debug('Sent email.', { res }))
226 .catch(err => logger.error('Error in email sender.', { err }))
227 }
228 }
229
230 private warnOnConnectionFailure (err?: Error) {
231 logger.error('Failed to connect to SMTP %s:%d.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT, { err })
232 }
233
234 private initSMTPTransport () {
235 logger.info('Using %s:%s as SMTP server.', CONFIG.SMTP.HOSTNAME, CONFIG.SMTP.PORT)
236
237 let tls
238 if (CONFIG.SMTP.CA_FILE) {
239 tls = {
240 ca: [ readFileSync(CONFIG.SMTP.CA_FILE) ]
241 }
242 }
243
244 let auth
245 if (CONFIG.SMTP.USERNAME && CONFIG.SMTP.PASSWORD) {
246 auth = {
247 user: CONFIG.SMTP.USERNAME,
248 pass: CONFIG.SMTP.PASSWORD
249 }
250 }
251
252 this.transporter = createTransport({
253 host: CONFIG.SMTP.HOSTNAME,
254 port: CONFIG.SMTP.PORT,
255 secure: CONFIG.SMTP.TLS,
256 debug: CONFIG.LOG.LEVEL === 'debug',
257 logger: bunyanLogger as any,
258 ignoreTLS: CONFIG.SMTP.DISABLE_STARTTLS,
259 tls,
260 auth
261 })
262 }
263
264 private initSendmailTransport () {
265 logger.info('Using sendmail to send emails')
266
267 this.transporter = createTransport({
268 sendmail: true,
269 newline: 'unix',
270 path: CONFIG.SMTP.SENDMAIL,
271 logger: bunyanLogger
272 })
273 }
274
275 static get Instance () {
276 return this.instance || (this.instance = new this())
277 }
278}
279
280// ---------------------------------------------------------------------------
281
282export {
283 Emailer
284}
diff --git a/server/lib/emails/abuse-new-message/html.pug b/server/lib/emails/abuse-new-message/html.pug
deleted file mode 100644
index c1d452e7d..000000000
--- a/server/lib/emails/abuse-new-message/html.pug
+++ /dev/null
@@ -1,11 +0,0 @@
1extends ../common/greetings
2include ../common/mixins.pug
3
4block title
5 | New message on abuse report
6
7block content
8 p
9 | A new message by #{messageAccountName} was posted on #[a(href=abuseUrl) abuse report ##{abuseId}] on #{instanceName}
10 blockquote #{messageText}
11 br(style="display: none;")
diff --git a/server/lib/emails/abuse-state-change/html.pug b/server/lib/emails/abuse-state-change/html.pug
deleted file mode 100644
index bb243e729..000000000
--- a/server/lib/emails/abuse-state-change/html.pug
+++ /dev/null
@@ -1,9 +0,0 @@
1extends ../common/greetings
2include ../common/mixins.pug
3
4block title
5 | Abuse report state changed
6
7block content
8 p
9 | #[a(href=abuseUrl) Your abuse report ##{abuseId}] on #{instanceName} has been #{isAccepted ? 'accepted' : 'rejected'}
diff --git a/server/lib/emails/account-abuse-new/html.pug b/server/lib/emails/account-abuse-new/html.pug
deleted file mode 100644
index e4c0366fb..000000000
--- a/server/lib/emails/account-abuse-new/html.pug
+++ /dev/null
@@ -1,14 +0,0 @@
1extends ../common/greetings
2include ../common/mixins.pug
3
4block title
5 | An account is pending moderation
6
7block content
8 p
9 | #[a(href=WEBSERVER.URL) #{instanceName}] received an abuse report for the #{isLocal ? '' : 'remote '}account
10 a(href=accountUrl) #{accountDisplayName}
11
12 p The reporter, #{reporter}, cited the following reason(s):
13 blockquote #{reason}
14 br(style="display: none;")
diff --git a/server/lib/emails/common/base.pug b/server/lib/emails/common/base.pug
deleted file mode 100644
index 41e94564d..000000000
--- a/server/lib/emails/common/base.pug
+++ /dev/null
@@ -1,258 +0,0 @@
1//-
2 The email background color is defined in three places:
3 1. body tag: for most email clients
4 2. center tag: for Gmail and Inbox mobile apps and web versions of Gmail, GSuite, Inbox, Yahoo, AOL, Libero, Comcast, freenet, Mail.ru, Orange.fr
5 3. mso conditional: For Windows 10 Mail
6- var backgroundColor = "#fff";
7- var mainColor = "#f2690d";
8doctype html
9head
10 // This template is heavily adapted from the Cerberus Fluid template. Kudos to them!
11 meta(charset='utf-8')
12 //- utf-8 works for most cases
13 meta(name='viewport' content='width=device-width')
14 //- Forcing initial-scale shouldn't be necessary
15 meta(http-equiv='X-UA-Compatible' content='IE=edge')
16 //- Use the latest (edge) version of IE rendering engine
17 meta(name='x-apple-disable-message-reformatting')
18 //- Disable auto-scale in iOS 10 Mail entirely
19 meta(name='format-detection' content='telephone=no,address=no,email=no,date=no,url=no')
20 //- Tell iOS not to automatically link certain text strings.
21 meta(name='color-scheme' content='light')
22 meta(name='supported-color-schemes' content='light')
23 //- The title tag shows in email notifications, like Android 4.4.
24 title #{subject}
25 //- What it does: Makes background images in 72ppi Outlook render at correct size.
26 //if gte mso 9
27 xml
28 o:officedocumentsettings
29 o:allowpng
30 o:pixelsperinch 96
31 //- CSS Reset : BEGIN
32 style.
33 /* What it does: Tells the email client that only light styles are provided but the client can transform them to dark. A duplicate of meta color-scheme meta tag above. */
34 :root {
35 color-scheme: light;
36 supported-color-schemes: light;
37 }
38 /* What it does: Remove spaces around the email design added by some email clients. */
39 /* Beware: It can remove the padding / margin and add a background color to the compose a reply window. */
40 html,
41 body {
42 margin: 0 auto !important;
43 padding: 0 !important;
44 height: 100% !important;
45 width: 100% !important;
46 }
47 /* What it does: Stops email clients resizing small text. */
48 * {
49 -ms-text-size-adjust: 100%;
50 -webkit-text-size-adjust: 100%;
51 }
52 /* What it does: Centers email on Android 4.4 */
53 div[style*="margin: 16px 0"] {
54 margin: 0 !important;
55 }
56 /* What it does: forces Samsung Android mail clients to use the entire viewport */
57 #MessageViewBody, #MessageWebViewDiv{
58 width: 100% !important;
59 }
60 /* What it does: Stops Outlook from adding extra spacing to tables. */
61 table,
62 td {
63 mso-table-lspace: 0pt !important;
64 mso-table-rspace: 0pt !important;
65 }
66 /* What it does: Fixes webkit padding issue. */
67 table {
68 border-spacing: 0 !important;
69 border-collapse: collapse !important;
70 table-layout: fixed !important;
71 margin: 0 auto !important;
72 }
73 /* What it does: Uses a better rendering method when resizing images in IE. */
74 img {
75 -ms-interpolation-mode:bicubic;
76 }
77 /* What it does: Prevents Windows 10 Mail from underlining links despite inline CSS. Styles for underlined links should be inline. */
78 a {
79 text-decoration: none;
80 }
81 a:not(.nocolor) {
82 color: #{mainColor};
83 }
84 a.nocolor {
85 color: inherit !important;
86 }
87 /* What it does: A work-around for email clients meddling in triggered links. */
88 a[x-apple-data-detectors], /* iOS */
89 .unstyle-auto-detected-links a,
90 .aBn {
91 border-bottom: 0 !important;
92 cursor: default !important;
93 color: inherit !important;
94 text-decoration: none !important;
95 font-size: inherit !important;
96 font-family: inherit !important;
97 font-weight: inherit !important;
98 line-height: inherit !important;
99 }
100 /* What it does: Prevents Gmail from displaying a download button on large, non-linked images. */
101 .a6S {
102 display: none !important;
103 opacity: 0.01 !important;
104 }
105 /* What it does: Prevents Gmail from changing the text color in conversation threads. */
106 .im {
107 color: inherit !important;
108 }
109 /* If the above doesn't work, add a .g-img class to any image in question. */
110 img.g-img + div {
111 display: none !important;
112 }
113 /* What it does: Removes right gutter in Gmail iOS app: https://github.com/TedGoas/Cerberus/issues/89 */
114 /* Create one of these media queries for each additional viewport size you'd like to fix */
115 /* iPhone 4, 4S, 5, 5S, 5C, and 5SE */
116 @media only screen and (min-device-width: 320px) and (max-device-width: 374px) {
117 u ~ div .email-container {
118 min-width: 320px !important;
119 }
120 }
121 /* iPhone 6, 6S, 7, 8, and X */
122 @media only screen and (min-device-width: 375px) and (max-device-width: 413px) {
123 u ~ div .email-container {
124 min-width: 375px !important;
125 }
126 }
127 /* iPhone 6+, 7+, and 8+ */
128 @media only screen and (min-device-width: 414px) {
129 u ~ div .email-container {
130 min-width: 414px !important;
131 }
132 }
133 //- CSS Reset : END
134 //- CSS for PeerTube : START
135 style.
136 blockquote {
137 margin-left: 0;
138 padding-left: 20px;
139 border-left: 2px solid #f2690d;
140 }
141 //- CSS for PeerTube : END
142 //- Progressive Enhancements : BEGIN
143 style.
144 /* What it does: Hover styles for buttons */
145 .button-td,
146 .button-a {
147 transition: all 100ms ease-in;
148 }
149 .button-td-primary:hover,
150 .button-a-primary:hover {
151 background: #555555 !important;
152 border-color: #555555 !important;
153 }
154 /* Media Queries */
155 @media screen and (max-width: 600px) {
156 /* What it does: Adjust typography on small screens to improve readability */
157 .email-container p {
158 font-size: 17px !important;
159 }
160 }
161 //- Progressive Enhancements : END
162
163body(width="100%" style="margin: 0; padding: 0 !important; mso-line-height-rule: exactly; background-color: #{backgroundColor};")
164 center(role='article' aria-roledescription='email' lang='en' style='width: 100%; background-color: #{backgroundColor};')
165 //if mso | IE
166 table(role='presentation' border='0' cellpadding='0' cellspacing='0' width='100%' style='background-color: #fff;')
167 tr
168 td
169 //- Visually Hidden Preheader Text : BEGIN
170 div(style='max-height:0; overflow:hidden; mso-hide:all;' aria-hidden='true')
171 block preheader
172 //- Visually Hidden Preheader Text : END
173
174 //- Create white space after the desired preview text so email clients don’t pull other distracting text into the inbox preview. Extend as necessary.
175 //- Preview Text Spacing Hack : BEGIN
176 div(style='display: none; font-size: 1px; line-height: 1px; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden; mso-hide: all; font-family: sans-serif;')
177 | &zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;
178 //- Preview Text Spacing Hack : END
179
180 //-
181 Set the email width. Defined in two places:
182 1. max-width for all clients except Desktop Windows Outlook, allowing the email to squish on narrow but never go wider than 600px.
183 2. MSO tags for Desktop Windows Outlook enforce a 600px width.
184 .email-container(style='max-width: 600px; margin: 0 auto;')
185 //if mso
186 table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='600')
187 tr
188 td
189 //- Email Body : BEGIN
190 table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style='margin: auto;')
191 //- 1 Column Text + Button : BEGIN
192 tr
193 td(style='background-color: #ffffff;')
194 table(role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%')
195 tr
196 td(style='padding: 20px; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #555555;')
197 table(role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%")
198 tr
199 td(width="40px")
200 img(src=`${WEBSERVER.URL}/client/assets/images/icons/icon-192x192.png` width="auto" height="30px" alt="" border="0" style="height: 30px; background: #ffffff; font-family: sans-serif; font-size: 15px; line-height: 15px; color: #555555;")
201 td
202 h1(style='margin: 10px 0 10px 0; font-family: sans-serif; font-size: 25px; line-height: 30px; color: #333333; font-weight: normal;')
203 block title
204 if title
205 | #{title}
206 else
207 | Something requires your attention
208 p(style='margin: 0;')
209 block body
210 if action
211 tr
212 td(style='padding: 0 20px;')
213 //- Button : BEGIN
214 table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' style='margin: auto;')
215 tr
216 td.button-td.button-td-primary(style='border-radius: 4px; background: #222222;')
217 a.button-a.button-a-primary(href=action.url style='background: #222222; border: 1px solid #000000; font-family: sans-serif; font-size: 15px; line-height: 15px; text-decoration: none; padding: 13px 17px; color: #ffffff; display: block; border-radius: 4px;') #{action.text}
218 //- Button : END
219 //- 1 Column Text + Button : END
220 //- Clear Spacer : BEGIN
221 tr
222 td(aria-hidden='true' height='20' style='font-size: 0px; line-height: 0px;')
223 br
224 //- Clear Spacer : END
225 //- Email Body : END
226 //- Email Footer : BEGIN
227 unless hideNotificationPreferencesLink
228 table(align='center' role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style='margin: auto;')
229 tr
230 td(style='padding: 20px; padding-bottom: 0px; font-family: sans-serif; font-size: 12px; line-height: 15px; text-align: center; color: #888888;')
231 webversion
232 a.nocolor(href=`${WEBSERVER.URL}/my-account/notifications` style='color: #cccccc; font-weight: bold;') View in your notifications
233 br
234 tr
235 td(style='padding: 20px; padding-top: 10px; font-family: sans-serif; font-size: 12px; line-height: 15px; text-align: center; color: #888888;')
236 unsubscribe
237 a.nocolor(href=`${WEBSERVER.URL}/my-account/settings#notifications` style='color: #888888;') Manage your notification preferences in your profile
238 br
239 //- Email Footer : END
240 //if mso
241 //- Full Bleed Background Section : BEGIN
242 table(role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%' style=`background-color: ${mainColor};`)
243 tr
244 td
245 .email-container(align='center' style='max-width: 600px; margin: auto;')
246 //if mso
247 table(role='presentation' cellspacing='0' cellpadding='0' border='0' width='600' align='center')
248 tr
249 td
250 table(role='presentation' cellspacing='0' cellpadding='0' border='0' width='100%')
251 tr
252 td(style='padding: 20px; text-align: left; font-family: sans-serif; font-size: 12px; line-height: 20px; color: #ffffff;')
253 table(role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%")
254 tr
255 td(valign="top") #[a(href="https://github.com/Chocobozzz/PeerTube" style="color: white !important") PeerTube © 2015-#{new Date().getFullYear()}] #[a(href="https://github.com/Chocobozzz/PeerTube/blob/master/CREDITS.md" style="color: white !important") PeerTube Contributors]
256 //if mso
257 //- Full Bleed Background Section : END
258 //if mso | IE
diff --git a/server/lib/emails/common/greetings.pug b/server/lib/emails/common/greetings.pug
deleted file mode 100644
index 5efe29dfb..000000000
--- a/server/lib/emails/common/greetings.pug
+++ /dev/null
@@ -1,11 +0,0 @@
1extends base
2
3block body
4 if username
5 p Hi #{username},
6 else
7 p Hi,
8 block content
9 p
10 | Cheers,#[br]
11 | #{EMAIL.BODY.SIGNATURE} \ No newline at end of file
diff --git a/server/lib/emails/common/html.pug b/server/lib/emails/common/html.pug
deleted file mode 100644
index d76168b85..000000000
--- a/server/lib/emails/common/html.pug
+++ /dev/null
@@ -1,4 +0,0 @@
1extends greetings
2
3block content
4 p !{text} \ No newline at end of file
diff --git a/server/lib/emails/common/mixins.pug b/server/lib/emails/common/mixins.pug
deleted file mode 100644
index 831211864..000000000
--- a/server/lib/emails/common/mixins.pug
+++ /dev/null
@@ -1,7 +0,0 @@
1mixin channel(channel)
2 - var handle = `${channel.name}@${channel.host}`
3 | #[a(href=`${WEBSERVER.URL}/video-channels/${handle}` title=handle) #{channel.displayName}]
4
5mixin account(account)
6 - var handle = `${account.name}@${account.host}`
7 | #[a(href=`${WEBSERVER.URL}/accounts/${handle}` title=handle) #{account.displayName}]
diff --git a/server/lib/emails/contact-form/html.pug b/server/lib/emails/contact-form/html.pug
deleted file mode 100644
index 5a24fa6f1..000000000
--- a/server/lib/emails/contact-form/html.pug
+++ /dev/null
@@ -1,9 +0,0 @@
1extends ../common/greetings
2
3block title
4 | Someone just used the contact form
5
6block content
7 p #{fromName} sent you a message via the contact form on #[a(href=WEBSERVER.URL) #{instanceName}]:
8 blockquote(style='white-space: pre-wrap') #{body}
9 p You can contact them at #[a(href=`mailto:${fromEmail}`) #{fromEmail}], or simply reply to this email to get in touch. \ No newline at end of file
diff --git a/server/lib/emails/follower-on-channel/html.pug b/server/lib/emails/follower-on-channel/html.pug
deleted file mode 100644
index 8a352e90f..000000000
--- a/server/lib/emails/follower-on-channel/html.pug
+++ /dev/null
@@ -1,9 +0,0 @@
1extends ../common/greetings
2
3block title
4 | New follower on your channel
5
6block content
7 p.
8 Your #{followType} #[a(href=followingUrl) #{followingName}] has a new subscriber:
9 #[a(href=followerUrl) #{followerName}]. \ No newline at end of file
diff --git a/server/lib/emails/password-create/html.pug b/server/lib/emails/password-create/html.pug
deleted file mode 100644
index afa30ae97..000000000
--- a/server/lib/emails/password-create/html.pug
+++ /dev/null
@@ -1,10 +0,0 @@
1extends ../common/greetings
2
3block title
4 | Password creation for your account
5
6block content
7 p.
8 Welcome to #[a(href=WEBSERVER.URL) #{instanceName}]. Your username is: #{username}.
9 Please set your password by following #[a(href=createPasswordUrl) this link]: #[a(href=createPasswordUrl) #{createPasswordUrl}]
10 (this link will expire within seven days). \ No newline at end of file
diff --git a/server/lib/emails/password-reset/html.pug b/server/lib/emails/password-reset/html.pug
deleted file mode 100644
index 2af2885bc..000000000
--- a/server/lib/emails/password-reset/html.pug
+++ /dev/null
@@ -1,12 +0,0 @@
1extends ../common/greetings
2
3block title
4 | Password reset for your account
5
6block content
7 p.
8 A reset password procedure for your account #{username} has been requested on #[a(href=WEBSERVER.URL) #{instanceName}].
9 Please follow #[a(href=resetPasswordUrl) this link] to reset it: #[a(href=resetPasswordUrl) #{resetPasswordUrl}]
10 (the link will expire within 1 hour).
11 p.
12 If you are not the person who initiated this request, please ignore this email.
diff --git a/server/lib/emails/peertube-version-new/html.pug b/server/lib/emails/peertube-version-new/html.pug
deleted file mode 100644
index 2f4d9399d..000000000
--- a/server/lib/emails/peertube-version-new/html.pug
+++ /dev/null
@@ -1,9 +0,0 @@
1extends ../common/greetings
2
3block title
4 | New PeerTube version available
5
6block content
7 p
8 | A new version of PeerTube is available: #{latestVersion}.
9 | You can check the latest news on #[a(href="https://joinpeertube.org/news") JoinPeerTube].
diff --git a/server/lib/emails/plugin-version-new/html.pug b/server/lib/emails/plugin-version-new/html.pug
deleted file mode 100644
index 86d3d87e8..000000000
--- a/server/lib/emails/plugin-version-new/html.pug
+++ /dev/null
@@ -1,9 +0,0 @@
1extends ../common/greetings
2
3block title
4 | New plugin version available
5
6block content
7 p
8 | A new version of the plugin/theme #{pluginName} is available: #{latestVersion}.
9 | You might want to upgrade it on #[a(href=pluginUrl) the PeerTube admin interface].
diff --git a/server/lib/emails/user-registered/html.pug b/server/lib/emails/user-registered/html.pug
deleted file mode 100644
index 20f62125e..000000000
--- a/server/lib/emails/user-registered/html.pug
+++ /dev/null
@@ -1,10 +0,0 @@
1extends ../common/greetings
2
3block title
4 | A new user registered
5
6block content
7 - var mail = user.email || user.pendingEmail;
8 p
9 | User #[a(href=`${WEBSERVER.URL}/accounts/${user.username}`) #{user.username}] just registered.
10 | You might want to contact them at #[a(href=`mailto:${mail}`) #{mail}]. \ No newline at end of file
diff --git a/server/lib/emails/user-registration-request-accepted/html.pug b/server/lib/emails/user-registration-request-accepted/html.pug
deleted file mode 100644
index 7a52c3fe1..000000000
--- a/server/lib/emails/user-registration-request-accepted/html.pug
+++ /dev/null
@@ -1,10 +0,0 @@
1extends ../common/greetings
2
3block title
4 | Congratulation #{username}, your registration request has been accepted!
5
6block content
7 p Your registration request has been accepted.
8 p Moderators sent you the following message:
9 blockquote(style='white-space: pre-wrap') #{moderationResponse}
10 p Your account has been created and you can login on #[a(href=loginLink) #{loginLink}]
diff --git a/server/lib/emails/user-registration-request-rejected/html.pug b/server/lib/emails/user-registration-request-rejected/html.pug
deleted file mode 100644
index ec0aa8dfe..000000000
--- a/server/lib/emails/user-registration-request-rejected/html.pug
+++ /dev/null
@@ -1,9 +0,0 @@
1extends ../common/greetings
2
3block title
4 | Registration request of your account #{username} has rejected
5
6block content
7 p Your registration request has been rejected.
8 p Moderators sent you the following message:
9 blockquote(style='white-space: pre-wrap') #{moderationResponse}
diff --git a/server/lib/emails/user-registration-request/html.pug b/server/lib/emails/user-registration-request/html.pug
deleted file mode 100644
index 64898f3f2..000000000
--- a/server/lib/emails/user-registration-request/html.pug
+++ /dev/null
@@ -1,9 +0,0 @@
1extends ../common/greetings
2
3block title
4 | A new user wants to register
5
6block content
7 p User #{registration.username} wants to register on your PeerTube instance with the following reason:
8 blockquote(style='white-space: pre-wrap') #{registration.registrationReason}
9 p You can accept or reject the registration request in the #[a(href=`${WEBSERVER.URL}/admin/moderation/registrations/list`) administration].
diff --git a/server/lib/emails/verify-email/html.pug b/server/lib/emails/verify-email/html.pug
deleted file mode 100644
index 19ef65f75..000000000
--- a/server/lib/emails/verify-email/html.pug
+++ /dev/null
@@ -1,19 +0,0 @@
1extends ../common/greetings
2
3block title
4 | Email verification
5
6block content
7 if isRegistrationRequest
8 p You just requested an account on #[a(href=WEBSERVER.URL) #{instanceName}].
9 else
10 p You just created an account on #[a(href=WEBSERVER.URL) #{instanceName}].
11
12 if isRegistrationRequest
13 p To complete your registration request you must verify your email first!
14 else
15 p To start using your account you must verify your email first!
16
17 p Please follow #[a(href=verifyEmailUrl) this link] to verify this email belongs to you.
18 p If you can't see the verification link above you can use the following link #[a(href=verifyEmailUrl) #{verifyEmailUrl}]
19 p If you are not the person who initiated this request, please ignore this email.
diff --git a/server/lib/emails/video-abuse-new/html.pug b/server/lib/emails/video-abuse-new/html.pug
deleted file mode 100644
index a085b4b38..000000000
--- a/server/lib/emails/video-abuse-new/html.pug
+++ /dev/null
@@ -1,18 +0,0 @@
1extends ../common/greetings
2include ../common/mixins.pug
3
4block title
5 | A video is pending moderation
6
7block content
8 p
9 | #[a(href=WEBSERVER.URL) #{instanceName}] received an abuse report for the #{isLocal ? '' : 'remote '}video "
10 a(href=videoUrl) #{videoName}
11 | " by #[+channel(videoChannel)]
12 if videoPublishedAt
13 | , published the #{videoPublishedAt}.
14 else
15 | , uploaded the #{videoCreatedAt} but not yet published.
16 p The reporter, #{reporter}, cited the following reason(s):
17 blockquote #{reason}
18 br(style="display: none;")
diff --git a/server/lib/emails/video-auto-blacklist-new/html.pug b/server/lib/emails/video-auto-blacklist-new/html.pug
deleted file mode 100644
index 07c8dfd16..000000000
--- a/server/lib/emails/video-auto-blacklist-new/html.pug
+++ /dev/null
@@ -1,17 +0,0 @@
1extends ../common/greetings
2include ../common/mixins
3
4block title
5 | A video is pending moderation
6
7block content
8 p
9 | A recently added video was auto-blacklisted and requires moderator review before going public:
10 |
11 a(href=videoUrl) #{videoName}
12 |
13 | by #[+channel(channel)].
14 p.
15 Apart from the publisher and the moderation team, no one will be able to see the video until you
16 unblacklist it. If you trust the publisher, any admin can whitelist the user for later videos so
17 that they don't require approval before going public.
diff --git a/server/lib/emails/video-comment-abuse-new/html.pug b/server/lib/emails/video-comment-abuse-new/html.pug
deleted file mode 100644
index 752bf7c10..000000000
--- a/server/lib/emails/video-comment-abuse-new/html.pug
+++ /dev/null
@@ -1,16 +0,0 @@
1extends ../common/greetings
2include ../common/mixins.pug
3
4block title
5 | A comment is pending moderation
6
7block content
8 p
9 | #[a(href=WEBSERVER.URL) #{instanceName}] received an abuse report for the #{isLocal ? '' : 'remote '}
10 a(href=commentUrl) comment on video "#{videoName}"
11 | of #{flaggedAccount}
12 | created on #{commentCreatedAt}
13
14 p The reporter, #{reporter}, cited the following reason(s):
15 blockquote #{reason}
16 br(style="display: none;")
diff --git a/server/lib/emails/video-comment-mention/html.pug b/server/lib/emails/video-comment-mention/html.pug
deleted file mode 100644
index a34c6b090..000000000
--- a/server/lib/emails/video-comment-mention/html.pug
+++ /dev/null
@@ -1,11 +0,0 @@
1extends ../common/greetings
2
3block title
4 | Someone mentioned you
5
6block content
7 p.
8 #[a(href=accountUrl title=handle) #{accountName}] mentioned you in a comment on video
9 "#[a(href=videoUrl) #{video.name}]":
10 blockquote !{commentHtml}
11 br(style="display: none;")
diff --git a/server/lib/emails/video-comment-new/html.pug b/server/lib/emails/video-comment-new/html.pug
deleted file mode 100644
index cbb683fee..000000000
--- a/server/lib/emails/video-comment-new/html.pug
+++ /dev/null
@@ -1,11 +0,0 @@
1extends ../common/greetings
2
3block title
4 | Someone commented your video
5
6block content
7 p.
8 #[a(href=accountUrl title=handle) #{accountName}] added a comment on your video
9 "#[a(href=videoUrl) #{video.name}]":
10 blockquote !{commentHtml}
11 br(style="display: none;")
diff --git a/server/lib/files-cache/avatar-permanent-file-cache.ts b/server/lib/files-cache/avatar-permanent-file-cache.ts
deleted file mode 100644
index 0c508b063..000000000
--- a/server/lib/files-cache/avatar-permanent-file-cache.ts
+++ /dev/null
@@ -1,27 +0,0 @@
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
deleted file mode 100644
index 5630a9b80..000000000
--- a/server/lib/files-cache/index.ts
+++ /dev/null
@@ -1,6 +0,0 @@
1export * from './avatar-permanent-file-cache'
2export * from './video-miniature-permanent-file-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
deleted file mode 100644
index f990e9872..000000000
--- a/server/lib/files-cache/shared/abstract-permanent-file-cache.ts
+++ /dev/null
@@ -1,132 +0,0 @@
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/shared/abstract-simple-file-cache.ts b/server/lib/files-cache/shared/abstract-simple-file-cache.ts
deleted file mode 100644
index 6fab322cd..000000000
--- a/server/lib/files-cache/shared/abstract-simple-file-cache.ts
+++ /dev/null
@@ -1,30 +0,0 @@
1import { remove } from 'fs-extra'
2import { logger } from '../../../helpers/logger'
3import memoizee from 'memoizee'
4
5type GetFilePathResult = { isOwned: boolean, path: string, downloadName?: string } | undefined
6
7export abstract class AbstractSimpleFileCache <T> {
8
9 getFilePath: (params: T) => Promise<GetFilePathResult>
10
11 abstract getFilePathImpl (params: T): Promise<GetFilePathResult>
12
13 // Load and save the remote file, then return the local path from filesystem
14 protected abstract loadRemoteFile (key: string): Promise<GetFilePathResult>
15
16 init (max: number, maxAge: number) {
17 this.getFilePath = memoizee(this.getFilePathImpl, {
18 maxAge,
19 max,
20 promise: true,
21 dispose: (result?: GetFilePathResult) => {
22 if (result && result.isOwned !== true) {
23 remove(result.path)
24 .then(() => logger.debug('%s removed from %s', result.path, this.constructor.name))
25 .catch(err => logger.error('Cannot remove %s from cache %s.', result.path, this.constructor.name, { err }))
26 }
27 }
28 })
29 }
30}
diff --git a/server/lib/files-cache/shared/index.ts b/server/lib/files-cache/shared/index.ts
deleted file mode 100644
index 61c4aacc7..000000000
--- a/server/lib/files-cache/shared/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
1export * from './abstract-permanent-file-cache'
2export * from './abstract-simple-file-cache'
diff --git a/server/lib/files-cache/video-captions-simple-file-cache.ts b/server/lib/files-cache/video-captions-simple-file-cache.ts
deleted file mode 100644
index cbeeff732..000000000
--- a/server/lib/files-cache/video-captions-simple-file-cache.ts
+++ /dev/null
@@ -1,61 +0,0 @@
1import { join } from 'path'
2import { logger } from '@server/helpers/logger'
3import { doRequestAndSaveToFile } from '@server/helpers/requests'
4import { CONFIG } from '../../initializers/config'
5import { FILES_CACHE } from '../../initializers/constants'
6import { VideoModel } from '../../models/video/video'
7import { VideoCaptionModel } from '../../models/video/video-caption'
8import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache'
9
10class VideoCaptionsSimpleFileCache extends AbstractSimpleFileCache <string> {
11
12 private static instance: VideoCaptionsSimpleFileCache
13
14 private constructor () {
15 super()
16 }
17
18 static get Instance () {
19 return this.instance || (this.instance = new this())
20 }
21
22 async getFilePathImpl (filename: string) {
23 const videoCaption = await VideoCaptionModel.loadWithVideoByFilename(filename)
24 if (!videoCaption) return undefined
25
26 if (videoCaption.isOwned()) {
27 return { isOwned: true, path: join(CONFIG.STORAGE.CAPTIONS_DIR, videoCaption.filename) }
28 }
29
30 return this.loadRemoteFile(filename)
31 }
32
33 // Key is the caption filename
34 protected async loadRemoteFile (key: string) {
35 const videoCaption = await VideoCaptionModel.loadWithVideoByFilename(key)
36 if (!videoCaption) return undefined
37
38 if (videoCaption.isOwned()) throw new Error('Cannot load remote caption of owned video.')
39
40 // Used to fetch the path
41 const video = await VideoModel.loadFull(videoCaption.videoId)
42 if (!video) return undefined
43
44 const remoteUrl = videoCaption.getFileUrl(video)
45 const destPath = join(FILES_CACHE.VIDEO_CAPTIONS.DIRECTORY, videoCaption.filename)
46
47 try {
48 await doRequestAndSaveToFile(remoteUrl, destPath)
49
50 return { isOwned: false, path: destPath }
51 } catch (err) {
52 logger.info('Cannot fetch remote caption file %s.', remoteUrl, { err })
53
54 return undefined
55 }
56 }
57}
58
59export {
60 VideoCaptionsSimpleFileCache
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
deleted file mode 100644
index 35d9466f7..000000000
--- a/server/lib/files-cache/video-miniature-permanent-file-cache.ts
+++ /dev/null
@@ -1,28 +0,0 @@
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/video-previews-simple-file-cache.ts b/server/lib/files-cache/video-previews-simple-file-cache.ts
deleted file mode 100644
index a05e80e16..000000000
--- a/server/lib/files-cache/video-previews-simple-file-cache.ts
+++ /dev/null
@@ -1,58 +0,0 @@
1import { join } from 'path'
2import { FILES_CACHE } from '../../initializers/constants'
3import { VideoModel } from '../../models/video/video'
4import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache'
5import { doRequestAndSaveToFile } from '@server/helpers/requests'
6import { ThumbnailModel } from '@server/models/video/thumbnail'
7import { ThumbnailType } from '@shared/models'
8import { logger } from '@server/helpers/logger'
9
10class VideoPreviewsSimpleFileCache extends AbstractSimpleFileCache <string> {
11
12 private static instance: VideoPreviewsSimpleFileCache
13
14 private constructor () {
15 super()
16 }
17
18 static get Instance () {
19 return this.instance || (this.instance = new this())
20 }
21
22 async getFilePathImpl (filename: string) {
23 const thumbnail = await ThumbnailModel.loadWithVideoByFilename(filename, ThumbnailType.PREVIEW)
24 if (!thumbnail) return undefined
25
26 if (thumbnail.Video.isOwned()) return { isOwned: true, path: thumbnail.getPath() }
27
28 return this.loadRemoteFile(thumbnail.Video.uuid)
29 }
30
31 // Key is the video UUID
32 protected async loadRemoteFile (key: string) {
33 const video = await VideoModel.loadFull(key)
34 if (!video) return undefined
35
36 if (video.isOwned()) throw new Error('Cannot load remote preview of owned video.')
37
38 const preview = video.getPreview()
39 const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, preview.filename)
40 const remoteUrl = preview.getOriginFileUrl(video)
41
42 try {
43 await doRequestAndSaveToFile(remoteUrl, destPath)
44
45 logger.debug('Fetched remote preview %s to %s.', remoteUrl, destPath)
46
47 return { isOwned: false, path: destPath }
48 } catch (err) {
49 logger.info('Cannot fetch remote preview file %s.', remoteUrl, { err })
50
51 return undefined
52 }
53 }
54}
55
56export {
57 VideoPreviewsSimpleFileCache
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
deleted file mode 100644
index 4cd96e70c..000000000
--- a/server/lib/files-cache/video-storyboards-simple-file-cache.ts
+++ /dev/null
@@ -1,53 +0,0 @@
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/video-torrents-simple-file-cache.ts b/server/lib/files-cache/video-torrents-simple-file-cache.ts
deleted file mode 100644
index 8bcd0b9bf..000000000
--- a/server/lib/files-cache/video-torrents-simple-file-cache.ts
+++ /dev/null
@@ -1,70 +0,0 @@
1import { join } from 'path'
2import { logger } from '@server/helpers/logger'
3import { doRequestAndSaveToFile } from '@server/helpers/requests'
4import { VideoFileModel } from '@server/models/video/video-file'
5import { MVideo, MVideoFile } from '@server/types/models'
6import { CONFIG } from '../../initializers/config'
7import { FILES_CACHE } from '../../initializers/constants'
8import { VideoModel } from '../../models/video/video'
9import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache'
10
11class VideoTorrentsSimpleFileCache extends AbstractSimpleFileCache <string> {
12
13 private static instance: VideoTorrentsSimpleFileCache
14
15 private constructor () {
16 super()
17 }
18
19 static get Instance () {
20 return this.instance || (this.instance = new this())
21 }
22
23 async getFilePathImpl (filename: string) {
24 const file = await VideoFileModel.loadWithVideoOrPlaylistByTorrentFilename(filename)
25 if (!file) return undefined
26
27 if (file.getVideo().isOwned()) {
28 const downloadName = this.buildDownloadName(file.getVideo(), file)
29
30 return { isOwned: true, path: join(CONFIG.STORAGE.TORRENTS_DIR, file.torrentFilename), downloadName }
31 }
32
33 return this.loadRemoteFile(filename)
34 }
35
36 // Key is the torrent filename
37 protected async loadRemoteFile (key: string) {
38 const file = await VideoFileModel.loadWithVideoOrPlaylistByTorrentFilename(key)
39 if (!file) return undefined
40
41 if (file.getVideo().isOwned()) throw new Error('Cannot load remote file of owned video.')
42
43 // Used to fetch the path
44 const video = await VideoModel.loadFull(file.getVideo().id)
45 if (!video) return undefined
46
47 const remoteUrl = file.getRemoteTorrentUrl(video)
48 const destPath = join(FILES_CACHE.TORRENTS.DIRECTORY, file.torrentFilename)
49
50 try {
51 await doRequestAndSaveToFile(remoteUrl, destPath)
52
53 const downloadName = this.buildDownloadName(video, file)
54
55 return { isOwned: false, path: destPath, downloadName }
56 } catch (err) {
57 logger.info('Cannot fetch remote torrent file %s.', remoteUrl, { err })
58
59 return undefined
60 }
61 }
62
63 private buildDownloadName (video: MVideo, file: MVideoFile) {
64 return `${video.name}-${file.resolution}p.torrent`
65 }
66}
67
68export {
69 VideoTorrentsSimpleFileCache
70}
diff --git a/server/lib/hls.ts b/server/lib/hls.ts
deleted file mode 100644
index 19044d7c2..000000000
--- a/server/lib/hls.ts
+++ /dev/null
@@ -1,285 +0,0 @@
1import { close, ensureDir, move, open, outputJSON, read, readFile, remove, stat, writeFile } from 'fs-extra'
2import { flatten } from 'lodash'
3import PQueue from 'p-queue'
4import { basename, dirname, join } from 'path'
5import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models'
6import { uniqify, uuidRegex } from '@shared/core-utils'
7import { sha256 } from '@shared/extra-utils'
8import { getVideoStreamDimensionsInfo } from '@shared/ffmpeg'
9import { VideoStorage } from '@shared/models'
10import { getAudioStreamCodec, getVideoStreamCodec } from '../helpers/ffmpeg'
11import { logger, loggerTagsFactory } from '../helpers/logger'
12import { doRequest, doRequestAndSaveToFile } from '../helpers/requests'
13import { generateRandomString } from '../helpers/utils'
14import { CONFIG } from '../initializers/config'
15import { P2P_MEDIA_LOADER_PEER_VERSION, REQUEST_TIMEOUTS } from '../initializers/constants'
16import { sequelizeTypescript } from '../initializers/database'
17import { VideoFileModel } from '../models/video/video-file'
18import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
19import { storeHLSFileFromFilename } from './object-storage'
20import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getHlsResolutionPlaylistFilename } from './paths'
21import { VideoPathManager } from './video-path-manager'
22
23const lTags = loggerTagsFactory('hls')
24
25async function updateStreamingPlaylistsInfohashesIfNeeded () {
26 const playlistsToUpdate = await VideoStreamingPlaylistModel.listByIncorrectPeerVersion()
27
28 // Use separate SQL queries, because we could have many videos to update
29 for (const playlist of playlistsToUpdate) {
30 await sequelizeTypescript.transaction(async t => {
31 const videoFiles = await VideoFileModel.listByStreamingPlaylist(playlist.id, t)
32
33 playlist.assignP2PMediaLoaderInfoHashes(playlist.Video, videoFiles)
34 playlist.p2pMediaLoaderPeerVersion = P2P_MEDIA_LOADER_PEER_VERSION
35
36 await playlist.save({ transaction: t })
37 })
38 }
39}
40
41async function updatePlaylistAfterFileChange (video: MVideo, playlist: MStreamingPlaylist) {
42 try {
43 let playlistWithFiles = await updateMasterHLSPlaylist(video, playlist)
44 playlistWithFiles = await updateSha256VODSegments(video, playlist)
45
46 // Refresh playlist, operations can take some time
47 playlistWithFiles = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlist.id)
48 playlistWithFiles.assignP2PMediaLoaderInfoHashes(video, playlistWithFiles.VideoFiles)
49 await playlistWithFiles.save()
50
51 video.setHLSPlaylist(playlistWithFiles)
52 } catch (err) {
53 logger.warn('Cannot update playlist after file change. Maybe due to concurrent transcoding', { err })
54 }
55}
56
57// ---------------------------------------------------------------------------
58
59// Avoid concurrency issues when updating streaming playlist files
60const playlistFilesQueue = new PQueue({ concurrency: 1 })
61
62function updateMasterHLSPlaylist (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
63 return playlistFilesQueue.add(async () => {
64 const playlist = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlistArg.id)
65
66 const masterPlaylists: string[] = [ '#EXTM3U', '#EXT-X-VERSION:3' ]
67
68 for (const file of playlist.VideoFiles) {
69 const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
70
71 await VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(playlist), async videoFilePath => {
72 const size = await getVideoStreamDimensionsInfo(videoFilePath)
73
74 const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file)
75 const resolution = `RESOLUTION=${size?.width || 0}x${size?.height || 0}`
76
77 let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}`
78 if (file.fps) line += ',FRAME-RATE=' + file.fps
79
80 const codecs = await Promise.all([
81 getVideoStreamCodec(videoFilePath),
82 getAudioStreamCodec(videoFilePath)
83 ])
84
85 line += `,CODECS="${codecs.filter(c => !!c).join(',')}"`
86
87 masterPlaylists.push(line)
88 masterPlaylists.push(playlistFilename)
89 })
90 }
91
92 if (playlist.playlistFilename) {
93 await video.removeStreamingPlaylistFile(playlist, playlist.playlistFilename)
94 }
95 playlist.playlistFilename = generateHLSMasterPlaylistFilename(video.isLive)
96
97 const masterPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.playlistFilename)
98 await writeFile(masterPlaylistPath, masterPlaylists.join('\n') + '\n')
99
100 logger.info('Updating %s master playlist file of video %s', masterPlaylistPath, video.uuid, lTags(video.uuid))
101
102 if (playlist.storage === VideoStorage.OBJECT_STORAGE) {
103 playlist.playlistUrl = await storeHLSFileFromFilename(playlist, playlist.playlistFilename)
104 await remove(masterPlaylistPath)
105 }
106
107 return playlist.save()
108 })
109}
110
111// ---------------------------------------------------------------------------
112
113function updateSha256VODSegments (video: MVideo, playlistArg: MStreamingPlaylist): Promise<MStreamingPlaylistFilesVideo> {
114 return playlistFilesQueue.add(async () => {
115 const json: { [filename: string]: { [range: string]: string } } = {}
116
117 const playlist = await VideoStreamingPlaylistModel.loadWithVideoAndFiles(playlistArg.id)
118
119 // For all the resolutions available for this video
120 for (const file of playlist.VideoFiles) {
121 const rangeHashes: { [range: string]: string } = {}
122 const fileWithPlaylist = file.withVideoOrPlaylist(playlist)
123
124 await VideoPathManager.Instance.makeAvailableVideoFile(fileWithPlaylist, videoPath => {
125
126 return VideoPathManager.Instance.makeAvailableResolutionPlaylistFile(fileWithPlaylist, async resolutionPlaylistPath => {
127 const playlistContent = await readFile(resolutionPlaylistPath)
128 const ranges = getRangesFromPlaylist(playlistContent.toString())
129
130 const fd = await open(videoPath, 'r')
131 for (const range of ranges) {
132 const buf = Buffer.alloc(range.length)
133 await read(fd, buf, 0, range.length, range.offset)
134
135 rangeHashes[`${range.offset}-${range.offset + range.length - 1}`] = sha256(buf)
136 }
137 await close(fd)
138
139 const videoFilename = file.filename
140 json[videoFilename] = rangeHashes
141 })
142 })
143 }
144
145 if (playlist.segmentsSha256Filename) {
146 await video.removeStreamingPlaylistFile(playlist, playlist.segmentsSha256Filename)
147 }
148 playlist.segmentsSha256Filename = generateHlsSha256SegmentsFilename(video.isLive)
149
150 const outputPath = VideoPathManager.Instance.getFSHLSOutputPath(video, playlist.segmentsSha256Filename)
151 await outputJSON(outputPath, json)
152
153 if (playlist.storage === VideoStorage.OBJECT_STORAGE) {
154 playlist.segmentsSha256Url = await storeHLSFileFromFilename(playlist, playlist.segmentsSha256Filename)
155 await remove(outputPath)
156 }
157
158 return playlist.save()
159 })
160}
161
162// ---------------------------------------------------------------------------
163
164async function buildSha256Segment (segmentPath: string) {
165 const buf = await readFile(segmentPath)
166 return sha256(buf)
167}
168
169function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, timeout: number, bodyKBLimit: number) {
170 let timer
171 let remainingBodyKBLimit = bodyKBLimit
172
173 logger.info('Importing HLS playlist %s', playlistUrl)
174
175 return new Promise<void>(async (res, rej) => {
176 const tmpDirectory = join(CONFIG.STORAGE.TMP_DIR, await generateRandomString(10))
177
178 await ensureDir(tmpDirectory)
179
180 timer = setTimeout(() => {
181 deleteTmpDirectory(tmpDirectory)
182
183 return rej(new Error('HLS download timeout.'))
184 }, timeout)
185
186 try {
187 // Fetch master playlist
188 const subPlaylistUrls = await fetchUniqUrls(playlistUrl)
189
190 const subRequests = subPlaylistUrls.map(u => fetchUniqUrls(u))
191 const fileUrls = uniqify(flatten(await Promise.all(subRequests)))
192
193 logger.debug('Will download %d HLS files.', fileUrls.length, { fileUrls })
194
195 for (const fileUrl of fileUrls) {
196 const destPath = join(tmpDirectory, basename(fileUrl))
197
198 await doRequestAndSaveToFile(fileUrl, destPath, { bodyKBLimit: remainingBodyKBLimit, timeout: REQUEST_TIMEOUTS.REDUNDANCY })
199
200 const { size } = await stat(destPath)
201 remainingBodyKBLimit -= (size / 1000)
202
203 logger.debug('Downloaded HLS playlist file %s with %d kB remained limit.', fileUrl, Math.floor(remainingBodyKBLimit))
204 }
205
206 clearTimeout(timer)
207
208 await move(tmpDirectory, destinationDir, { overwrite: true })
209
210 return res()
211 } catch (err) {
212 deleteTmpDirectory(tmpDirectory)
213
214 return rej(err)
215 }
216 })
217
218 function deleteTmpDirectory (directory: string) {
219 remove(directory)
220 .catch(err => logger.error('Cannot delete path on HLS download error.', { err }))
221 }
222
223 async function fetchUniqUrls (playlistUrl: string) {
224 const { body } = await doRequest(playlistUrl)
225
226 if (!body) return []
227
228 const urls = body.split('\n')
229 .filter(line => line.endsWith('.m3u8') || line.endsWith('.mp4'))
230 .map(url => {
231 if (url.startsWith('http://') || url.startsWith('https://')) return url
232
233 return `${dirname(playlistUrl)}/${url}`
234 })
235
236 return uniqify(urls)
237 }
238}
239
240// ---------------------------------------------------------------------------
241
242async function renameVideoFileInPlaylist (playlistPath: string, newVideoFilename: string) {
243 const content = await readFile(playlistPath, 'utf8')
244
245 const newContent = content.replace(new RegExp(`${uuidRegex}-\\d+-fragmented.mp4`, 'g'), newVideoFilename)
246
247 await writeFile(playlistPath, newContent, 'utf8')
248}
249
250// ---------------------------------------------------------------------------
251
252function injectQueryToPlaylistUrls (content: string, queryString: string) {
253 return content.replace(/\.(m3u8|ts|mp4)/gm, '.$1?' + queryString)
254}
255
256// ---------------------------------------------------------------------------
257
258export {
259 updateMasterHLSPlaylist,
260 updateSha256VODSegments,
261 buildSha256Segment,
262 downloadPlaylistSegments,
263 updateStreamingPlaylistsInfohashesIfNeeded,
264 updatePlaylistAfterFileChange,
265 injectQueryToPlaylistUrls,
266 renameVideoFileInPlaylist
267}
268
269// ---------------------------------------------------------------------------
270
271function getRangesFromPlaylist (playlistContent: string) {
272 const ranges: { offset: number, length: number }[] = []
273 const lines = playlistContent.split('\n')
274 const regex = /^#EXT-X-BYTERANGE:(\d+)@(\d+)$/
275
276 for (const line of lines) {
277 const captured = regex.exec(line)
278
279 if (captured) {
280 ranges.push({ length: parseInt(captured[1], 10), offset: parseInt(captured[2], 10) })
281 }
282 }
283
284 return ranges
285}
diff --git a/server/lib/internal-event-emitter.ts b/server/lib/internal-event-emitter.ts
deleted file mode 100644
index 08b46a5c3..000000000
--- a/server/lib/internal-event-emitter.ts
+++ /dev/null
@@ -1,35 +0,0 @@
1import { MChannel, MVideo } from '@server/types/models'
2import { EventEmitter } from 'events'
3
4export interface PeerTubeInternalEvents {
5 'video-created': (options: { video: MVideo }) => void
6 'video-updated': (options: { video: MVideo }) => void
7 'video-deleted': (options: { video: MVideo }) => void
8
9 'channel-created': (options: { channel: MChannel }) => void
10 'channel-updated': (options: { channel: MChannel }) => void
11 'channel-deleted': (options: { channel: MChannel }) => void
12}
13
14declare interface InternalEventEmitter {
15 on<U extends keyof PeerTubeInternalEvents>(
16 event: U, listener: PeerTubeInternalEvents[U]
17 ): this
18
19 emit<U extends keyof PeerTubeInternalEvents>(
20 event: U, ...args: Parameters<PeerTubeInternalEvents[U]>
21 ): boolean
22}
23
24class InternalEventEmitter extends EventEmitter {
25
26 private static instance: InternalEventEmitter
27
28 static get Instance () {
29 return this.instance || (this.instance = new this())
30 }
31}
32
33export {
34 InternalEventEmitter
35}
diff --git a/server/lib/job-queue/handlers/activitypub-cleaner.ts b/server/lib/job-queue/handlers/activitypub-cleaner.ts
deleted file mode 100644
index 6ee9e2429..000000000
--- a/server/lib/job-queue/handlers/activitypub-cleaner.ts
+++ /dev/null
@@ -1,202 +0,0 @@
1import { map } from 'bluebird'
2import { Job } from 'bullmq'
3import {
4 isAnnounceActivityValid,
5 isDislikeActivityValid,
6 isLikeActivityValid
7} from '@server/helpers/custom-validators/activitypub/activity'
8import { sanitizeAndCheckVideoCommentObject } from '@server/helpers/custom-validators/activitypub/video-comments'
9import { PeerTubeRequestError } from '@server/helpers/requests'
10import { AP_CLEANER } from '@server/initializers/constants'
11import { fetchAP } from '@server/lib/activitypub/activity'
12import { checkUrlsSameHost } from '@server/lib/activitypub/url'
13import { Redis } from '@server/lib/redis'
14import { VideoModel } from '@server/models/video/video'
15import { VideoCommentModel } from '@server/models/video/video-comment'
16import { VideoShareModel } from '@server/models/video/video-share'
17import { HttpStatusCode } from '@shared/models'
18import { logger, loggerTagsFactory } from '../../../helpers/logger'
19import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
20
21const lTags = loggerTagsFactory('ap-cleaner')
22
23// Job to clean remote interactions off local videos
24
25async function processActivityPubCleaner (_job: Job) {
26 logger.info('Processing ActivityPub cleaner.', lTags())
27
28 {
29 const rateUrls = await AccountVideoRateModel.listRemoteRateUrlsOfLocalVideos()
30 const { bodyValidator, deleter, updater } = rateOptionsFactory()
31
32 await map(rateUrls, async rateUrl => {
33 // TODO: remove when https://github.com/mastodon/mastodon/issues/13571 is fixed
34 if (rateUrl.includes('#')) return
35
36 const result = await updateObjectIfNeeded({ url: rateUrl, bodyValidator, updater, deleter })
37
38 if (result?.status === 'deleted') {
39 const { videoId, type } = result.data
40
41 await VideoModel.syncLocalRates(videoId, type, undefined)
42 }
43 }, { concurrency: AP_CLEANER.CONCURRENCY })
44 }
45
46 {
47 const shareUrls = await VideoShareModel.listRemoteShareUrlsOfLocalVideos()
48 const { bodyValidator, deleter, updater } = shareOptionsFactory()
49
50 await map(shareUrls, async shareUrl => {
51 await updateObjectIfNeeded({ url: shareUrl, bodyValidator, updater, deleter })
52 }, { concurrency: AP_CLEANER.CONCURRENCY })
53 }
54
55 {
56 const commentUrls = await VideoCommentModel.listRemoteCommentUrlsOfLocalVideos()
57 const { bodyValidator, deleter, updater } = commentOptionsFactory()
58
59 await map(commentUrls, async commentUrl => {
60 await updateObjectIfNeeded({ url: commentUrl, bodyValidator, updater, deleter })
61 }, { concurrency: AP_CLEANER.CONCURRENCY })
62 }
63}
64
65// ---------------------------------------------------------------------------
66
67export {
68 processActivityPubCleaner
69}
70
71// ---------------------------------------------------------------------------
72
73async function updateObjectIfNeeded <T> (options: {
74 url: string
75 bodyValidator: (body: any) => boolean
76 updater: (url: string, newUrl: string) => Promise<T>
77 deleter: (url: string) => Promise<T> }
78): Promise<{ data: T, status: 'deleted' | 'updated' } | null> {
79 const { url, bodyValidator, updater, deleter } = options
80
81 const on404OrTombstone = async () => {
82 logger.info('Removing remote AP object %s.', url, lTags(url))
83 const data = await deleter(url)
84
85 return { status: 'deleted' as 'deleted', data }
86 }
87
88 try {
89 const { body } = await fetchAP<any>(url)
90
91 // If not same id, check same host and update
92 if (!body?.id || !bodyValidator(body)) throw new Error(`Body or body id of ${url} is invalid`)
93
94 if (body.type === 'Tombstone') {
95 return on404OrTombstone()
96 }
97
98 const newUrl = body.id
99 if (newUrl !== url) {
100 if (checkUrlsSameHost(newUrl, url) !== true) {
101 throw new Error(`New url ${newUrl} has not the same host than old url ${url}`)
102 }
103
104 logger.info('Updating remote AP object %s.', url, lTags(url))
105 const data = await updater(url, newUrl)
106
107 return { status: 'updated', data }
108 }
109
110 return null
111 } catch (err) {
112 // Does not exist anymore, remove entry
113 if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) {
114 return on404OrTombstone()
115 }
116
117 logger.debug('Remote AP object %s is unavailable.', url, lTags(url))
118
119 const unavailability = await Redis.Instance.addAPUnavailability(url)
120 if (unavailability >= AP_CLEANER.UNAVAILABLE_TRESHOLD) {
121 logger.info('Removing unavailable AP resource %s.', url, lTags(url))
122 return on404OrTombstone()
123 }
124
125 return null
126 }
127}
128
129function rateOptionsFactory () {
130 return {
131 bodyValidator: (body: any) => isLikeActivityValid(body) || isDislikeActivityValid(body),
132
133 updater: async (url: string, newUrl: string) => {
134 const rate = await AccountVideoRateModel.loadByUrl(url, undefined)
135 rate.url = newUrl
136
137 const videoId = rate.videoId
138 const type = rate.type
139
140 await rate.save()
141
142 return { videoId, type }
143 },
144
145 deleter: async (url) => {
146 const rate = await AccountVideoRateModel.loadByUrl(url, undefined)
147
148 const videoId = rate.videoId
149 const type = rate.type
150
151 await rate.destroy()
152
153 return { videoId, type }
154 }
155 }
156}
157
158function shareOptionsFactory () {
159 return {
160 bodyValidator: (body: any) => isAnnounceActivityValid(body),
161
162 updater: async (url: string, newUrl: string) => {
163 const share = await VideoShareModel.loadByUrl(url, undefined)
164 share.url = newUrl
165
166 await share.save()
167
168 return undefined
169 },
170
171 deleter: async (url) => {
172 const share = await VideoShareModel.loadByUrl(url, undefined)
173
174 await share.destroy()
175
176 return undefined
177 }
178 }
179}
180
181function commentOptionsFactory () {
182 return {
183 bodyValidator: (body: any) => sanitizeAndCheckVideoCommentObject(body),
184
185 updater: async (url: string, newUrl: string) => {
186 const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(url)
187 comment.url = newUrl
188
189 await comment.save()
190
191 return undefined
192 },
193
194 deleter: async (url) => {
195 const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(url)
196
197 await comment.destroy()
198
199 return undefined
200 }
201 }
202}
diff --git a/server/lib/job-queue/handlers/activitypub-follow.ts b/server/lib/job-queue/handlers/activitypub-follow.ts
deleted file mode 100644
index a68c32ba0..000000000
--- a/server/lib/job-queue/handlers/activitypub-follow.ts
+++ /dev/null
@@ -1,82 +0,0 @@
1import { Job } from 'bullmq'
2import { getLocalActorFollowActivityPubUrl } from '@server/lib/activitypub/url'
3import { ActivitypubFollowPayload } from '@shared/models'
4import { sanitizeHost } from '../../../helpers/core-utils'
5import { retryTransactionWrapper } from '../../../helpers/database-utils'
6import { logger } from '../../../helpers/logger'
7import { REMOTE_SCHEME, WEBSERVER } from '../../../initializers/constants'
8import { sequelizeTypescript } from '../../../initializers/database'
9import { ActorModel } from '../../../models/actor/actor'
10import { ActorFollowModel } from '../../../models/actor/actor-follow'
11import { MActor, MActorFull } from '../../../types/models'
12import { getOrCreateAPActor, loadActorUrlOrGetFromWebfinger } from '../../activitypub/actors'
13import { sendFollow } from '../../activitypub/send'
14import { Notifier } from '../../notifier'
15
16async function processActivityPubFollow (job: Job) {
17 const payload = job.data as ActivitypubFollowPayload
18 const host = payload.host
19
20 logger.info('Processing ActivityPub follow in job %s.', job.id)
21
22 let targetActor: MActorFull
23 if (!host || host === WEBSERVER.HOST) {
24 targetActor = await ActorModel.loadLocalByName(payload.name)
25 } else {
26 const sanitizedHost = sanitizeHost(host, REMOTE_SCHEME.HTTP)
27 const actorUrl = await loadActorUrlOrGetFromWebfinger(payload.name + '@' + sanitizedHost)
28 targetActor = await getOrCreateAPActor(actorUrl, 'all')
29 }
30
31 if (payload.assertIsChannel && !targetActor.VideoChannel) {
32 logger.warn('Do not follow %s@%s because it is not a channel.', payload.name, host)
33 return
34 }
35
36 const fromActor = await ActorModel.load(payload.followerActorId)
37
38 return retryTransactionWrapper(follow, fromActor, targetActor, payload.isAutoFollow)
39}
40// ---------------------------------------------------------------------------
41
42export {
43 processActivityPubFollow
44}
45
46// ---------------------------------------------------------------------------
47
48async function follow (fromActor: MActor, targetActor: MActorFull, isAutoFollow = false) {
49 if (fromActor.id === targetActor.id) {
50 throw new Error('Follower is the same as target actor.')
51 }
52
53 // Same server, direct accept
54 const state = !fromActor.serverId && !targetActor.serverId ? 'accepted' : 'pending'
55
56 const actorFollow = await sequelizeTypescript.transaction(async t => {
57 const [ actorFollow ] = await ActorFollowModel.findOrCreateCustom({
58 byActor: fromActor,
59 state,
60 targetActor,
61 activityId: getLocalActorFollowActivityPubUrl(fromActor, targetActor),
62 transaction: t
63 })
64
65 // Send a notification to remote server if our follow is not already accepted
66 if (actorFollow.state !== 'accepted') sendFollow(actorFollow, t)
67
68 return actorFollow
69 })
70
71 const followerFull = await ActorModel.loadFull(fromActor.id)
72
73 const actorFollowFull = Object.assign(actorFollow, {
74 ActorFollowing: targetActor,
75 ActorFollower: followerFull
76 })
77
78 if (actorFollow.state === 'accepted') Notifier.Instance.notifyOfNewUserFollow(actorFollowFull)
79 if (isAutoFollow === true) Notifier.Instance.notifyOfAutoInstanceFollowing(actorFollowFull)
80
81 return actorFollow
82}
diff --git a/server/lib/job-queue/handlers/activitypub-http-broadcast.ts b/server/lib/job-queue/handlers/activitypub-http-broadcast.ts
deleted file mode 100644
index 8904d086f..000000000
--- a/server/lib/job-queue/handlers/activitypub-http-broadcast.ts
+++ /dev/null
@@ -1,49 +0,0 @@
1import { Job } from 'bullmq'
2import { buildGlobalHeaders, buildSignedRequestOptions, computeBody } from '@server/lib/activitypub/send'
3import { ActorFollowHealthCache } from '@server/lib/actor-follow-health-cache'
4import { parallelHTTPBroadcastFromWorker, sequentialHTTPBroadcastFromWorker } from '@server/lib/worker/parent-process'
5import { ActivitypubHttpBroadcastPayload } from '@shared/models'
6import { logger } from '../../../helpers/logger'
7
8// Prefer using a worker thread for HTTP requests because on high load we may have to sign many requests, which can be CPU intensive
9
10async function processActivityPubHttpSequentialBroadcast (job: Job<ActivitypubHttpBroadcastPayload>) {
11 logger.info('Processing ActivityPub broadcast in job %s.', job.id)
12
13 const requestOptions = await buildRequestOptions(job.data)
14
15 const { badUrls, goodUrls } = await sequentialHTTPBroadcastFromWorker({ uris: job.data.uris, requestOptions })
16
17 return ActorFollowHealthCache.Instance.updateActorFollowsHealth(goodUrls, badUrls)
18}
19
20async function processActivityPubParallelHttpBroadcast (job: Job<ActivitypubHttpBroadcastPayload>) {
21 logger.info('Processing ActivityPub parallel broadcast in job %s.', job.id)
22
23 const requestOptions = await buildRequestOptions(job.data)
24
25 const { badUrls, goodUrls } = await parallelHTTPBroadcastFromWorker({ uris: job.data.uris, requestOptions })
26
27 return ActorFollowHealthCache.Instance.updateActorFollowsHealth(goodUrls, badUrls)
28}
29
30// ---------------------------------------------------------------------------
31
32export {
33 processActivityPubHttpSequentialBroadcast,
34 processActivityPubParallelHttpBroadcast
35}
36
37// ---------------------------------------------------------------------------
38
39async function buildRequestOptions (payload: ActivitypubHttpBroadcastPayload) {
40 const body = await computeBody(payload)
41 const httpSignatureOptions = await buildSignedRequestOptions({ signatureActorId: payload.signatureActorId, hasPayload: true })
42
43 return {
44 method: 'POST' as 'POST',
45 json: body,
46 httpSignature: httpSignatureOptions,
47 headers: buildGlobalHeaders(body)
48 }
49}
diff --git a/server/lib/job-queue/handlers/activitypub-http-fetcher.ts b/server/lib/job-queue/handlers/activitypub-http-fetcher.ts
deleted file mode 100644
index b6cb3c4a6..000000000
--- a/server/lib/job-queue/handlers/activitypub-http-fetcher.ts
+++ /dev/null
@@ -1,41 +0,0 @@
1import { Job } from 'bullmq'
2import { ActivitypubHttpFetcherPayload, FetchType } from '@shared/models'
3import { logger } from '../../../helpers/logger'
4import { VideoModel } from '../../../models/video/video'
5import { VideoCommentModel } from '../../../models/video/video-comment'
6import { VideoShareModel } from '../../../models/video/video-share'
7import { MVideoFullLight } from '../../../types/models'
8import { crawlCollectionPage } from '../../activitypub/crawl'
9import { createAccountPlaylists } from '../../activitypub/playlists'
10import { processActivities } from '../../activitypub/process'
11import { addVideoShares } from '../../activitypub/share'
12import { addVideoComments } from '../../activitypub/video-comments'
13
14async function processActivityPubHttpFetcher (job: Job) {
15 logger.info('Processing ActivityPub fetcher in job %s.', job.id)
16
17 const payload = job.data as ActivitypubHttpFetcherPayload
18
19 let video: MVideoFullLight
20 if (payload.videoId) video = await VideoModel.loadFull(payload.videoId)
21
22 const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise<any> } = {
23 'activity': items => processActivities(items, { outboxUrl: payload.uri, fromFetch: true }),
24 'video-shares': items => addVideoShares(items, video),
25 'video-comments': items => addVideoComments(items),
26 'account-playlists': items => createAccountPlaylists(items)
27 }
28
29 const cleanerType: { [ id in FetchType ]?: (crawlStartDate: Date) => Promise<any> } = {
30 'video-shares': crawlStartDate => VideoShareModel.cleanOldSharesOf(video.id, crawlStartDate),
31 'video-comments': crawlStartDate => VideoCommentModel.cleanOldCommentsOf(video.id, crawlStartDate)
32 }
33
34 return crawlCollectionPage(payload.uri, fetcherType[payload.type], cleanerType[payload.type])
35}
36
37// ---------------------------------------------------------------------------
38
39export {
40 processActivityPubHttpFetcher
41}
diff --git a/server/lib/job-queue/handlers/activitypub-http-unicast.ts b/server/lib/job-queue/handlers/activitypub-http-unicast.ts
deleted file mode 100644
index 50fca3f94..000000000
--- a/server/lib/job-queue/handlers/activitypub-http-unicast.ts
+++ /dev/null
@@ -1,38 +0,0 @@
1import { Job } from 'bullmq'
2import { buildGlobalHeaders, buildSignedRequestOptions, computeBody } from '@server/lib/activitypub/send'
3import { ActivitypubHttpUnicastPayload } from '@shared/models'
4import { logger } from '../../../helpers/logger'
5import { doRequest } from '../../../helpers/requests'
6import { ActorFollowHealthCache } from '../../actor-follow-health-cache'
7
8async function processActivityPubHttpUnicast (job: Job) {
9 logger.info('Processing ActivityPub unicast in job %s.', job.id)
10
11 const payload = job.data as ActivitypubHttpUnicastPayload
12 const uri = payload.uri
13
14 const body = await computeBody(payload)
15 const httpSignatureOptions = await buildSignedRequestOptions({ signatureActorId: payload.signatureActorId, hasPayload: true })
16
17 const options = {
18 method: 'POST' as 'POST',
19 json: body,
20 httpSignature: httpSignatureOptions,
21 headers: buildGlobalHeaders(body)
22 }
23
24 try {
25 await doRequest(uri, options)
26 ActorFollowHealthCache.Instance.updateActorFollowsHealth([ uri ], [])
27 } catch (err) {
28 ActorFollowHealthCache.Instance.updateActorFollowsHealth([], [ uri ])
29
30 throw err
31 }
32}
33
34// ---------------------------------------------------------------------------
35
36export {
37 processActivityPubHttpUnicast
38}
diff --git a/server/lib/job-queue/handlers/activitypub-refresher.ts b/server/lib/job-queue/handlers/activitypub-refresher.ts
deleted file mode 100644
index 706bf17fa..000000000
--- a/server/lib/job-queue/handlers/activitypub-refresher.ts
+++ /dev/null
@@ -1,60 +0,0 @@
1import { Job } from 'bullmq'
2import { refreshVideoPlaylistIfNeeded } from '@server/lib/activitypub/playlists'
3import { refreshVideoIfNeeded } from '@server/lib/activitypub/videos'
4import { loadVideoByUrl } from '@server/lib/model-loaders'
5import { RefreshPayload } from '@shared/models'
6import { logger } from '../../../helpers/logger'
7import { ActorModel } from '../../../models/actor/actor'
8import { VideoPlaylistModel } from '../../../models/video/video-playlist'
9import { refreshActorIfNeeded } from '../../activitypub/actors'
10
11async function refreshAPObject (job: Job) {
12 const payload = job.data as RefreshPayload
13
14 logger.info('Processing AP refresher in job %s for %s.', job.id, payload.url)
15
16 if (payload.type === 'video') return refreshVideo(payload.url)
17 if (payload.type === 'video-playlist') return refreshVideoPlaylist(payload.url)
18 if (payload.type === 'actor') return refreshActor(payload.url)
19}
20
21// ---------------------------------------------------------------------------
22
23export {
24 refreshAPObject
25}
26
27// ---------------------------------------------------------------------------
28
29async function refreshVideo (videoUrl: string) {
30 const fetchType = 'all' as 'all'
31 const syncParam = { rates: true, shares: true, comments: true }
32
33 const videoFromDatabase = await loadVideoByUrl(videoUrl, fetchType)
34 if (videoFromDatabase) {
35 const refreshOptions = {
36 video: videoFromDatabase,
37 fetchedType: fetchType,
38 syncParam
39 }
40
41 await refreshVideoIfNeeded(refreshOptions)
42 }
43}
44
45async function refreshActor (actorUrl: string) {
46 const fetchType = 'all' as 'all'
47 const actor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(actorUrl)
48
49 if (actor) {
50 await refreshActorIfNeeded({ actor, fetchedType: fetchType })
51 }
52}
53
54async function refreshVideoPlaylist (playlistUrl: string) {
55 const playlist = await VideoPlaylistModel.loadByUrlAndPopulateAccount(playlistUrl)
56
57 if (playlist) {
58 await refreshVideoPlaylistIfNeeded(playlist)
59 }
60}
diff --git a/server/lib/job-queue/handlers/actor-keys.ts b/server/lib/job-queue/handlers/actor-keys.ts
deleted file mode 100644
index 27a2d431b..000000000
--- a/server/lib/job-queue/handlers/actor-keys.ts
+++ /dev/null
@@ -1,20 +0,0 @@
1import { Job } from 'bullmq'
2import { generateAndSaveActorKeys } from '@server/lib/activitypub/actors'
3import { ActorModel } from '@server/models/actor/actor'
4import { ActorKeysPayload } from '@shared/models'
5import { logger } from '../../../helpers/logger'
6
7async function processActorKeys (job: Job) {
8 const payload = job.data as ActorKeysPayload
9 logger.info('Processing actor keys in job %s.', job.id)
10
11 const actor = await ActorModel.load(payload.actorId)
12
13 await generateAndSaveActorKeys(actor)
14}
15
16// ---------------------------------------------------------------------------
17
18export {
19 processActorKeys
20}
diff --git a/server/lib/job-queue/handlers/after-video-channel-import.ts b/server/lib/job-queue/handlers/after-video-channel-import.ts
deleted file mode 100644
index ffdd8c5b5..000000000
--- a/server/lib/job-queue/handlers/after-video-channel-import.ts
+++ /dev/null
@@ -1,37 +0,0 @@
1import { Job } from 'bullmq'
2import { logger } from '@server/helpers/logger'
3import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
4import { AfterVideoChannelImportPayload, VideoChannelSyncState, VideoImportPreventExceptionResult } from '@shared/models'
5
6export async function processAfterVideoChannelImport (job: Job) {
7 const payload = job.data as AfterVideoChannelImportPayload
8 if (!payload.channelSyncId) return
9
10 logger.info('Processing after video channel import in job %s.', job.id)
11
12 const sync = await VideoChannelSyncModel.loadWithChannel(payload.channelSyncId)
13 if (!sync) {
14 logger.error('Unknown sync id %d.', payload.channelSyncId)
15 return
16 }
17
18 const childrenValues = await job.getChildrenValues<VideoImportPreventExceptionResult>()
19
20 let errors = 0
21 let successes = 0
22
23 for (const value of Object.values(childrenValues)) {
24 if (value.resultType === 'success') successes++
25 else if (value.resultType === 'error') errors++
26 }
27
28 if (errors > 0) {
29 sync.state = VideoChannelSyncState.FAILED
30 logger.error(`Finished synchronizing "${sync.VideoChannel.Actor.preferredUsername}" with failures.`, { errors, successes })
31 } else {
32 sync.state = VideoChannelSyncState.SYNCED
33 logger.info(`Finished synchronizing "${sync.VideoChannel.Actor.preferredUsername}" successfully.`, { successes })
34 }
35
36 await sync.save()
37}
diff --git a/server/lib/job-queue/handlers/email.ts b/server/lib/job-queue/handlers/email.ts
deleted file mode 100644
index 567bcc076..000000000
--- a/server/lib/job-queue/handlers/email.ts
+++ /dev/null
@@ -1,17 +0,0 @@
1import { Job } from 'bullmq'
2import { EmailPayload } from '@shared/models'
3import { logger } from '../../../helpers/logger'
4import { Emailer } from '../../emailer'
5
6async function processEmail (job: Job) {
7 const payload = job.data as EmailPayload
8 logger.info('Processing email in job %s.', job.id)
9
10 return Emailer.Instance.sendMail(payload)
11}
12
13// ---------------------------------------------------------------------------
14
15export {
16 processEmail
17}
diff --git a/server/lib/job-queue/handlers/federate-video.ts b/server/lib/job-queue/handlers/federate-video.ts
deleted file mode 100644
index 6aac36741..000000000
--- a/server/lib/job-queue/handlers/federate-video.ts
+++ /dev/null
@@ -1,28 +0,0 @@
1import { Job } from 'bullmq'
2import { retryTransactionWrapper } from '@server/helpers/database-utils'
3import { sequelizeTypescript } from '@server/initializers/database'
4import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
5import { VideoModel } from '@server/models/video/video'
6import { FederateVideoPayload } from '@shared/models'
7import { logger } from '../../../helpers/logger'
8
9function processFederateVideo (job: Job) {
10 const payload = job.data as FederateVideoPayload
11
12 logger.info('Processing video federation in job %s.', job.id)
13
14 return retryTransactionWrapper(() => {
15 return sequelizeTypescript.transaction(async t => {
16 const video = await VideoModel.loadFull(payload.videoUUID, t)
17 if (!video) return
18
19 return federateVideoIfNeeded(video, payload.isNewVideo, t)
20 })
21 })
22}
23
24// ---------------------------------------------------------------------------
25
26export {
27 processFederateVideo
28}
diff --git a/server/lib/job-queue/handlers/generate-storyboard.ts b/server/lib/job-queue/handlers/generate-storyboard.ts
deleted file mode 100644
index eea20274a..000000000
--- a/server/lib/job-queue/handlers/generate-storyboard.ts
+++ /dev/null
@@ -1,163 +0,0 @@
1import { Job } from 'bullmq'
2import { join } from 'path'
3import { retryTransactionWrapper } from '@server/helpers/database-utils'
4import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg'
5import { generateImageFilename, getImageSize } from '@server/helpers/image-utils'
6import { logger, loggerTagsFactory } from '@server/helpers/logger'
7import { deleteFileAndCatch } from '@server/helpers/utils'
8import { CONFIG } from '@server/initializers/config'
9import { STORYBOARD } from '@server/initializers/constants'
10import { sequelizeTypescript } from '@server/initializers/database'
11import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
12import { VideoPathManager } from '@server/lib/video-path-manager'
13import { StoryboardModel } from '@server/models/video/storyboard'
14import { VideoModel } from '@server/models/video/video'
15import { MVideo } from '@server/types/models'
16import { FFmpegImage, isAudioFile } from '@shared/ffmpeg'
17import { GenerateStoryboardPayload } from '@shared/models'
18
19const lTagsBase = loggerTagsFactory('storyboard')
20
21async function processGenerateStoryboard (job: Job): Promise<void> {
22 const payload = job.data as GenerateStoryboardPayload
23 const lTags = lTagsBase(payload.videoUUID)
24
25 logger.info('Processing generate storyboard of %s in job %s.', payload.videoUUID, job.id, lTags)
26
27 const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(payload.videoUUID)
28
29 try {
30 const video = await VideoModel.loadFull(payload.videoUUID)
31 if (!video) {
32 logger.info('Video %s does not exist anymore, skipping storyboard generation.', payload.videoUUID, lTags)
33 return
34 }
35
36 const inputFile = video.getMaxQualityFile()
37
38 await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async videoPath => {
39 const isAudio = await isAudioFile(videoPath)
40
41 if (isAudio) {
42 logger.info('Do not generate a storyboard of %s since the video does not have a video stream', payload.videoUUID, lTags)
43 return
44 }
45
46 const ffmpeg = new FFmpegImage(getFFmpegCommandWrapperOptions('thumbnail'))
47
48 const filename = generateImageFilename()
49 const destination = join(CONFIG.STORAGE.STORYBOARDS_DIR, filename)
50
51 const totalSprites = buildTotalSprites(video)
52 if (totalSprites === 0) {
53 logger.info('Do not generate a storyboard of %s because the video is not long enough', payload.videoUUID, lTags)
54 return
55 }
56
57 const spriteDuration = Math.round(video.duration / totalSprites)
58
59 const spritesCount = findGridSize({
60 toFind: totalSprites,
61 maxEdgeCount: STORYBOARD.SPRITES_MAX_EDGE_COUNT
62 })
63
64 logger.debug(
65 'Generating storyboard from video of %s to %s', video.uuid, destination,
66 { ...lTags, spritesCount, spriteDuration, videoDuration: video.duration }
67 )
68
69 await ffmpeg.generateStoryboardFromVideo({
70 destination,
71 path: videoPath,
72 sprites: {
73 size: STORYBOARD.SPRITE_SIZE,
74 count: spritesCount,
75 duration: spriteDuration
76 }
77 })
78
79 const imageSize = await getImageSize(destination)
80
81 await retryTransactionWrapper(() => {
82 return sequelizeTypescript.transaction(async transaction => {
83 const videoStillExists = await VideoModel.load(video.id, transaction)
84 if (!videoStillExists) {
85 logger.info('Video %s does not exist anymore, skipping storyboard generation.', payload.videoUUID, lTags)
86 deleteFileAndCatch(destination)
87 return
88 }
89
90 const existing = await StoryboardModel.loadByVideo(video.id, transaction)
91 if (existing) await existing.destroy({ transaction })
92
93 await StoryboardModel.create({
94 filename,
95 totalHeight: imageSize.height,
96 totalWidth: imageSize.width,
97 spriteHeight: STORYBOARD.SPRITE_SIZE.height,
98 spriteWidth: STORYBOARD.SPRITE_SIZE.width,
99 spriteDuration,
100 videoId: video.id
101 }, { transaction })
102
103 logger.info('Storyboard generation %s ended for video %s.', destination, video.uuid, lTags)
104
105 if (payload.federate) {
106 await federateVideoIfNeeded(video, false, transaction)
107 }
108 })
109 })
110 })
111 } finally {
112 inputFileMutexReleaser()
113 }
114}
115
116// ---------------------------------------------------------------------------
117
118export {
119 processGenerateStoryboard
120}
121
122function buildTotalSprites (video: MVideo) {
123 const maxSprites = STORYBOARD.SPRITE_SIZE.height * STORYBOARD.SPRITE_SIZE.width
124 const totalSprites = Math.min(Math.ceil(video.duration), maxSprites)
125
126 // We can generate a single line
127 if (totalSprites <= STORYBOARD.SPRITES_MAX_EDGE_COUNT) return totalSprites
128
129 return findGridFit(totalSprites, STORYBOARD.SPRITES_MAX_EDGE_COUNT)
130}
131
132function findGridSize (options: {
133 toFind: number
134 maxEdgeCount: number
135}) {
136 const { toFind, maxEdgeCount } = options
137
138 for (let i = 1; i <= maxEdgeCount; i++) {
139 for (let j = i; j <= maxEdgeCount; j++) {
140 if (toFind === i * j) return { width: j, height: i }
141 }
142 }
143
144 throw new Error(`Could not find grid size (to find: ${toFind}, max edge count: ${maxEdgeCount}`)
145}
146
147function findGridFit (value: number, maxMultiplier: number) {
148 for (let i = value; i--; i > 0) {
149 if (!isPrimeWithin(i, maxMultiplier)) return i
150 }
151
152 throw new Error('Could not find prime number below ' + value)
153}
154
155function isPrimeWithin (value: number, maxMultiplier: number) {
156 if (value < 2) return false
157
158 for (let i = 2, end = Math.min(Math.sqrt(value), maxMultiplier); i <= end; i++) {
159 if (value % i === 0 && value / i <= maxMultiplier) return false
160 }
161
162 return true
163}
diff --git a/server/lib/job-queue/handlers/manage-video-torrent.ts b/server/lib/job-queue/handlers/manage-video-torrent.ts
deleted file mode 100644
index edf52de0c..000000000
--- a/server/lib/job-queue/handlers/manage-video-torrent.ts
+++ /dev/null
@@ -1,110 +0,0 @@
1import { Job } from 'bullmq'
2import { extractVideo } from '@server/helpers/video'
3import { createTorrentAndSetInfoHash, updateTorrentMetadata } from '@server/helpers/webtorrent'
4import { VideoPathManager } from '@server/lib/video-path-manager'
5import { VideoModel } from '@server/models/video/video'
6import { VideoFileModel } from '@server/models/video/video-file'
7import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
8import { ManageVideoTorrentPayload } from '@shared/models'
9import { logger } from '../../../helpers/logger'
10
11async function processManageVideoTorrent (job: Job) {
12 const payload = job.data as ManageVideoTorrentPayload
13 logger.info('Processing torrent in job %s.', job.id)
14
15 if (payload.action === 'create') return doCreateAction(payload)
16 if (payload.action === 'update-metadata') return doUpdateMetadataAction(payload)
17}
18
19// ---------------------------------------------------------------------------
20
21export {
22 processManageVideoTorrent
23}
24
25// ---------------------------------------------------------------------------
26
27async function doCreateAction (payload: ManageVideoTorrentPayload & { action: 'create' }) {
28 const [ video, file ] = await Promise.all([
29 loadVideoOrLog(payload.videoId),
30 loadFileOrLog(payload.videoFileId)
31 ])
32
33 if (!video || !file) return
34
35 const fileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
36
37 try {
38 await video.reload()
39 await file.reload()
40
41 await createTorrentAndSetInfoHash(video, file)
42
43 // Refresh videoFile because the createTorrentAndSetInfoHash could be long
44 const refreshedFile = await VideoFileModel.loadWithVideo(file.id)
45 // File does not exist anymore, remove the generated torrent
46 if (!refreshedFile) return file.removeTorrent()
47
48 refreshedFile.infoHash = file.infoHash
49 refreshedFile.torrentFilename = file.torrentFilename
50
51 await refreshedFile.save()
52 } finally {
53 fileMutexReleaser()
54 }
55}
56
57async function doUpdateMetadataAction (payload: ManageVideoTorrentPayload & { action: 'update-metadata' }) {
58 const [ video, streamingPlaylist, file ] = await Promise.all([
59 loadVideoOrLog(payload.videoId),
60 loadStreamingPlaylistOrLog(payload.streamingPlaylistId),
61 loadFileOrLog(payload.videoFileId)
62 ])
63
64 if ((!video && !streamingPlaylist) || !file) return
65
66 const extractedVideo = extractVideo(video || streamingPlaylist)
67 const fileMutexReleaser = await VideoPathManager.Instance.lockFiles(extractedVideo.uuid)
68
69 try {
70 await updateTorrentMetadata(video || streamingPlaylist, file)
71
72 await file.save()
73 } finally {
74 fileMutexReleaser()
75 }
76}
77
78async function loadVideoOrLog (videoId: number) {
79 if (!videoId) return undefined
80
81 const video = await VideoModel.load(videoId)
82 if (!video) {
83 logger.debug('Do not process torrent for video %d: does not exist anymore.', videoId)
84 }
85
86 return video
87}
88
89async function loadStreamingPlaylistOrLog (streamingPlaylistId: number) {
90 if (!streamingPlaylistId) return undefined
91
92 const streamingPlaylist = await VideoStreamingPlaylistModel.loadWithVideo(streamingPlaylistId)
93 if (!streamingPlaylist) {
94 logger.debug('Do not process torrent for streaming playlist %d: does not exist anymore.', streamingPlaylistId)
95 }
96
97 return streamingPlaylist
98}
99
100async function loadFileOrLog (videoFileId: number) {
101 if (!videoFileId) return undefined
102
103 const file = await VideoFileModel.load(videoFileId)
104
105 if (!file) {
106 logger.debug('Do not process torrent for file %d: does not exist anymore.', videoFileId)
107 }
108
109 return file
110}
diff --git a/server/lib/job-queue/handlers/move-to-object-storage.ts b/server/lib/job-queue/handlers/move-to-object-storage.ts
deleted file mode 100644
index 9a99b6722..000000000
--- a/server/lib/job-queue/handlers/move-to-object-storage.ts
+++ /dev/null
@@ -1,159 +0,0 @@
1import { Job } from 'bullmq'
2import { remove } from 'fs-extra'
3import { join } from 'path'
4import { logger, loggerTagsFactory } from '@server/helpers/logger'
5import { updateTorrentMetadata } from '@server/helpers/webtorrent'
6import { P2P_MEDIA_LOADER_PEER_VERSION } from '@server/initializers/constants'
7import { storeHLSFileFromFilename, storeWebVideoFile } from '@server/lib/object-storage'
8import { getHLSDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths'
9import { VideoPathManager } from '@server/lib/video-path-manager'
10import { moveToFailedMoveToObjectStorageState, moveToNextState } from '@server/lib/video-state'
11import { VideoModel } from '@server/models/video/video'
12import { VideoJobInfoModel } from '@server/models/video/video-job-info'
13import { MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoWithAllFiles } from '@server/types/models'
14import { MoveObjectStoragePayload, VideoState, VideoStorage } from '@shared/models'
15
16const lTagsBase = loggerTagsFactory('move-object-storage')
17
18export async function processMoveToObjectStorage (job: Job) {
19 const payload = job.data as MoveObjectStoragePayload
20 logger.info('Moving video %s in job %s.', payload.videoUUID, job.id)
21
22 const fileMutexReleaser = await VideoPathManager.Instance.lockFiles(payload.videoUUID)
23
24 const video = await VideoModel.loadWithFiles(payload.videoUUID)
25 // No video, maybe deleted?
26 if (!video) {
27 logger.info('Can\'t process job %d, video does not exist.', job.id, lTagsBase(payload.videoUUID))
28 fileMutexReleaser()
29 return undefined
30 }
31
32 const lTags = lTagsBase(video.uuid, video.url)
33
34 try {
35 if (video.VideoFiles) {
36 logger.debug('Moving %d web video files for video %s.', video.VideoFiles.length, video.uuid, lTags)
37
38 await moveWebVideoFiles(video)
39 }
40
41 if (video.VideoStreamingPlaylists) {
42 logger.debug('Moving HLS playlist of %s.', video.uuid)
43
44 await moveHLSFiles(video)
45 }
46
47 const pendingMove = await VideoJobInfoModel.decrease(video.uuid, 'pendingMove')
48 if (pendingMove === 0) {
49 logger.info('Running cleanup after moving files to object storage (video %s in job %s)', video.uuid, job.id, lTags)
50
51 await doAfterLastJob({ video, previousVideoState: payload.previousVideoState, isNewVideo: payload.isNewVideo })
52 }
53 } catch (err) {
54 await onMoveToObjectStorageFailure(job, err)
55
56 throw err
57 } finally {
58 fileMutexReleaser()
59 }
60
61 return payload.videoUUID
62}
63
64export async function onMoveToObjectStorageFailure (job: Job, err: any) {
65 const payload = job.data as MoveObjectStoragePayload
66
67 const video = await VideoModel.loadWithFiles(payload.videoUUID)
68 if (!video) return
69
70 logger.error('Cannot move video %s to object storage.', video.url, { err, ...lTagsBase(video.uuid, video.url) })
71
72 await moveToFailedMoveToObjectStorageState(video)
73 await VideoJobInfoModel.abortAllTasks(video.uuid, 'pendingMove')
74}
75
76// ---------------------------------------------------------------------------
77
78async function moveWebVideoFiles (video: MVideoWithAllFiles) {
79 for (const file of video.VideoFiles) {
80 if (file.storage !== VideoStorage.FILE_SYSTEM) continue
81
82 const fileUrl = await storeWebVideoFile(video, file)
83
84 const oldPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, file)
85 await onFileMoved({ videoOrPlaylist: video, file, fileUrl, oldPath })
86 }
87}
88
89async function moveHLSFiles (video: MVideoWithAllFiles) {
90 for (const playlist of video.VideoStreamingPlaylists) {
91 const playlistWithVideo = playlist.withVideo(video)
92
93 for (const file of playlist.VideoFiles) {
94 if (file.storage !== VideoStorage.FILE_SYSTEM) continue
95
96 // Resolution playlist
97 const playlistFilename = getHlsResolutionPlaylistFilename(file.filename)
98 await storeHLSFileFromFilename(playlistWithVideo, playlistFilename)
99
100 // Resolution fragmented file
101 const fileUrl = await storeHLSFileFromFilename(playlistWithVideo, file.filename)
102
103 const oldPath = join(getHLSDirectory(video), file.filename)
104
105 await onFileMoved({ videoOrPlaylist: Object.assign(playlist, { Video: video }), file, fileUrl, oldPath })
106 }
107 }
108}
109
110async function doAfterLastJob (options: {
111 video: MVideoWithAllFiles
112 previousVideoState: VideoState
113 isNewVideo: boolean
114}) {
115 const { video, previousVideoState, isNewVideo } = options
116
117 for (const playlist of video.VideoStreamingPlaylists) {
118 if (playlist.storage === VideoStorage.OBJECT_STORAGE) continue
119
120 const playlistWithVideo = playlist.withVideo(video)
121
122 // Master playlist
123 playlist.playlistUrl = await storeHLSFileFromFilename(playlistWithVideo, playlist.playlistFilename)
124 // Sha256 segments file
125 playlist.segmentsSha256Url = await storeHLSFileFromFilename(playlistWithVideo, playlist.segmentsSha256Filename)
126
127 playlist.storage = VideoStorage.OBJECT_STORAGE
128
129 playlist.assignP2PMediaLoaderInfoHashes(video, playlist.VideoFiles)
130 playlist.p2pMediaLoaderPeerVersion = P2P_MEDIA_LOADER_PEER_VERSION
131
132 await playlist.save()
133 }
134
135 // Remove empty hls video directory
136 if (video.VideoStreamingPlaylists) {
137 await remove(getHLSDirectory(video))
138 }
139
140 await moveToNextState({ video, previousVideoState, isNewVideo })
141}
142
143async function onFileMoved (options: {
144 videoOrPlaylist: MVideo | MStreamingPlaylistVideo
145 file: MVideoFile
146 fileUrl: string
147 oldPath: string
148}) {
149 const { videoOrPlaylist, file, fileUrl, oldPath } = options
150
151 file.fileUrl = fileUrl
152 file.storage = VideoStorage.OBJECT_STORAGE
153
154 await updateTorrentMetadata(videoOrPlaylist, file)
155 await file.save()
156
157 logger.debug('Removing %s because it\'s now on object storage', oldPath)
158 await remove(oldPath)
159}
diff --git a/server/lib/job-queue/handlers/notify.ts b/server/lib/job-queue/handlers/notify.ts
deleted file mode 100644
index 83605396c..000000000
--- a/server/lib/job-queue/handlers/notify.ts
+++ /dev/null
@@ -1,27 +0,0 @@
1import { Job } from 'bullmq'
2import { Notifier } from '@server/lib/notifier'
3import { VideoModel } from '@server/models/video/video'
4import { NotifyPayload } from '@shared/models'
5import { logger } from '../../../helpers/logger'
6
7async function processNotify (job: Job) {
8 const payload = job.data as NotifyPayload
9 logger.info('Processing %s notification in job %s.', payload.action, job.id)
10
11 if (payload.action === 'new-video') return doNotifyNewVideo(payload)
12}
13
14// ---------------------------------------------------------------------------
15
16export {
17 processNotify
18}
19
20// ---------------------------------------------------------------------------
21
22async function doNotifyNewVideo (payload: NotifyPayload & { action: 'new-video' }) {
23 const refreshedVideo = await VideoModel.loadFull(payload.videoUUID)
24 if (!refreshedVideo) return
25
26 Notifier.Instance.notifyOnNewVideoIfNeeded(refreshedVideo)
27}
diff --git a/server/lib/job-queue/handlers/transcoding-job-builder.ts b/server/lib/job-queue/handlers/transcoding-job-builder.ts
deleted file mode 100644
index 8621b109f..000000000
--- a/server/lib/job-queue/handlers/transcoding-job-builder.ts
+++ /dev/null
@@ -1,48 +0,0 @@
1import { Job } from 'bullmq'
2import { createOptimizeOrMergeAudioJobs } from '@server/lib/transcoding/create-transcoding-job'
3import { UserModel } from '@server/models/user/user'
4import { VideoModel } from '@server/models/video/video'
5import { VideoJobInfoModel } from '@server/models/video/video-job-info'
6import { pick } from '@shared/core-utils'
7import { TranscodingJobBuilderPayload } from '@shared/models'
8import { logger } from '../../../helpers/logger'
9import { JobQueue } from '../job-queue'
10
11async function processTranscodingJobBuilder (job: Job) {
12 const payload = job.data as TranscodingJobBuilderPayload
13
14 logger.info('Processing transcoding job builder in job %s.', job.id)
15
16 if (payload.optimizeJob) {
17 const video = await VideoModel.loadFull(payload.videoUUID)
18 const user = await UserModel.loadByVideoId(video.id)
19 const videoFile = video.getMaxQualityFile()
20
21 await createOptimizeOrMergeAudioJobs({
22 ...pick(payload.optimizeJob, [ 'isNewVideo' ]),
23
24 video,
25 videoFile,
26 user,
27 videoFileAlreadyLocked: false
28 })
29 }
30
31 for (const job of (payload.jobs || [])) {
32 await JobQueue.Instance.createJob(job)
33
34 await VideoJobInfoModel.increaseOrCreate(payload.videoUUID, 'pendingTranscode')
35 }
36
37 for (const sequentialJobs of (payload.sequentialJobs || [])) {
38 await JobQueue.Instance.createSequentialJobFlow(...sequentialJobs)
39
40 await VideoJobInfoModel.increaseOrCreate(payload.videoUUID, 'pendingTranscode', sequentialJobs.filter(s => !!s).length)
41 }
42}
43
44// ---------------------------------------------------------------------------
45
46export {
47 processTranscodingJobBuilder
48}
diff --git a/server/lib/job-queue/handlers/video-channel-import.ts b/server/lib/job-queue/handlers/video-channel-import.ts
deleted file mode 100644
index 035f88e96..000000000
--- a/server/lib/job-queue/handlers/video-channel-import.ts
+++ /dev/null
@@ -1,43 +0,0 @@
1import { Job } from 'bullmq'
2import { logger } from '@server/helpers/logger'
3import { CONFIG } from '@server/initializers/config'
4import { synchronizeChannel } from '@server/lib/sync-channel'
5import { VideoChannelModel } from '@server/models/video/video-channel'
6import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
7import { MChannelSync } from '@server/types/models'
8import { VideoChannelImportPayload } from '@shared/models'
9
10export async function processVideoChannelImport (job: Job) {
11 const payload = job.data as VideoChannelImportPayload
12
13 logger.info('Processing video channel import in job %s.', job.id)
14
15 // Channel import requires only http upload to be allowed
16 if (!CONFIG.IMPORT.VIDEOS.HTTP.ENABLED) {
17 throw new Error('Cannot import channel as the HTTP upload is disabled')
18 }
19
20 if (!CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.ENABLED) {
21 throw new Error('Cannot import channel as the synchronization is disabled')
22 }
23
24 let channelSync: MChannelSync
25 if (payload.partOfChannelSyncId) {
26 channelSync = await VideoChannelSyncModel.loadWithChannel(payload.partOfChannelSyncId)
27
28 if (!channelSync) {
29 throw new Error('Unlnown channel sync specified in videos channel import')
30 }
31 }
32
33 const videoChannel = await VideoChannelModel.loadAndPopulateAccount(payload.videoChannelId)
34
35 logger.info(`Starting importing videos from external channel "${payload.externalChannelUrl}" to "${videoChannel.name}" `)
36
37 await synchronizeChannel({
38 channel: videoChannel,
39 externalChannelUrl: payload.externalChannelUrl,
40 channelSync,
41 videosCountLimit: CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.FULL_SYNC_VIDEOS_LIMIT
42 })
43}
diff --git a/server/lib/job-queue/handlers/video-file-import.ts b/server/lib/job-queue/handlers/video-file-import.ts
deleted file mode 100644
index d221e8968..000000000
--- a/server/lib/job-queue/handlers/video-file-import.ts
+++ /dev/null
@@ -1,83 +0,0 @@
1import { Job } from 'bullmq'
2import { copy, stat } from 'fs-extra'
3import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
4import { CONFIG } from '@server/initializers/config'
5import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
6import { generateWebVideoFilename } from '@server/lib/paths'
7import { buildMoveToObjectStorageJob } from '@server/lib/video'
8import { VideoPathManager } from '@server/lib/video-path-manager'
9import { VideoModel } from '@server/models/video/video'
10import { VideoFileModel } from '@server/models/video/video-file'
11import { MVideoFullLight } from '@server/types/models'
12import { getLowercaseExtension } from '@shared/core-utils'
13import { getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@shared/ffmpeg'
14import { VideoFileImportPayload, VideoStorage } from '@shared/models'
15import { logger } from '../../../helpers/logger'
16import { JobQueue } from '../job-queue'
17
18async function processVideoFileImport (job: Job) {
19 const payload = job.data as VideoFileImportPayload
20 logger.info('Processing video file import in job %s.', job.id)
21
22 const video = await VideoModel.loadFull(payload.videoUUID)
23 // No video, maybe deleted?
24 if (!video) {
25 logger.info('Do not process job %d, video does not exist.', job.id)
26 return undefined
27 }
28
29 await updateVideoFile(video, payload.filePath)
30
31 if (CONFIG.OBJECT_STORAGE.ENABLED) {
32 await JobQueue.Instance.createJob(await buildMoveToObjectStorageJob({ video, previousVideoState: video.state }))
33 } else {
34 await federateVideoIfNeeded(video, false)
35 }
36
37 return video
38}
39
40// ---------------------------------------------------------------------------
41
42export {
43 processVideoFileImport
44}
45
46// ---------------------------------------------------------------------------
47
48async function updateVideoFile (video: MVideoFullLight, inputFilePath: string) {
49 const { resolution } = await getVideoStreamDimensionsInfo(inputFilePath)
50 const { size } = await stat(inputFilePath)
51 const fps = await getVideoStreamFPS(inputFilePath)
52
53 const fileExt = getLowercaseExtension(inputFilePath)
54
55 const currentVideoFile = video.VideoFiles.find(videoFile => videoFile.resolution === resolution)
56
57 if (currentVideoFile) {
58 // Remove old file and old torrent
59 await video.removeWebVideoFile(currentVideoFile)
60 // Remove the old video file from the array
61 video.VideoFiles = video.VideoFiles.filter(f => f !== currentVideoFile)
62
63 await currentVideoFile.destroy()
64 }
65
66 const newVideoFile = new VideoFileModel({
67 resolution,
68 extname: fileExt,
69 filename: generateWebVideoFilename(resolution, fileExt),
70 storage: VideoStorage.FILE_SYSTEM,
71 size,
72 fps,
73 videoId: video.id
74 })
75
76 const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newVideoFile)
77 await copy(inputFilePath, outputPath)
78
79 video.VideoFiles.push(newVideoFile)
80 await createTorrentAndSetInfoHash(video, newVideoFile)
81
82 await newVideoFile.save()
83}
diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts
deleted file mode 100644
index e5cd258d6..000000000
--- a/server/lib/job-queue/handlers/video-import.ts
+++ /dev/null
@@ -1,344 +0,0 @@
1import { Job } from 'bullmq'
2import { move, remove, stat } from 'fs-extra'
3import { retryTransactionWrapper } from '@server/helpers/database-utils'
4import { YoutubeDLWrapper } from '@server/helpers/youtube-dl'
5import { CONFIG } from '@server/initializers/config'
6import { isPostImportVideoAccepted } from '@server/lib/moderation'
7import { generateWebVideoFilename } from '@server/lib/paths'
8import { Hooks } from '@server/lib/plugins/hooks'
9import { ServerConfigManager } from '@server/lib/server-config-manager'
10import { createOptimizeOrMergeAudioJobs } from '@server/lib/transcoding/create-transcoding-job'
11import { isAbleToUploadVideo } from '@server/lib/user'
12import { buildMoveToObjectStorageJob } from '@server/lib/video'
13import { VideoPathManager } from '@server/lib/video-path-manager'
14import { buildNextVideoState } from '@server/lib/video-state'
15import { ThumbnailModel } from '@server/models/video/thumbnail'
16import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models'
17import { MVideoImport, MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/types/models/video/video-import'
18import { getLowercaseExtension } from '@shared/core-utils'
19import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamDuration, getVideoStreamFPS, isAudioFile } from '@shared/ffmpeg'
20import {
21 ThumbnailType,
22 VideoImportPayload,
23 VideoImportPreventExceptionResult,
24 VideoImportState,
25 VideoImportTorrentPayload,
26 VideoImportTorrentPayloadType,
27 VideoImportYoutubeDLPayload,
28 VideoImportYoutubeDLPayloadType,
29 VideoResolution,
30 VideoState
31} from '@shared/models'
32import { logger } from '../../../helpers/logger'
33import { getSecureTorrentName } from '../../../helpers/utils'
34import { createTorrentAndSetInfoHash, downloadWebTorrentVideo } from '../../../helpers/webtorrent'
35import { JOB_TTL } from '../../../initializers/constants'
36import { sequelizeTypescript } from '../../../initializers/database'
37import { VideoModel } from '../../../models/video/video'
38import { VideoFileModel } from '../../../models/video/video-file'
39import { VideoImportModel } from '../../../models/video/video-import'
40import { federateVideoIfNeeded } from '../../activitypub/videos'
41import { Notifier } from '../../notifier'
42import { generateLocalVideoMiniature } from '../../thumbnail'
43import { JobQueue } from '../job-queue'
44
45async function processVideoImport (job: Job): Promise<VideoImportPreventExceptionResult> {
46 const payload = job.data as VideoImportPayload
47
48 const videoImport = await getVideoImportOrDie(payload)
49 if (videoImport.state === VideoImportState.CANCELLED) {
50 logger.info('Do not process import since it has been cancelled', { payload })
51 return { resultType: 'success' }
52 }
53
54 videoImport.state = VideoImportState.PROCESSING
55 await videoImport.save()
56
57 try {
58 if (payload.type === 'youtube-dl') await processYoutubeDLImport(job, videoImport, payload)
59 if (payload.type === 'magnet-uri' || payload.type === 'torrent-file') await processTorrentImport(job, videoImport, payload)
60
61 return { resultType: 'success' }
62 } catch (err) {
63 if (!payload.preventException) throw err
64
65 logger.warn('Catch error in video import to send value to parent job.', { payload, err })
66 return { resultType: 'error' }
67 }
68}
69
70// ---------------------------------------------------------------------------
71
72export {
73 processVideoImport
74}
75
76// ---------------------------------------------------------------------------
77
78async function processTorrentImport (job: Job, videoImport: MVideoImportDefault, payload: VideoImportTorrentPayload) {
79 logger.info('Processing torrent video import in job %s.', job.id)
80
81 const options = { type: payload.type, videoImportId: payload.videoImportId }
82
83 const target = {
84 torrentName: videoImport.torrentName ? getSecureTorrentName(videoImport.torrentName) : undefined,
85 uri: videoImport.magnetUri
86 }
87 return processFile(() => downloadWebTorrentVideo(target, JOB_TTL['video-import']), videoImport, options)
88}
89
90async function processYoutubeDLImport (job: Job, videoImport: MVideoImportDefault, payload: VideoImportYoutubeDLPayload) {
91 logger.info('Processing youtubeDL video import in job %s.', job.id)
92
93 const options = { type: payload.type, videoImportId: videoImport.id }
94
95 const youtubeDL = new YoutubeDLWrapper(
96 videoImport.targetUrl,
97 ServerConfigManager.Instance.getEnabledResolutions('vod'),
98 CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION
99 )
100
101 return processFile(
102 () => youtubeDL.downloadVideo(payload.fileExt, JOB_TTL['video-import']),
103 videoImport,
104 options
105 )
106}
107
108async function getVideoImportOrDie (payload: VideoImportPayload) {
109 const videoImport = await VideoImportModel.loadAndPopulateVideo(payload.videoImportId)
110 if (!videoImport?.Video) {
111 throw new Error(`Cannot import video ${payload.videoImportId}: the video import or video linked to this import does not exist anymore.`)
112 }
113
114 return videoImport
115}
116
117type ProcessFileOptions = {
118 type: VideoImportYoutubeDLPayloadType | VideoImportTorrentPayloadType
119 videoImportId: number
120}
121async function processFile (downloader: () => Promise<string>, videoImport: MVideoImportDefault, options: ProcessFileOptions) {
122 let tempVideoPath: string
123 let videoFile: VideoFileModel
124
125 try {
126 // Download video from youtubeDL
127 tempVideoPath = await downloader()
128
129 // Get information about this video
130 const stats = await stat(tempVideoPath)
131 const isAble = await isAbleToUploadVideo(videoImport.User.id, stats.size)
132 if (isAble === false) {
133 throw new Error('The user video quota is exceeded with this video to import.')
134 }
135
136 const probe = await ffprobePromise(tempVideoPath)
137
138 const { resolution } = await isAudioFile(tempVideoPath, probe)
139 ? { resolution: VideoResolution.H_NOVIDEO }
140 : await getVideoStreamDimensionsInfo(tempVideoPath, probe)
141
142 const fps = await getVideoStreamFPS(tempVideoPath, probe)
143 const duration = await getVideoStreamDuration(tempVideoPath, probe)
144
145 // Prepare video file object for creation in database
146 const fileExt = getLowercaseExtension(tempVideoPath)
147 const videoFileData = {
148 extname: fileExt,
149 resolution,
150 size: stats.size,
151 filename: generateWebVideoFilename(resolution, fileExt),
152 fps,
153 videoId: videoImport.videoId
154 }
155 videoFile = new VideoFileModel(videoFileData)
156
157 const hookName = options.type === 'youtube-dl'
158 ? 'filter:api.video.post-import-url.accept.result'
159 : 'filter:api.video.post-import-torrent.accept.result'
160
161 // Check we accept this video
162 const acceptParameters = {
163 videoImport,
164 video: videoImport.Video,
165 videoFilePath: tempVideoPath,
166 videoFile,
167 user: videoImport.User
168 }
169 const acceptedResult = await Hooks.wrapFun(isPostImportVideoAccepted, acceptParameters, hookName)
170
171 if (acceptedResult.accepted !== true) {
172 logger.info('Refused imported video.', { acceptedResult, acceptParameters })
173
174 videoImport.state = VideoImportState.REJECTED
175 await videoImport.save()
176
177 throw new Error(acceptedResult.errorMessage)
178 }
179
180 // Video is accepted, resuming preparation
181 const videoFileLockReleaser = await VideoPathManager.Instance.lockFiles(videoImport.Video.uuid)
182
183 try {
184 const videoImportWithFiles = await refreshVideoImportFromDB(videoImport, videoFile)
185
186 // Move file
187 const videoDestFile = VideoPathManager.Instance.getFSVideoFileOutputPath(videoImportWithFiles.Video, videoFile)
188 await move(tempVideoPath, videoDestFile)
189
190 tempVideoPath = null // This path is not used anymore
191
192 let {
193 miniatureModel: thumbnailModel,
194 miniatureJSONSave: thumbnailSave
195 } = await generateMiniature(videoImportWithFiles, videoFile, ThumbnailType.MINIATURE)
196
197 let {
198 miniatureModel: previewModel,
199 miniatureJSONSave: previewSave
200 } = await generateMiniature(videoImportWithFiles, videoFile, ThumbnailType.PREVIEW)
201
202 // Create torrent
203 await createTorrentAndSetInfoHash(videoImportWithFiles.Video, videoFile)
204
205 const videoFileSave = videoFile.toJSON()
206
207 const { videoImportUpdated, video } = await retryTransactionWrapper(() => {
208 return sequelizeTypescript.transaction(async t => {
209 // Refresh video
210 const video = await VideoModel.load(videoImportWithFiles.videoId, t)
211 if (!video) throw new Error('Video linked to import ' + videoImportWithFiles.videoId + ' does not exist anymore.')
212
213 await videoFile.save({ transaction: t })
214
215 // Update video DB object
216 video.duration = duration
217 video.state = buildNextVideoState(video.state)
218 await video.save({ transaction: t })
219
220 if (thumbnailModel) await video.addAndSaveThumbnail(thumbnailModel, t)
221 if (previewModel) await video.addAndSaveThumbnail(previewModel, t)
222
223 // Now we can federate the video (reload from database, we need more attributes)
224 const videoForFederation = await VideoModel.loadFull(video.uuid, t)
225 await federateVideoIfNeeded(videoForFederation, true, t)
226
227 // Update video import object
228 videoImportWithFiles.state = VideoImportState.SUCCESS
229 const videoImportUpdated = await videoImportWithFiles.save({ transaction: t }) as MVideoImport
230
231 logger.info('Video %s imported.', video.uuid)
232
233 return { videoImportUpdated, video: videoForFederation }
234 }).catch(err => {
235 // Reset fields
236 if (thumbnailModel) thumbnailModel = new ThumbnailModel(thumbnailSave)
237 if (previewModel) previewModel = new ThumbnailModel(previewSave)
238
239 videoFile = new VideoFileModel(videoFileSave)
240
241 throw err
242 })
243 })
244
245 await afterImportSuccess({ videoImport: videoImportUpdated, video, videoFile, user: videoImport.User, videoFileAlreadyLocked: true })
246 } finally {
247 videoFileLockReleaser()
248 }
249 } catch (err) {
250 await onImportError(err, tempVideoPath, videoImport)
251
252 throw err
253 }
254}
255
256async function refreshVideoImportFromDB (videoImport: MVideoImportDefault, videoFile: MVideoFile): Promise<MVideoImportDefaultFiles> {
257 // Refresh video, privacy may have changed
258 const video = await videoImport.Video.reload()
259 const videoWithFiles = Object.assign(video, { VideoFiles: [ videoFile ], VideoStreamingPlaylists: [] })
260
261 return Object.assign(videoImport, { Video: videoWithFiles })
262}
263
264async function generateMiniature (videoImportWithFiles: MVideoImportDefaultFiles, videoFile: MVideoFile, thumbnailType: ThumbnailType) {
265 // Generate miniature if the import did not created it
266 const needsMiniature = thumbnailType === ThumbnailType.MINIATURE
267 ? !videoImportWithFiles.Video.getMiniature()
268 : !videoImportWithFiles.Video.getPreview()
269
270 if (!needsMiniature) {
271 return {
272 miniatureModel: null,
273 miniatureJSONSave: null
274 }
275 }
276
277 const miniatureModel = await generateLocalVideoMiniature({
278 video: videoImportWithFiles.Video,
279 videoFile,
280 type: thumbnailType
281 })
282 const miniatureJSONSave = miniatureModel.toJSON()
283
284 return {
285 miniatureModel,
286 miniatureJSONSave
287 }
288}
289
290async function afterImportSuccess (options: {
291 videoImport: MVideoImport
292 video: MVideoFullLight
293 videoFile: MVideoFile
294 user: MUserId
295 videoFileAlreadyLocked: boolean
296}) {
297 const { video, videoFile, videoImport, user, videoFileAlreadyLocked } = options
298
299 Notifier.Instance.notifyOnFinishedVideoImport({ videoImport: Object.assign(videoImport, { Video: video }), success: true })
300
301 if (video.isBlacklisted()) {
302 const videoBlacklist = Object.assign(video.VideoBlacklist, { Video: video })
303
304 Notifier.Instance.notifyOnVideoAutoBlacklist(videoBlacklist)
305 } else {
306 Notifier.Instance.notifyOnNewVideoIfNeeded(video)
307 }
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
318 if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
319 await JobQueue.Instance.createJob(
320 await buildMoveToObjectStorageJob({ video, previousVideoState: VideoState.TO_IMPORT })
321 )
322 return
323 }
324
325 if (video.state === VideoState.TO_TRANSCODE) { // Create transcoding jobs?
326 await createOptimizeOrMergeAudioJobs({ video, videoFile, isNewVideo: true, user, videoFileAlreadyLocked })
327 }
328}
329
330async function onImportError (err: Error, tempVideoPath: string, videoImport: MVideoImportVideo) {
331 try {
332 if (tempVideoPath) await remove(tempVideoPath)
333 } catch (errUnlink) {
334 logger.warn('Cannot cleanup files after a video import error.', { err: errUnlink })
335 }
336
337 videoImport.error = err.message
338 if (videoImport.state !== VideoImportState.REJECTED) {
339 videoImport.state = VideoImportState.FAILED
340 }
341 await videoImport.save()
342
343 Notifier.Instance.notifyOnFinishedVideoImport({ videoImport, success: false })
344}
diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts
deleted file mode 100644
index 070d1d7a2..000000000
--- a/server/lib/job-queue/handlers/video-live-ending.ts
+++ /dev/null
@@ -1,279 +0,0 @@
1import { Job } from 'bullmq'
2import { readdir, remove } from 'fs-extra'
3import { join } from 'path'
4import { peertubeTruncate } from '@server/helpers/core-utils'
5import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
6import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
7import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
8import { cleanupAndDestroyPermanentLive, cleanupTMPLiveFiles, cleanupUnsavedNormalLive } from '@server/lib/live'
9import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveReplayBaseDirectory } from '@server/lib/paths'
10import { generateLocalVideoMiniature, regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail'
11import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/hls-transcoding'
12import { VideoPathManager } from '@server/lib/video-path-manager'
13import { moveToNextState } from '@server/lib/video-state'
14import { VideoModel } from '@server/models/video/video'
15import { VideoBlacklistModel } from '@server/models/video/video-blacklist'
16import { VideoFileModel } from '@server/models/video/video-file'
17import { VideoLiveModel } from '@server/models/video/video-live'
18import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting'
19import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
20import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
21import { MVideo, MVideoLive, MVideoLiveSession, MVideoWithAllFiles } from '@server/types/models'
22import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS } from '@shared/ffmpeg'
23import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models'
24import { logger, loggerTagsFactory } from '../../../helpers/logger'
25import { JobQueue } from '../job-queue'
26
27const lTags = loggerTagsFactory('live', 'job')
28
29async function processVideoLiveEnding (job: Job) {
30 const payload = job.data as VideoLiveEndingPayload
31
32 logger.info('Processing video live ending for %s.', payload.videoId, { payload, ...lTags() })
33
34 function logError () {
35 logger.warn('Video live %d does not exist anymore. Cannot process live ending.', payload.videoId, lTags())
36 }
37
38 const video = await VideoModel.load(payload.videoId)
39 const live = await VideoLiveModel.loadByVideoId(payload.videoId)
40 const liveSession = await VideoLiveSessionModel.load(payload.liveSessionId)
41
42 if (!video || !live || !liveSession) {
43 logError()
44 return
45 }
46
47 const permanentLive = live.permanentLive
48
49 liveSession.endingProcessed = true
50 await liveSession.save()
51
52 if (liveSession.saveReplay !== true) {
53 return cleanupLiveAndFederate({ permanentLive, video, streamingPlaylistId: payload.streamingPlaylistId })
54 }
55
56 if (permanentLive) {
57 await saveReplayToExternalVideo({
58 liveVideo: video,
59 liveSession,
60 publishedAt: payload.publishedAt,
61 replayDirectory: payload.replayDirectory
62 })
63
64 return cleanupLiveAndFederate({ permanentLive, video, streamingPlaylistId: payload.streamingPlaylistId })
65 }
66
67 return replaceLiveByReplay({
68 video,
69 liveSession,
70 live,
71 permanentLive,
72 replayDirectory: payload.replayDirectory
73 })
74}
75
76// ---------------------------------------------------------------------------
77
78export {
79 processVideoLiveEnding
80}
81
82// ---------------------------------------------------------------------------
83
84async function saveReplayToExternalVideo (options: {
85 liveVideo: MVideo
86 liveSession: MVideoLiveSession
87 publishedAt: string
88 replayDirectory: string
89}) {
90 const { liveVideo, liveSession, publishedAt, replayDirectory } = options
91
92 const replaySettings = await VideoLiveReplaySettingModel.load(liveSession.replaySettingId)
93
94 const videoNameSuffix = ` - ${new Date(publishedAt).toLocaleString()}`
95 const truncatedVideoName = peertubeTruncate(liveVideo.name, {
96 length: CONSTRAINTS_FIELDS.VIDEOS.NAME.max - videoNameSuffix.length
97 })
98
99 const replayVideo = new VideoModel({
100 name: truncatedVideoName + videoNameSuffix,
101 isLive: false,
102 state: VideoState.TO_TRANSCODE,
103 duration: 0,
104
105 remote: liveVideo.remote,
106 category: liveVideo.category,
107 licence: liveVideo.licence,
108 language: liveVideo.language,
109 commentsEnabled: liveVideo.commentsEnabled,
110 downloadEnabled: liveVideo.downloadEnabled,
111 waitTranscoding: true,
112 nsfw: liveVideo.nsfw,
113 description: liveVideo.description,
114 support: liveVideo.support,
115 privacy: replaySettings.privacy,
116 channelId: liveVideo.channelId
117 }) as MVideoWithAllFiles
118
119 replayVideo.Thumbnails = []
120 replayVideo.VideoFiles = []
121 replayVideo.VideoStreamingPlaylists = []
122
123 replayVideo.url = getLocalVideoActivityPubUrl(replayVideo)
124
125 await replayVideo.save()
126
127 liveSession.replayVideoId = replayVideo.id
128 await liveSession.save()
129
130 // If live is blacklisted, also blacklist the replay
131 const blacklist = await VideoBlacklistModel.loadByVideoId(liveVideo.id)
132 if (blacklist) {
133 await VideoBlacklistModel.create({
134 videoId: replayVideo.id,
135 unfederated: blacklist.unfederated,
136 reason: blacklist.reason,
137 type: blacklist.type
138 })
139 }
140
141 await assignReplayFilesToVideo({ video: replayVideo, replayDirectory })
142
143 await remove(replayDirectory)
144
145 for (const type of [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ]) {
146 const image = await generateLocalVideoMiniature({ video: replayVideo, videoFile: replayVideo.getMaxQualityFile(), type })
147 await replayVideo.addAndSaveThumbnail(image)
148 }
149
150 await moveToNextState({ video: replayVideo, isNewVideo: true })
151
152 await createStoryboardJob(replayVideo)
153}
154
155async function replaceLiveByReplay (options: {
156 video: MVideo
157 liveSession: MVideoLiveSession
158 live: MVideoLive
159 permanentLive: boolean
160 replayDirectory: string
161}) {
162 const { video, liveSession, live, permanentLive, replayDirectory } = options
163
164 const replaySettings = await VideoLiveReplaySettingModel.load(liveSession.replaySettingId)
165 const videoWithFiles = await VideoModel.loadFull(video.id)
166 const hlsPlaylist = videoWithFiles.getHLSPlaylist()
167
168 await cleanupTMPLiveFiles(videoWithFiles, hlsPlaylist)
169
170 await live.destroy()
171
172 videoWithFiles.isLive = false
173 videoWithFiles.privacy = replaySettings.privacy
174 videoWithFiles.waitTranscoding = true
175 videoWithFiles.state = VideoState.TO_TRANSCODE
176
177 await videoWithFiles.save()
178
179 liveSession.replayVideoId = videoWithFiles.id
180 await liveSession.save()
181
182 await VideoFileModel.removeHLSFilesOfVideoId(hlsPlaylist.id)
183
184 // Reset playlist
185 hlsPlaylist.VideoFiles = []
186 hlsPlaylist.playlistFilename = generateHLSMasterPlaylistFilename()
187 hlsPlaylist.segmentsSha256Filename = generateHlsSha256SegmentsFilename()
188 await hlsPlaylist.save()
189
190 await assignReplayFilesToVideo({ video: videoWithFiles, replayDirectory })
191
192 // Should not happen in this function, but we keep the code if in the future we can replace the permanent live by a replay
193 if (permanentLive) { // Remove session replay
194 await remove(replayDirectory)
195 } else { // We won't stream again in this live, we can delete the base replay directory
196 await remove(getLiveReplayBaseDirectory(videoWithFiles))
197 }
198
199 // Regenerate the thumbnail & preview?
200 await regenerateMiniaturesIfNeeded(videoWithFiles)
201
202 // We consider this is a new video
203 await moveToNextState({ video: videoWithFiles, isNewVideo: true })
204
205 await createStoryboardJob(videoWithFiles)
206}
207
208async function assignReplayFilesToVideo (options: {
209 video: MVideo
210 replayDirectory: string
211}) {
212 const { video, replayDirectory } = options
213
214 const concatenatedTsFiles = await readdir(replayDirectory)
215
216 for (const concatenatedTsFile of concatenatedTsFiles) {
217 const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
218 await video.reload()
219
220 const concatenatedTsFilePath = join(replayDirectory, concatenatedTsFile)
221
222 const probe = await ffprobePromise(concatenatedTsFilePath)
223 const { audioStream } = await getAudioStream(concatenatedTsFilePath, probe)
224 const { resolution } = await getVideoStreamDimensionsInfo(concatenatedTsFilePath, probe)
225 const fps = await getVideoStreamFPS(concatenatedTsFilePath, probe)
226
227 try {
228 await generateHlsPlaylistResolutionFromTS({
229 video,
230 inputFileMutexReleaser,
231 concatenatedTsFilePath,
232 resolution,
233 fps,
234 isAAC: audioStream?.codec_name === 'aac'
235 })
236 } catch (err) {
237 logger.error('Cannot generate HLS playlist resolution from TS files.', { err })
238 }
239
240 inputFileMutexReleaser()
241 }
242
243 return video
244}
245
246async function cleanupLiveAndFederate (options: {
247 video: MVideo
248 permanentLive: boolean
249 streamingPlaylistId: number
250}) {
251 const { permanentLive, video, streamingPlaylistId } = options
252
253 const streamingPlaylist = await VideoStreamingPlaylistModel.loadWithVideo(streamingPlaylistId)
254
255 if (streamingPlaylist) {
256 if (permanentLive) {
257 await cleanupAndDestroyPermanentLive(video, streamingPlaylist)
258 } else {
259 await cleanupUnsavedNormalLive(video, streamingPlaylist)
260 }
261 }
262
263 try {
264 const fullVideo = await VideoModel.loadFull(video.id)
265 return federateVideoIfNeeded(fullVideo, false, undefined)
266 } catch (err) {
267 logger.warn('Cannot federate live after cleanup', { videoId: video.id, err })
268 }
269}
270
271function createStoryboardJob (video: MVideo) {
272 return JobQueue.Instance.createJob({
273 type: 'generate-video-storyboard' as 'generate-video-storyboard',
274 payload: {
275 videoUUID: video.uuid,
276 federate: true
277 }
278 })
279}
diff --git a/server/lib/job-queue/handlers/video-redundancy.ts b/server/lib/job-queue/handlers/video-redundancy.ts
deleted file mode 100644
index bac99fdb7..000000000
--- a/server/lib/job-queue/handlers/video-redundancy.ts
+++ /dev/null
@@ -1,17 +0,0 @@
1import { Job } from 'bullmq'
2import { VideosRedundancyScheduler } from '@server/lib/schedulers/videos-redundancy-scheduler'
3import { VideoRedundancyPayload } from '@shared/models'
4import { logger } from '../../../helpers/logger'
5
6async function processVideoRedundancy (job: Job) {
7 const payload = job.data as VideoRedundancyPayload
8 logger.info('Processing video redundancy in job %s.', job.id)
9
10 return VideosRedundancyScheduler.Instance.createManualRedundancy(payload.videoId)
11}
12
13// ---------------------------------------------------------------------------
14
15export {
16 processVideoRedundancy
17}
diff --git a/server/lib/job-queue/handlers/video-studio-edition.ts b/server/lib/job-queue/handlers/video-studio-edition.ts
deleted file mode 100644
index caf051bfa..000000000
--- a/server/lib/job-queue/handlers/video-studio-edition.ts
+++ /dev/null
@@ -1,180 +0,0 @@
1import { Job } from 'bullmq'
2import { remove } from 'fs-extra'
3import { join } from 'path'
4import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg'
5import { CONFIG } from '@server/initializers/config'
6import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles'
7import { isAbleToUploadVideo } from '@server/lib/user'
8import { VideoPathManager } from '@server/lib/video-path-manager'
9import { approximateIntroOutroAdditionalSize, onVideoStudioEnded, safeCleanupStudioTMPFiles } from '@server/lib/video-studio'
10import { UserModel } from '@server/models/user/user'
11import { VideoModel } from '@server/models/video/video'
12import { MVideo, MVideoFullLight } from '@server/types/models'
13import { pick } from '@shared/core-utils'
14import { buildUUID } from '@shared/extra-utils'
15import { FFmpegEdition } from '@shared/ffmpeg'
16import {
17 VideoStudioEditionPayload,
18 VideoStudioTask,
19 VideoStudioTaskCutPayload,
20 VideoStudioTaskIntroPayload,
21 VideoStudioTaskOutroPayload,
22 VideoStudioTaskPayload,
23 VideoStudioTaskWatermarkPayload
24} from '@shared/models'
25import { logger, loggerTagsFactory } from '../../../helpers/logger'
26
27const lTagsBase = loggerTagsFactory('video-studio')
28
29async function processVideoStudioEdition (job: Job) {
30 const payload = job.data as VideoStudioEditionPayload
31 const lTags = lTagsBase(payload.videoUUID)
32
33 logger.info('Process video studio edition of %s in job %s.', payload.videoUUID, job.id, lTags)
34
35 try {
36 const video = await VideoModel.loadFull(payload.videoUUID)
37
38 // No video, maybe deleted?
39 if (!video) {
40 logger.info('Can\'t process job %d, video does not exist.', job.id, lTags)
41
42 await safeCleanupStudioTMPFiles(payload.tasks)
43 return undefined
44 }
45
46 await checkUserQuotaOrThrow(video, payload)
47
48 const inputFile = video.getMaxQualityFile()
49
50 const editionResultPath = await VideoPathManager.Instance.makeAvailableVideoFile(inputFile, async originalFilePath => {
51 let tmpInputFilePath: string
52 let outputPath: string
53
54 for (const task of payload.tasks) {
55 const outputFilename = buildUUID() + inputFile.extname
56 outputPath = join(CONFIG.STORAGE.TMP_DIR, outputFilename)
57
58 await processTask({
59 inputPath: tmpInputFilePath ?? originalFilePath,
60 video,
61 outputPath,
62 task,
63 lTags
64 })
65
66 if (tmpInputFilePath) await remove(tmpInputFilePath)
67
68 // For the next iteration
69 tmpInputFilePath = outputPath
70 }
71
72 return outputPath
73 })
74
75 logger.info('Video edition ended for video %s.', video.uuid, lTags)
76
77 await onVideoStudioEnded({ video, editionResultPath, tasks: payload.tasks })
78 } catch (err) {
79 await safeCleanupStudioTMPFiles(payload.tasks)
80
81 throw err
82 }
83}
84
85// ---------------------------------------------------------------------------
86
87export {
88 processVideoStudioEdition
89}
90
91// ---------------------------------------------------------------------------
92
93type TaskProcessorOptions <T extends VideoStudioTaskPayload = VideoStudioTaskPayload> = {
94 inputPath: string
95 outputPath: string
96 video: MVideo
97 task: T
98 lTags: { tags: string[] }
99}
100
101const taskProcessors: { [id in VideoStudioTask['name']]: (options: TaskProcessorOptions) => Promise<any> } = {
102 'add-intro': processAddIntroOutro,
103 'add-outro': processAddIntroOutro,
104 'cut': processCut,
105 'add-watermark': processAddWatermark
106}
107
108async function processTask (options: TaskProcessorOptions) {
109 const { video, task, lTags } = options
110
111 logger.info('Processing %s task for video %s.', task.name, video.uuid, { task, ...lTags })
112
113 const processor = taskProcessors[options.task.name]
114 if (!process) throw new Error('Unknown task ' + task.name)
115
116 return processor(options)
117}
118
119function processAddIntroOutro (options: TaskProcessorOptions<VideoStudioTaskIntroPayload | VideoStudioTaskOutroPayload>) {
120 const { task, lTags } = options
121
122 logger.debug('Will add intro/outro to the video.', { options, ...lTags })
123
124 return buildFFmpegEdition().addIntroOutro({
125 ...pick(options, [ 'inputPath', 'outputPath' ]),
126
127 introOutroPath: task.options.file,
128 type: task.name === 'add-intro'
129 ? 'intro'
130 : 'outro'
131 })
132}
133
134function processCut (options: TaskProcessorOptions<VideoStudioTaskCutPayload>) {
135 const { task, lTags } = options
136
137 logger.debug('Will cut the video.', { options, ...lTags })
138
139 return buildFFmpegEdition().cutVideo({
140 ...pick(options, [ 'inputPath', 'outputPath' ]),
141
142 start: task.options.start,
143 end: task.options.end
144 })
145}
146
147function processAddWatermark (options: TaskProcessorOptions<VideoStudioTaskWatermarkPayload>) {
148 const { task, lTags } = options
149
150 logger.debug('Will add watermark to the video.', { options, ...lTags })
151
152 return buildFFmpegEdition().addWatermark({
153 ...pick(options, [ 'inputPath', 'outputPath' ]),
154
155 watermarkPath: task.options.file,
156
157 videoFilters: {
158 watermarkSizeRatio: task.options.watermarkSizeRatio,
159 horitonzalMarginRatio: task.options.horitonzalMarginRatio,
160 verticalMarginRatio: task.options.verticalMarginRatio
161 }
162 })
163}
164
165// ---------------------------------------------------------------------------
166
167async function checkUserQuotaOrThrow (video: MVideoFullLight, payload: VideoStudioEditionPayload) {
168 const user = await UserModel.loadByVideoId(video.id)
169
170 const filePathFinder = (i: number) => (payload.tasks[i] as VideoStudioTaskIntroPayload | VideoStudioTaskOutroPayload).options.file
171
172 const additionalBytes = await approximateIntroOutroAdditionalSize(video, payload.tasks, filePathFinder)
173 if (await isAbleToUploadVideo(user.id, additionalBytes) === false) {
174 throw new Error('Quota exceeded for this user to edit the video')
175 }
176}
177
178function buildFFmpegEdition () {
179 return new FFmpegEdition(getFFmpegCommandWrapperOptions('vod', VideoTranscodingProfilesManager.Instance.getAvailableEncoders()))
180}
diff --git a/server/lib/job-queue/handlers/video-transcoding.ts b/server/lib/job-queue/handlers/video-transcoding.ts
deleted file mode 100644
index 1c8f4fd9f..000000000
--- a/server/lib/job-queue/handlers/video-transcoding.ts
+++ /dev/null
@@ -1,150 +0,0 @@
1import { Job } from 'bullmq'
2import { onTranscodingEnded } from '@server/lib/transcoding/ended-transcoding'
3import { generateHlsPlaylistResolution } from '@server/lib/transcoding/hls-transcoding'
4import { mergeAudioVideofile, optimizeOriginalVideofile, transcodeNewWebVideoResolution } from '@server/lib/transcoding/web-transcoding'
5import { removeAllWebVideoFiles } from '@server/lib/video-file'
6import { VideoPathManager } from '@server/lib/video-path-manager'
7import { moveToFailedTranscodingState } from '@server/lib/video-state'
8import { UserModel } from '@server/models/user/user'
9import { VideoJobInfoModel } from '@server/models/video/video-job-info'
10import { MUser, MUserId, MVideoFullLight } from '@server/types/models'
11import {
12 HLSTranscodingPayload,
13 MergeAudioTranscodingPayload,
14 NewWebVideoResolutionTranscodingPayload,
15 OptimizeTranscodingPayload,
16 VideoTranscodingPayload
17} from '@shared/models'
18import { logger, loggerTagsFactory } from '../../../helpers/logger'
19import { VideoModel } from '../../../models/video/video'
20
21type HandlerFunction = (job: Job, payload: VideoTranscodingPayload, video: MVideoFullLight, user: MUser) => Promise<void>
22
23const handlers: { [ id in VideoTranscodingPayload['type'] ]: HandlerFunction } = {
24 'new-resolution-to-hls': handleHLSJob,
25 'new-resolution-to-web-video': handleNewWebVideoResolutionJob,
26 'merge-audio-to-web-video': handleWebVideoMergeAudioJob,
27 'optimize-to-web-video': handleWebVideoOptimizeJob
28}
29
30const lTags = loggerTagsFactory('transcoding')
31
32async function processVideoTranscoding (job: Job) {
33 const payload = job.data as VideoTranscodingPayload
34 logger.info('Processing transcoding job %s.', job.id, lTags(payload.videoUUID))
35
36 const video = await VideoModel.loadFull(payload.videoUUID)
37 // No video, maybe deleted?
38 if (!video) {
39 logger.info('Do not process job %d, video does not exist.', job.id, lTags(payload.videoUUID))
40 return undefined
41 }
42
43 const user = await UserModel.loadByChannelActorId(video.VideoChannel.actorId)
44
45 const handler = handlers[payload.type]
46
47 if (!handler) {
48 await moveToFailedTranscodingState(video)
49 await VideoJobInfoModel.decrease(video.uuid, 'pendingTranscode')
50
51 throw new Error('Cannot find transcoding handler for ' + payload.type)
52 }
53
54 try {
55 await handler(job, payload, video, user)
56 } catch (error) {
57 await moveToFailedTranscodingState(video)
58
59 await VideoJobInfoModel.decrease(video.uuid, 'pendingTranscode')
60
61 throw error
62 }
63
64 return video
65}
66
67// ---------------------------------------------------------------------------
68
69export {
70 processVideoTranscoding
71}
72
73// ---------------------------------------------------------------------------
74// Job handlers
75// ---------------------------------------------------------------------------
76
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 })
79
80 await mergeAudioVideofile({ video, resolution: payload.resolution, fps: payload.fps, job })
81
82 logger.info('Merge audio transcoding job for %s ended.', video.uuid, lTags(video.uuid), { payload })
83
84 await onTranscodingEnded({ isNewVideo: payload.isNewVideo, moveVideoToNextState: !payload.hasChildren, video })
85}
86
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 })
89
90 await optimizeOriginalVideofile({ video, inputVideoFile: video.getMaxQualityFile(), quickTranscode: payload.quickTranscode, job })
91
92 logger.info('Optimize transcoding job for %s ended.', video.uuid, lTags(video.uuid), { payload })
93
94 await onTranscodingEnded({ isNewVideo: payload.isNewVideo, moveVideoToNextState: !payload.hasChildren, video })
95}
96
97// ---------------------------------------------------------------------------
98
99async function handleNewWebVideoResolutionJob (job: Job, payload: NewWebVideoResolutionTranscodingPayload, video: MVideoFullLight) {
100 logger.info('Handling Web Video transcoding job for %s.', video.uuid, lTags(video.uuid), { payload })
101
102 await transcodeNewWebVideoResolution({ video, resolution: payload.resolution, fps: payload.fps, job })
103
104 logger.info('Web Video transcoding job for %s ended.', video.uuid, lTags(video.uuid), { payload })
105
106 await onTranscodingEnded({ isNewVideo: payload.isNewVideo, moveVideoToNextState: true, video })
107}
108
109// ---------------------------------------------------------------------------
110
111async function handleHLSJob (job: Job, payload: HLSTranscodingPayload, videoArg: MVideoFullLight) {
112 logger.info('Handling HLS transcoding job for %s.', videoArg.uuid, lTags(videoArg.uuid), { payload })
113
114 const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(videoArg.uuid)
115 let video: MVideoFullLight
116
117 try {
118 video = await VideoModel.loadFull(videoArg.uuid)
119
120 const videoFileInput = payload.copyCodecs
121 ? video.getWebVideoFile(payload.resolution)
122 : video.getMaxQualityFile()
123
124 const videoOrStreamingPlaylist = videoFileInput.getVideoOrStreamingPlaylist()
125
126 await VideoPathManager.Instance.makeAvailableVideoFile(videoFileInput.withVideoOrPlaylist(videoOrStreamingPlaylist), videoInputPath => {
127 return generateHlsPlaylistResolution({
128 video,
129 videoInputPath,
130 inputFileMutexReleaser,
131 resolution: payload.resolution,
132 fps: payload.fps,
133 copyCodecs: payload.copyCodecs,
134 job
135 })
136 })
137 } finally {
138 inputFileMutexReleaser()
139 }
140
141 logger.info('HLS transcoding job for %s ended.', video.uuid, lTags(video.uuid), { payload })
142
143 if (payload.deleteWebVideoFiles === true) {
144 logger.info('Removing Web Video files of %s now we have a HLS version of it.', video.uuid, lTags(video.uuid))
145
146 await removeAllWebVideoFiles(video)
147 }
148
149 await onTranscodingEnded({ isNewVideo: payload.isNewVideo, moveVideoToNextState: true, video })
150}
diff --git a/server/lib/job-queue/handlers/video-views-stats.ts b/server/lib/job-queue/handlers/video-views-stats.ts
deleted file mode 100644
index c9aa218e5..000000000
--- a/server/lib/job-queue/handlers/video-views-stats.ts
+++ /dev/null
@@ -1,57 +0,0 @@
1import { VideoViewModel } from '@server/models/view/video-view'
2import { isTestOrDevInstance } from '../../../helpers/core-utils'
3import { logger } from '../../../helpers/logger'
4import { VideoModel } from '../../../models/video/video'
5import { Redis } from '../../redis'
6
7async function processVideosViewsStats () {
8 const lastHour = new Date()
9
10 // In test mode, we run this function multiple times per hour, so we don't want the values of the previous hour
11 if (!isTestOrDevInstance()) lastHour.setHours(lastHour.getHours() - 1)
12
13 const hour = lastHour.getHours()
14 const startDate = lastHour.setMinutes(0, 0, 0)
15 const endDate = lastHour.setMinutes(59, 59, 999)
16
17 const videoIds = await Redis.Instance.listVideosViewedForStats(hour)
18 if (videoIds.length === 0) return
19
20 logger.info('Processing videos views stats in job for hour %d.', hour)
21
22 for (const videoId of videoIds) {
23 try {
24 const views = await Redis.Instance.getVideoViewsStats(videoId, hour)
25 await Redis.Instance.deleteVideoViewsStats(videoId, hour)
26
27 if (views) {
28 logger.debug('Adding %d views to video %d stats in hour %d.', views, videoId, hour)
29
30 try {
31 const video = await VideoModel.load(videoId)
32 if (!video) {
33 logger.debug('Video %d does not exist anymore, skipping videos view stats.', videoId)
34 continue
35 }
36
37 await VideoViewModel.create({
38 startDate: new Date(startDate),
39 endDate: new Date(endDate),
40 views,
41 videoId
42 })
43 } catch (err) {
44 logger.error('Cannot create video views stats for video %d in hour %d.', videoId, hour, { err })
45 }
46 }
47 } catch (err) {
48 logger.error('Cannot update video views stats of video %d in hour %d.', videoId, hour, { err })
49 }
50 }
51}
52
53// ---------------------------------------------------------------------------
54
55export {
56 processVideosViewsStats
57}
diff --git a/server/lib/job-queue/index.ts b/server/lib/job-queue/index.ts
deleted file mode 100644
index 57231e649..000000000
--- a/server/lib/job-queue/index.ts
+++ /dev/null
@@ -1 +0,0 @@
1export * from './job-queue'
diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts
deleted file mode 100644
index 177bca285..000000000
--- a/server/lib/job-queue/job-queue.ts
+++ /dev/null
@@ -1,537 +0,0 @@
1import {
2 FlowJob,
3 FlowProducer,
4 Job,
5 JobsOptions,
6 Queue,
7 QueueEvents,
8 QueueEventsOptions,
9 QueueOptions,
10 Worker,
11 WorkerOptions
12} from 'bullmq'
13import { parseDurationToMs } from '@server/helpers/core-utils'
14import { jobStates } from '@server/helpers/custom-validators/jobs'
15import { CONFIG } from '@server/initializers/config'
16import { processVideoRedundancy } from '@server/lib/job-queue/handlers/video-redundancy'
17import { pick, timeoutPromise } from '@shared/core-utils'
18import {
19 ActivitypubFollowPayload,
20 ActivitypubHttpBroadcastPayload,
21 ActivitypubHttpFetcherPayload,
22 ActivitypubHttpUnicastPayload,
23 ActorKeysPayload,
24 AfterVideoChannelImportPayload,
25 DeleteResumableUploadMetaFilePayload,
26 EmailPayload,
27 FederateVideoPayload,
28 GenerateStoryboardPayload,
29 JobState,
30 JobType,
31 ManageVideoTorrentPayload,
32 MoveObjectStoragePayload,
33 NotifyPayload,
34 RefreshPayload,
35 TranscodingJobBuilderPayload,
36 VideoChannelImportPayload,
37 VideoFileImportPayload,
38 VideoImportPayload,
39 VideoLiveEndingPayload,
40 VideoRedundancyPayload,
41 VideoStudioEditionPayload,
42 VideoTranscodingPayload
43} from '../../../shared/models'
44import { logger } from '../../helpers/logger'
45import { JOB_ATTEMPTS, JOB_CONCURRENCY, JOB_REMOVAL_OPTIONS, JOB_TTL, REPEAT_JOBS, WEBSERVER } from '../../initializers/constants'
46import { Hooks } from '../plugins/hooks'
47import { Redis } from '../redis'
48import { processActivityPubCleaner } from './handlers/activitypub-cleaner'
49import { processActivityPubFollow } from './handlers/activitypub-follow'
50import { processActivityPubHttpSequentialBroadcast, processActivityPubParallelHttpBroadcast } from './handlers/activitypub-http-broadcast'
51import { processActivityPubHttpFetcher } from './handlers/activitypub-http-fetcher'
52import { processActivityPubHttpUnicast } from './handlers/activitypub-http-unicast'
53import { refreshAPObject } from './handlers/activitypub-refresher'
54import { processActorKeys } from './handlers/actor-keys'
55import { processAfterVideoChannelImport } from './handlers/after-video-channel-import'
56import { processEmail } from './handlers/email'
57import { processFederateVideo } from './handlers/federate-video'
58import { processManageVideoTorrent } from './handlers/manage-video-torrent'
59import { onMoveToObjectStorageFailure, processMoveToObjectStorage } from './handlers/move-to-object-storage'
60import { processNotify } from './handlers/notify'
61import { processTranscodingJobBuilder } from './handlers/transcoding-job-builder'
62import { processVideoChannelImport } from './handlers/video-channel-import'
63import { processVideoFileImport } from './handlers/video-file-import'
64import { processVideoImport } from './handlers/video-import'
65import { processVideoLiveEnding } from './handlers/video-live-ending'
66import { processVideoStudioEdition } from './handlers/video-studio-edition'
67import { processVideoTranscoding } from './handlers/video-transcoding'
68import { processVideosViewsStats } from './handlers/video-views-stats'
69import { processGenerateStoryboard } from './handlers/generate-storyboard'
70
71export type CreateJobArgument =
72 { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
73 { type: 'activitypub-http-broadcast-parallel', payload: ActivitypubHttpBroadcastPayload } |
74 { type: 'activitypub-http-unicast', payload: ActivitypubHttpUnicastPayload } |
75 { type: 'activitypub-http-fetcher', payload: ActivitypubHttpFetcherPayload } |
76 { type: 'activitypub-cleaner', payload: {} } |
77 { type: 'activitypub-follow', payload: ActivitypubFollowPayload } |
78 { type: 'video-file-import', payload: VideoFileImportPayload } |
79 { type: 'video-transcoding', payload: VideoTranscodingPayload } |
80 { type: 'email', payload: EmailPayload } |
81 { type: 'transcoding-job-builder', payload: TranscodingJobBuilderPayload } |
82 { type: 'video-import', payload: VideoImportPayload } |
83 { type: 'activitypub-refresher', payload: RefreshPayload } |
84 { type: 'videos-views-stats', payload: {} } |
85 { type: 'video-live-ending', payload: VideoLiveEndingPayload } |
86 { type: 'actor-keys', payload: ActorKeysPayload } |
87 { type: 'video-redundancy', payload: VideoRedundancyPayload } |
88 { type: 'delete-resumable-upload-meta-file', payload: DeleteResumableUploadMetaFilePayload } |
89 { type: 'video-studio-edition', payload: VideoStudioEditionPayload } |
90 { type: 'manage-video-torrent', payload: ManageVideoTorrentPayload } |
91 { type: 'move-to-object-storage', payload: MoveObjectStoragePayload } |
92 { type: 'video-channel-import', payload: VideoChannelImportPayload } |
93 { type: 'after-video-channel-import', payload: AfterVideoChannelImportPayload } |
94 { type: 'notify', payload: NotifyPayload } |
95 { type: 'move-to-object-storage', payload: MoveObjectStoragePayload } |
96 { type: 'federate-video', payload: FederateVideoPayload } |
97 { type: 'generate-video-storyboard', payload: GenerateStoryboardPayload }
98
99export type CreateJobOptions = {
100 delay?: number
101 priority?: number
102 failParentOnFailure?: boolean
103}
104
105const handlers: { [id in JobType]: (job: Job) => Promise<any> } = {
106 'activitypub-cleaner': processActivityPubCleaner,
107 'activitypub-follow': processActivityPubFollow,
108 'activitypub-http-broadcast-parallel': processActivityPubParallelHttpBroadcast,
109 'activitypub-http-broadcast': processActivityPubHttpSequentialBroadcast,
110 'activitypub-http-fetcher': processActivityPubHttpFetcher,
111 'activitypub-http-unicast': processActivityPubHttpUnicast,
112 'activitypub-refresher': refreshAPObject,
113 'actor-keys': processActorKeys,
114 'after-video-channel-import': processAfterVideoChannelImport,
115 'email': processEmail,
116 'federate-video': processFederateVideo,
117 'transcoding-job-builder': processTranscodingJobBuilder,
118 'manage-video-torrent': processManageVideoTorrent,
119 'move-to-object-storage': processMoveToObjectStorage,
120 'notify': processNotify,
121 'video-channel-import': processVideoChannelImport,
122 'video-file-import': processVideoFileImport,
123 'video-import': processVideoImport,
124 'video-live-ending': processVideoLiveEnding,
125 'video-redundancy': processVideoRedundancy,
126 'video-studio-edition': processVideoStudioEdition,
127 'video-transcoding': processVideoTranscoding,
128 'videos-views-stats': processVideosViewsStats,
129 'generate-video-storyboard': processGenerateStoryboard
130}
131
132const errorHandlers: { [id in JobType]?: (job: Job, err: any) => Promise<any> } = {
133 'move-to-object-storage': onMoveToObjectStorageFailure
134}
135
136const jobTypes: JobType[] = [
137 'activitypub-cleaner',
138 'activitypub-follow',
139 'activitypub-http-broadcast-parallel',
140 'activitypub-http-broadcast',
141 'activitypub-http-fetcher',
142 'activitypub-http-unicast',
143 'activitypub-refresher',
144 'actor-keys',
145 'after-video-channel-import',
146 'email',
147 'federate-video',
148 'generate-video-storyboard',
149 'manage-video-torrent',
150 'move-to-object-storage',
151 'notify',
152 'transcoding-job-builder',
153 'video-channel-import',
154 'video-file-import',
155 'video-import',
156 'video-live-ending',
157 'video-redundancy',
158 'video-studio-edition',
159 'video-transcoding',
160 'videos-views-stats'
161]
162
163const silentFailure = new Set<JobType>([ 'activitypub-http-unicast' ])
164
165class JobQueue {
166
167 private static instance: JobQueue
168
169 private workers: { [id in JobType]?: Worker } = {}
170 private queues: { [id in JobType]?: Queue } = {}
171 private queueEvents: { [id in JobType]?: QueueEvents } = {}
172
173 private flowProducer: FlowProducer
174
175 private initialized = false
176 private jobRedisPrefix: string
177
178 private constructor () {
179 }
180
181 init () {
182 // Already initialized
183 if (this.initialized === true) return
184 this.initialized = true
185
186 this.jobRedisPrefix = 'bull-' + WEBSERVER.HOST
187
188 for (const handlerName of Object.keys(handlers)) {
189 this.buildWorker(handlerName)
190 this.buildQueue(handlerName)
191 this.buildQueueEvent(handlerName)
192 }
193
194 this.flowProducer = new FlowProducer({
195 connection: Redis.getRedisClientOptions('FlowProducer'),
196 prefix: this.jobRedisPrefix
197 })
198 this.flowProducer.on('error', err => { logger.error('Error in flow producer', { err }) })
199
200 this.addRepeatableJobs()
201 }
202
203 private buildWorker (handlerName: JobType) {
204 const workerOptions: WorkerOptions = {
205 autorun: false,
206 concurrency: this.getJobConcurrency(handlerName),
207 prefix: this.jobRedisPrefix,
208 connection: Redis.getRedisClientOptions('Worker'),
209 maxStalledCount: 10
210 }
211
212 const handler = function (job: Job) {
213 const timeout = JOB_TTL[handlerName]
214 const p = handlers[handlerName](job)
215
216 if (!timeout) return p
217
218 return timeoutPromise(p, timeout)
219 }
220
221 const processor = async (jobArg: Job<any>) => {
222 const job = await Hooks.wrapObject(jobArg, 'filter:job-queue.process.params', { type: handlerName })
223
224 return Hooks.wrapPromiseFun(handler, job, 'filter:job-queue.process.result')
225 }
226
227 const worker = new Worker(handlerName, processor, workerOptions)
228
229 worker.on('failed', (job, err) => {
230 const logLevel = silentFailure.has(handlerName)
231 ? 'debug'
232 : 'error'
233
234 logger.log(logLevel, 'Cannot execute job %s in queue %s.', job.id, handlerName, { payload: job.data, err })
235
236 if (errorHandlers[job.name]) {
237 errorHandlers[job.name](job, err)
238 .catch(err => logger.error('Cannot run error handler for job failure %d in queue %s.', job.id, handlerName, { err }))
239 }
240 })
241
242 worker.on('error', err => { logger.error('Error in job worker %s.', handlerName, { err }) })
243
244 this.workers[handlerName] = worker
245 }
246
247 private buildQueue (handlerName: JobType) {
248 const queueOptions: QueueOptions = {
249 connection: Redis.getRedisClientOptions('Queue'),
250 prefix: this.jobRedisPrefix
251 }
252
253 const queue = new Queue(handlerName, queueOptions)
254 queue.on('error', err => { logger.error('Error in job queue %s.', handlerName, { err }) })
255
256 this.queues[handlerName] = queue
257 }
258
259 private buildQueueEvent (handlerName: JobType) {
260 const queueEventsOptions: QueueEventsOptions = {
261 autorun: false,
262 connection: Redis.getRedisClientOptions('QueueEvent'),
263 prefix: this.jobRedisPrefix
264 }
265
266 const queueEvents = new QueueEvents(handlerName, queueEventsOptions)
267 queueEvents.on('error', err => { logger.error('Error in job queue events %s.', handlerName, { err }) })
268
269 this.queueEvents[handlerName] = queueEvents
270 }
271
272 // ---------------------------------------------------------------------------
273
274 async terminate () {
275 const promises = Object.keys(this.workers)
276 .map(handlerName => {
277 const worker: Worker = this.workers[handlerName]
278 const queue: Queue = this.queues[handlerName]
279 const queueEvent: QueueEvents = this.queueEvents[handlerName]
280
281 return Promise.all([
282 worker.close(false),
283 queue.close(),
284 queueEvent.close()
285 ])
286 })
287
288 return Promise.all(promises)
289 }
290
291 start () {
292 const promises = Object.keys(this.workers)
293 .map(handlerName => {
294 const worker: Worker = this.workers[handlerName]
295 const queueEvent: QueueEvents = this.queueEvents[handlerName]
296
297 return Promise.all([
298 worker.run(),
299 queueEvent.run()
300 ])
301 })
302
303 return Promise.all(promises)
304 }
305
306 async pause () {
307 for (const handlerName of Object.keys(this.workers)) {
308 const worker: Worker = this.workers[handlerName]
309
310 await worker.pause()
311 }
312 }
313
314 resume () {
315 for (const handlerName of Object.keys(this.workers)) {
316 const worker: Worker = this.workers[handlerName]
317
318 worker.resume()
319 }
320 }
321
322 // ---------------------------------------------------------------------------
323
324 createJobAsync (options: CreateJobArgument & CreateJobOptions): void {
325 this.createJob(options)
326 .catch(err => logger.error('Cannot create job.', { err, options }))
327 }
328
329 createJob (options: CreateJobArgument & CreateJobOptions) {
330 const queue: Queue = this.queues[options.type]
331 if (queue === undefined) {
332 logger.error('Unknown queue %s: cannot create job.', options.type)
333 return
334 }
335
336 const jobOptions = this.buildJobOptions(options.type as JobType, pick(options, [ 'priority', 'delay' ]))
337
338 return queue.add('job', options.payload, jobOptions)
339 }
340
341 createSequentialJobFlow (...jobs: ((CreateJobArgument & CreateJobOptions) | undefined)[]) {
342 let lastJob: FlowJob
343
344 for (const job of jobs) {
345 if (!job) continue
346
347 lastJob = {
348 ...this.buildJobFlowOption(job),
349
350 children: lastJob
351 ? [ lastJob ]
352 : []
353 }
354 }
355
356 return this.flowProducer.add(lastJob)
357 }
358
359 createJobWithChildren (parent: CreateJobArgument & CreateJobOptions, children: (CreateJobArgument & CreateJobOptions)[]) {
360 return this.flowProducer.add({
361 ...this.buildJobFlowOption(parent),
362
363 children: children.map(c => this.buildJobFlowOption(c))
364 })
365 }
366
367 private buildJobFlowOption (job: CreateJobArgument & CreateJobOptions): FlowJob {
368 return {
369 name: 'job',
370 data: job.payload,
371 queueName: job.type,
372 opts: {
373 failParentOnFailure: true,
374
375 ...this.buildJobOptions(job.type as JobType, pick(job, [ 'priority', 'delay', 'failParentOnFailure' ]))
376 }
377 }
378 }
379
380 private buildJobOptions (type: JobType, options: CreateJobOptions = {}): JobsOptions {
381 return {
382 backoff: { delay: 60 * 1000, type: 'exponential' },
383 attempts: JOB_ATTEMPTS[type],
384 priority: options.priority,
385 delay: options.delay,
386
387 ...this.buildJobRemovalOptions(type)
388 }
389 }
390
391 // ---------------------------------------------------------------------------
392
393 async listForApi (options: {
394 state?: JobState
395 start: number
396 count: number
397 asc?: boolean
398 jobType: JobType
399 }): Promise<Job[]> {
400 const { state, start, count, asc, jobType } = options
401
402 const states = this.buildStateFilter(state)
403 const filteredJobTypes = this.buildTypeFilter(jobType)
404
405 let results: Job[] = []
406
407 for (const jobType of filteredJobTypes) {
408 const queue: Queue = this.queues[jobType]
409
410 if (queue === undefined) {
411 logger.error('Unknown queue %s to list jobs.', jobType)
412 continue
413 }
414
415 const jobs = await queue.getJobs(states, 0, start + count, asc)
416 results = results.concat(jobs)
417 }
418
419 results.sort((j1: any, j2: any) => {
420 if (j1.timestamp < j2.timestamp) return -1
421 else if (j1.timestamp === j2.timestamp) return 0
422
423 return 1
424 })
425
426 if (asc === false) results.reverse()
427
428 return results.slice(start, start + count)
429 }
430
431 async count (state: JobState, jobType?: JobType): Promise<number> {
432 const states = state ? [ state ] : jobStates
433 const filteredJobTypes = this.buildTypeFilter(jobType)
434
435 let total = 0
436
437 for (const type of filteredJobTypes) {
438 const queue = this.queues[type]
439 if (queue === undefined) {
440 logger.error('Unknown queue %s to count jobs.', type)
441 continue
442 }
443
444 const counts = await queue.getJobCounts()
445
446 for (const s of states) {
447 total += counts[s]
448 }
449 }
450
451 return total
452 }
453
454 private buildStateFilter (state?: JobState) {
455 if (!state) return jobStates
456
457 const states = [ state ]
458
459 // Include parent if filtering on waiting
460 if (state === 'waiting') states.push('waiting-children')
461
462 return states
463 }
464
465 private buildTypeFilter (jobType?: JobType) {
466 if (!jobType) return jobTypes
467
468 return jobTypes.filter(t => t === jobType)
469 }
470
471 async getStats () {
472 const promises = jobTypes.map(async t => ({ jobType: t, counts: await this.queues[t].getJobCounts() }))
473
474 return Promise.all(promises)
475 }
476
477 // ---------------------------------------------------------------------------
478
479 async removeOldJobs () {
480 for (const key of Object.keys(this.queues)) {
481 const queue: Queue = this.queues[key]
482 await queue.clean(parseDurationToMs('7 days'), 1000, 'completed')
483 await queue.clean(parseDurationToMs('7 days'), 1000, 'failed')
484 }
485 }
486
487 private addRepeatableJobs () {
488 this.queues['videos-views-stats'].add('job', {}, {
489 repeat: REPEAT_JOBS['videos-views-stats'],
490
491 ...this.buildJobRemovalOptions('videos-views-stats')
492 }).catch(err => logger.error('Cannot add repeatable job.', { err }))
493
494 if (CONFIG.FEDERATION.VIDEOS.CLEANUP_REMOTE_INTERACTIONS) {
495 this.queues['activitypub-cleaner'].add('job', {}, {
496 repeat: REPEAT_JOBS['activitypub-cleaner'],
497
498 ...this.buildJobRemovalOptions('activitypub-cleaner')
499 }).catch(err => logger.error('Cannot add repeatable job.', { err }))
500 }
501 }
502
503 private getJobConcurrency (jobType: JobType) {
504 if (jobType === 'video-transcoding') return CONFIG.TRANSCODING.CONCURRENCY
505 if (jobType === 'video-import') return CONFIG.IMPORT.VIDEOS.CONCURRENCY
506
507 return JOB_CONCURRENCY[jobType]
508 }
509
510 private buildJobRemovalOptions (queueName: string) {
511 return {
512 removeOnComplete: {
513 // Wants seconds
514 age: (JOB_REMOVAL_OPTIONS.SUCCESS[queueName] || JOB_REMOVAL_OPTIONS.SUCCESS.DEFAULT) / 1000,
515
516 count: JOB_REMOVAL_OPTIONS.COUNT
517 },
518 removeOnFail: {
519 // Wants seconds
520 age: (JOB_REMOVAL_OPTIONS.FAILURE[queueName] || JOB_REMOVAL_OPTIONS.FAILURE.DEFAULT) / 1000,
521
522 count: JOB_REMOVAL_OPTIONS.COUNT / 1000
523 }
524 }
525 }
526
527 static get Instance () {
528 return this.instance || (this.instance = new this())
529 }
530}
531
532// ---------------------------------------------------------------------------
533
534export {
535 jobTypes,
536 JobQueue
537}
diff --git a/server/lib/live/index.ts b/server/lib/live/index.ts
deleted file mode 100644
index 8b46800da..000000000
--- a/server/lib/live/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
1export * from './live-manager'
2export * from './live-quota-store'
3export * from './live-segment-sha-store'
4export * from './live-utils'
diff --git a/server/lib/live/live-manager.ts b/server/lib/live/live-manager.ts
deleted file mode 100644
index acb7af274..000000000
--- a/server/lib/live/live-manager.ts
+++ /dev/null
@@ -1,552 +0,0 @@
1import { readdir, readFile } from 'fs-extra'
2import { createServer, Server } from 'net'
3import { join } from 'path'
4import { createServer as createServerTLS, Server as ServerTLS } from 'tls'
5import { logger, loggerTagsFactory } from '@server/helpers/logger'
6import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config'
7import { VIDEO_LIVE, WEBSERVER } from '@server/initializers/constants'
8import { sequelizeTypescript } from '@server/initializers/database'
9import { RunnerJobModel } from '@server/models/runner/runner-job'
10import { UserModel } from '@server/models/user/user'
11import { VideoModel } from '@server/models/video/video'
12import { VideoLiveModel } from '@server/models/video/video-live'
13import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting'
14import { VideoLiveSessionModel } from '@server/models/video/video-live-session'
15import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
16import { MVideo, MVideoLiveSession, MVideoLiveVideo, MVideoLiveVideoWithSetting } from '@server/types/models'
17import { pick, wait } from '@shared/core-utils'
18import { ffprobePromise, getVideoStreamBitrate, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream } from '@shared/ffmpeg'
19import { LiveVideoError, VideoState } from '@shared/models'
20import { federateVideoIfNeeded } from '../activitypub/videos'
21import { JobQueue } from '../job-queue'
22import { getLiveReplayBaseDirectory } from '../paths'
23import { PeerTubeSocket } from '../peertube-socket'
24import { Hooks } from '../plugins/hooks'
25import { computeResolutionsToTranscode } from '../transcoding/transcoding-resolutions'
26import { LiveQuotaStore } from './live-quota-store'
27import { cleanupAndDestroyPermanentLive, getLiveSegmentTime } from './live-utils'
28import { MuxingSession } from './shared'
29
30const NodeRtmpSession = require('node-media-server/src/node_rtmp_session')
31const context = require('node-media-server/src/node_core_ctx')
32const nodeMediaServerLogger = require('node-media-server/src/node_core_logger')
33
34// Disable node media server logs
35nodeMediaServerLogger.setLogType(0)
36
37const config = {
38 rtmp: {
39 port: CONFIG.LIVE.RTMP.PORT,
40 chunk_size: VIDEO_LIVE.RTMP.CHUNK_SIZE,
41 gop_cache: VIDEO_LIVE.RTMP.GOP_CACHE,
42 ping: VIDEO_LIVE.RTMP.PING,
43 ping_timeout: VIDEO_LIVE.RTMP.PING_TIMEOUT
44 }
45}
46
47const lTags = loggerTagsFactory('live')
48
49class LiveManager {
50
51 private static instance: LiveManager
52
53 private readonly muxingSessions = new Map<string, MuxingSession>()
54 private readonly videoSessions = new Map<string, string>()
55
56 private rtmpServer: Server
57 private rtmpsServer: ServerTLS
58
59 private running = false
60
61 private constructor () {
62 }
63
64 init () {
65 const events = this.getContext().nodeEvent
66 events.on('postPublish', (sessionId: string, streamPath: string) => {
67 logger.debug('RTMP received stream', { id: sessionId, streamPath, ...lTags(sessionId) })
68
69 const splittedPath = streamPath.split('/')
70 if (splittedPath.length !== 3 || splittedPath[1] !== VIDEO_LIVE.RTMP.BASE_PATH) {
71 logger.warn('Live path is incorrect.', { streamPath, ...lTags(sessionId) })
72 return this.abortSession(sessionId)
73 }
74
75 const session = this.getContext().sessions.get(sessionId)
76 const inputLocalUrl = session.inputOriginLocalUrl + streamPath
77 const inputPublicUrl = session.inputOriginPublicUrl + streamPath
78
79 this.handleSession({ sessionId, inputPublicUrl, inputLocalUrl, streamKey: splittedPath[2] })
80 .catch(err => logger.error('Cannot handle sessions.', { err, ...lTags(sessionId) }))
81 })
82
83 events.on('donePublish', sessionId => {
84 logger.info('Live session ended.', { sessionId, ...lTags(sessionId) })
85
86 // Force session aborting, so we kill ffmpeg even if it still has data to process (slow CPU)
87 setTimeout(() => this.abortSession(sessionId), 2000)
88 })
89
90 registerConfigChangedHandler(() => {
91 if (!this.running && CONFIG.LIVE.ENABLED === true) {
92 this.run().catch(err => logger.error('Cannot run live server.', { err }))
93 return
94 }
95
96 if (this.running && CONFIG.LIVE.ENABLED === false) {
97 this.stop()
98 }
99 })
100
101 // Cleanup broken lives, that were terminated by a server restart for example
102 this.handleBrokenLives()
103 .catch(err => logger.error('Cannot handle broken lives.', { err, ...lTags() }))
104 }
105
106 async run () {
107 this.running = true
108
109 if (CONFIG.LIVE.RTMP.ENABLED) {
110 logger.info('Running RTMP server on port %d', CONFIG.LIVE.RTMP.PORT, lTags())
111
112 this.rtmpServer = createServer(socket => {
113 const session = new NodeRtmpSession(config, socket)
114
115 session.inputOriginLocalUrl = 'rtmp://127.0.0.1:' + CONFIG.LIVE.RTMP.PORT
116 session.inputOriginPublicUrl = WEBSERVER.RTMP_URL
117 session.run()
118 })
119
120 this.rtmpServer.on('error', err => {
121 logger.error('Cannot run RTMP server.', { err, ...lTags() })
122 })
123
124 this.rtmpServer.listen(CONFIG.LIVE.RTMP.PORT, CONFIG.LIVE.RTMP.HOSTNAME)
125 }
126
127 if (CONFIG.LIVE.RTMPS.ENABLED) {
128 logger.info('Running RTMPS server on port %d', CONFIG.LIVE.RTMPS.PORT, lTags())
129
130 const [ key, cert ] = await Promise.all([
131 readFile(CONFIG.LIVE.RTMPS.KEY_FILE),
132 readFile(CONFIG.LIVE.RTMPS.CERT_FILE)
133 ])
134 const serverOptions = { key, cert }
135
136 this.rtmpsServer = createServerTLS(serverOptions, socket => {
137 const session = new NodeRtmpSession(config, socket)
138
139 session.inputOriginLocalUrl = 'rtmps://127.0.0.1:' + CONFIG.LIVE.RTMPS.PORT
140 session.inputOriginPublicUrl = WEBSERVER.RTMPS_URL
141 session.run()
142 })
143
144 this.rtmpsServer.on('error', err => {
145 logger.error('Cannot run RTMPS server.', { err, ...lTags() })
146 })
147
148 this.rtmpsServer.listen(CONFIG.LIVE.RTMPS.PORT, CONFIG.LIVE.RTMPS.HOSTNAME)
149 }
150 }
151
152 stop () {
153 this.running = false
154
155 if (this.rtmpServer) {
156 logger.info('Stopping RTMP server.', lTags())
157
158 this.rtmpServer.close()
159 this.rtmpServer = undefined
160 }
161
162 if (this.rtmpsServer) {
163 logger.info('Stopping RTMPS server.', lTags())
164
165 this.rtmpsServer.close()
166 this.rtmpsServer = undefined
167 }
168
169 // Sessions is an object
170 this.getContext().sessions.forEach((session: any) => {
171 if (session instanceof NodeRtmpSession) {
172 session.stop()
173 }
174 })
175 }
176
177 isRunning () {
178 return !!this.rtmpServer
179 }
180
181 hasSession (sessionId: string) {
182 return this.getContext().sessions.has(sessionId)
183 }
184
185 stopSessionOf (videoUUID: string, error: LiveVideoError | null) {
186 const sessionId = this.videoSessions.get(videoUUID)
187 if (!sessionId) {
188 logger.debug('No live session to stop for video %s', videoUUID, lTags(sessionId, videoUUID))
189 return
190 }
191
192 logger.info('Stopping live session of video %s', videoUUID, { error, ...lTags(sessionId, videoUUID) })
193
194 this.saveEndingSession(videoUUID, error)
195 .catch(err => logger.error('Cannot save ending session.', { err, ...lTags(sessionId, videoUUID) }))
196
197 this.videoSessions.delete(videoUUID)
198 this.abortSession(sessionId)
199 }
200
201 private getContext () {
202 return context
203 }
204
205 private abortSession (sessionId: string) {
206 const session = this.getContext().sessions.get(sessionId)
207 if (session) {
208 session.stop()
209 this.getContext().sessions.delete(sessionId)
210 }
211
212 const muxingSession = this.muxingSessions.get(sessionId)
213 if (muxingSession) {
214 // Muxing session will fire and event so we correctly cleanup the session
215 muxingSession.abort()
216
217 this.muxingSessions.delete(sessionId)
218 }
219 }
220
221 private async handleSession (options: {
222 sessionId: string
223 inputLocalUrl: string
224 inputPublicUrl: string
225 streamKey: string
226 }) {
227 const { inputLocalUrl, inputPublicUrl, sessionId, streamKey } = options
228
229 const videoLive = await VideoLiveModel.loadByStreamKey(streamKey)
230 if (!videoLive) {
231 logger.warn('Unknown live video with stream key %s.', streamKey, lTags(sessionId))
232 return this.abortSession(sessionId)
233 }
234
235 const video = videoLive.Video
236 if (video.isBlacklisted()) {
237 logger.warn('Video is blacklisted. Refusing stream %s.', streamKey, lTags(sessionId, video.uuid))
238 return this.abortSession(sessionId)
239 }
240
241 if (this.videoSessions.has(video.uuid)) {
242 logger.warn('Video %s has already a live session. Refusing stream %s.', video.uuid, streamKey, lTags(sessionId, video.uuid))
243 return this.abortSession(sessionId)
244 }
245
246 // Cleanup old potential live (could happen with a permanent live)
247 const oldStreamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id)
248 if (oldStreamingPlaylist) {
249 if (!videoLive.permanentLive) throw new Error('Found previous session in a non permanent live: ' + video.uuid)
250
251 await cleanupAndDestroyPermanentLive(video, oldStreamingPlaylist)
252 }
253
254 this.videoSessions.set(video.uuid, sessionId)
255
256 const now = Date.now()
257 const probe = await ffprobePromise(inputLocalUrl)
258
259 const [ { resolution, ratio }, fps, bitrate, hasAudio ] = await Promise.all([
260 getVideoStreamDimensionsInfo(inputLocalUrl, probe),
261 getVideoStreamFPS(inputLocalUrl, probe),
262 getVideoStreamBitrate(inputLocalUrl, probe),
263 hasAudioStream(inputLocalUrl, probe)
264 ])
265
266 logger.info(
267 '%s probing took %d ms (bitrate: %d, fps: %d, resolution: %d)',
268 inputLocalUrl, Date.now() - now, bitrate, fps, resolution, lTags(sessionId, video.uuid)
269 )
270
271 const allResolutions = await Hooks.wrapObject(
272 this.buildAllResolutionsToTranscode(resolution, hasAudio),
273 'filter:transcoding.auto.resolutions-to-transcode.result',
274 { video }
275 )
276
277 logger.info(
278 'Handling live video of original resolution %d.', resolution,
279 { allResolutions, ...lTags(sessionId, video.uuid) }
280 )
281
282 return this.runMuxingSession({
283 sessionId,
284 videoLive,
285
286 inputLocalUrl,
287 inputPublicUrl,
288 fps,
289 bitrate,
290 ratio,
291 allResolutions,
292 hasAudio
293 })
294 }
295
296 private async runMuxingSession (options: {
297 sessionId: string
298 videoLive: MVideoLiveVideoWithSetting
299
300 inputLocalUrl: string
301 inputPublicUrl: string
302
303 fps: number
304 bitrate: number
305 ratio: number
306 allResolutions: number[]
307 hasAudio: boolean
308 }) {
309 const { sessionId, videoLive } = options
310 const videoUUID = videoLive.Video.uuid
311 const localLTags = lTags(sessionId, videoUUID)
312
313 const liveSession = await this.saveStartingSession(videoLive)
314
315 const user = await UserModel.loadByLiveId(videoLive.id)
316 LiveQuotaStore.Instance.addNewLive(user.id, sessionId)
317
318 const muxingSession = new MuxingSession({
319 context: this.getContext(),
320 sessionId,
321 videoLive,
322 user,
323
324 ...pick(options, [ 'inputLocalUrl', 'inputPublicUrl', 'bitrate', 'ratio', 'fps', 'allResolutions', 'hasAudio' ])
325 })
326
327 muxingSession.on('live-ready', () => this.publishAndFederateLive(videoLive, localLTags))
328
329 muxingSession.on('bad-socket-health', ({ videoUUID }) => {
330 logger.error(
331 'Too much data in client socket stream (ffmpeg is too slow to transcode the video).' +
332 ' Stopping session of video %s.', videoUUID,
333 localLTags
334 )
335
336 this.stopSessionOf(videoUUID, LiveVideoError.BAD_SOCKET_HEALTH)
337 })
338
339 muxingSession.on('duration-exceeded', ({ videoUUID }) => {
340 logger.info('Stopping session of %s: max duration exceeded.', videoUUID, localLTags)
341
342 this.stopSessionOf(videoUUID, LiveVideoError.DURATION_EXCEEDED)
343 })
344
345 muxingSession.on('quota-exceeded', ({ videoUUID }) => {
346 logger.info('Stopping session of %s: user quota exceeded.', videoUUID, localLTags)
347
348 this.stopSessionOf(videoUUID, LiveVideoError.QUOTA_EXCEEDED)
349 })
350
351 muxingSession.on('transcoding-error', ({ videoUUID }) => {
352 this.stopSessionOf(videoUUID, LiveVideoError.FFMPEG_ERROR)
353 })
354
355 muxingSession.on('transcoding-end', ({ videoUUID }) => {
356 this.onMuxingFFmpegEnd(videoUUID, sessionId)
357 })
358
359 muxingSession.on('after-cleanup', ({ videoUUID }) => {
360 this.muxingSessions.delete(sessionId)
361
362 LiveQuotaStore.Instance.removeLive(user.id, sessionId)
363
364 muxingSession.destroy()
365
366 return this.onAfterMuxingCleanup({ videoUUID, liveSession })
367 .catch(err => logger.error('Error in end transmuxing.', { err, ...localLTags }))
368 })
369
370 this.muxingSessions.set(sessionId, muxingSession)
371
372 muxingSession.runMuxing()
373 .catch(err => {
374 logger.error('Cannot run muxing.', { err, ...localLTags })
375 this.abortSession(sessionId)
376 })
377 }
378
379 private async publishAndFederateLive (live: MVideoLiveVideo, localLTags: { tags: string[] }) {
380 const videoId = live.videoId
381
382 try {
383 const video = await VideoModel.loadFull(videoId)
384
385 logger.info('Will publish and federate live %s.', video.url, localLTags)
386
387 video.state = VideoState.PUBLISHED
388 video.publishedAt = new Date()
389 await video.save()
390
391 live.Video = video
392
393 await wait(getLiveSegmentTime(live.latencyMode) * 1000 * VIDEO_LIVE.EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION)
394
395 try {
396 await federateVideoIfNeeded(video, false)
397 } catch (err) {
398 logger.error('Cannot federate live video %s.', video.url, { err, ...localLTags })
399 }
400
401 PeerTubeSocket.Instance.sendVideoLiveNewState(video)
402
403 Hooks.runAction('action:live.video.state.updated', { video })
404 } catch (err) {
405 logger.error('Cannot save/federate live video %d.', videoId, { err, ...localLTags })
406 }
407 }
408
409 private onMuxingFFmpegEnd (videoUUID: string, sessionId: string) {
410 // Session already cleaned up
411 if (!this.videoSessions.has(videoUUID)) return
412
413 this.videoSessions.delete(videoUUID)
414
415 this.saveEndingSession(videoUUID, null)
416 .catch(err => logger.error('Cannot save ending session.', { err, ...lTags(sessionId) }))
417 }
418
419 private async onAfterMuxingCleanup (options: {
420 videoUUID: string
421 liveSession?: MVideoLiveSession
422 cleanupNow?: boolean // Default false
423 }) {
424 const { videoUUID, liveSession: liveSessionArg, cleanupNow = false } = options
425
426 logger.debug('Live of video %s has been cleaned up. Moving to its next state.', videoUUID, lTags(videoUUID))
427
428 try {
429 const fullVideo = await VideoModel.loadFull(videoUUID)
430 if (!fullVideo) return
431
432 const live = await VideoLiveModel.loadByVideoId(fullVideo.id)
433
434 const liveSession = liveSessionArg ?? await VideoLiveSessionModel.findLatestSessionOf(fullVideo.id)
435
436 // On server restart during a live
437 if (!liveSession.endDate) {
438 liveSession.endDate = new Date()
439 await liveSession.save()
440 }
441
442 JobQueue.Instance.createJobAsync({
443 type: 'video-live-ending',
444 payload: {
445 videoId: fullVideo.id,
446
447 replayDirectory: live.saveReplay
448 ? await this.findReplayDirectory(fullVideo)
449 : undefined,
450
451 liveSessionId: liveSession.id,
452 streamingPlaylistId: fullVideo.getHLSPlaylist()?.id,
453
454 publishedAt: fullVideo.publishedAt.toISOString()
455 },
456
457 delay: cleanupNow
458 ? 0
459 : VIDEO_LIVE.CLEANUP_DELAY
460 })
461
462 fullVideo.state = live.permanentLive
463 ? VideoState.WAITING_FOR_LIVE
464 : VideoState.LIVE_ENDED
465
466 await fullVideo.save()
467
468 PeerTubeSocket.Instance.sendVideoLiveNewState(fullVideo)
469
470 await federateVideoIfNeeded(fullVideo, false)
471
472 Hooks.runAction('action:live.video.state.updated', { video: fullVideo })
473 } catch (err) {
474 logger.error('Cannot save/federate new video state of live streaming of video %s.', videoUUID, { err, ...lTags(videoUUID) })
475 }
476 }
477
478 private async handleBrokenLives () {
479 await RunnerJobModel.cancelAllJobs({ type: 'live-rtmp-hls-transcoding' })
480
481 const videoUUIDs = await VideoModel.listPublishedLiveUUIDs()
482
483 for (const uuid of videoUUIDs) {
484 await this.onAfterMuxingCleanup({ videoUUID: uuid, cleanupNow: true })
485 }
486 }
487
488 private async findReplayDirectory (video: MVideo) {
489 const directory = getLiveReplayBaseDirectory(video)
490 const files = await readdir(directory)
491
492 if (files.length === 0) return undefined
493
494 return join(directory, files.sort().reverse()[0])
495 }
496
497 private buildAllResolutionsToTranscode (originResolution: number, hasAudio: boolean) {
498 const includeInput = CONFIG.LIVE.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION
499
500 const resolutionsEnabled = CONFIG.LIVE.TRANSCODING.ENABLED
501 ? computeResolutionsToTranscode({ input: originResolution, type: 'live', includeInput, strictLower: false, hasAudio })
502 : []
503
504 if (resolutionsEnabled.length === 0) {
505 return [ originResolution ]
506 }
507
508 return resolutionsEnabled
509 }
510
511 private async saveStartingSession (videoLive: MVideoLiveVideoWithSetting) {
512 const replaySettings = videoLive.saveReplay
513 ? new VideoLiveReplaySettingModel({
514 privacy: videoLive.ReplaySetting.privacy
515 })
516 : null
517
518 return sequelizeTypescript.transaction(async t => {
519 if (videoLive.saveReplay) {
520 await replaySettings.save({ transaction: t })
521 }
522
523 return VideoLiveSessionModel.create({
524 startDate: new Date(),
525 liveVideoId: videoLive.videoId,
526 saveReplay: videoLive.saveReplay,
527 replaySettingId: videoLive.saveReplay ? replaySettings.id : null,
528 endingProcessed: false
529 }, { transaction: t })
530 })
531 }
532
533 private async saveEndingSession (videoUUID: string, error: LiveVideoError | null) {
534 const liveSession = await VideoLiveSessionModel.findCurrentSessionOf(videoUUID)
535 if (!liveSession) return
536
537 liveSession.endDate = new Date()
538 liveSession.error = error
539
540 return liveSession.save()
541 }
542
543 static get Instance () {
544 return this.instance || (this.instance = new this())
545 }
546}
547
548// ---------------------------------------------------------------------------
549
550export {
551 LiveManager
552}
diff --git a/server/lib/live/live-quota-store.ts b/server/lib/live/live-quota-store.ts
deleted file mode 100644
index 44539faaa..000000000
--- a/server/lib/live/live-quota-store.ts
+++ /dev/null
@@ -1,48 +0,0 @@
1class LiveQuotaStore {
2
3 private static instance: LiveQuotaStore
4
5 private readonly livesPerUser = new Map<number, { sessionId: string, size: number }[]>()
6
7 private constructor () {
8 }
9
10 addNewLive (userId: number, sessionId: string) {
11 if (!this.livesPerUser.has(userId)) {
12 this.livesPerUser.set(userId, [])
13 }
14
15 const currentUserLive = { sessionId, size: 0 }
16 const livesOfUser = this.livesPerUser.get(userId)
17 livesOfUser.push(currentUserLive)
18 }
19
20 removeLive (userId: number, sessionId: string) {
21 const newLivesPerUser = this.livesPerUser.get(userId)
22 .filter(o => o.sessionId !== sessionId)
23
24 this.livesPerUser.set(userId, newLivesPerUser)
25 }
26
27 addQuotaTo (userId: number, sessionId: string, size: number) {
28 const lives = this.livesPerUser.get(userId)
29 const live = lives.find(l => l.sessionId === sessionId)
30
31 live.size += size
32 }
33
34 getLiveQuotaOf (userId: number) {
35 const currentLives = this.livesPerUser.get(userId)
36 if (!currentLives) return 0
37
38 return currentLives.reduce((sum, obj) => sum + obj.size, 0)
39 }
40
41 static get Instance () {
42 return this.instance || (this.instance = new this())
43 }
44}
45
46export {
47 LiveQuotaStore
48}
diff --git a/server/lib/live/live-segment-sha-store.ts b/server/lib/live/live-segment-sha-store.ts
deleted file mode 100644
index 8253c0274..000000000
--- a/server/lib/live/live-segment-sha-store.ts
+++ /dev/null
@@ -1,95 +0,0 @@
1import { rename, writeJson } from 'fs-extra'
2import PQueue from 'p-queue'
3import { basename } from 'path'
4import { mapToJSON } from '@server/helpers/core-utils'
5import { logger, loggerTagsFactory } from '@server/helpers/logger'
6import { MStreamingPlaylistVideo } from '@server/types/models'
7import { buildSha256Segment } from '../hls'
8import { storeHLSFileFromPath } from '../object-storage'
9
10const lTags = loggerTagsFactory('live')
11
12class LiveSegmentShaStore {
13
14 private readonly segmentsSha256 = new Map<string, string>()
15
16 private readonly videoUUID: string
17
18 private readonly sha256Path: string
19 private readonly sha256PathTMP: string
20
21 private readonly streamingPlaylist: MStreamingPlaylistVideo
22 private readonly sendToObjectStorage: boolean
23 private readonly writeQueue = new PQueue({ concurrency: 1 })
24
25 constructor (options: {
26 videoUUID: string
27 sha256Path: string
28 streamingPlaylist: MStreamingPlaylistVideo
29 sendToObjectStorage: boolean
30 }) {
31 this.videoUUID = options.videoUUID
32
33 this.sha256Path = options.sha256Path
34 this.sha256PathTMP = options.sha256Path + '.tmp'
35
36 this.streamingPlaylist = options.streamingPlaylist
37 this.sendToObjectStorage = options.sendToObjectStorage
38 }
39
40 async addSegmentSha (segmentPath: string) {
41 logger.debug('Adding live sha segment %s.', segmentPath, lTags(this.videoUUID))
42
43 const shaResult = await buildSha256Segment(segmentPath)
44
45 const segmentName = basename(segmentPath)
46 this.segmentsSha256.set(segmentName, shaResult)
47
48 try {
49 await this.writeToDisk()
50 } catch (err) {
51 logger.error('Cannot write sha segments to disk.', { err })
52 }
53 }
54
55 async removeSegmentSha (segmentPath: string) {
56 const segmentName = basename(segmentPath)
57
58 logger.debug('Removing live sha segment %s.', segmentPath, lTags(this.videoUUID))
59
60 if (!this.segmentsSha256.has(segmentName)) {
61 logger.warn(
62 'Unknown segment in live segment hash store for video %s and segment %s.',
63 this.videoUUID, segmentPath, lTags(this.videoUUID)
64 )
65 return
66 }
67
68 this.segmentsSha256.delete(segmentName)
69
70 await this.writeToDisk()
71 }
72
73 private writeToDisk () {
74 return this.writeQueue.add(async () => {
75 logger.debug(`Writing segment sha JSON ${this.sha256Path} of ${this.videoUUID} on disk.`, lTags(this.videoUUID))
76
77 // Atomic write: use rename instead of move that is not atomic
78 await writeJson(this.sha256PathTMP, mapToJSON(this.segmentsSha256))
79 await rename(this.sha256PathTMP, this.sha256Path)
80
81 if (this.sendToObjectStorage) {
82 const url = await storeHLSFileFromPath(this.streamingPlaylist, this.sha256Path)
83
84 if (this.streamingPlaylist.segmentsSha256Url !== url) {
85 this.streamingPlaylist.segmentsSha256Url = url
86 await this.streamingPlaylist.save()
87 }
88 }
89 })
90 }
91}
92
93export {
94 LiveSegmentShaStore
95}
diff --git a/server/lib/live/live-utils.ts b/server/lib/live/live-utils.ts
deleted file mode 100644
index 3fb3ce1ce..000000000
--- a/server/lib/live/live-utils.ts
+++ /dev/null
@@ -1,99 +0,0 @@
1import { pathExists, readdir, remove } from 'fs-extra'
2import { basename, join } from 'path'
3import { logger } from '@server/helpers/logger'
4import { VIDEO_LIVE } from '@server/initializers/constants'
5import { MStreamingPlaylist, MStreamingPlaylistVideo, MVideo } from '@server/types/models'
6import { LiveVideoLatencyMode, VideoStorage } from '@shared/models'
7import { listHLSFileKeysOf, removeHLSFileObjectStorageByFullKey, removeHLSObjectStorage } from '../object-storage'
8import { getLiveDirectory } from '../paths'
9
10function buildConcatenatedName (segmentOrPlaylistPath: string) {
11 const num = basename(segmentOrPlaylistPath).match(/^(\d+)(-|\.)/)
12
13 return 'concat-' + num[1] + '.ts'
14}
15
16async function cleanupAndDestroyPermanentLive (video: MVideo, streamingPlaylist: MStreamingPlaylist) {
17 await cleanupTMPLiveFiles(video, streamingPlaylist)
18
19 await streamingPlaylist.destroy()
20}
21
22async function cleanupUnsavedNormalLive (video: MVideo, streamingPlaylist: MStreamingPlaylist) {
23 const hlsDirectory = getLiveDirectory(video)
24
25 // We uploaded files to object storage too, remove them
26 if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
27 await removeHLSObjectStorage(streamingPlaylist.withVideo(video))
28 }
29
30 await remove(hlsDirectory)
31
32 await streamingPlaylist.destroy()
33}
34
35async function cleanupTMPLiveFiles (video: MVideo, streamingPlaylist: MStreamingPlaylist) {
36 await cleanupTMPLiveFilesFromObjectStorage(streamingPlaylist.withVideo(video))
37
38 await cleanupTMPLiveFilesFromFilesystem(video)
39}
40
41function getLiveSegmentTime (latencyMode: LiveVideoLatencyMode) {
42 if (latencyMode === LiveVideoLatencyMode.SMALL_LATENCY) {
43 return VIDEO_LIVE.SEGMENT_TIME_SECONDS.SMALL_LATENCY
44 }
45
46 return VIDEO_LIVE.SEGMENT_TIME_SECONDS.DEFAULT_LATENCY
47}
48
49export {
50 cleanupAndDestroyPermanentLive,
51 cleanupUnsavedNormalLive,
52 cleanupTMPLiveFiles,
53 getLiveSegmentTime,
54 buildConcatenatedName
55}
56
57// ---------------------------------------------------------------------------
58
59function isTMPLiveFile (name: string) {
60 return name.endsWith('.ts') ||
61 name.endsWith('.m3u8') ||
62 name.endsWith('.json') ||
63 name.endsWith('.mpd') ||
64 name.endsWith('.m4s') ||
65 name.endsWith('.tmp')
66}
67
68async function cleanupTMPLiveFilesFromFilesystem (video: MVideo) {
69 const hlsDirectory = getLiveDirectory(video)
70
71 if (!await pathExists(hlsDirectory)) return
72
73 logger.info('Cleanup TMP live files from filesystem of %s.', hlsDirectory)
74
75 const files = await readdir(hlsDirectory)
76
77 for (const filename of files) {
78 if (isTMPLiveFile(filename)) {
79 const p = join(hlsDirectory, filename)
80
81 remove(p)
82 .catch(err => logger.error('Cannot remove %s.', p, { err }))
83 }
84 }
85}
86
87async function cleanupTMPLiveFilesFromObjectStorage (streamingPlaylist: MStreamingPlaylistVideo) {
88 if (streamingPlaylist.storage !== VideoStorage.OBJECT_STORAGE) return
89
90 logger.info('Cleanup TMP live files from object storage for %s.', streamingPlaylist.Video.uuid)
91
92 const keys = await listHLSFileKeysOf(streamingPlaylist)
93
94 for (const key of keys) {
95 if (isTMPLiveFile(key)) {
96 await removeHLSFileObjectStorageByFullKey(key)
97 }
98 }
99}
diff --git a/server/lib/live/shared/index.ts b/server/lib/live/shared/index.ts
deleted file mode 100644
index c4d1b59ec..000000000
--- a/server/lib/live/shared/index.ts
+++ /dev/null
@@ -1 +0,0 @@
1export * from './muxing-session'
diff --git a/server/lib/live/shared/muxing-session.ts b/server/lib/live/shared/muxing-session.ts
deleted file mode 100644
index 02691b651..000000000
--- a/server/lib/live/shared/muxing-session.ts
+++ /dev/null
@@ -1,518 +0,0 @@
1import { mapSeries } from 'bluebird'
2import { FSWatcher, watch } from 'chokidar'
3import { EventEmitter } from 'events'
4import { appendFile, ensureDir, readFile, stat } from 'fs-extra'
5import PQueue from 'p-queue'
6import { basename, join } from 'path'
7import { computeOutputFPS } from '@server/helpers/ffmpeg'
8import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger'
9import { CONFIG } from '@server/initializers/config'
10import { MEMOIZE_TTL, P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE } from '@server/initializers/constants'
11import { removeHLSFileObjectStorageByPath, storeHLSFileFromContent, storeHLSFileFromPath } from '@server/lib/object-storage'
12import { VideoFileModel } from '@server/models/video/video-file'
13import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
14import { MStreamingPlaylistVideo, MUserId, MVideoLiveVideo } from '@server/types/models'
15import { VideoStorage, VideoStreamingPlaylistType } from '@shared/models'
16import {
17 generateHLSMasterPlaylistFilename,
18 generateHlsSha256SegmentsFilename,
19 getLiveDirectory,
20 getLiveReplayBaseDirectory
21} from '../../paths'
22import { isAbleToUploadVideo } from '../../user'
23import { LiveQuotaStore } from '../live-quota-store'
24import { LiveSegmentShaStore } from '../live-segment-sha-store'
25import { buildConcatenatedName, getLiveSegmentTime } from '../live-utils'
26import { AbstractTranscodingWrapper, FFmpegTranscodingWrapper, RemoteTranscodingWrapper } from './transcoding-wrapper'
27
28import memoizee = require('memoizee')
29interface MuxingSessionEvents {
30 'live-ready': (options: { videoUUID: string }) => void
31
32 'bad-socket-health': (options: { videoUUID: string }) => void
33 'duration-exceeded': (options: { videoUUID: string }) => void
34 'quota-exceeded': (options: { videoUUID: string }) => void
35
36 'transcoding-end': (options: { videoUUID: string }) => void
37 'transcoding-error': (options: { videoUUID: string }) => void
38
39 'after-cleanup': (options: { videoUUID: string }) => void
40}
41
42declare interface MuxingSession {
43 on<U extends keyof MuxingSessionEvents>(
44 event: U, listener: MuxingSessionEvents[U]
45 ): this
46
47 emit<U extends keyof MuxingSessionEvents>(
48 event: U, ...args: Parameters<MuxingSessionEvents[U]>
49 ): boolean
50}
51
52class MuxingSession extends EventEmitter {
53
54 private transcodingWrapper: AbstractTranscodingWrapper
55
56 private readonly context: any
57 private readonly user: MUserId
58 private readonly sessionId: string
59 private readonly videoLive: MVideoLiveVideo
60
61 private readonly inputLocalUrl: string
62 private readonly inputPublicUrl: string
63
64 private readonly fps: number
65 private readonly allResolutions: number[]
66
67 private readonly bitrate: number
68 private readonly ratio: number
69
70 private readonly hasAudio: boolean
71
72 private readonly videoUUID: string
73 private readonly saveReplay: boolean
74
75 private readonly outDirectory: string
76 private readonly replayDirectory: string
77
78 private readonly lTags: LoggerTagsFn
79
80 // Path -> Queue
81 private readonly objectStorageSendQueues = new Map<string, PQueue>()
82
83 private segmentsToProcessPerPlaylist: { [playlistId: string]: string[] } = {}
84
85 private streamingPlaylist: MStreamingPlaylistVideo
86 private liveSegmentShaStore: LiveSegmentShaStore
87
88 private filesWatcher: FSWatcher
89
90 private masterPlaylistCreated = false
91 private liveReady = false
92
93 private aborted = false
94
95 private readonly isAbleToUploadVideoWithCache = memoizee((userId: number) => {
96 return isAbleToUploadVideo(userId, 1000)
97 }, { maxAge: MEMOIZE_TTL.LIVE_ABLE_TO_UPLOAD })
98
99 private readonly hasClientSocketInBadHealthWithCache = memoizee((sessionId: string) => {
100 return this.hasClientSocketInBadHealth(sessionId)
101 }, { maxAge: MEMOIZE_TTL.LIVE_CHECK_SOCKET_HEALTH })
102
103 constructor (options: {
104 context: any
105 user: MUserId
106 sessionId: string
107 videoLive: MVideoLiveVideo
108
109 inputLocalUrl: string
110 inputPublicUrl: string
111
112 fps: number
113 bitrate: number
114 ratio: number
115 allResolutions: number[]
116 hasAudio: boolean
117 }) {
118 super()
119
120 this.context = options.context
121 this.user = options.user
122 this.sessionId = options.sessionId
123 this.videoLive = options.videoLive
124
125 this.inputLocalUrl = options.inputLocalUrl
126 this.inputPublicUrl = options.inputPublicUrl
127
128 this.fps = options.fps
129
130 this.bitrate = options.bitrate
131 this.ratio = options.ratio
132
133 this.hasAudio = options.hasAudio
134
135 this.allResolutions = options.allResolutions
136
137 this.videoUUID = this.videoLive.Video.uuid
138
139 this.saveReplay = this.videoLive.saveReplay
140
141 this.outDirectory = getLiveDirectory(this.videoLive.Video)
142 this.replayDirectory = join(getLiveReplayBaseDirectory(this.videoLive.Video), new Date().toISOString())
143
144 this.lTags = loggerTagsFactory('live', this.sessionId, this.videoUUID)
145 }
146
147 async runMuxing () {
148 this.streamingPlaylist = await this.createLivePlaylist()
149
150 this.createLiveShaStore()
151 this.createFiles()
152
153 await this.prepareDirectories()
154
155 this.transcodingWrapper = this.buildTranscodingWrapper()
156
157 this.transcodingWrapper.on('end', () => this.onTranscodedEnded())
158 this.transcodingWrapper.on('error', () => this.onTranscodingError())
159
160 await this.transcodingWrapper.run()
161
162 this.filesWatcher = watch(this.outDirectory, { depth: 0 })
163
164 this.watchMasterFile()
165 this.watchTSFiles()
166 }
167
168 abort () {
169 if (!this.transcodingWrapper) return
170
171 this.aborted = true
172 this.transcodingWrapper.abort()
173 }
174
175 destroy () {
176 this.removeAllListeners()
177 this.isAbleToUploadVideoWithCache.clear()
178 this.hasClientSocketInBadHealthWithCache.clear()
179 }
180
181 private watchMasterFile () {
182 this.filesWatcher.on('add', async path => {
183 if (path !== join(this.outDirectory, this.streamingPlaylist.playlistFilename)) return
184 if (this.masterPlaylistCreated === true) return
185
186 try {
187 if (this.streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
188 const masterContent = await readFile(path, 'utf-8')
189 logger.debug('Uploading live master playlist on object storage for %s', this.videoUUID, { masterContent, ...this.lTags() })
190
191 const url = await storeHLSFileFromContent(this.streamingPlaylist, this.streamingPlaylist.playlistFilename, masterContent)
192
193 this.streamingPlaylist.playlistUrl = url
194 }
195
196 this.streamingPlaylist.assignP2PMediaLoaderInfoHashes(this.videoLive.Video, this.allResolutions)
197
198 await this.streamingPlaylist.save()
199 } catch (err) {
200 logger.error('Cannot update streaming playlist.', { err, ...this.lTags() })
201 }
202
203 this.masterPlaylistCreated = true
204
205 logger.info('Master playlist file for %s has been created', this.videoUUID, this.lTags())
206 })
207 }
208
209 private watchTSFiles () {
210 const startStreamDateTime = new Date().getTime()
211
212 const addHandler = async (segmentPath: string) => {
213 if (segmentPath.endsWith('.ts') !== true) return
214
215 logger.debug('Live add handler of TS file %s.', segmentPath, this.lTags())
216
217 const playlistId = this.getPlaylistIdFromTS(segmentPath)
218
219 const segmentsToProcess = this.segmentsToProcessPerPlaylist[playlistId] || []
220 this.processSegments(segmentsToProcess)
221
222 this.segmentsToProcessPerPlaylist[playlistId] = [ segmentPath ]
223
224 if (this.hasClientSocketInBadHealthWithCache(this.sessionId)) {
225 this.emit('bad-socket-health', { videoUUID: this.videoUUID })
226 return
227 }
228
229 // Duration constraint check
230 if (this.isDurationConstraintValid(startStreamDateTime) !== true) {
231 this.emit('duration-exceeded', { videoUUID: this.videoUUID })
232 return
233 }
234
235 // Check user quota if the user enabled replay saving
236 if (await this.isQuotaExceeded(segmentPath) === true) {
237 this.emit('quota-exceeded', { videoUUID: this.videoUUID })
238 }
239 }
240
241 const deleteHandler = async (segmentPath: string) => {
242 if (segmentPath.endsWith('.ts') !== true) return
243
244 logger.debug('Live delete handler of TS file %s.', segmentPath, this.lTags())
245
246 try {
247 await this.liveSegmentShaStore.removeSegmentSha(segmentPath)
248 } catch (err) {
249 logger.warn('Cannot remove segment sha %s from sha store', segmentPath, { err, ...this.lTags() })
250 }
251
252 if (this.streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
253 try {
254 await removeHLSFileObjectStorageByPath(this.streamingPlaylist, segmentPath)
255 } catch (err) {
256 logger.error('Cannot remove segment %s from object storage', segmentPath, { err, ...this.lTags() })
257 }
258 }
259 }
260
261 this.filesWatcher.on('add', p => addHandler(p))
262 this.filesWatcher.on('unlink', p => deleteHandler(p))
263 }
264
265 private async isQuotaExceeded (segmentPath: string) {
266 if (this.saveReplay !== true) return false
267 if (this.aborted) return false
268
269 try {
270 const segmentStat = await stat(segmentPath)
271
272 LiveQuotaStore.Instance.addQuotaTo(this.user.id, this.sessionId, segmentStat.size)
273
274 const canUpload = await this.isAbleToUploadVideoWithCache(this.user.id)
275
276 return canUpload !== true
277 } catch (err) {
278 logger.error('Cannot stat %s or check quota of %d.', segmentPath, this.user.id, { err, ...this.lTags() })
279 }
280 }
281
282 private createFiles () {
283 for (let i = 0; i < this.allResolutions.length; i++) {
284 const resolution = this.allResolutions[i]
285
286 const file = new VideoFileModel({
287 resolution,
288 size: -1,
289 extname: '.ts',
290 infoHash: null,
291 fps: this.fps,
292 storage: this.streamingPlaylist.storage,
293 videoStreamingPlaylistId: this.streamingPlaylist.id
294 })
295
296 VideoFileModel.customUpsert(file, 'streaming-playlist', null)
297 .catch(err => logger.error('Cannot create file for live streaming.', { err, ...this.lTags() }))
298 }
299 }
300
301 private async prepareDirectories () {
302 await ensureDir(this.outDirectory)
303
304 if (this.videoLive.saveReplay === true) {
305 await ensureDir(this.replayDirectory)
306 }
307 }
308
309 private isDurationConstraintValid (streamingStartTime: number) {
310 const maxDuration = CONFIG.LIVE.MAX_DURATION
311 // No limit
312 if (maxDuration < 0) return true
313
314 const now = new Date().getTime()
315 const max = streamingStartTime + maxDuration
316
317 return now <= max
318 }
319
320 private processSegments (segmentPaths: string[]) {
321 mapSeries(segmentPaths, previousSegment => this.processSegment(previousSegment))
322 .catch(err => {
323 if (this.aborted) return
324
325 logger.error('Cannot process segments', { err, ...this.lTags() })
326 })
327 }
328
329 private async processSegment (segmentPath: string) {
330 // Add sha hash of previous segments, because ffmpeg should have finished generating them
331 await this.liveSegmentShaStore.addSegmentSha(segmentPath)
332
333 if (this.saveReplay) {
334 await this.addSegmentToReplay(segmentPath)
335 }
336
337 if (this.streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
338 try {
339 await storeHLSFileFromPath(this.streamingPlaylist, segmentPath)
340
341 await this.processM3U8ToObjectStorage(segmentPath)
342 } catch (err) {
343 logger.error('Cannot store TS segment %s in object storage', segmentPath, { err, ...this.lTags() })
344 }
345 }
346
347 // Master playlist and segment JSON file are created, live is ready
348 if (this.masterPlaylistCreated && !this.liveReady) {
349 this.liveReady = true
350
351 this.emit('live-ready', { videoUUID: this.videoUUID })
352 }
353 }
354
355 private async processM3U8ToObjectStorage (segmentPath: string) {
356 const m3u8Path = join(this.outDirectory, this.getPlaylistNameFromTS(segmentPath))
357
358 logger.debug('Process M3U8 file %s.', m3u8Path, this.lTags())
359
360 const segmentName = basename(segmentPath)
361
362 const playlistContent = await readFile(m3u8Path, 'utf-8')
363 // Remove new chunk references, that will be processed later
364 const filteredPlaylistContent = playlistContent.substring(0, playlistContent.lastIndexOf(segmentName) + segmentName.length) + '\n'
365
366 try {
367 if (!this.objectStorageSendQueues.has(m3u8Path)) {
368 this.objectStorageSendQueues.set(m3u8Path, new PQueue({ concurrency: 1 }))
369 }
370
371 const queue = this.objectStorageSendQueues.get(m3u8Path)
372 await queue.add(() => storeHLSFileFromContent(this.streamingPlaylist, m3u8Path, filteredPlaylistContent))
373 } catch (err) {
374 logger.error('Cannot store in object storage m3u8 file %s', m3u8Path, { err, ...this.lTags() })
375 }
376 }
377
378 private onTranscodingError () {
379 this.emit('transcoding-error', ({ videoUUID: this.videoUUID }))
380 }
381
382 private onTranscodedEnded () {
383 this.emit('transcoding-end', ({ videoUUID: this.videoUUID }))
384
385 logger.info('RTMP transmuxing for video %s ended. Scheduling cleanup', this.inputLocalUrl, this.lTags())
386
387 setTimeout(() => {
388 // Wait latest segments generation, and close watchers
389
390 const promise = this.filesWatcher?.close() || Promise.resolve()
391 promise
392 .then(() => {
393 // Process remaining segments hash
394 for (const key of Object.keys(this.segmentsToProcessPerPlaylist)) {
395 this.processSegments(this.segmentsToProcessPerPlaylist[key])
396 }
397 })
398 .catch(err => {
399 logger.error(
400 'Cannot close watchers of %s or process remaining hash segments.', this.outDirectory,
401 { err, ...this.lTags() }
402 )
403 })
404
405 this.emit('after-cleanup', { videoUUID: this.videoUUID })
406 }, 1000)
407 }
408
409 private hasClientSocketInBadHealth (sessionId: string) {
410 const rtmpSession = this.context.sessions.get(sessionId)
411
412 if (!rtmpSession) {
413 logger.warn('Cannot get session %s to check players socket health.', sessionId, this.lTags())
414 return
415 }
416
417 for (const playerSessionId of rtmpSession.players) {
418 const playerSession = this.context.sessions.get(playerSessionId)
419
420 if (!playerSession) {
421 logger.error('Cannot get player session %s to check socket health.', playerSession, this.lTags())
422 continue
423 }
424
425 if (playerSession.socket.writableLength > VIDEO_LIVE.MAX_SOCKET_WAITING_DATA) {
426 return true
427 }
428 }
429
430 return false
431 }
432
433 private async addSegmentToReplay (segmentPath: string) {
434 const segmentName = basename(segmentPath)
435 const dest = join(this.replayDirectory, buildConcatenatedName(segmentName))
436
437 try {
438 const data = await readFile(segmentPath)
439
440 await appendFile(dest, data)
441 } catch (err) {
442 logger.error('Cannot copy segment %s to replay directory.', segmentPath, { err, ...this.lTags() })
443 }
444 }
445
446 private async createLivePlaylist (): Promise<MStreamingPlaylistVideo> {
447 const playlist = await VideoStreamingPlaylistModel.loadOrGenerate(this.videoLive.Video)
448
449 playlist.playlistFilename = generateHLSMasterPlaylistFilename(true)
450 playlist.segmentsSha256Filename = generateHlsSha256SegmentsFilename(true)
451
452 playlist.p2pMediaLoaderPeerVersion = P2P_MEDIA_LOADER_PEER_VERSION
453 playlist.type = VideoStreamingPlaylistType.HLS
454
455 playlist.storage = CONFIG.OBJECT_STORAGE.ENABLED
456 ? VideoStorage.OBJECT_STORAGE
457 : VideoStorage.FILE_SYSTEM
458
459 return playlist.save()
460 }
461
462 private createLiveShaStore () {
463 this.liveSegmentShaStore = new LiveSegmentShaStore({
464 videoUUID: this.videoLive.Video.uuid,
465 sha256Path: join(this.outDirectory, this.streamingPlaylist.segmentsSha256Filename),
466 streamingPlaylist: this.streamingPlaylist,
467 sendToObjectStorage: CONFIG.OBJECT_STORAGE.ENABLED
468 })
469 }
470
471 private buildTranscodingWrapper () {
472 const options = {
473 streamingPlaylist: this.streamingPlaylist,
474 videoLive: this.videoLive,
475
476 lTags: this.lTags,
477
478 sessionId: this.sessionId,
479 inputLocalUrl: this.inputLocalUrl,
480 inputPublicUrl: this.inputPublicUrl,
481
482 toTranscode: this.allResolutions.map(resolution => ({
483 resolution,
484 fps: computeOutputFPS({ inputFPS: this.fps, resolution })
485 })),
486
487 fps: this.fps,
488 bitrate: this.bitrate,
489 ratio: this.ratio,
490 hasAudio: this.hasAudio,
491
492 segmentListSize: VIDEO_LIVE.SEGMENTS_LIST_SIZE,
493 segmentDuration: getLiveSegmentTime(this.videoLive.latencyMode),
494
495 outDirectory: this.outDirectory
496 }
497
498 return CONFIG.LIVE.TRANSCODING.ENABLED && CONFIG.LIVE.TRANSCODING.REMOTE_RUNNERS.ENABLED
499 ? new RemoteTranscodingWrapper(options)
500 : new FFmpegTranscodingWrapper(options)
501 }
502
503 private getPlaylistIdFromTS (segmentPath: string) {
504 const playlistIdMatcher = /^([\d+])-/
505
506 return basename(segmentPath).match(playlistIdMatcher)[1]
507 }
508
509 private getPlaylistNameFromTS (segmentPath: string) {
510 return `${this.getPlaylistIdFromTS(segmentPath)}.m3u8`
511 }
512}
513
514// ---------------------------------------------------------------------------
515
516export {
517 MuxingSession
518}
diff --git a/server/lib/live/shared/transcoding-wrapper/abstract-transcoding-wrapper.ts b/server/lib/live/shared/transcoding-wrapper/abstract-transcoding-wrapper.ts
deleted file mode 100644
index 95168745d..000000000
--- a/server/lib/live/shared/transcoding-wrapper/abstract-transcoding-wrapper.ts
+++ /dev/null
@@ -1,110 +0,0 @@
1import EventEmitter from 'events'
2import { LoggerTagsFn } from '@server/helpers/logger'
3import { MStreamingPlaylistVideo, MVideoLiveVideo } from '@server/types/models'
4import { LiveVideoError } from '@shared/models'
5
6interface TranscodingWrapperEvents {
7 'end': () => void
8
9 'error': (options: { err: Error }) => void
10}
11
12declare interface AbstractTranscodingWrapper {
13 on<U extends keyof TranscodingWrapperEvents>(
14 event: U, listener: TranscodingWrapperEvents[U]
15 ): this
16
17 emit<U extends keyof TranscodingWrapperEvents>(
18 event: U, ...args: Parameters<TranscodingWrapperEvents[U]>
19 ): boolean
20}
21
22interface AbstractTranscodingWrapperOptions {
23 streamingPlaylist: MStreamingPlaylistVideo
24 videoLive: MVideoLiveVideo
25
26 lTags: LoggerTagsFn
27
28 sessionId: string
29 inputLocalUrl: string
30 inputPublicUrl: string
31
32 fps: number
33 toTranscode: {
34 resolution: number
35 fps: number
36 }[]
37
38 bitrate: number
39 ratio: number
40 hasAudio: boolean
41
42 segmentListSize: number
43 segmentDuration: number
44
45 outDirectory: string
46}
47
48abstract class AbstractTranscodingWrapper extends EventEmitter {
49 protected readonly videoLive: MVideoLiveVideo
50
51 protected readonly toTranscode: {
52 resolution: number
53 fps: number
54 }[]
55
56 protected readonly sessionId: string
57 protected readonly inputLocalUrl: string
58 protected readonly inputPublicUrl: string
59
60 protected readonly fps: number
61 protected readonly bitrate: number
62 protected readonly ratio: number
63 protected readonly hasAudio: boolean
64
65 protected readonly segmentListSize: number
66 protected readonly segmentDuration: number
67
68 protected readonly videoUUID: string
69
70 protected readonly outDirectory: string
71
72 protected readonly lTags: LoggerTagsFn
73
74 protected readonly streamingPlaylist: MStreamingPlaylistVideo
75
76 constructor (options: AbstractTranscodingWrapperOptions) {
77 super()
78
79 this.lTags = options.lTags
80
81 this.videoLive = options.videoLive
82 this.videoUUID = options.videoLive.Video.uuid
83 this.streamingPlaylist = options.streamingPlaylist
84
85 this.sessionId = options.sessionId
86 this.inputLocalUrl = options.inputLocalUrl
87 this.inputPublicUrl = options.inputPublicUrl
88
89 this.fps = options.fps
90 this.toTranscode = options.toTranscode
91
92 this.bitrate = options.bitrate
93 this.ratio = options.ratio
94 this.hasAudio = options.hasAudio
95
96 this.segmentListSize = options.segmentListSize
97 this.segmentDuration = options.segmentDuration
98
99 this.outDirectory = options.outDirectory
100 }
101
102 abstract run (): Promise<void>
103
104 abstract abort (error?: LiveVideoError): void
105}
106
107export {
108 AbstractTranscodingWrapper,
109 AbstractTranscodingWrapperOptions
110}
diff --git a/server/lib/live/shared/transcoding-wrapper/ffmpeg-transcoding-wrapper.ts b/server/lib/live/shared/transcoding-wrapper/ffmpeg-transcoding-wrapper.ts
deleted file mode 100644
index c6ee8ebf1..000000000
--- a/server/lib/live/shared/transcoding-wrapper/ffmpeg-transcoding-wrapper.ts
+++ /dev/null
@@ -1,107 +0,0 @@
1import { FfmpegCommand } from 'fluent-ffmpeg'
2import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg'
3import { logger } from '@server/helpers/logger'
4import { CONFIG } from '@server/initializers/config'
5import { VIDEO_LIVE } from '@server/initializers/constants'
6import { VideoTranscodingProfilesManager } from '@server/lib/transcoding/default-transcoding-profiles'
7import { FFmpegLive } from '@shared/ffmpeg'
8import { getLiveSegmentTime } from '../../live-utils'
9import { AbstractTranscodingWrapper } from './abstract-transcoding-wrapper'
10
11export class FFmpegTranscodingWrapper extends AbstractTranscodingWrapper {
12 private ffmpegCommand: FfmpegCommand
13
14 private aborted = false
15 private errored = false
16 private ended = false
17
18 async run () {
19 this.ffmpegCommand = CONFIG.LIVE.TRANSCODING.ENABLED
20 ? await this.buildFFmpegLive().getLiveTranscodingCommand({
21 inputUrl: this.inputLocalUrl,
22
23 outPath: this.outDirectory,
24 masterPlaylistName: this.streamingPlaylist.playlistFilename,
25
26 segmentListSize: this.segmentListSize,
27 segmentDuration: this.segmentDuration,
28
29 toTranscode: this.toTranscode,
30
31 bitrate: this.bitrate,
32 ratio: this.ratio,
33
34 hasAudio: this.hasAudio
35 })
36 : this.buildFFmpegLive().getLiveMuxingCommand({
37 inputUrl: this.inputLocalUrl,
38 outPath: this.outDirectory,
39
40 masterPlaylistName: this.streamingPlaylist.playlistFilename,
41
42 segmentListSize: VIDEO_LIVE.SEGMENTS_LIST_SIZE,
43 segmentDuration: getLiveSegmentTime(this.videoLive.latencyMode)
44 })
45
46 logger.info('Running local live muxing/transcoding for %s.', this.videoUUID, this.lTags())
47
48 let ffmpegShellCommand: string
49 this.ffmpegCommand.on('start', cmdline => {
50 ffmpegShellCommand = cmdline
51
52 logger.debug('Running ffmpeg command for live', { ffmpegShellCommand, ...this.lTags() })
53 })
54
55 this.ffmpegCommand.on('error', (err, stdout, stderr) => {
56 this.onFFmpegError({ err, stdout, stderr, ffmpegShellCommand })
57 })
58
59 this.ffmpegCommand.on('end', () => {
60 this.onFFmpegEnded()
61 })
62
63 this.ffmpegCommand.run()
64 }
65
66 abort () {
67 if (this.ended || this.errored || this.aborted) return
68
69 logger.debug('Killing ffmpeg after live abort of ' + this.videoUUID, this.lTags())
70
71 this.ffmpegCommand.kill('SIGINT')
72
73 this.aborted = true
74 this.emit('end')
75 }
76
77 private onFFmpegError (options: {
78 err: any
79 stdout: string
80 stderr: string
81 ffmpegShellCommand: string
82 }) {
83 const { err, stdout, stderr, ffmpegShellCommand } = options
84
85 // Don't care that we killed the ffmpeg process
86 if (err?.message?.includes('Exiting normally')) return
87 if (this.ended || this.errored || this.aborted) return
88
89 logger.error('FFmpeg transcoding error.', { err, stdout, stderr, ffmpegShellCommand, ...this.lTags() })
90
91 this.errored = true
92 this.emit('error', { err })
93 }
94
95 private onFFmpegEnded () {
96 if (this.ended || this.errored || this.aborted) return
97
98 logger.debug('Live ffmpeg transcoding ended for ' + this.videoUUID, this.lTags())
99
100 this.ended = true
101 this.emit('end')
102 }
103
104 private buildFFmpegLive () {
105 return new FFmpegLive(getFFmpegCommandWrapperOptions('live', VideoTranscodingProfilesManager.Instance.getAvailableEncoders()))
106 }
107}
diff --git a/server/lib/live/shared/transcoding-wrapper/index.ts b/server/lib/live/shared/transcoding-wrapper/index.ts
deleted file mode 100644
index ae28fa1ca..000000000
--- a/server/lib/live/shared/transcoding-wrapper/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
1export * from './abstract-transcoding-wrapper'
2export * from './ffmpeg-transcoding-wrapper'
3export * from './remote-transcoding-wrapper'
diff --git a/server/lib/live/shared/transcoding-wrapper/remote-transcoding-wrapper.ts b/server/lib/live/shared/transcoding-wrapper/remote-transcoding-wrapper.ts
deleted file mode 100644
index 2aeeb31fb..000000000
--- a/server/lib/live/shared/transcoding-wrapper/remote-transcoding-wrapper.ts
+++ /dev/null
@@ -1,21 +0,0 @@
1import { LiveRTMPHLSTranscodingJobHandler } from '@server/lib/runners'
2import { AbstractTranscodingWrapper } from './abstract-transcoding-wrapper'
3
4export class RemoteTranscodingWrapper extends AbstractTranscodingWrapper {
5 async run () {
6 await new LiveRTMPHLSTranscodingJobHandler().create({
7 rtmpUrl: this.inputPublicUrl,
8 sessionId: this.sessionId,
9 toTranscode: this.toTranscode,
10 video: this.videoLive.Video,
11 outputDirectory: this.outDirectory,
12 playlist: this.streamingPlaylist,
13 segmentListSize: this.segmentListSize,
14 segmentDuration: this.segmentDuration
15 })
16 }
17
18 abort () {
19 this.emit('end')
20 }
21}
diff --git a/server/lib/local-actor.ts b/server/lib/local-actor.ts
deleted file mode 100644
index 611e6d0af..000000000
--- a/server/lib/local-actor.ts
+++ /dev/null
@@ -1,102 +0,0 @@
1import { remove } from 'fs-extra'
2import { join } from 'path'
3import { Transaction } from 'sequelize/types'
4import { ActorModel } from '@server/models/actor/actor'
5import { getLowercaseExtension } from '@shared/core-utils'
6import { buildUUID } from '@shared/extra-utils'
7import { ActivityPubActorType, ActorImageType } from '@shared/models'
8import { retryTransactionWrapper } from '../helpers/database-utils'
9import { CONFIG } from '../initializers/config'
10import { ACTOR_IMAGES_SIZE, WEBSERVER } from '../initializers/constants'
11import { sequelizeTypescript } from '../initializers/database'
12import { MAccountDefault, MActor, MChannelDefault } from '../types/models'
13import { deleteActorImages, updateActorImages } from './activitypub/actors'
14import { sendUpdateActor } from './activitypub/send'
15import { processImageFromWorker } from './worker/parent-process'
16
17export function buildActorInstance (type: ActivityPubActorType, url: string, preferredUsername: string) {
18 return new ActorModel({
19 type,
20 url,
21 preferredUsername,
22 publicKey: null,
23 privateKey: null,
24 followersCount: 0,
25 followingCount: 0,
26 inboxUrl: url + '/inbox',
27 outboxUrl: url + '/outbox',
28 sharedInboxUrl: WEBSERVER.URL + '/inbox',
29 followersUrl: url + '/followers',
30 followingUrl: url + '/following'
31 }) as MActor
32}
33
34export async function updateLocalActorImageFiles (
35 accountOrChannel: MAccountDefault | MChannelDefault,
36 imagePhysicalFile: Express.Multer.File,
37 type: ActorImageType
38) {
39 const processImageSize = async (imageSize: { width: number, height: number }) => {
40 const extension = getLowercaseExtension(imagePhysicalFile.filename)
41
42 const imageName = buildUUID() + extension
43 const destination = join(CONFIG.STORAGE.ACTOR_IMAGES_DIR, imageName)
44 await processImageFromWorker({ path: imagePhysicalFile.path, destination, newSize: imageSize, keepOriginal: true })
45
46 return {
47 imageName,
48 imageSize
49 }
50 }
51
52 const processedImages = await Promise.all(ACTOR_IMAGES_SIZE[type].map(processImageSize))
53 await remove(imagePhysicalFile.path)
54
55 return retryTransactionWrapper(() => sequelizeTypescript.transaction(async t => {
56 const actorImagesInfo = processedImages.map(({ imageName, imageSize }) => ({
57 name: imageName,
58 fileUrl: null,
59 height: imageSize.height,
60 width: imageSize.width,
61 onDisk: true
62 }))
63
64 const updatedActor = await updateActorImages(accountOrChannel.Actor, type, actorImagesInfo, t)
65 await updatedActor.save({ transaction: t })
66
67 await sendUpdateActor(accountOrChannel, t)
68
69 return type === ActorImageType.AVATAR
70 ? updatedActor.Avatars
71 : updatedActor.Banners
72 }))
73}
74
75export async function deleteLocalActorImageFile (accountOrChannel: MAccountDefault | MChannelDefault, type: ActorImageType) {
76 return retryTransactionWrapper(() => {
77 return sequelizeTypescript.transaction(async t => {
78 const updatedActor = await deleteActorImages(accountOrChannel.Actor, type, t)
79 await updatedActor.save({ transaction: t })
80
81 await sendUpdateActor(accountOrChannel, t)
82
83 return updatedActor.Avatars
84 })
85 })
86}
87
88// ---------------------------------------------------------------------------
89
90export async function findAvailableLocalActorName (baseActorName: string, transaction?: Transaction) {
91 let actor = await ActorModel.loadLocalByName(baseActorName, transaction)
92 if (!actor) return baseActorName
93
94 for (let i = 1; i < 30; i++) {
95 const name = `${baseActorName}-${i}`
96
97 actor = await ActorModel.loadLocalByName(name, transaction)
98 if (!actor) return name
99 }
100
101 throw new Error('Cannot find available actor local name (too much iterations).')
102}
diff --git a/server/lib/model-loaders/actor.ts b/server/lib/model-loaders/actor.ts
deleted file mode 100644
index 1355d8ee2..000000000
--- a/server/lib/model-loaders/actor.ts
+++ /dev/null
@@ -1,17 +0,0 @@
1
2import { ActorModel } from '../../models/actor/actor'
3import { MActorAccountChannelId, MActorFull } from '../../types/models'
4
5type ActorLoadByUrlType = 'all' | 'association-ids'
6
7function loadActorByUrl (url: string, fetchType: ActorLoadByUrlType): Promise<MActorFull | MActorAccountChannelId> {
8 if (fetchType === 'all') return ActorModel.loadByUrlAndPopulateAccountAndChannel(url)
9
10 if (fetchType === 'association-ids') return ActorModel.loadByUrl(url)
11}
12
13export {
14 ActorLoadByUrlType,
15
16 loadActorByUrl
17}
diff --git a/server/lib/model-loaders/index.ts b/server/lib/model-loaders/index.ts
deleted file mode 100644
index 9e5152cb2..000000000
--- a/server/lib/model-loaders/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
1export * from './actor'
2export * from './video'
diff --git a/server/lib/model-loaders/video.ts b/server/lib/model-loaders/video.ts
deleted file mode 100644
index 91057d405..000000000
--- a/server/lib/model-loaders/video.ts
+++ /dev/null
@@ -1,66 +0,0 @@
1import { VideoModel } from '@server/models/video/video'
2import {
3 MVideoAccountLightBlacklistAllFiles,
4 MVideoFormattableDetails,
5 MVideoFullLight,
6 MVideoId,
7 MVideoImmutable,
8 MVideoThumbnail
9} from '@server/types/models'
10
11type VideoLoadType = 'for-api' | 'all' | 'only-video' | 'id' | 'none' | 'only-immutable-attributes'
12
13function loadVideo (id: number | string, fetchType: 'for-api', userId?: number): Promise<MVideoFormattableDetails>
14function loadVideo (id: number | string, fetchType: 'all', userId?: number): Promise<MVideoFullLight>
15function loadVideo (id: number | string, fetchType: 'only-immutable-attributes'): Promise<MVideoImmutable>
16function loadVideo (id: number | string, fetchType: 'only-video', userId?: number): Promise<MVideoThumbnail>
17function loadVideo (id: number | string, fetchType: 'id' | 'none', userId?: number): Promise<MVideoId>
18function loadVideo (
19 id: number | string,
20 fetchType: VideoLoadType,
21 userId?: number
22): Promise<MVideoFullLight | MVideoThumbnail | MVideoId | MVideoImmutable>
23function loadVideo (
24 id: number | string,
25 fetchType: VideoLoadType,
26 userId?: number
27): Promise<MVideoFullLight | MVideoThumbnail | MVideoId | MVideoImmutable> {
28
29 if (fetchType === 'for-api') return VideoModel.loadForGetAPI({ id, userId })
30
31 if (fetchType === 'all') return VideoModel.loadFull(id, undefined, userId)
32
33 if (fetchType === 'only-immutable-attributes') return VideoModel.loadImmutableAttributes(id)
34
35 if (fetchType === 'only-video') return VideoModel.load(id)
36
37 if (fetchType === 'id' || fetchType === 'none') return VideoModel.loadOnlyId(id)
38}
39
40type VideoLoadByUrlType = 'all' | 'only-video' | 'only-immutable-attributes'
41
42function loadVideoByUrl (url: string, fetchType: 'all'): Promise<MVideoAccountLightBlacklistAllFiles>
43function loadVideoByUrl (url: string, fetchType: 'only-immutable-attributes'): Promise<MVideoImmutable>
44function loadVideoByUrl (url: string, fetchType: 'only-video'): Promise<MVideoThumbnail>
45function loadVideoByUrl (
46 url: string,
47 fetchType: VideoLoadByUrlType
48): Promise<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail | MVideoImmutable>
49function loadVideoByUrl (
50 url: string,
51 fetchType: VideoLoadByUrlType
52): Promise<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail | MVideoImmutable> {
53 if (fetchType === 'all') return VideoModel.loadByUrlAndPopulateAccount(url)
54
55 if (fetchType === 'only-immutable-attributes') return VideoModel.loadByUrlImmutableAttributes(url)
56
57 if (fetchType === 'only-video') return VideoModel.loadByUrl(url)
58}
59
60export {
61 VideoLoadType,
62 VideoLoadByUrlType,
63
64 loadVideo,
65 loadVideoByUrl
66}
diff --git a/server/lib/moderation.ts b/server/lib/moderation.ts
deleted file mode 100644
index db8284872..000000000
--- a/server/lib/moderation.ts
+++ /dev/null
@@ -1,258 +0,0 @@
1import express, { VideoUploadFile } from 'express'
2import { PathLike } from 'fs-extra'
3import { Transaction } from 'sequelize/types'
4import { AbuseAuditView, auditLoggerFactory } from '@server/helpers/audit-logger'
5import { afterCommitIfTransaction } from '@server/helpers/database-utils'
6import { logger } from '@server/helpers/logger'
7import { AbuseModel } from '@server/models/abuse/abuse'
8import { VideoAbuseModel } from '@server/models/abuse/video-abuse'
9import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse'
10import { VideoFileModel } from '@server/models/video/video-file'
11import { FilteredModelAttributes } from '@server/types'
12import {
13 MAbuseFull,
14 MAccountDefault,
15 MAccountLight,
16 MComment,
17 MCommentAbuseAccountVideo,
18 MCommentOwnerVideo,
19 MUser,
20 MVideoAbuseVideoFull,
21 MVideoAccountLightBlacklistAllFiles
22} from '@server/types/models'
23import { LiveVideoCreate, VideoCreate, VideoImportCreate } from '../../shared/models/videos'
24import { VideoCommentCreate } from '../../shared/models/videos/comment'
25import { UserModel } from '../models/user/user'
26import { VideoModel } from '../models/video/video'
27import { VideoCommentModel } from '../models/video/video-comment'
28import { sendAbuse } from './activitypub/send/send-flag'
29import { Notifier } from './notifier'
30
31export type AcceptResult = {
32 accepted: boolean
33 errorMessage?: string
34}
35
36// ---------------------------------------------------------------------------
37
38// Stub function that can be filtered by plugins
39function isLocalVideoFileAccepted (object: {
40 videoBody: VideoCreate
41 videoFile: VideoUploadFile
42 user: UserModel
43}): AcceptResult {
44 return { accepted: true }
45}
46
47// ---------------------------------------------------------------------------
48
49// Stub function that can be filtered by plugins
50function isLocalLiveVideoAccepted (object: {
51 liveVideoBody: LiveVideoCreate
52 user: UserModel
53}): AcceptResult {
54 return { accepted: true }
55}
56
57// ---------------------------------------------------------------------------
58
59// Stub function that can be filtered by plugins
60function isLocalVideoThreadAccepted (_object: {
61 req: express.Request
62 commentBody: VideoCommentCreate
63 video: VideoModel
64 user: UserModel
65}): AcceptResult {
66 return { accepted: true }
67}
68
69// Stub function that can be filtered by plugins
70function isLocalVideoCommentReplyAccepted (_object: {
71 req: express.Request
72 commentBody: VideoCommentCreate
73 parentComment: VideoCommentModel
74 video: VideoModel
75 user: UserModel
76}): AcceptResult {
77 return { accepted: true }
78}
79
80// ---------------------------------------------------------------------------
81
82// Stub function that can be filtered by plugins
83function isRemoteVideoCommentAccepted (_object: {
84 comment: MComment
85}): AcceptResult {
86 return { accepted: true }
87}
88
89// ---------------------------------------------------------------------------
90
91// Stub function that can be filtered by plugins
92function isPreImportVideoAccepted (object: {
93 videoImportBody: VideoImportCreate
94 user: MUser
95}): AcceptResult {
96 return { accepted: true }
97}
98
99// Stub function that can be filtered by plugins
100function isPostImportVideoAccepted (object: {
101 videoFilePath: PathLike
102 videoFile: VideoFileModel
103 user: MUser
104}): AcceptResult {
105 return { accepted: true }
106}
107
108// ---------------------------------------------------------------------------
109
110async function createVideoAbuse (options: {
111 baseAbuse: FilteredModelAttributes<AbuseModel>
112 videoInstance: MVideoAccountLightBlacklistAllFiles
113 startAt: number
114 endAt: number
115 transaction: Transaction
116 reporterAccount: MAccountDefault
117 skipNotification: boolean
118}) {
119 const { baseAbuse, videoInstance, startAt, endAt, transaction, reporterAccount, skipNotification } = options
120
121 const associateFun = async (abuseInstance: MAbuseFull) => {
122 const videoAbuseInstance: MVideoAbuseVideoFull = await VideoAbuseModel.create({
123 abuseId: abuseInstance.id,
124 videoId: videoInstance.id,
125 startAt,
126 endAt
127 }, { transaction })
128
129 videoAbuseInstance.Video = videoInstance
130 abuseInstance.VideoAbuse = videoAbuseInstance
131
132 return { isOwned: videoInstance.isOwned() }
133 }
134
135 return createAbuse({
136 base: baseAbuse,
137 reporterAccount,
138 flaggedAccount: videoInstance.VideoChannel.Account,
139 transaction,
140 skipNotification,
141 associateFun
142 })
143}
144
145function createVideoCommentAbuse (options: {
146 baseAbuse: FilteredModelAttributes<AbuseModel>
147 commentInstance: MCommentOwnerVideo
148 transaction: Transaction
149 reporterAccount: MAccountDefault
150 skipNotification: boolean
151}) {
152 const { baseAbuse, commentInstance, transaction, reporterAccount, skipNotification } = options
153
154 const associateFun = async (abuseInstance: MAbuseFull) => {
155 const commentAbuseInstance: MCommentAbuseAccountVideo = await VideoCommentAbuseModel.create({
156 abuseId: abuseInstance.id,
157 videoCommentId: commentInstance.id
158 }, { transaction })
159
160 commentAbuseInstance.VideoComment = commentInstance
161 abuseInstance.VideoCommentAbuse = commentAbuseInstance
162
163 return { isOwned: commentInstance.isOwned() }
164 }
165
166 return createAbuse({
167 base: baseAbuse,
168 reporterAccount,
169 flaggedAccount: commentInstance.Account,
170 transaction,
171 skipNotification,
172 associateFun
173 })
174}
175
176function createAccountAbuse (options: {
177 baseAbuse: FilteredModelAttributes<AbuseModel>
178 accountInstance: MAccountDefault
179 transaction: Transaction
180 reporterAccount: MAccountDefault
181 skipNotification: boolean
182}) {
183 const { baseAbuse, accountInstance, transaction, reporterAccount, skipNotification } = options
184
185 const associateFun = () => {
186 return Promise.resolve({ isOwned: accountInstance.isOwned() })
187 }
188
189 return createAbuse({
190 base: baseAbuse,
191 reporterAccount,
192 flaggedAccount: accountInstance,
193 transaction,
194 skipNotification,
195 associateFun
196 })
197}
198
199// ---------------------------------------------------------------------------
200
201export {
202 isLocalLiveVideoAccepted,
203
204 isLocalVideoFileAccepted,
205 isLocalVideoThreadAccepted,
206 isRemoteVideoCommentAccepted,
207 isLocalVideoCommentReplyAccepted,
208 isPreImportVideoAccepted,
209 isPostImportVideoAccepted,
210
211 createAbuse,
212 createVideoAbuse,
213 createVideoCommentAbuse,
214 createAccountAbuse
215}
216
217// ---------------------------------------------------------------------------
218
219async function createAbuse (options: {
220 base: FilteredModelAttributes<AbuseModel>
221 reporterAccount: MAccountDefault
222 flaggedAccount: MAccountLight
223 associateFun: (abuseInstance: MAbuseFull) => Promise<{ isOwned: boolean }>
224 skipNotification: boolean
225 transaction: Transaction
226}) {
227 const { base, reporterAccount, flaggedAccount, associateFun, transaction, skipNotification } = options
228 const auditLogger = auditLoggerFactory('abuse')
229
230 const abuseAttributes = Object.assign({}, base, { flaggedAccountId: flaggedAccount.id })
231 const abuseInstance: MAbuseFull = await AbuseModel.create(abuseAttributes, { transaction })
232
233 abuseInstance.ReporterAccount = reporterAccount
234 abuseInstance.FlaggedAccount = flaggedAccount
235
236 const { isOwned } = await associateFun(abuseInstance)
237
238 if (isOwned === false) {
239 sendAbuse(reporterAccount.Actor, abuseInstance, abuseInstance.FlaggedAccount, transaction)
240 }
241
242 const abuseJSON = abuseInstance.toFormattedAdminJSON()
243 auditLogger.create(reporterAccount.Actor.getIdentifier(), new AbuseAuditView(abuseJSON))
244
245 if (!skipNotification) {
246 afterCommitIfTransaction(transaction, () => {
247 Notifier.Instance.notifyOnNewAbuse({
248 abuse: abuseJSON,
249 abuseInstance,
250 reporter: reporterAccount.Actor.getIdentifier()
251 })
252 })
253 }
254
255 logger.info('Abuse report %d created.', abuseInstance.id)
256
257 return abuseJSON
258}
diff --git a/server/lib/notifier/index.ts b/server/lib/notifier/index.ts
deleted file mode 100644
index 5bc2f5f50..000000000
--- a/server/lib/notifier/index.ts
+++ /dev/null
@@ -1 +0,0 @@
1export * from './notifier'
diff --git a/server/lib/notifier/notifier.ts b/server/lib/notifier/notifier.ts
deleted file mode 100644
index 920c55df0..000000000
--- a/server/lib/notifier/notifier.ts
+++ /dev/null
@@ -1,284 +0,0 @@
1import { MRegistration, MUser, MUserDefault } from '@server/types/models/user'
2import { MVideoBlacklistLightVideo, MVideoBlacklistVideo } from '@server/types/models/video/video-blacklist'
3import { UserNotificationSettingValue } from '../../../shared/models/users'
4import { logger } from '../../helpers/logger'
5import { CONFIG } from '../../initializers/config'
6import { MAbuseFull, MAbuseMessage, MActorFollowFull, MApplication, MPlugin } from '../../types/models'
7import { MCommentOwnerVideo, MVideoAccountLight, MVideoFullLight } from '../../types/models/video'
8import { JobQueue } from '../job-queue'
9import { PeerTubeSocket } from '../peertube-socket'
10import { Hooks } from '../plugins/hooks'
11import {
12 AbstractNotification,
13 AbuseStateChangeForReporter,
14 AutoFollowForInstance,
15 CommentMention,
16 DirectRegistrationForModerators,
17 FollowForInstance,
18 FollowForUser,
19 ImportFinishedForOwner,
20 ImportFinishedForOwnerPayload,
21 NewAbuseForModerators,
22 NewAbuseMessageForModerators,
23 NewAbuseMessageForReporter,
24 NewAbusePayload,
25 NewAutoBlacklistForModerators,
26 NewBlacklistForOwner,
27 NewCommentForVideoOwner,
28 NewPeerTubeVersionForAdmins,
29 NewPluginVersionForAdmins,
30 NewVideoForSubscribers,
31 OwnedPublicationAfterAutoUnblacklist,
32 OwnedPublicationAfterScheduleUpdate,
33 OwnedPublicationAfterTranscoding,
34 RegistrationRequestForModerators,
35 StudioEditionFinishedForOwner,
36 UnblacklistForOwner
37} from './shared'
38
39class Notifier {
40
41 private readonly notificationModels = {
42 newVideo: [ NewVideoForSubscribers ],
43 publicationAfterTranscoding: [ OwnedPublicationAfterTranscoding ],
44 publicationAfterScheduleUpdate: [ OwnedPublicationAfterScheduleUpdate ],
45 publicationAfterAutoUnblacklist: [ OwnedPublicationAfterAutoUnblacklist ],
46 newComment: [ CommentMention, NewCommentForVideoOwner ],
47 newAbuse: [ NewAbuseForModerators ],
48 newBlacklist: [ NewBlacklistForOwner ],
49 unblacklist: [ UnblacklistForOwner ],
50 importFinished: [ ImportFinishedForOwner ],
51 directRegistration: [ DirectRegistrationForModerators ],
52 registrationRequest: [ RegistrationRequestForModerators ],
53 userFollow: [ FollowForUser ],
54 instanceFollow: [ FollowForInstance ],
55 autoInstanceFollow: [ AutoFollowForInstance ],
56 newAutoBlacklist: [ NewAutoBlacklistForModerators ],
57 abuseStateChange: [ AbuseStateChangeForReporter ],
58 newAbuseMessage: [ NewAbuseMessageForReporter, NewAbuseMessageForModerators ],
59 newPeertubeVersion: [ NewPeerTubeVersionForAdmins ],
60 newPluginVersion: [ NewPluginVersionForAdmins ],
61 videoStudioEditionFinished: [ StudioEditionFinishedForOwner ]
62 }
63
64 private static instance: Notifier
65
66 private constructor () {
67 }
68
69 notifyOnNewVideoIfNeeded (video: MVideoAccountLight): void {
70 const models = this.notificationModels.newVideo
71
72 this.sendNotifications(models, video)
73 .catch(err => logger.error('Cannot notify subscribers of new video %s.', video.url, { err }))
74 }
75
76 notifyOnVideoPublishedAfterTranscoding (video: MVideoFullLight): void {
77 const models = this.notificationModels.publicationAfterTranscoding
78
79 this.sendNotifications(models, video)
80 .catch(err => logger.error('Cannot notify owner that its video %s has been published after transcoding.', video.url, { err }))
81 }
82
83 notifyOnVideoPublishedAfterScheduledUpdate (video: MVideoFullLight): void {
84 const models = this.notificationModels.publicationAfterScheduleUpdate
85
86 this.sendNotifications(models, video)
87 .catch(err => logger.error('Cannot notify owner that its video %s has been published after scheduled update.', video.url, { err }))
88 }
89
90 notifyOnVideoPublishedAfterRemovedFromAutoBlacklist (video: MVideoFullLight): void {
91 const models = this.notificationModels.publicationAfterAutoUnblacklist
92
93 this.sendNotifications(models, video)
94 .catch(err => {
95 logger.error('Cannot notify owner that its video %s has been published after removed from auto-blacklist.', video.url, { err })
96 })
97 }
98
99 notifyOnNewComment (comment: MCommentOwnerVideo): void {
100 const models = this.notificationModels.newComment
101
102 this.sendNotifications(models, comment)
103 .catch(err => logger.error('Cannot notify of new comment.', comment.url, { err }))
104 }
105
106 notifyOnNewAbuse (payload: NewAbusePayload): void {
107 const models = this.notificationModels.newAbuse
108
109 this.sendNotifications(models, payload)
110 .catch(err => logger.error('Cannot notify of new abuse %d.', payload.abuseInstance.id, { err }))
111 }
112
113 notifyOnVideoAutoBlacklist (videoBlacklist: MVideoBlacklistLightVideo): void {
114 const models = this.notificationModels.newAutoBlacklist
115
116 this.sendNotifications(models, videoBlacklist)
117 .catch(err => logger.error('Cannot notify of auto-blacklist of video %s.', videoBlacklist.Video.url, { err }))
118 }
119
120 notifyOnVideoBlacklist (videoBlacklist: MVideoBlacklistVideo): void {
121 const models = this.notificationModels.newBlacklist
122
123 this.sendNotifications(models, videoBlacklist)
124 .catch(err => logger.error('Cannot notify video owner of new video blacklist of %s.', videoBlacklist.Video.url, { err }))
125 }
126
127 notifyOnVideoUnblacklist (video: MVideoFullLight): void {
128 const models = this.notificationModels.unblacklist
129
130 this.sendNotifications(models, video)
131 .catch(err => logger.error('Cannot notify video owner of unblacklist of %s.', video.url, { err }))
132 }
133
134 notifyOnFinishedVideoImport (payload: ImportFinishedForOwnerPayload): void {
135 const models = this.notificationModels.importFinished
136
137 this.sendNotifications(models, payload)
138 .catch(err => {
139 logger.error('Cannot notify owner that its video import %s is finished.', payload.videoImport.getTargetIdentifier(), { err })
140 })
141 }
142
143 notifyOnNewDirectRegistration (user: MUserDefault): void {
144 const models = this.notificationModels.directRegistration
145
146 this.sendNotifications(models, user)
147 .catch(err => logger.error('Cannot notify moderators of new user registration (%s).', user.username, { err }))
148 }
149
150 notifyOnNewRegistrationRequest (registration: MRegistration): void {
151 const models = this.notificationModels.registrationRequest
152
153 this.sendNotifications(models, registration)
154 .catch(err => logger.error('Cannot notify moderators of new registration request (%s).', registration.username, { err }))
155 }
156
157 notifyOfNewUserFollow (actorFollow: MActorFollowFull): void {
158 const models = this.notificationModels.userFollow
159
160 this.sendNotifications(models, actorFollow)
161 .catch(err => {
162 logger.error(
163 'Cannot notify owner of channel %s of a new follow by %s.',
164 actorFollow.ActorFollowing.VideoChannel.getDisplayName(),
165 actorFollow.ActorFollower.Account.getDisplayName(),
166 { err }
167 )
168 })
169 }
170
171 notifyOfNewInstanceFollow (actorFollow: MActorFollowFull): void {
172 const models = this.notificationModels.instanceFollow
173
174 this.sendNotifications(models, actorFollow)
175 .catch(err => logger.error('Cannot notify administrators of new follower %s.', actorFollow.ActorFollower.url, { err }))
176 }
177
178 notifyOfAutoInstanceFollowing (actorFollow: MActorFollowFull): void {
179 const models = this.notificationModels.autoInstanceFollow
180
181 this.sendNotifications(models, actorFollow)
182 .catch(err => logger.error('Cannot notify administrators of auto instance following %s.', actorFollow.ActorFollowing.url, { err }))
183 }
184
185 notifyOnAbuseStateChange (abuse: MAbuseFull): void {
186 const models = this.notificationModels.abuseStateChange
187
188 this.sendNotifications(models, abuse)
189 .catch(err => logger.error('Cannot notify of abuse %d state change.', abuse.id, { err }))
190 }
191
192 notifyOnAbuseMessage (abuse: MAbuseFull, message: MAbuseMessage): void {
193 const models = this.notificationModels.newAbuseMessage
194
195 this.sendNotifications(models, { abuse, message })
196 .catch(err => logger.error('Cannot notify on new abuse %d message.', abuse.id, { err }))
197 }
198
199 notifyOfNewPeerTubeVersion (application: MApplication, latestVersion: string) {
200 const models = this.notificationModels.newPeertubeVersion
201
202 this.sendNotifications(models, { application, latestVersion })
203 .catch(err => logger.error('Cannot notify on new PeerTubeb version %s.', latestVersion, { err }))
204 }
205
206 notifyOfNewPluginVersion (plugin: MPlugin) {
207 const models = this.notificationModels.newPluginVersion
208
209 this.sendNotifications(models, plugin)
210 .catch(err => logger.error('Cannot notify on new plugin version %s.', plugin.name, { err }))
211 }
212
213 notifyOfFinishedVideoStudioEdition (video: MVideoFullLight) {
214 const models = this.notificationModels.videoStudioEditionFinished
215
216 this.sendNotifications(models, video)
217 .catch(err => logger.error('Cannot notify on finished studio edition %s.', video.url, { err }))
218 }
219
220 private async notify <T> (object: AbstractNotification<T>) {
221 await object.prepare()
222
223 const users = object.getTargetUsers()
224
225 if (users.length === 0) return
226 if (await object.isDisabled()) return
227
228 object.log()
229
230 const toEmails: string[] = []
231
232 for (const user of users) {
233 const setting = object.getSetting(user)
234
235 const webNotificationEnabled = this.isWebNotificationEnabled(setting)
236 const emailNotificationEnabled = this.isEmailEnabled(user, setting)
237 const notification = object.createNotification(user)
238
239 if (webNotificationEnabled) {
240 await notification.save()
241
242 PeerTubeSocket.Instance.sendNotification(user.id, notification)
243 }
244
245 if (emailNotificationEnabled) {
246 toEmails.push(user.email)
247 }
248
249 Hooks.runAction('action:notifier.notification.created', { webNotificationEnabled, emailNotificationEnabled, user, notification })
250 }
251
252 for (const to of toEmails) {
253 const payload = await object.createEmail(to)
254 JobQueue.Instance.createJobAsync({ type: 'email', payload })
255 }
256 }
257
258 private isEmailEnabled (user: MUser, value: UserNotificationSettingValue) {
259 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION === true && user.emailVerified === false) return false
260
261 return value & UserNotificationSettingValue.EMAIL
262 }
263
264 private isWebNotificationEnabled (value: UserNotificationSettingValue) {
265 return value & UserNotificationSettingValue.WEB
266 }
267
268 private async sendNotifications <T> (models: (new (payload: T) => AbstractNotification<T>)[], payload: T) {
269 for (const model of models) {
270 // eslint-disable-next-line new-cap
271 await this.notify(new model(payload))
272 }
273 }
274
275 static get Instance () {
276 return this.instance || (this.instance = new this())
277 }
278}
279
280// ---------------------------------------------------------------------------
281
282export {
283 Notifier
284}
diff --git a/server/lib/notifier/shared/abuse/abstract-new-abuse-message.ts b/server/lib/notifier/shared/abuse/abstract-new-abuse-message.ts
deleted file mode 100644
index 1dc1ccfc2..000000000
--- a/server/lib/notifier/shared/abuse/abstract-new-abuse-message.ts
+++ /dev/null
@@ -1,67 +0,0 @@
1import { WEBSERVER } from '@server/initializers/constants'
2import { AccountModel } from '@server/models/account/account'
3import { UserNotificationModel } from '@server/models/user/user-notification'
4import { MAbuseFull, MAbuseMessage, MAccountDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
5import { UserNotificationType } from '@shared/models'
6import { AbstractNotification } from '../common/abstract-notification'
7
8type NewAbuseMessagePayload = {
9 abuse: MAbuseFull
10 message: MAbuseMessage
11}
12
13export abstract class AbstractNewAbuseMessage extends AbstractNotification <NewAbuseMessagePayload> {
14 protected messageAccount: MAccountDefault
15
16 async loadMessageAccount () {
17 this.messageAccount = await AccountModel.load(this.message.accountId)
18 }
19
20 getSetting (user: MUserWithNotificationSetting) {
21 return user.NotificationSetting.abuseNewMessage
22 }
23
24 createNotification (user: MUserWithNotificationSetting) {
25 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
26 type: UserNotificationType.ABUSE_NEW_MESSAGE,
27 userId: user.id,
28 abuseId: this.abuse.id
29 })
30 notification.Abuse = this.abuse
31
32 return notification
33 }
34
35 protected createEmailFor (to: string, target: 'moderator' | 'reporter') {
36 const text = 'New message on report #' + this.abuse.id
37 const abuseUrl = target === 'moderator'
38 ? WEBSERVER.URL + '/admin/moderation/abuses/list?search=%23' + this.abuse.id
39 : WEBSERVER.URL + '/my-account/abuses?search=%23' + this.abuse.id
40
41 const action = {
42 text: 'View report #' + this.abuse.id,
43 url: abuseUrl
44 }
45
46 return {
47 template: 'abuse-new-message',
48 to,
49 subject: text,
50 locals: {
51 abuseId: this.abuse.id,
52 abuseUrl: action.url,
53 messageAccountName: this.messageAccount.getDisplayName(),
54 messageText: this.message.message,
55 action
56 }
57 }
58 }
59
60 protected get abuse () {
61 return this.payload.abuse
62 }
63
64 protected get message () {
65 return this.payload.message
66 }
67}
diff --git a/server/lib/notifier/shared/abuse/abuse-state-change-for-reporter.ts b/server/lib/notifier/shared/abuse/abuse-state-change-for-reporter.ts
deleted file mode 100644
index 97e896c6a..000000000
--- a/server/lib/notifier/shared/abuse/abuse-state-change-for-reporter.ts
+++ /dev/null
@@ -1,74 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { WEBSERVER } from '@server/initializers/constants'
3import { getAbuseTargetUrl } from '@server/lib/activitypub/url'
4import { UserModel } from '@server/models/user/user'
5import { UserNotificationModel } from '@server/models/user/user-notification'
6import { MAbuseFull, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
7import { AbuseState, UserNotificationType } from '@shared/models'
8import { AbstractNotification } from '../common/abstract-notification'
9
10export class AbuseStateChangeForReporter extends AbstractNotification <MAbuseFull> {
11
12 private user: MUserDefault
13
14 async prepare () {
15 const reporter = this.abuse.ReporterAccount
16 if (reporter.isOwned() !== true) return
17
18 this.user = await UserModel.loadByAccountActorId(this.abuse.ReporterAccount.actorId)
19 }
20
21 log () {
22 logger.info('Notifying reporter of abuse % of state change.', getAbuseTargetUrl(this.abuse))
23 }
24
25 getSetting (user: MUserWithNotificationSetting) {
26 return user.NotificationSetting.abuseStateChange
27 }
28
29 getTargetUsers () {
30 if (!this.user) return []
31
32 return [ this.user ]
33 }
34
35 createNotification (user: MUserWithNotificationSetting) {
36 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
37 type: UserNotificationType.ABUSE_STATE_CHANGE,
38 userId: user.id,
39 abuseId: this.abuse.id
40 })
41 notification.Abuse = this.abuse
42
43 return notification
44 }
45
46 createEmail (to: string) {
47 const text = this.abuse.state === AbuseState.ACCEPTED
48 ? 'Report #' + this.abuse.id + ' has been accepted'
49 : 'Report #' + this.abuse.id + ' has been rejected'
50
51 const abuseUrl = WEBSERVER.URL + '/my-account/abuses?search=%23' + this.abuse.id
52
53 const action = {
54 text: 'View report #' + this.abuse.id,
55 url: abuseUrl
56 }
57
58 return {
59 template: 'abuse-state-change',
60 to,
61 subject: text,
62 locals: {
63 action,
64 abuseId: this.abuse.id,
65 abuseUrl,
66 isAccepted: this.abuse.state === AbuseState.ACCEPTED
67 }
68 }
69 }
70
71 private get abuse () {
72 return this.payload
73 }
74}
diff --git a/server/lib/notifier/shared/abuse/index.ts b/server/lib/notifier/shared/abuse/index.ts
deleted file mode 100644
index 7b54c5591..000000000
--- a/server/lib/notifier/shared/abuse/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
1export * from './abuse-state-change-for-reporter'
2export * from './new-abuse-for-moderators'
3export * from './new-abuse-message-for-reporter'
4export * from './new-abuse-message-for-moderators'
diff --git a/server/lib/notifier/shared/abuse/new-abuse-for-moderators.ts b/server/lib/notifier/shared/abuse/new-abuse-for-moderators.ts
deleted file mode 100644
index 7d86fb55f..000000000
--- a/server/lib/notifier/shared/abuse/new-abuse-for-moderators.ts
+++ /dev/null
@@ -1,119 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { WEBSERVER } from '@server/initializers/constants'
3import { getAbuseTargetUrl } from '@server/lib/activitypub/url'
4import { UserModel } from '@server/models/user/user'
5import { UserNotificationModel } from '@server/models/user/user-notification'
6import { MAbuseFull, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
7import { UserAbuse, UserNotificationType, UserRight } from '@shared/models'
8import { AbstractNotification } from '../common/abstract-notification'
9
10export type NewAbusePayload = { abuse: UserAbuse, abuseInstance: MAbuseFull, reporter: string }
11
12export class NewAbuseForModerators extends AbstractNotification <NewAbusePayload> {
13 private moderators: MUserDefault[]
14
15 async prepare () {
16 this.moderators = await UserModel.listWithRight(UserRight.MANAGE_ABUSES)
17 }
18
19 log () {
20 logger.info('Notifying %s user/moderators of new abuse %s.', this.moderators.length, getAbuseTargetUrl(this.payload.abuseInstance))
21 }
22
23 getSetting (user: MUserWithNotificationSetting) {
24 return user.NotificationSetting.abuseAsModerator
25 }
26
27 getTargetUsers () {
28 return this.moderators
29 }
30
31 createNotification (user: MUserWithNotificationSetting) {
32 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
33 type: UserNotificationType.NEW_ABUSE_FOR_MODERATORS,
34 userId: user.id,
35 abuseId: this.payload.abuseInstance.id
36 })
37 notification.Abuse = this.payload.abuseInstance
38
39 return notification
40 }
41
42 createEmail (to: string) {
43 const abuseInstance = this.payload.abuseInstance
44
45 if (abuseInstance.VideoAbuse) return this.createVideoAbuseEmail(to)
46 if (abuseInstance.VideoCommentAbuse) return this.createCommentAbuseEmail(to)
47
48 return this.createAccountAbuseEmail(to)
49 }
50
51 private createVideoAbuseEmail (to: string) {
52 const video = this.payload.abuseInstance.VideoAbuse.Video
53 const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
54
55 return {
56 template: 'video-abuse-new',
57 to,
58 subject: `New video abuse report from ${this.payload.reporter}`,
59 locals: {
60 videoUrl,
61 isLocal: video.remote === false,
62 videoCreatedAt: new Date(video.createdAt).toLocaleString(),
63 videoPublishedAt: new Date(video.publishedAt).toLocaleString(),
64 videoName: video.name,
65 reason: this.payload.abuse.reason,
66 videoChannel: this.payload.abuse.video.channel,
67 reporter: this.payload.reporter,
68 action: this.buildEmailAction()
69 }
70 }
71 }
72
73 private createCommentAbuseEmail (to: string) {
74 const comment = this.payload.abuseInstance.VideoCommentAbuse.VideoComment
75 const commentUrl = WEBSERVER.URL + comment.Video.getWatchStaticPath() + ';threadId=' + comment.getThreadId()
76
77 return {
78 template: 'video-comment-abuse-new',
79 to,
80 subject: `New comment abuse report from ${this.payload.reporter}`,
81 locals: {
82 commentUrl,
83 videoName: comment.Video.name,
84 isLocal: comment.isOwned(),
85 commentCreatedAt: new Date(comment.createdAt).toLocaleString(),
86 reason: this.payload.abuse.reason,
87 flaggedAccount: this.payload.abuseInstance.FlaggedAccount.getDisplayName(),
88 reporter: this.payload.reporter,
89 action: this.buildEmailAction()
90 }
91 }
92 }
93
94 private createAccountAbuseEmail (to: string) {
95 const account = this.payload.abuseInstance.FlaggedAccount
96 const accountUrl = account.getClientUrl()
97
98 return {
99 template: 'account-abuse-new',
100 to,
101 subject: `New account abuse report from ${this.payload.reporter}`,
102 locals: {
103 accountUrl,
104 accountDisplayName: account.getDisplayName(),
105 isLocal: account.isOwned(),
106 reason: this.payload.abuse.reason,
107 reporter: this.payload.reporter,
108 action: this.buildEmailAction()
109 }
110 }
111 }
112
113 private buildEmailAction () {
114 return {
115 text: 'View report #' + this.payload.abuseInstance.id,
116 url: WEBSERVER.URL + '/admin/moderation/abuses/list?search=%23' + this.payload.abuseInstance.id
117 }
118 }
119}
diff --git a/server/lib/notifier/shared/abuse/new-abuse-message-for-moderators.ts b/server/lib/notifier/shared/abuse/new-abuse-message-for-moderators.ts
deleted file mode 100644
index 9d0629690..000000000
--- a/server/lib/notifier/shared/abuse/new-abuse-message-for-moderators.ts
+++ /dev/null
@@ -1,32 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { getAbuseTargetUrl } from '@server/lib/activitypub/url'
3import { UserModel } from '@server/models/user/user'
4import { MUserDefault } from '@server/types/models'
5import { UserRight } from '@shared/models'
6import { AbstractNewAbuseMessage } from './abstract-new-abuse-message'
7
8export class NewAbuseMessageForModerators extends AbstractNewAbuseMessage {
9 private moderators: MUserDefault[]
10
11 async prepare () {
12 this.moderators = await UserModel.listWithRight(UserRight.MANAGE_ABUSES)
13
14 // Don't notify my own message
15 this.moderators = this.moderators.filter(m => m.Account.id !== this.message.accountId)
16 if (this.moderators.length === 0) return
17
18 await this.loadMessageAccount()
19 }
20
21 log () {
22 logger.info('Notifying moderators of new abuse message on %s.', getAbuseTargetUrl(this.abuse))
23 }
24
25 getTargetUsers () {
26 return this.moderators
27 }
28
29 createEmail (to: string) {
30 return this.createEmailFor(to, 'moderator')
31 }
32}
diff --git a/server/lib/notifier/shared/abuse/new-abuse-message-for-reporter.ts b/server/lib/notifier/shared/abuse/new-abuse-message-for-reporter.ts
deleted file mode 100644
index c5bbb5447..000000000
--- a/server/lib/notifier/shared/abuse/new-abuse-message-for-reporter.ts
+++ /dev/null
@@ -1,36 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { getAbuseTargetUrl } from '@server/lib/activitypub/url'
3import { UserModel } from '@server/models/user/user'
4import { MUserDefault } from '@server/types/models'
5import { AbstractNewAbuseMessage } from './abstract-new-abuse-message'
6
7export class NewAbuseMessageForReporter extends AbstractNewAbuseMessage {
8 private reporter: MUserDefault
9
10 async prepare () {
11 // Only notify our users
12 if (this.abuse.ReporterAccount.isOwned() !== true) return
13
14 await this.loadMessageAccount()
15
16 const reporter = await UserModel.loadByAccountActorId(this.abuse.ReporterAccount.actorId)
17 // Don't notify my own message
18 if (reporter.Account.id === this.message.accountId) return
19
20 this.reporter = reporter
21 }
22
23 log () {
24 logger.info('Notifying reporter of new abuse message on %s.', getAbuseTargetUrl(this.abuse))
25 }
26
27 getTargetUsers () {
28 if (!this.reporter) return []
29
30 return [ this.reporter ]
31 }
32
33 createEmail (to: string) {
34 return this.createEmailFor(to, 'reporter')
35 }
36}
diff --git a/server/lib/notifier/shared/blacklist/index.ts b/server/lib/notifier/shared/blacklist/index.ts
deleted file mode 100644
index 2f98d88ae..000000000
--- a/server/lib/notifier/shared/blacklist/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
1export * from './new-auto-blacklist-for-moderators'
2export * from './new-blacklist-for-owner'
3export * from './unblacklist-for-owner'
diff --git a/server/lib/notifier/shared/blacklist/new-auto-blacklist-for-moderators.ts b/server/lib/notifier/shared/blacklist/new-auto-blacklist-for-moderators.ts
deleted file mode 100644
index ad2cc00ea..000000000
--- a/server/lib/notifier/shared/blacklist/new-auto-blacklist-for-moderators.ts
+++ /dev/null
@@ -1,60 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { WEBSERVER } from '@server/initializers/constants'
3import { UserModel } from '@server/models/user/user'
4import { UserNotificationModel } from '@server/models/user/user-notification'
5import { VideoChannelModel } from '@server/models/video/video-channel'
6import { MUserDefault, MUserWithNotificationSetting, MVideoBlacklistLightVideo, UserNotificationModelForApi } from '@server/types/models'
7import { UserNotificationType, UserRight } from '@shared/models'
8import { AbstractNotification } from '../common/abstract-notification'
9
10export class NewAutoBlacklistForModerators extends AbstractNotification <MVideoBlacklistLightVideo> {
11 private moderators: MUserDefault[]
12
13 async prepare () {
14 this.moderators = await UserModel.listWithRight(UserRight.MANAGE_VIDEO_BLACKLIST)
15 }
16
17 log () {
18 logger.info('Notifying %s moderators of video auto-blacklist %s.', this.moderators.length, this.payload.Video.url)
19 }
20
21 getSetting (user: MUserWithNotificationSetting) {
22 return user.NotificationSetting.videoAutoBlacklistAsModerator
23 }
24
25 getTargetUsers () {
26 return this.moderators
27 }
28
29 createNotification (user: MUserWithNotificationSetting) {
30 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
31 type: UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS,
32 userId: user.id,
33 videoBlacklistId: this.payload.id
34 })
35 notification.VideoBlacklist = this.payload
36
37 return notification
38 }
39
40 async createEmail (to: string) {
41 const videoAutoBlacklistUrl = WEBSERVER.URL + '/admin/moderation/video-auto-blacklist/list'
42 const videoUrl = WEBSERVER.URL + this.payload.Video.getWatchStaticPath()
43 const channel = await VideoChannelModel.loadAndPopulateAccount(this.payload.Video.channelId)
44
45 return {
46 template: 'video-auto-blacklist-new',
47 to,
48 subject: 'A new video is pending moderation',
49 locals: {
50 channel: channel.toFormattedSummaryJSON(),
51 videoUrl,
52 videoName: this.payload.Video.name,
53 action: {
54 text: 'Review autoblacklist',
55 url: videoAutoBlacklistUrl
56 }
57 }
58 }
59 }
60}
diff --git a/server/lib/notifier/shared/blacklist/new-blacklist-for-owner.ts b/server/lib/notifier/shared/blacklist/new-blacklist-for-owner.ts
deleted file mode 100644
index 342b69ec7..000000000
--- a/server/lib/notifier/shared/blacklist/new-blacklist-for-owner.ts
+++ /dev/null
@@ -1,58 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { CONFIG } from '@server/initializers/config'
3import { WEBSERVER } from '@server/initializers/constants'
4import { UserModel } from '@server/models/user/user'
5import { UserNotificationModel } from '@server/models/user/user-notification'
6import { MUserDefault, MUserWithNotificationSetting, MVideoBlacklistVideo, UserNotificationModelForApi } from '@server/types/models'
7import { UserNotificationType } from '@shared/models'
8import { AbstractNotification } from '../common/abstract-notification'
9
10export class NewBlacklistForOwner extends AbstractNotification <MVideoBlacklistVideo> {
11 private user: MUserDefault
12
13 async prepare () {
14 this.user = await UserModel.loadByVideoId(this.payload.videoId)
15 }
16
17 log () {
18 logger.info('Notifying user %s that its video %s has been blacklisted.', this.user.username, this.payload.Video.url)
19 }
20
21 getSetting (user: MUserWithNotificationSetting) {
22 return user.NotificationSetting.blacklistOnMyVideo
23 }
24
25 getTargetUsers () {
26 if (!this.user) return []
27
28 return [ this.user ]
29 }
30
31 createNotification (user: MUserWithNotificationSetting) {
32 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
33 type: UserNotificationType.BLACKLIST_ON_MY_VIDEO,
34 userId: user.id,
35 videoBlacklistId: this.payload.id
36 })
37 notification.VideoBlacklist = this.payload
38
39 return notification
40 }
41
42 createEmail (to: string) {
43 const videoName = this.payload.Video.name
44 const videoUrl = WEBSERVER.URL + this.payload.Video.getWatchStaticPath()
45
46 const reasonString = this.payload.reason ? ` for the following reason: ${this.payload.reason}` : ''
47 const blockedString = `Your video ${videoName} (${videoUrl} on ${CONFIG.INSTANCE.NAME} has been blacklisted${reasonString}.`
48
49 return {
50 to,
51 subject: `Video ${videoName} blacklisted`,
52 text: blockedString,
53 locals: {
54 title: 'Your video was blacklisted'
55 }
56 }
57 }
58}
diff --git a/server/lib/notifier/shared/blacklist/unblacklist-for-owner.ts b/server/lib/notifier/shared/blacklist/unblacklist-for-owner.ts
deleted file mode 100644
index e6f90e23c..000000000
--- a/server/lib/notifier/shared/blacklist/unblacklist-for-owner.ts
+++ /dev/null
@@ -1,55 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { CONFIG } from '@server/initializers/config'
3import { WEBSERVER } from '@server/initializers/constants'
4import { UserModel } from '@server/models/user/user'
5import { UserNotificationModel } from '@server/models/user/user-notification'
6import { MUserDefault, MUserWithNotificationSetting, MVideoFullLight, UserNotificationModelForApi } from '@server/types/models'
7import { UserNotificationType } from '@shared/models'
8import { AbstractNotification } from '../common/abstract-notification'
9
10export class UnblacklistForOwner extends AbstractNotification <MVideoFullLight> {
11 private user: MUserDefault
12
13 async prepare () {
14 this.user = await UserModel.loadByVideoId(this.payload.id)
15 }
16
17 log () {
18 logger.info('Notifying user %s that its video %s has been unblacklisted.', this.user.username, this.payload.url)
19 }
20
21 getSetting (user: MUserWithNotificationSetting) {
22 return user.NotificationSetting.blacklistOnMyVideo
23 }
24
25 getTargetUsers () {
26 if (!this.user) return []
27
28 return [ this.user ]
29 }
30
31 createNotification (user: MUserWithNotificationSetting) {
32 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
33 type: UserNotificationType.UNBLACKLIST_ON_MY_VIDEO,
34 userId: user.id,
35 videoId: this.payload.id
36 })
37 notification.Video = this.payload
38
39 return notification
40 }
41
42 createEmail (to: string) {
43 const video = this.payload
44 const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
45
46 return {
47 to,
48 subject: `Video ${video.name} unblacklisted`,
49 text: `Your video "${video.name}" (${videoUrl}) on ${CONFIG.INSTANCE.NAME} has been unblacklisted.`,
50 locals: {
51 title: 'Your video was unblacklisted'
52 }
53 }
54 }
55}
diff --git a/server/lib/notifier/shared/comment/comment-mention.ts b/server/lib/notifier/shared/comment/comment-mention.ts
deleted file mode 100644
index 3074e97db..000000000
--- a/server/lib/notifier/shared/comment/comment-mention.ts
+++ /dev/null
@@ -1,111 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { toSafeHtml } from '@server/helpers/markdown'
3import { WEBSERVER } from '@server/initializers/constants'
4import { AccountBlocklistModel } from '@server/models/account/account-blocklist'
5import { getServerActor } from '@server/models/application/application'
6import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
7import { UserModel } from '@server/models/user/user'
8import { UserNotificationModel } from '@server/models/user/user-notification'
9import {
10 MCommentOwnerVideo,
11 MUserDefault,
12 MUserNotifSettingAccount,
13 MUserWithNotificationSetting,
14 UserNotificationModelForApi
15} from '@server/types/models'
16import { UserNotificationSettingValue, UserNotificationType } from '@shared/models'
17import { AbstractNotification } from '../common'
18
19export class CommentMention extends AbstractNotification <MCommentOwnerVideo, MUserNotifSettingAccount> {
20 private users: MUserDefault[]
21
22 private serverAccountId: number
23
24 private accountMutedHash: { [ id: number ]: boolean }
25 private instanceMutedHash: { [ id: number ]: boolean }
26
27 async prepare () {
28 const extractedUsernames = this.payload.extractMentions()
29 logger.debug(
30 'Extracted %d username from comment %s.', extractedUsernames.length, this.payload.url,
31 { usernames: extractedUsernames, text: this.payload.text }
32 )
33
34 this.users = await UserModel.listByUsernames(extractedUsernames)
35
36 if (this.payload.Video.isOwned()) {
37 const userException = await UserModel.loadByVideoId(this.payload.videoId)
38 this.users = this.users.filter(u => u.id !== userException.id)
39 }
40
41 // Don't notify if I mentioned myself
42 this.users = this.users.filter(u => u.Account.id !== this.payload.accountId)
43
44 if (this.users.length === 0) return
45
46 this.serverAccountId = (await getServerActor()).Account.id
47
48 const sourceAccounts = this.users.map(u => u.Account.id).concat([ this.serverAccountId ])
49
50 this.accountMutedHash = await AccountBlocklistModel.isAccountMutedByAccounts(sourceAccounts, this.payload.accountId)
51 this.instanceMutedHash = await ServerBlocklistModel.isServerMutedByAccounts(sourceAccounts, this.payload.Account.Actor.serverId)
52 }
53
54 log () {
55 logger.info('Notifying %d users of new comment %s.', this.users.length, this.payload.url)
56 }
57
58 getSetting (user: MUserNotifSettingAccount) {
59 const accountId = user.Account.id
60 if (
61 this.accountMutedHash[accountId] === true || this.instanceMutedHash[accountId] === true ||
62 this.accountMutedHash[this.serverAccountId] === true || this.instanceMutedHash[this.serverAccountId] === true
63 ) {
64 return UserNotificationSettingValue.NONE
65 }
66
67 return user.NotificationSetting.commentMention
68 }
69
70 getTargetUsers () {
71 return this.users
72 }
73
74 createNotification (user: MUserWithNotificationSetting) {
75 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
76 type: UserNotificationType.COMMENT_MENTION,
77 userId: user.id,
78 commentId: this.payload.id
79 })
80 notification.VideoComment = this.payload
81
82 return notification
83 }
84
85 createEmail (to: string) {
86 const comment = this.payload
87
88 const accountName = comment.Account.getDisplayName()
89 const video = comment.Video
90 const videoUrl = WEBSERVER.URL + comment.Video.getWatchStaticPath()
91 const commentUrl = WEBSERVER.URL + comment.getCommentStaticPath()
92 const commentHtml = toSafeHtml(comment.text)
93
94 return {
95 template: 'video-comment-mention',
96 to,
97 subject: 'Mention on video ' + video.name,
98 locals: {
99 comment,
100 commentHtml,
101 video,
102 videoUrl,
103 accountName,
104 action: {
105 text: 'View comment',
106 url: commentUrl
107 }
108 }
109 }
110 }
111}
diff --git a/server/lib/notifier/shared/comment/index.ts b/server/lib/notifier/shared/comment/index.ts
deleted file mode 100644
index ae01a9646..000000000
--- a/server/lib/notifier/shared/comment/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
1export * from './comment-mention'
2export * from './new-comment-for-video-owner'
diff --git a/server/lib/notifier/shared/comment/new-comment-for-video-owner.ts b/server/lib/notifier/shared/comment/new-comment-for-video-owner.ts
deleted file mode 100644
index 4f96439a3..000000000
--- a/server/lib/notifier/shared/comment/new-comment-for-video-owner.ts
+++ /dev/null
@@ -1,76 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { toSafeHtml } from '@server/helpers/markdown'
3import { WEBSERVER } from '@server/initializers/constants'
4import { isBlockedByServerOrAccount } from '@server/lib/blocklist'
5import { UserModel } from '@server/models/user/user'
6import { UserNotificationModel } from '@server/models/user/user-notification'
7import { MCommentOwnerVideo, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
8import { UserNotificationType } from '@shared/models'
9import { AbstractNotification } from '../common/abstract-notification'
10
11export class NewCommentForVideoOwner extends AbstractNotification <MCommentOwnerVideo> {
12 private user: MUserDefault
13
14 async prepare () {
15 this.user = await UserModel.loadByVideoId(this.payload.videoId)
16 }
17
18 log () {
19 logger.info('Notifying owner of a video %s of new comment %s.', this.user.username, this.payload.url)
20 }
21
22 isDisabled () {
23 if (this.payload.Video.isOwned() === false) return true
24
25 // Not our user or user comments its own video
26 if (!this.user || this.payload.Account.userId === this.user.id) return true
27
28 return isBlockedByServerOrAccount(this.payload.Account, this.user.Account)
29 }
30
31 getSetting (user: MUserWithNotificationSetting) {
32 return user.NotificationSetting.newCommentOnMyVideo
33 }
34
35 getTargetUsers () {
36 if (!this.user) return []
37
38 return [ this.user ]
39 }
40
41 createNotification (user: MUserWithNotificationSetting) {
42 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
43 type: UserNotificationType.NEW_COMMENT_ON_MY_VIDEO,
44 userId: user.id,
45 commentId: this.payload.id
46 })
47 notification.VideoComment = this.payload
48
49 return notification
50 }
51
52 createEmail (to: string) {
53 const video = this.payload.Video
54 const videoUrl = WEBSERVER.URL + this.payload.Video.getWatchStaticPath()
55 const commentUrl = WEBSERVER.URL + this.payload.getCommentStaticPath()
56 const commentHtml = toSafeHtml(this.payload.text)
57
58 return {
59 template: 'video-comment-new',
60 to,
61 subject: 'New comment on your video ' + video.name,
62 locals: {
63 accountName: this.payload.Account.getDisplayName(),
64 accountUrl: this.payload.Account.Actor.url,
65 comment: this.payload,
66 commentHtml,
67 video,
68 videoUrl,
69 action: {
70 text: 'View comment',
71 url: commentUrl
72 }
73 }
74 }
75 }
76}
diff --git a/server/lib/notifier/shared/common/abstract-notification.ts b/server/lib/notifier/shared/common/abstract-notification.ts
deleted file mode 100644
index 79403611e..000000000
--- a/server/lib/notifier/shared/common/abstract-notification.ts
+++ /dev/null
@@ -1,23 +0,0 @@
1import { MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
2import { EmailPayload, UserNotificationSettingValue } from '@shared/models'
3
4export abstract class AbstractNotification <T, U = MUserWithNotificationSetting> {
5
6 constructor (protected readonly payload: T) {
7
8 }
9
10 abstract prepare (): Promise<void>
11 abstract log (): void
12
13 abstract getSetting (user: U): UserNotificationSettingValue
14 abstract getTargetUsers (): U[]
15
16 abstract createNotification (user: U): UserNotificationModelForApi
17 abstract createEmail (to: string): EmailPayload | Promise<EmailPayload>
18
19 isDisabled (): boolean | Promise<boolean> {
20 return false
21 }
22
23}
diff --git a/server/lib/notifier/shared/common/index.ts b/server/lib/notifier/shared/common/index.ts
deleted file mode 100644
index 0b2570278..000000000
--- a/server/lib/notifier/shared/common/index.ts
+++ /dev/null
@@ -1 +0,0 @@
1export * from './abstract-notification'
diff --git a/server/lib/notifier/shared/follow/auto-follow-for-instance.ts b/server/lib/notifier/shared/follow/auto-follow-for-instance.ts
deleted file mode 100644
index ab9747ba8..000000000
--- a/server/lib/notifier/shared/follow/auto-follow-for-instance.ts
+++ /dev/null
@@ -1,51 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { UserModel } from '@server/models/user/user'
3import { UserNotificationModel } from '@server/models/user/user-notification'
4import { MActorFollowFull, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
5import { UserNotificationType, UserRight } from '@shared/models'
6import { AbstractNotification } from '../common/abstract-notification'
7
8export class AutoFollowForInstance extends AbstractNotification <MActorFollowFull> {
9 private admins: MUserDefault[]
10
11 async prepare () {
12 this.admins = await UserModel.listWithRight(UserRight.MANAGE_SERVER_FOLLOW)
13 }
14
15 log () {
16 logger.info('Notifying %d administrators of auto instance following: %s.', this.admins.length, this.actorFollow.ActorFollowing.url)
17 }
18
19 getSetting (user: MUserWithNotificationSetting) {
20 return user.NotificationSetting.autoInstanceFollowing
21 }
22
23 getTargetUsers () {
24 return this.admins
25 }
26
27 createNotification (user: MUserWithNotificationSetting) {
28 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
29 type: UserNotificationType.AUTO_INSTANCE_FOLLOWING,
30 userId: user.id,
31 actorFollowId: this.actorFollow.id
32 })
33 notification.ActorFollow = this.actorFollow
34
35 return notification
36 }
37
38 createEmail (to: string) {
39 const instanceUrl = this.actorFollow.ActorFollowing.url
40
41 return {
42 to,
43 subject: 'Auto instance following',
44 text: `Your instance automatically followed a new instance: <a href="${instanceUrl}">${instanceUrl}</a>.`
45 }
46 }
47
48 private get actorFollow () {
49 return this.payload
50 }
51}
diff --git a/server/lib/notifier/shared/follow/follow-for-instance.ts b/server/lib/notifier/shared/follow/follow-for-instance.ts
deleted file mode 100644
index 777a12ef4..000000000
--- a/server/lib/notifier/shared/follow/follow-for-instance.ts
+++ /dev/null
@@ -1,68 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { WEBSERVER } from '@server/initializers/constants'
3import { isBlockedByServerOrAccount } from '@server/lib/blocklist'
4import { UserModel } from '@server/models/user/user'
5import { UserNotificationModel } from '@server/models/user/user-notification'
6import { MActorFollowFull, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
7import { UserNotificationType, UserRight } from '@shared/models'
8import { AbstractNotification } from '../common/abstract-notification'
9
10export class FollowForInstance extends AbstractNotification <MActorFollowFull> {
11 private admins: MUserDefault[]
12
13 async prepare () {
14 this.admins = await UserModel.listWithRight(UserRight.MANAGE_SERVER_FOLLOW)
15 }
16
17 isDisabled () {
18 const follower = Object.assign(this.actorFollow.ActorFollower.Account, { Actor: this.actorFollow.ActorFollower })
19
20 return isBlockedByServerOrAccount(follower)
21 }
22
23 log () {
24 logger.info('Notifying %d administrators of new instance follower: %s.', this.admins.length, this.actorFollow.ActorFollower.url)
25 }
26
27 getSetting (user: MUserWithNotificationSetting) {
28 return user.NotificationSetting.newInstanceFollower
29 }
30
31 getTargetUsers () {
32 return this.admins
33 }
34
35 createNotification (user: MUserWithNotificationSetting) {
36 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
37 type: UserNotificationType.NEW_INSTANCE_FOLLOWER,
38 userId: user.id,
39 actorFollowId: this.actorFollow.id
40 })
41 notification.ActorFollow = this.actorFollow
42
43 return notification
44 }
45
46 createEmail (to: string) {
47 const awaitingApproval = this.actorFollow.state === 'pending'
48 ? ' awaiting manual approval.'
49 : ''
50
51 return {
52 to,
53 subject: 'New instance follower',
54 text: `Your instance has a new follower: ${this.actorFollow.ActorFollower.url}${awaitingApproval}.`,
55 locals: {
56 title: 'New instance follower',
57 action: {
58 text: 'Review followers',
59 url: WEBSERVER.URL + '/admin/follows/followers-list'
60 }
61 }
62 }
63 }
64
65 private get actorFollow () {
66 return this.payload
67 }
68}
diff --git a/server/lib/notifier/shared/follow/follow-for-user.ts b/server/lib/notifier/shared/follow/follow-for-user.ts
deleted file mode 100644
index 697c82cdd..000000000
--- a/server/lib/notifier/shared/follow/follow-for-user.ts
+++ /dev/null
@@ -1,82 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { isBlockedByServerOrAccount } from '@server/lib/blocklist'
3import { UserModel } from '@server/models/user/user'
4import { UserNotificationModel } from '@server/models/user/user-notification'
5import { MActorFollowFull, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
6import { UserNotificationType } from '@shared/models'
7import { AbstractNotification } from '../common/abstract-notification'
8
9export class FollowForUser extends AbstractNotification <MActorFollowFull> {
10 private followType: 'account' | 'channel'
11 private user: MUserDefault
12
13 async prepare () {
14 // Account follows one of our account?
15 this.followType = 'channel'
16 this.user = await UserModel.loadByChannelActorId(this.actorFollow.ActorFollowing.id)
17
18 // Account follows one of our channel?
19 if (!this.user) {
20 this.user = await UserModel.loadByAccountActorId(this.actorFollow.ActorFollowing.id)
21 this.followType = 'account'
22 }
23 }
24
25 async isDisabled () {
26 if (this.payload.ActorFollowing.isOwned() === false) return true
27
28 const followerAccount = this.actorFollow.ActorFollower.Account
29 const followerAccountWithActor = Object.assign(followerAccount, { Actor: this.actorFollow.ActorFollower })
30
31 return isBlockedByServerOrAccount(followerAccountWithActor, this.user.Account)
32 }
33
34 log () {
35 logger.info('Notifying user %s of new follower: %s.', this.user.username, this.actorFollow.ActorFollower.Account.getDisplayName())
36 }
37
38 getSetting (user: MUserWithNotificationSetting) {
39 return user.NotificationSetting.newFollow
40 }
41
42 getTargetUsers () {
43 if (!this.user) return []
44
45 return [ this.user ]
46 }
47
48 createNotification (user: MUserWithNotificationSetting) {
49 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
50 type: UserNotificationType.NEW_FOLLOW,
51 userId: user.id,
52 actorFollowId: this.actorFollow.id
53 })
54 notification.ActorFollow = this.actorFollow
55
56 return notification
57 }
58
59 createEmail (to: string) {
60 const following = this.actorFollow.ActorFollowing
61 const follower = this.actorFollow.ActorFollower
62
63 const followingName = (following.VideoChannel || following.Account).getDisplayName()
64
65 return {
66 template: 'follower-on-channel',
67 to,
68 subject: `New follower on your channel ${followingName}`,
69 locals: {
70 followerName: follower.Account.getDisplayName(),
71 followerUrl: follower.url,
72 followingName,
73 followingUrl: following.url,
74 followType: this.followType
75 }
76 }
77 }
78
79 private get actorFollow () {
80 return this.payload
81 }
82}
diff --git a/server/lib/notifier/shared/follow/index.ts b/server/lib/notifier/shared/follow/index.ts
deleted file mode 100644
index 27f5289d9..000000000
--- a/server/lib/notifier/shared/follow/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
1export * from './auto-follow-for-instance'
2export * from './follow-for-instance'
3export * from './follow-for-user'
diff --git a/server/lib/notifier/shared/index.ts b/server/lib/notifier/shared/index.ts
deleted file mode 100644
index cc3ce8c7c..000000000
--- a/server/lib/notifier/shared/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
1export * from './abuse'
2export * from './blacklist'
3export * from './comment'
4export * from './common'
5export * from './follow'
6export * from './instance'
7export * from './video-publication'
diff --git a/server/lib/notifier/shared/instance/direct-registration-for-moderators.ts b/server/lib/notifier/shared/instance/direct-registration-for-moderators.ts
deleted file mode 100644
index 5044f2068..000000000
--- a/server/lib/notifier/shared/instance/direct-registration-for-moderators.ts
+++ /dev/null
@@ -1,49 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { CONFIG } from '@server/initializers/config'
3import { UserModel } from '@server/models/user/user'
4import { UserNotificationModel } from '@server/models/user/user-notification'
5import { MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
6import { UserNotificationType, UserRight } from '@shared/models'
7import { AbstractNotification } from '../common/abstract-notification'
8
9export class DirectRegistrationForModerators extends AbstractNotification <MUserDefault> {
10 private moderators: MUserDefault[]
11
12 async prepare () {
13 this.moderators = await UserModel.listWithRight(UserRight.MANAGE_USERS)
14 }
15
16 log () {
17 logger.info('Notifying %s moderators of new user registration of %s.', this.moderators.length, this.payload.username)
18 }
19
20 getSetting (user: MUserWithNotificationSetting) {
21 return user.NotificationSetting.newUserRegistration
22 }
23
24 getTargetUsers () {
25 return this.moderators
26 }
27
28 createNotification (user: MUserWithNotificationSetting) {
29 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
30 type: UserNotificationType.NEW_USER_REGISTRATION,
31 userId: user.id,
32 accountId: this.payload.Account.id
33 })
34 notification.Account = this.payload.Account
35
36 return notification
37 }
38
39 createEmail (to: string) {
40 return {
41 template: 'user-registered',
42 to,
43 subject: `A new user registered on ${CONFIG.INSTANCE.NAME}: ${this.payload.username}`,
44 locals: {
45 user: this.payload
46 }
47 }
48 }
49}
diff --git a/server/lib/notifier/shared/instance/index.ts b/server/lib/notifier/shared/instance/index.ts
deleted file mode 100644
index 8c75a8ee9..000000000
--- a/server/lib/notifier/shared/instance/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
1export * from './new-peertube-version-for-admins'
2export * from './new-plugin-version-for-admins'
3export * from './direct-registration-for-moderators'
4export * from './registration-request-for-moderators'
diff --git a/server/lib/notifier/shared/instance/new-peertube-version-for-admins.ts b/server/lib/notifier/shared/instance/new-peertube-version-for-admins.ts
deleted file mode 100644
index f5646c666..000000000
--- a/server/lib/notifier/shared/instance/new-peertube-version-for-admins.ts
+++ /dev/null
@@ -1,54 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { UserModel } from '@server/models/user/user'
3import { UserNotificationModel } from '@server/models/user/user-notification'
4import { MApplication, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
5import { UserNotificationType, UserRight } from '@shared/models'
6import { AbstractNotification } from '../common/abstract-notification'
7
8export type NewPeerTubeVersionForAdminsPayload = {
9 application: MApplication
10 latestVersion: string
11}
12
13export class NewPeerTubeVersionForAdmins extends AbstractNotification <NewPeerTubeVersionForAdminsPayload> {
14 private admins: MUserDefault[]
15
16 async prepare () {
17 // Use the debug right to know who is an administrator
18 this.admins = await UserModel.listWithRight(UserRight.MANAGE_DEBUG)
19 }
20
21 log () {
22 logger.info('Notifying %s admins of new PeerTube version %s.', this.admins.length, this.payload.latestVersion)
23 }
24
25 getSetting (user: MUserWithNotificationSetting) {
26 return user.NotificationSetting.newPeerTubeVersion
27 }
28
29 getTargetUsers () {
30 return this.admins
31 }
32
33 createNotification (user: MUserWithNotificationSetting) {
34 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
35 type: UserNotificationType.NEW_PEERTUBE_VERSION,
36 userId: user.id,
37 applicationId: this.payload.application.id
38 })
39 notification.Application = this.payload.application
40
41 return notification
42 }
43
44 createEmail (to: string) {
45 return {
46 to,
47 template: 'peertube-version-new',
48 subject: `A new PeerTube version is available: ${this.payload.latestVersion}`,
49 locals: {
50 latestVersion: this.payload.latestVersion
51 }
52 }
53 }
54}
diff --git a/server/lib/notifier/shared/instance/new-plugin-version-for-admins.ts b/server/lib/notifier/shared/instance/new-plugin-version-for-admins.ts
deleted file mode 100644
index 547c6726c..000000000
--- a/server/lib/notifier/shared/instance/new-plugin-version-for-admins.ts
+++ /dev/null
@@ -1,58 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { WEBSERVER } from '@server/initializers/constants'
3import { UserModel } from '@server/models/user/user'
4import { UserNotificationModel } from '@server/models/user/user-notification'
5import { MPlugin, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
6import { UserNotificationType, UserRight } from '@shared/models'
7import { AbstractNotification } from '../common/abstract-notification'
8
9export class NewPluginVersionForAdmins extends AbstractNotification <MPlugin> {
10 private admins: MUserDefault[]
11
12 async prepare () {
13 // Use the debug right to know who is an administrator
14 this.admins = await UserModel.listWithRight(UserRight.MANAGE_DEBUG)
15 }
16
17 log () {
18 logger.info('Notifying %s admins of new PeerTube version %s.', this.admins.length, this.payload.latestVersion)
19 }
20
21 getSetting (user: MUserWithNotificationSetting) {
22 return user.NotificationSetting.newPluginVersion
23 }
24
25 getTargetUsers () {
26 return this.admins
27 }
28
29 createNotification (user: MUserWithNotificationSetting) {
30 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
31 type: UserNotificationType.NEW_PLUGIN_VERSION,
32 userId: user.id,
33 pluginId: this.plugin.id
34 })
35 notification.Plugin = this.plugin
36
37 return notification
38 }
39
40 createEmail (to: string) {
41 const pluginUrl = WEBSERVER.URL + '/admin/plugins/list-installed?pluginType=' + this.plugin.type
42
43 return {
44 to,
45 template: 'plugin-version-new',
46 subject: `A new plugin/theme version is available: ${this.plugin.name}@${this.plugin.latestVersion}`,
47 locals: {
48 pluginName: this.plugin.name,
49 latestVersion: this.plugin.latestVersion,
50 pluginUrl
51 }
52 }
53 }
54
55 private get plugin () {
56 return this.payload
57 }
58}
diff --git a/server/lib/notifier/shared/instance/registration-request-for-moderators.ts b/server/lib/notifier/shared/instance/registration-request-for-moderators.ts
deleted file mode 100644
index 79920245a..000000000
--- a/server/lib/notifier/shared/instance/registration-request-for-moderators.ts
+++ /dev/null
@@ -1,48 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { UserModel } from '@server/models/user/user'
3import { UserNotificationModel } from '@server/models/user/user-notification'
4import { MRegistration, MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models'
5import { UserNotificationType, UserRight } from '@shared/models'
6import { AbstractNotification } from '../common/abstract-notification'
7
8export class RegistrationRequestForModerators extends AbstractNotification <MRegistration> {
9 private moderators: MUserDefault[]
10
11 async prepare () {
12 this.moderators = await UserModel.listWithRight(UserRight.MANAGE_REGISTRATIONS)
13 }
14
15 log () {
16 logger.info('Notifying %s moderators of new user registration request of %s.', this.moderators.length, this.payload.username)
17 }
18
19 getSetting (user: MUserWithNotificationSetting) {
20 return user.NotificationSetting.newUserRegistration
21 }
22
23 getTargetUsers () {
24 return this.moderators
25 }
26
27 createNotification (user: MUserWithNotificationSetting) {
28 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
29 type: UserNotificationType.NEW_USER_REGISTRATION_REQUEST,
30 userId: user.id,
31 userRegistrationId: this.payload.id
32 })
33 notification.UserRegistration = this.payload
34
35 return notification
36 }
37
38 createEmail (to: string) {
39 return {
40 template: 'user-registration-request',
41 to,
42 subject: `A new user wants to register: ${this.payload.username}`,
43 locals: {
44 registration: this.payload
45 }
46 }
47 }
48}
diff --git a/server/lib/notifier/shared/video-publication/abstract-owned-video-publication.ts b/server/lib/notifier/shared/video-publication/abstract-owned-video-publication.ts
deleted file mode 100644
index a940cde69..000000000
--- a/server/lib/notifier/shared/video-publication/abstract-owned-video-publication.ts
+++ /dev/null
@@ -1,57 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { WEBSERVER } from '@server/initializers/constants'
3import { UserModel } from '@server/models/user/user'
4import { UserNotificationModel } from '@server/models/user/user-notification'
5import { MUserDefault, MUserWithNotificationSetting, MVideoFullLight, UserNotificationModelForApi } from '@server/types/models'
6import { UserNotificationType } from '@shared/models'
7import { AbstractNotification } from '../common/abstract-notification'
8
9export abstract class AbstractOwnedVideoPublication extends AbstractNotification <MVideoFullLight> {
10 protected user: MUserDefault
11
12 async prepare () {
13 this.user = await UserModel.loadByVideoId(this.payload.id)
14 }
15
16 log () {
17 logger.info('Notifying user %s of the publication of its video %s.', this.user.username, this.payload.url)
18 }
19
20 getSetting (user: MUserWithNotificationSetting) {
21 return user.NotificationSetting.myVideoPublished
22 }
23
24 getTargetUsers () {
25 if (!this.user) return []
26
27 return [ this.user ]
28 }
29
30 createNotification (user: MUserWithNotificationSetting) {
31 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
32 type: UserNotificationType.MY_VIDEO_PUBLISHED,
33 userId: user.id,
34 videoId: this.payload.id
35 })
36 notification.Video = this.payload
37
38 return notification
39 }
40
41 createEmail (to: string) {
42 const videoUrl = WEBSERVER.URL + this.payload.getWatchStaticPath()
43
44 return {
45 to,
46 subject: `Your video ${this.payload.name} has been published`,
47 text: `Your video "${this.payload.name}" has been published.`,
48 locals: {
49 title: 'Your video is live',
50 action: {
51 text: 'View video',
52 url: videoUrl
53 }
54 }
55 }
56 }
57}
diff --git a/server/lib/notifier/shared/video-publication/import-finished-for-owner.ts b/server/lib/notifier/shared/video-publication/import-finished-for-owner.ts
deleted file mode 100644
index 3bd64692f..000000000
--- a/server/lib/notifier/shared/video-publication/import-finished-for-owner.ts
+++ /dev/null
@@ -1,97 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { WEBSERVER } from '@server/initializers/constants'
3import { UserModel } from '@server/models/user/user'
4import { UserNotificationModel } from '@server/models/user/user-notification'
5import { MUserDefault, MUserWithNotificationSetting, MVideoImportVideo, UserNotificationModelForApi } from '@server/types/models'
6import { UserNotificationType } from '@shared/models'
7import { AbstractNotification } from '../common/abstract-notification'
8
9export type ImportFinishedForOwnerPayload = {
10 videoImport: MVideoImportVideo
11 success: boolean
12}
13
14export class ImportFinishedForOwner extends AbstractNotification <ImportFinishedForOwnerPayload> {
15 private user: MUserDefault
16
17 async prepare () {
18 this.user = await UserModel.loadByVideoImportId(this.videoImport.id)
19 }
20
21 log () {
22 logger.info('Notifying user %s its video import %s is finished.', this.user.username, this.videoImport.getTargetIdentifier())
23 }
24
25 getSetting (user: MUserWithNotificationSetting) {
26 return user.NotificationSetting.myVideoImportFinished
27 }
28
29 getTargetUsers () {
30 if (!this.user) return []
31
32 return [ this.user ]
33 }
34
35 createNotification (user: MUserWithNotificationSetting) {
36 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
37 type: this.payload.success
38 ? UserNotificationType.MY_VIDEO_IMPORT_SUCCESS
39 : UserNotificationType.MY_VIDEO_IMPORT_ERROR,
40
41 userId: user.id,
42 videoImportId: this.videoImport.id
43 })
44 notification.VideoImport = this.videoImport
45
46 return notification
47 }
48
49 createEmail (to: string) {
50 if (this.payload.success) return this.createSuccessEmail(to)
51
52 return this.createFailEmail(to)
53 }
54
55 private createSuccessEmail (to: string) {
56 const videoUrl = WEBSERVER.URL + this.videoImport.Video.getWatchStaticPath()
57
58 return {
59 to,
60 subject: `Your video import ${this.videoImport.getTargetIdentifier()} is complete`,
61 text: `Your video "${this.videoImport.getTargetIdentifier()}" just finished importing.`,
62 locals: {
63 title: 'Import complete',
64 action: {
65 text: 'View video',
66 url: videoUrl
67 }
68 }
69 }
70 }
71
72 private createFailEmail (to: string) {
73 const importUrl = WEBSERVER.URL + '/my-library/video-imports'
74
75 const text =
76 `Your video import "${this.videoImport.getTargetIdentifier()}" encountered an error.` +
77 '\n\n' +
78 `See your videos import dashboard for more information: <a href="${importUrl}">${importUrl}</a>.`
79
80 return {
81 to,
82 subject: `Your video import "${this.videoImport.getTargetIdentifier()}" encountered an error`,
83 text,
84 locals: {
85 title: 'Import failed',
86 action: {
87 text: 'Review imports',
88 url: importUrl
89 }
90 }
91 }
92 }
93
94 private get videoImport () {
95 return this.payload.videoImport
96 }
97}
diff --git a/server/lib/notifier/shared/video-publication/index.ts b/server/lib/notifier/shared/video-publication/index.ts
deleted file mode 100644
index 5e92cb011..000000000
--- a/server/lib/notifier/shared/video-publication/index.ts
+++ /dev/null
@@ -1,6 +0,0 @@
1export * from './new-video-for-subscribers'
2export * from './import-finished-for-owner'
3export * from './owned-publication-after-auto-unblacklist'
4export * from './owned-publication-after-schedule-update'
5export * from './owned-publication-after-transcoding'
6export * from './studio-edition-finished-for-owner'
diff --git a/server/lib/notifier/shared/video-publication/new-video-for-subscribers.ts b/server/lib/notifier/shared/video-publication/new-video-for-subscribers.ts
deleted file mode 100644
index df7a5561d..000000000
--- a/server/lib/notifier/shared/video-publication/new-video-for-subscribers.ts
+++ /dev/null
@@ -1,61 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { WEBSERVER } from '@server/initializers/constants'
3import { UserModel } from '@server/models/user/user'
4import { UserNotificationModel } from '@server/models/user/user-notification'
5import { MUserWithNotificationSetting, MVideoAccountLight, UserNotificationModelForApi } from '@server/types/models'
6import { UserNotificationType, VideoPrivacy, VideoState } from '@shared/models'
7import { AbstractNotification } from '../common/abstract-notification'
8
9export class NewVideoForSubscribers extends AbstractNotification <MVideoAccountLight> {
10 private users: MUserWithNotificationSetting[]
11
12 async prepare () {
13 // List all followers that are users
14 this.users = await UserModel.listUserSubscribersOf(this.payload.VideoChannel.actorId)
15 }
16
17 log () {
18 logger.info('Notifying %d users of new video %s.', this.users.length, this.payload.url)
19 }
20
21 isDisabled () {
22 return this.payload.privacy !== VideoPrivacy.PUBLIC || this.payload.state !== VideoState.PUBLISHED || this.payload.isBlacklisted()
23 }
24
25 getSetting (user: MUserWithNotificationSetting) {
26 return user.NotificationSetting.newVideoFromSubscription
27 }
28
29 getTargetUsers () {
30 return this.users
31 }
32
33 createNotification (user: MUserWithNotificationSetting) {
34 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
35 type: UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION,
36 userId: user.id,
37 videoId: this.payload.id
38 })
39 notification.Video = this.payload
40
41 return notification
42 }
43
44 createEmail (to: string) {
45 const channelName = this.payload.VideoChannel.getDisplayName()
46 const videoUrl = WEBSERVER.URL + this.payload.getWatchStaticPath()
47
48 return {
49 to,
50 subject: channelName + ' just published a new video',
51 text: `Your subscription ${channelName} just published a new video: "${this.payload.name}".`,
52 locals: {
53 title: 'New content ',
54 action: {
55 text: 'View video',
56 url: videoUrl
57 }
58 }
59 }
60 }
61}
diff --git a/server/lib/notifier/shared/video-publication/owned-publication-after-auto-unblacklist.ts b/server/lib/notifier/shared/video-publication/owned-publication-after-auto-unblacklist.ts
deleted file mode 100644
index 27d89a5c7..000000000
--- a/server/lib/notifier/shared/video-publication/owned-publication-after-auto-unblacklist.ts
+++ /dev/null
@@ -1,11 +0,0 @@
1
2import { VideoState } from '@shared/models'
3import { AbstractOwnedVideoPublication } from './abstract-owned-video-publication'
4
5export class OwnedPublicationAfterAutoUnblacklist extends AbstractOwnedVideoPublication {
6
7 isDisabled () {
8 // Don't notify if video is still waiting for transcoding or scheduled update
9 return !!this.payload.ScheduleVideoUpdate || (this.payload.waitTranscoding && this.payload.state !== VideoState.PUBLISHED)
10 }
11}
diff --git a/server/lib/notifier/shared/video-publication/owned-publication-after-schedule-update.ts b/server/lib/notifier/shared/video-publication/owned-publication-after-schedule-update.ts
deleted file mode 100644
index 2e253b358..000000000
--- a/server/lib/notifier/shared/video-publication/owned-publication-after-schedule-update.ts
+++ /dev/null
@@ -1,10 +0,0 @@
1import { VideoState } from '@shared/models'
2import { AbstractOwnedVideoPublication } from './abstract-owned-video-publication'
3
4export class OwnedPublicationAfterScheduleUpdate extends AbstractOwnedVideoPublication {
5
6 isDisabled () {
7 // Don't notify if video is still blacklisted or waiting for transcoding
8 return !!this.payload.VideoBlacklist || (this.payload.waitTranscoding && this.payload.state !== VideoState.PUBLISHED)
9 }
10}
diff --git a/server/lib/notifier/shared/video-publication/owned-publication-after-transcoding.ts b/server/lib/notifier/shared/video-publication/owned-publication-after-transcoding.ts
deleted file mode 100644
index 4fab1090f..000000000
--- a/server/lib/notifier/shared/video-publication/owned-publication-after-transcoding.ts
+++ /dev/null
@@ -1,9 +0,0 @@
1import { AbstractOwnedVideoPublication } from './abstract-owned-video-publication'
2
3export class OwnedPublicationAfterTranscoding extends AbstractOwnedVideoPublication {
4
5 isDisabled () {
6 // Don't notify if didn't wait for transcoding or video is still blacklisted/waiting for scheduled update
7 return !this.payload.waitTranscoding || !!this.payload.VideoBlacklist || !!this.payload.ScheduleVideoUpdate
8 }
9}
diff --git a/server/lib/notifier/shared/video-publication/studio-edition-finished-for-owner.ts b/server/lib/notifier/shared/video-publication/studio-edition-finished-for-owner.ts
deleted file mode 100644
index f36399f05..000000000
--- a/server/lib/notifier/shared/video-publication/studio-edition-finished-for-owner.ts
+++ /dev/null
@@ -1,57 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { WEBSERVER } from '@server/initializers/constants'
3import { UserModel } from '@server/models/user/user'
4import { UserNotificationModel } from '@server/models/user/user-notification'
5import { MUserDefault, MUserWithNotificationSetting, MVideoFullLight, UserNotificationModelForApi } from '@server/types/models'
6import { UserNotificationType } from '@shared/models'
7import { AbstractNotification } from '../common/abstract-notification'
8
9export class StudioEditionFinishedForOwner extends AbstractNotification <MVideoFullLight> {
10 private user: MUserDefault
11
12 async prepare () {
13 this.user = await UserModel.loadByVideoId(this.payload.id)
14 }
15
16 log () {
17 logger.info('Notifying user %s its video studio edition %s is finished.', this.user.username, this.payload.url)
18 }
19
20 getSetting (user: MUserWithNotificationSetting) {
21 return user.NotificationSetting.myVideoStudioEditionFinished
22 }
23
24 getTargetUsers () {
25 if (!this.user) return []
26
27 return [ this.user ]
28 }
29
30 createNotification (user: MUserWithNotificationSetting) {
31 const notification = UserNotificationModel.build<UserNotificationModelForApi>({
32 type: UserNotificationType.MY_VIDEO_STUDIO_EDITION_FINISHED,
33 userId: user.id,
34 videoId: this.payload.id
35 })
36 notification.Video = this.payload
37
38 return notification
39 }
40
41 createEmail (to: string) {
42 const videoUrl = WEBSERVER.URL + this.payload.getWatchStaticPath()
43
44 return {
45 to,
46 subject: `Edition of your video ${this.payload.name} has finished`,
47 text: `Edition of your video ${this.payload.name} has finished.`,
48 locals: {
49 title: 'Video edition has finished',
50 action: {
51 text: 'View video',
52 url: videoUrl
53 }
54 }
55 }
56 }
57}
diff --git a/server/lib/object-storage/index.ts b/server/lib/object-storage/index.ts
deleted file mode 100644
index 3ad6cab63..000000000
--- a/server/lib/object-storage/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
1export * from './keys'
2export * from './proxy'
3export * from './pre-signed-urls'
4export * from './urls'
5export * from './videos'
diff --git a/server/lib/object-storage/keys.ts b/server/lib/object-storage/keys.ts
deleted file mode 100644
index 6d2098298..000000000
--- a/server/lib/object-storage/keys.ts
+++ /dev/null
@@ -1,20 +0,0 @@
1import { join } from 'path'
2import { MStreamingPlaylistVideo } from '@server/types/models'
3
4function generateHLSObjectStorageKey (playlist: MStreamingPlaylistVideo, filename: string) {
5 return join(generateHLSObjectBaseStorageKey(playlist), filename)
6}
7
8function generateHLSObjectBaseStorageKey (playlist: MStreamingPlaylistVideo) {
9 return join(playlist.getStringType(), playlist.Video.uuid)
10}
11
12function generateWebVideoObjectStorageKey (filename: string) {
13 return filename
14}
15
16export {
17 generateHLSObjectStorageKey,
18 generateHLSObjectBaseStorageKey,
19 generateWebVideoObjectStorageKey
20}
diff --git a/server/lib/object-storage/pre-signed-urls.ts b/server/lib/object-storage/pre-signed-urls.ts
deleted file mode 100644
index caf149bb8..000000000
--- a/server/lib/object-storage/pre-signed-urls.ts
+++ /dev/null
@@ -1,46 +0,0 @@
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
deleted file mode 100644
index c09a0d1b0..000000000
--- a/server/lib/object-storage/proxy.ts
+++ /dev/null
@@ -1,97 +0,0 @@
1import express from 'express'
2import { PassThrough, pipeline } from 'stream'
3import { GetObjectCommandOutput } from '@aws-sdk/client-s3'
4import { buildReinjectVideoFileTokenQuery } from '@server/controllers/shared/m3u8-playlist'
5import { logger } from '@server/helpers/logger'
6import { StreamReplacer } from '@server/helpers/stream-replacer'
7import { MStreamingPlaylist, MVideo } from '@server/types/models'
8import { HttpStatusCode } from '@shared/models'
9import { injectQueryToPlaylistUrls } from '../hls'
10import { getHLSFileReadStream, getWebVideoFileReadStream } from './videos'
11
12export async function proxifyWebVideoFile (options: {
13 req: express.Request
14 res: express.Response
15 filename: string
16}) {
17 const { req, res, filename } = options
18
19 logger.debug('Proxifying Web Video file %s from object storage.', filename)
20
21 try {
22 const { response: s3Response, stream } = await getWebVideoFileReadStream({
23 filename,
24 rangeHeader: req.header('range')
25 })
26
27 setS3Headers(res, s3Response)
28
29 return stream.pipe(res)
30 } catch (err) {
31 return handleObjectStorageFailure(res, err)
32 }
33}
34
35export async function proxifyHLS (options: {
36 req: express.Request
37 res: express.Response
38 playlist: MStreamingPlaylist
39 video: MVideo
40 filename: string
41 reinjectVideoFileToken: boolean
42}) {
43 const { req, res, playlist, video, filename, reinjectVideoFileToken } = options
44
45 logger.debug('Proxifying HLS file %s from object storage.', filename)
46
47 try {
48 const { response: s3Response, stream } = await getHLSFileReadStream({
49 playlist: playlist.withVideo(video),
50 filename,
51 rangeHeader: req.header('range')
52 })
53
54 setS3Headers(res, s3Response)
55
56 const streamReplacer = reinjectVideoFileToken
57 ? new StreamReplacer(line => injectQueryToPlaylistUrls(line, buildReinjectVideoFileTokenQuery(req, filename.endsWith('master.m3u8'))))
58 : new PassThrough()
59
60 return pipeline(
61 stream,
62 streamReplacer,
63 res,
64 err => {
65 if (!err) return
66
67 handleObjectStorageFailure(res, err)
68 }
69 )
70 } catch (err) {
71 return handleObjectStorageFailure(res, err)
72 }
73}
74
75// ---------------------------------------------------------------------------
76// Private
77// ---------------------------------------------------------------------------
78
79function handleObjectStorageFailure (res: express.Response, err: Error) {
80 if (err.name === 'NoSuchKey') {
81 logger.debug('Could not find key in object storage to proxify private HLS video file.', { err })
82 return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
83 }
84
85 return res.fail({
86 status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
87 message: err.message,
88 type: err.name
89 })
90}
91
92function setS3Headers (res: express.Response, s3Response: GetObjectCommandOutput) {
93 if (s3Response.$metadata.httpStatusCode === HttpStatusCode.PARTIAL_CONTENT_206) {
94 res.setHeader('Content-Range', s3Response.ContentRange)
95 res.status(HttpStatusCode.PARTIAL_CONTENT_206)
96 }
97}
diff --git a/server/lib/object-storage/shared/client.ts b/server/lib/object-storage/shared/client.ts
deleted file mode 100644
index d5cb074df..000000000
--- a/server/lib/object-storage/shared/client.ts
+++ /dev/null
@@ -1,71 +0,0 @@
1import { S3Client } from '@aws-sdk/client-s3'
2import { NodeHttpHandler } from '@aws-sdk/node-http-handler'
3import { logger } from '@server/helpers/logger'
4import { isProxyEnabled } from '@server/helpers/proxy'
5import { getAgent } from '@server/helpers/requests'
6import { CONFIG } from '@server/initializers/config'
7import { lTags } from './logger'
8
9function getProxyRequestHandler () {
10 if (!isProxyEnabled()) return null
11
12 const { agent } = getAgent()
13
14 return new NodeHttpHandler({
15 httpAgent: agent.http,
16 httpsAgent: agent.https
17 })
18}
19
20let endpointParsed: URL
21function getEndpointParsed () {
22 if (endpointParsed) return endpointParsed
23
24 endpointParsed = new URL(getEndpoint())
25
26 return endpointParsed
27}
28
29let s3Client: S3Client
30function getClient () {
31 if (s3Client) return s3Client
32
33 const OBJECT_STORAGE = CONFIG.OBJECT_STORAGE
34
35 s3Client = new S3Client({
36 endpoint: getEndpoint(),
37 region: OBJECT_STORAGE.REGION,
38 credentials: OBJECT_STORAGE.CREDENTIALS.ACCESS_KEY_ID
39 ? {
40 accessKeyId: OBJECT_STORAGE.CREDENTIALS.ACCESS_KEY_ID,
41 secretAccessKey: OBJECT_STORAGE.CREDENTIALS.SECRET_ACCESS_KEY
42 }
43 : undefined,
44 requestHandler: getProxyRequestHandler()
45 })
46
47 logger.info('Initialized S3 client %s with region %s.', getEndpoint(), OBJECT_STORAGE.REGION, lTags())
48
49 return s3Client
50}
51
52// ---------------------------------------------------------------------------
53
54export {
55 getEndpointParsed,
56 getClient
57}
58
59// ---------------------------------------------------------------------------
60
61let endpoint: string
62function getEndpoint () {
63 if (endpoint) return endpoint
64
65 const endpointConfig = CONFIG.OBJECT_STORAGE.ENDPOINT
66 endpoint = endpointConfig.startsWith('http://') || endpointConfig.startsWith('https://')
67 ? CONFIG.OBJECT_STORAGE.ENDPOINT
68 : 'https://' + CONFIG.OBJECT_STORAGE.ENDPOINT
69
70 return endpoint
71}
diff --git a/server/lib/object-storage/shared/index.ts b/server/lib/object-storage/shared/index.ts
deleted file mode 100644
index 11e10aa9f..000000000
--- a/server/lib/object-storage/shared/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
1export * from './client'
2export * from './logger'
3export * from './object-storage-helpers'
diff --git a/server/lib/object-storage/shared/logger.ts b/server/lib/object-storage/shared/logger.ts
deleted file mode 100644
index 8ab7cbd71..000000000
--- a/server/lib/object-storage/shared/logger.ts
+++ /dev/null
@@ -1,7 +0,0 @@
1import { loggerTagsFactory } from '@server/helpers/logger'
2
3const lTags = loggerTagsFactory('object-storage')
4
5export {
6 lTags
7}
diff --git a/server/lib/object-storage/shared/object-storage-helpers.ts b/server/lib/object-storage/shared/object-storage-helpers.ts
deleted file mode 100644
index 0d8878bd2..000000000
--- a/server/lib/object-storage/shared/object-storage-helpers.ts
+++ /dev/null
@@ -1,328 +0,0 @@
1import { map } from 'bluebird'
2import { createReadStream, createWriteStream, ensureDir, ReadStream } from 'fs-extra'
3import { dirname } from 'path'
4import { Readable } from 'stream'
5import {
6 _Object,
7 CompleteMultipartUploadCommandOutput,
8 DeleteObjectCommand,
9 GetObjectCommand,
10 ListObjectsV2Command,
11 PutObjectAclCommand,
12 PutObjectCommandInput,
13 S3Client
14} from '@aws-sdk/client-s3'
15import { Upload } from '@aws-sdk/lib-storage'
16import { pipelinePromise } from '@server/helpers/core-utils'
17import { isArray } from '@server/helpers/custom-validators/misc'
18import { logger } from '@server/helpers/logger'
19import { CONFIG } from '@server/initializers/config'
20import { getInternalUrl } from '../urls'
21import { getClient } from './client'
22import { lTags } from './logger'
23
24type BucketInfo = {
25 BUCKET_NAME: string
26 PREFIX?: string
27}
28
29async function listKeysOfPrefix (prefix: string, bucketInfo: BucketInfo) {
30 const s3Client = getClient()
31
32 const commandPrefix = bucketInfo.PREFIX + prefix
33 const listCommand = new ListObjectsV2Command({
34 Bucket: bucketInfo.BUCKET_NAME,
35 Prefix: commandPrefix
36 })
37
38 const listedObjects = await s3Client.send(listCommand)
39
40 if (isArray(listedObjects.Contents) !== true) return []
41
42 return listedObjects.Contents.map(c => c.Key)
43}
44
45// ---------------------------------------------------------------------------
46
47async function storeObject (options: {
48 inputPath: string
49 objectStorageKey: string
50 bucketInfo: BucketInfo
51 isPrivate: boolean
52}): Promise<string> {
53 const { inputPath, objectStorageKey, bucketInfo, isPrivate } = options
54
55 logger.debug('Uploading file %s to %s%s in bucket %s', inputPath, bucketInfo.PREFIX, objectStorageKey, bucketInfo.BUCKET_NAME, lTags())
56
57 const fileStream = createReadStream(inputPath)
58
59 return uploadToStorage({ objectStorageKey, content: fileStream, bucketInfo, isPrivate })
60}
61
62async function storeContent (options: {
63 content: string
64 inputPath: string
65 objectStorageKey: string
66 bucketInfo: BucketInfo
67 isPrivate: boolean
68}): Promise<string> {
69 const { content, objectStorageKey, bucketInfo, inputPath, isPrivate } = options
70
71 logger.debug('Uploading %s content to %s%s in bucket %s', inputPath, bucketInfo.PREFIX, objectStorageKey, bucketInfo.BUCKET_NAME, lTags())
72
73 return uploadToStorage({ objectStorageKey, content, bucketInfo, isPrivate })
74}
75
76// ---------------------------------------------------------------------------
77
78async function updateObjectACL (options: {
79 objectStorageKey: string
80 bucketInfo: BucketInfo
81 isPrivate: boolean
82}) {
83 const { objectStorageKey, bucketInfo, isPrivate } = options
84
85 const acl = getACL(isPrivate)
86 if (!acl) return
87
88 const key = buildKey(objectStorageKey, bucketInfo)
89
90 logger.debug('Updating ACL file %s in bucket %s', key, bucketInfo.BUCKET_NAME, lTags())
91
92 const command = new PutObjectAclCommand({
93 Bucket: bucketInfo.BUCKET_NAME,
94 Key: key,
95 ACL: acl
96 })
97
98 await getClient().send(command)
99}
100
101function updatePrefixACL (options: {
102 prefix: string
103 bucketInfo: BucketInfo
104 isPrivate: boolean
105}) {
106 const { prefix, bucketInfo, isPrivate } = options
107
108 const acl = getACL(isPrivate)
109 if (!acl) return
110
111 logger.debug('Updating ACL of files in prefix %s in bucket %s', prefix, bucketInfo.BUCKET_NAME, lTags())
112
113 return applyOnPrefix({
114 prefix,
115 bucketInfo,
116 commandBuilder: obj => {
117 logger.debug('Updating ACL of %s inside prefix %s in bucket %s', obj.Key, prefix, bucketInfo.BUCKET_NAME, lTags())
118
119 return new PutObjectAclCommand({
120 Bucket: bucketInfo.BUCKET_NAME,
121 Key: obj.Key,
122 ACL: acl
123 })
124 }
125 })
126}
127
128// ---------------------------------------------------------------------------
129
130function removeObject (objectStorageKey: string, bucketInfo: BucketInfo) {
131 const key = buildKey(objectStorageKey, bucketInfo)
132
133 return removeObjectByFullKey(key, bucketInfo)
134}
135
136function removeObjectByFullKey (fullKey: string, bucketInfo: BucketInfo) {
137 logger.debug('Removing file %s in bucket %s', fullKey, bucketInfo.BUCKET_NAME, lTags())
138
139 const command = new DeleteObjectCommand({
140 Bucket: bucketInfo.BUCKET_NAME,
141 Key: fullKey
142 })
143
144 return getClient().send(command)
145}
146
147async function removePrefix (prefix: string, bucketInfo: BucketInfo) {
148 logger.debug('Removing prefix %s in bucket %s', prefix, bucketInfo.BUCKET_NAME, lTags())
149
150 return applyOnPrefix({
151 prefix,
152 bucketInfo,
153 commandBuilder: obj => {
154 logger.debug('Removing %s inside prefix %s in bucket %s', obj.Key, prefix, bucketInfo.BUCKET_NAME, lTags())
155
156 return new DeleteObjectCommand({
157 Bucket: bucketInfo.BUCKET_NAME,
158 Key: obj.Key
159 })
160 }
161 })
162}
163
164// ---------------------------------------------------------------------------
165
166async function makeAvailable (options: {
167 key: string
168 destination: string
169 bucketInfo: BucketInfo
170}) {
171 const { key, destination, bucketInfo } = options
172
173 await ensureDir(dirname(options.destination))
174
175 const command = new GetObjectCommand({
176 Bucket: bucketInfo.BUCKET_NAME,
177 Key: buildKey(key, bucketInfo)
178 })
179 const response = await getClient().send(command)
180
181 const file = createWriteStream(destination)
182 await pipelinePromise(response.Body as Readable, file)
183
184 file.close()
185}
186
187function buildKey (key: string, bucketInfo: BucketInfo) {
188 return bucketInfo.PREFIX + key
189}
190
191// ---------------------------------------------------------------------------
192
193async function createObjectReadStream (options: {
194 key: string
195 bucketInfo: BucketInfo
196 rangeHeader: string
197}) {
198 const { key, bucketInfo, rangeHeader } = options
199
200 const command = new GetObjectCommand({
201 Bucket: bucketInfo.BUCKET_NAME,
202 Key: buildKey(key, bucketInfo),
203 Range: rangeHeader
204 })
205
206 const response = await getClient().send(command)
207
208 return {
209 response,
210 stream: response.Body as Readable
211 }
212}
213
214// ---------------------------------------------------------------------------
215
216export {
217 BucketInfo,
218 buildKey,
219
220 storeObject,
221 storeContent,
222
223 removeObject,
224 removeObjectByFullKey,
225 removePrefix,
226
227 makeAvailable,
228
229 updateObjectACL,
230 updatePrefixACL,
231
232 listKeysOfPrefix,
233 createObjectReadStream
234}
235
236// ---------------------------------------------------------------------------
237
238async function uploadToStorage (options: {
239 content: ReadStream | string
240 objectStorageKey: string
241 bucketInfo: BucketInfo
242 isPrivate: boolean
243}) {
244 const { content, objectStorageKey, bucketInfo, isPrivate } = options
245
246 const input: PutObjectCommandInput = {
247 Body: content,
248 Bucket: bucketInfo.BUCKET_NAME,
249 Key: buildKey(objectStorageKey, bucketInfo)
250 }
251
252 const acl = getACL(isPrivate)
253 if (acl) input.ACL = acl
254
255 const parallelUploads3 = new Upload({
256 client: getClient(),
257 queueSize: 4,
258 partSize: CONFIG.OBJECT_STORAGE.MAX_UPLOAD_PART,
259
260 // `leavePartsOnError` must be set to `true` to avoid silently dropping failed parts
261 // More detailed explanation:
262 // https://github.com/aws/aws-sdk-js-v3/blob/v3.164.0/lib/lib-storage/src/Upload.ts#L274
263 // https://github.com/aws/aws-sdk-js-v3/issues/2311#issuecomment-939413928
264 leavePartsOnError: true,
265 params: input
266 })
267
268 const response = (await parallelUploads3.done()) as CompleteMultipartUploadCommandOutput
269 // Check is needed even if the HTTP status code is 200 OK
270 // For more information, see https://docs.aws.amazon.com/AmazonS3/latest/API/API_CompleteMultipartUpload.html
271 if (!response.Bucket) {
272 const message = `Error uploading ${objectStorageKey} to bucket ${bucketInfo.BUCKET_NAME}`
273 logger.error(message, { response, ...lTags() })
274 throw new Error(message)
275 }
276
277 logger.debug(
278 'Completed %s%s in bucket %s',
279 bucketInfo.PREFIX, objectStorageKey, bucketInfo.BUCKET_NAME, { ...lTags(), reseponseMetadata: response.$metadata }
280 )
281
282 return getInternalUrl(bucketInfo, objectStorageKey)
283}
284
285async function applyOnPrefix (options: {
286 prefix: string
287 bucketInfo: BucketInfo
288 commandBuilder: (obj: _Object) => Parameters<S3Client['send']>[0]
289
290 continuationToken?: string
291}) {
292 const { prefix, bucketInfo, commandBuilder, continuationToken } = options
293
294 const s3Client = getClient()
295
296 const commandPrefix = buildKey(prefix, bucketInfo)
297 const listCommand = new ListObjectsV2Command({
298 Bucket: bucketInfo.BUCKET_NAME,
299 Prefix: commandPrefix,
300 ContinuationToken: continuationToken
301 })
302
303 const listedObjects = await s3Client.send(listCommand)
304
305 if (isArray(listedObjects.Contents) !== true) {
306 const message = `Cannot apply function on ${commandPrefix} prefix in bucket ${bucketInfo.BUCKET_NAME}: no files listed.`
307
308 logger.error(message, { response: listedObjects, ...lTags() })
309 throw new Error(message)
310 }
311
312 await map(listedObjects.Contents, object => {
313 const command = commandBuilder(object)
314
315 return s3Client.send(command)
316 }, { concurrency: 10 })
317
318 // Repeat if not all objects could be listed at once (limit of 1000?)
319 if (listedObjects.IsTruncated) {
320 await applyOnPrefix({ ...options, continuationToken: listedObjects.ContinuationToken })
321 }
322}
323
324function getACL (isPrivate: boolean) {
325 return isPrivate
326 ? CONFIG.OBJECT_STORAGE.UPLOAD_ACL.PRIVATE
327 : CONFIG.OBJECT_STORAGE.UPLOAD_ACL.PUBLIC
328}
diff --git a/server/lib/object-storage/urls.ts b/server/lib/object-storage/urls.ts
deleted file mode 100644
index 40619cd5a..000000000
--- a/server/lib/object-storage/urls.ts
+++ /dev/null
@@ -1,63 +0,0 @@
1import { CONFIG } from '@server/initializers/config'
2import { OBJECT_STORAGE_PROXY_PATHS, WEBSERVER } from '@server/initializers/constants'
3import { MVideoUUID } from '@server/types/models'
4import { BucketInfo, buildKey, getEndpointParsed } from './shared'
5
6function getInternalUrl (config: BucketInfo, keyWithoutPrefix: string) {
7 return getBaseUrl(config) + buildKey(keyWithoutPrefix, config)
8}
9
10// ---------------------------------------------------------------------------
11
12function getWebVideoPublicFileUrl (fileUrl: string) {
13 const baseUrl = CONFIG.OBJECT_STORAGE.WEB_VIDEOS.BASE_URL
14 if (!baseUrl) return fileUrl
15
16 return replaceByBaseUrl(fileUrl, baseUrl)
17}
18
19function getHLSPublicFileUrl (fileUrl: string) {
20 const baseUrl = CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS.BASE_URL
21 if (!baseUrl) return fileUrl
22
23 return replaceByBaseUrl(fileUrl, baseUrl)
24}
25
26// ---------------------------------------------------------------------------
27
28function getHLSPrivateFileUrl (video: MVideoUUID, filename: string) {
29 return WEBSERVER.URL + OBJECT_STORAGE_PROXY_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + video.uuid + `/${filename}`
30}
31
32function getWebVideoPrivateFileUrl (filename: string) {
33 return WEBSERVER.URL + OBJECT_STORAGE_PROXY_PATHS.PRIVATE_WEB_VIDEOS + filename
34}
35
36// ---------------------------------------------------------------------------
37
38export {
39 getInternalUrl,
40
41 getWebVideoPublicFileUrl,
42 getHLSPublicFileUrl,
43
44 getHLSPrivateFileUrl,
45 getWebVideoPrivateFileUrl,
46
47 replaceByBaseUrl
48}
49
50// ---------------------------------------------------------------------------
51
52function getBaseUrl (bucketInfo: BucketInfo, baseUrl?: string) {
53 if (baseUrl) return baseUrl
54
55 return `${getEndpointParsed().protocol}//${bucketInfo.BUCKET_NAME}.${getEndpointParsed().host}/`
56}
57
58const regex = new RegExp('https?://[^/]+')
59function replaceByBaseUrl (fileUrl: string, baseUrl: string) {
60 if (!fileUrl) return fileUrl
61
62 return fileUrl.replace(regex, baseUrl)
63}
diff --git a/server/lib/object-storage/videos.ts b/server/lib/object-storage/videos.ts
deleted file mode 100644
index 891e9ff76..000000000
--- a/server/lib/object-storage/videos.ts
+++ /dev/null
@@ -1,197 +0,0 @@
1import { basename, join } from 'path'
2import { logger } from '@server/helpers/logger'
3import { CONFIG } from '@server/initializers/config'
4import { MStreamingPlaylistVideo, MVideo, MVideoFile } from '@server/types/models'
5import { getHLSDirectory } from '../paths'
6import { VideoPathManager } from '../video-path-manager'
7import { generateHLSObjectBaseStorageKey, generateHLSObjectStorageKey, generateWebVideoObjectStorageKey } from './keys'
8import {
9 createObjectReadStream,
10 listKeysOfPrefix,
11 lTags,
12 makeAvailable,
13 removeObject,
14 removeObjectByFullKey,
15 removePrefix,
16 storeContent,
17 storeObject,
18 updateObjectACL,
19 updatePrefixACL
20} from './shared'
21
22function listHLSFileKeysOf (playlist: MStreamingPlaylistVideo) {
23 return listKeysOfPrefix(generateHLSObjectBaseStorageKey(playlist), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
24}
25
26// ---------------------------------------------------------------------------
27
28function storeHLSFileFromFilename (playlist: MStreamingPlaylistVideo, filename: string) {
29 return storeObject({
30 inputPath: join(getHLSDirectory(playlist.Video), filename),
31 objectStorageKey: generateHLSObjectStorageKey(playlist, filename),
32 bucketInfo: CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS,
33 isPrivate: playlist.Video.hasPrivateStaticPath()
34 })
35}
36
37function storeHLSFileFromPath (playlist: MStreamingPlaylistVideo, path: string) {
38 return storeObject({
39 inputPath: path,
40 objectStorageKey: generateHLSObjectStorageKey(playlist, basename(path)),
41 bucketInfo: CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS,
42 isPrivate: playlist.Video.hasPrivateStaticPath()
43 })
44}
45
46function storeHLSFileFromContent (playlist: MStreamingPlaylistVideo, path: string, content: string) {
47 return storeContent({
48 content,
49 inputPath: path,
50 objectStorageKey: generateHLSObjectStorageKey(playlist, basename(path)),
51 bucketInfo: CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS,
52 isPrivate: playlist.Video.hasPrivateStaticPath()
53 })
54}
55
56// ---------------------------------------------------------------------------
57
58function storeWebVideoFile (video: MVideo, file: MVideoFile) {
59 return storeObject({
60 inputPath: VideoPathManager.Instance.getFSVideoFileOutputPath(video, file),
61 objectStorageKey: generateWebVideoObjectStorageKey(file.filename),
62 bucketInfo: CONFIG.OBJECT_STORAGE.WEB_VIDEOS,
63 isPrivate: video.hasPrivateStaticPath()
64 })
65}
66
67// ---------------------------------------------------------------------------
68
69async function updateWebVideoFileACL (video: MVideo, file: MVideoFile) {
70 await updateObjectACL({
71 objectStorageKey: generateWebVideoObjectStorageKey(file.filename),
72 bucketInfo: CONFIG.OBJECT_STORAGE.WEB_VIDEOS,
73 isPrivate: video.hasPrivateStaticPath()
74 })
75}
76
77async function updateHLSFilesACL (playlist: MStreamingPlaylistVideo) {
78 await updatePrefixACL({
79 prefix: generateHLSObjectBaseStorageKey(playlist),
80 bucketInfo: CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS,
81 isPrivate: playlist.Video.hasPrivateStaticPath()
82 })
83}
84
85// ---------------------------------------------------------------------------
86
87function removeHLSObjectStorage (playlist: MStreamingPlaylistVideo) {
88 return removePrefix(generateHLSObjectBaseStorageKey(playlist), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
89}
90
91function removeHLSFileObjectStorageByFilename (playlist: MStreamingPlaylistVideo, filename: string) {
92 return removeObject(generateHLSObjectStorageKey(playlist, filename), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
93}
94
95function removeHLSFileObjectStorageByPath (playlist: MStreamingPlaylistVideo, path: string) {
96 return removeObject(generateHLSObjectStorageKey(playlist, basename(path)), CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
97}
98
99function removeHLSFileObjectStorageByFullKey (key: string) {
100 return removeObjectByFullKey(key, CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS)
101}
102
103// ---------------------------------------------------------------------------
104
105function removeWebVideoObjectStorage (videoFile: MVideoFile) {
106 return removeObject(generateWebVideoObjectStorageKey(videoFile.filename), CONFIG.OBJECT_STORAGE.WEB_VIDEOS)
107}
108
109// ---------------------------------------------------------------------------
110
111async function makeHLSFileAvailable (playlist: MStreamingPlaylistVideo, filename: string, destination: string) {
112 const key = generateHLSObjectStorageKey(playlist, filename)
113
114 logger.info('Fetching HLS file %s from object storage to %s.', key, destination, lTags())
115
116 await makeAvailable({
117 key,
118 destination,
119 bucketInfo: CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS
120 })
121
122 return destination
123}
124
125async function makeWebVideoFileAvailable (filename: string, destination: string) {
126 const key = generateWebVideoObjectStorageKey(filename)
127
128 logger.info('Fetching Web Video file %s from object storage to %s.', key, destination, lTags())
129
130 await makeAvailable({
131 key,
132 destination,
133 bucketInfo: CONFIG.OBJECT_STORAGE.WEB_VIDEOS
134 })
135
136 return destination
137}
138
139// ---------------------------------------------------------------------------
140
141function getWebVideoFileReadStream (options: {
142 filename: string
143 rangeHeader: string
144}) {
145 const { filename, rangeHeader } = options
146
147 const key = generateWebVideoObjectStorageKey(filename)
148
149 return createObjectReadStream({
150 key,
151 bucketInfo: CONFIG.OBJECT_STORAGE.WEB_VIDEOS,
152 rangeHeader
153 })
154}
155
156function getHLSFileReadStream (options: {
157 playlist: MStreamingPlaylistVideo
158 filename: string
159 rangeHeader: string
160}) {
161 const { playlist, filename, rangeHeader } = options
162
163 const key = generateHLSObjectStorageKey(playlist, filename)
164
165 return createObjectReadStream({
166 key,
167 bucketInfo: CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS,
168 rangeHeader
169 })
170}
171
172// ---------------------------------------------------------------------------
173
174export {
175 listHLSFileKeysOf,
176
177 storeWebVideoFile,
178 storeHLSFileFromFilename,
179 storeHLSFileFromPath,
180 storeHLSFileFromContent,
181
182 updateWebVideoFileACL,
183 updateHLSFilesACL,
184
185 removeHLSObjectStorage,
186 removeHLSFileObjectStorageByFilename,
187 removeHLSFileObjectStorageByPath,
188 removeHLSFileObjectStorageByFullKey,
189
190 removeWebVideoObjectStorage,
191
192 makeWebVideoFileAvailable,
193 makeHLSFileAvailable,
194
195 getWebVideoFileReadStream,
196 getHLSFileReadStream
197}
diff --git a/server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts b/server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts
deleted file mode 100644
index ef40c0fa9..000000000
--- a/server/lib/opentelemetry/metric-helpers/bittorrent-tracker-observers-builder.ts
+++ /dev/null
@@ -1,51 +0,0 @@
1import { Meter } from '@opentelemetry/api'
2
3export class BittorrentTrackerObserversBuilder {
4
5 constructor (private readonly meter: Meter, private readonly trackerServer: any) {
6
7 }
8
9 buildObservers () {
10 const activeInfohashes = this.meter.createObservableGauge('peertube_bittorrent_tracker_active_infohashes_total', {
11 description: 'Total active infohashes in the PeerTube BitTorrent Tracker'
12 })
13 const inactiveInfohashes = this.meter.createObservableGauge('peertube_bittorrent_tracker_inactive_infohashes_total', {
14 description: 'Total inactive infohashes in the PeerTube BitTorrent Tracker'
15 })
16 const peers = this.meter.createObservableGauge('peertube_bittorrent_tracker_peers_total', {
17 description: 'Total peers in the PeerTube BitTorrent Tracker'
18 })
19
20 this.meter.addBatchObservableCallback(observableResult => {
21 const infohashes = Object.keys(this.trackerServer.torrents)
22
23 const counters = {
24 activeInfohashes: 0,
25 inactiveInfohashes: 0,
26 peers: 0,
27 uncompletedPeers: 0
28 }
29
30 for (const infohash of infohashes) {
31 const content = this.trackerServer.torrents[infohash]
32
33 const peers = content.peers
34 if (peers.keys.length !== 0) counters.activeInfohashes++
35 else counters.inactiveInfohashes++
36
37 for (const peerId of peers.keys) {
38 const peer = peers.peek(peerId)
39 if (peer == null) return
40
41 counters.peers++
42 }
43 }
44
45 observableResult.observe(activeInfohashes, counters.activeInfohashes)
46 observableResult.observe(inactiveInfohashes, counters.inactiveInfohashes)
47 observableResult.observe(peers, counters.peers)
48 }, [ activeInfohashes, inactiveInfohashes, peers ])
49 }
50
51}
diff --git a/server/lib/opentelemetry/metric-helpers/index.ts b/server/lib/opentelemetry/metric-helpers/index.ts
deleted file mode 100644
index 47b24a54f..000000000
--- a/server/lib/opentelemetry/metric-helpers/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
1export * from './bittorrent-tracker-observers-builder'
2export * from './lives-observers-builder'
3export * from './job-queue-observers-builder'
4export * from './nodejs-observers-builder'
5export * from './playback-metrics'
6export * from './stats-observers-builder'
7export * from './viewers-observers-builder'
diff --git a/server/lib/opentelemetry/metric-helpers/job-queue-observers-builder.ts b/server/lib/opentelemetry/metric-helpers/job-queue-observers-builder.ts
deleted file mode 100644
index 56713ede8..000000000
--- a/server/lib/opentelemetry/metric-helpers/job-queue-observers-builder.ts
+++ /dev/null
@@ -1,24 +0,0 @@
1import { Meter } from '@opentelemetry/api'
2import { JobQueue } from '@server/lib/job-queue'
3
4export class JobQueueObserversBuilder {
5
6 constructor (private readonly meter: Meter) {
7
8 }
9
10 buildObservers () {
11 this.meter.createObservableGauge('peertube_job_queue_total', {
12 description: 'Total jobs in the PeerTube job queue'
13 }).addCallback(async observableResult => {
14 const stats = await JobQueue.Instance.getStats()
15
16 for (const { jobType, counts } of stats) {
17 for (const state of Object.keys(counts)) {
18 observableResult.observe(counts[state], { jobType, state })
19 }
20 }
21 })
22 }
23
24}
diff --git a/server/lib/opentelemetry/metric-helpers/lives-observers-builder.ts b/server/lib/opentelemetry/metric-helpers/lives-observers-builder.ts
deleted file mode 100644
index 5effc18e1..000000000
--- a/server/lib/opentelemetry/metric-helpers/lives-observers-builder.ts
+++ /dev/null
@@ -1,21 +0,0 @@
1import { Meter } from '@opentelemetry/api'
2import { VideoModel } from '@server/models/video/video'
3
4export class LivesObserversBuilder {
5
6 constructor (private readonly meter: Meter) {
7
8 }
9
10 buildObservers () {
11 this.meter.createObservableGauge('peertube_running_lives_total', {
12 description: 'Total running lives on the instance'
13 }).addCallback(async observableResult => {
14 const local = await VideoModel.countLives({ remote: false, mode: 'published' })
15 const remote = await VideoModel.countLives({ remote: true, mode: 'published' })
16
17 observableResult.observe(local, { liveOrigin: 'local' })
18 observableResult.observe(remote, { liveOrigin: 'remote' })
19 })
20 }
21}
diff --git a/server/lib/opentelemetry/metric-helpers/nodejs-observers-builder.ts b/server/lib/opentelemetry/metric-helpers/nodejs-observers-builder.ts
deleted file mode 100644
index 8ed219e9e..000000000
--- a/server/lib/opentelemetry/metric-helpers/nodejs-observers-builder.ts
+++ /dev/null
@@ -1,202 +0,0 @@
1import { readdir } from 'fs-extra'
2import { constants, NodeGCPerformanceDetail, PerformanceObserver } from 'perf_hooks'
3import * as process from 'process'
4import { Meter, ObservableResult } from '@opentelemetry/api'
5import { ExplicitBucketHistogramAggregation } from '@opentelemetry/sdk-metrics'
6import { View } from '@opentelemetry/sdk-metrics/build/src/view/View'
7import { logger } from '@server/helpers/logger'
8
9// Thanks to https://github.com/siimon/prom-client
10// We took their logic and adapter it for opentelemetry
11// Try to keep consistency with their metric name/description so it's easier to process (grafana dashboard template etc)
12
13export class NodeJSObserversBuilder {
14
15 constructor (private readonly meter: Meter) {
16 }
17
18 static getViews () {
19 return [
20 new View({
21 aggregation: new ExplicitBucketHistogramAggregation([ 0.001, 0.01, 0.1, 1, 2, 5 ]),
22 instrumentName: 'nodejs_gc_duration_seconds'
23 })
24 ]
25 }
26
27 buildObservers () {
28 this.buildCPUObserver()
29 this.buildMemoryObserver()
30
31 this.buildHandlesObserver()
32 this.buildFileDescriptorsObserver()
33
34 this.buildGCObserver()
35 this.buildEventLoopLagObserver()
36
37 this.buildLibUVActiveRequestsObserver()
38 this.buildActiveResourcesObserver()
39 }
40
41 private buildCPUObserver () {
42 const cpuTotal = this.meter.createObservableCounter('process_cpu_seconds_total', {
43 description: 'Total user and system CPU time spent in seconds.'
44 })
45 const cpuUser = this.meter.createObservableCounter('process_cpu_user_seconds_total', {
46 description: 'Total user CPU time spent in seconds.'
47 })
48 const cpuSystem = this.meter.createObservableCounter('process_cpu_system_seconds_total', {
49 description: 'Total system CPU time spent in seconds.'
50 })
51
52 let lastCpuUsage = process.cpuUsage()
53
54 this.meter.addBatchObservableCallback(observableResult => {
55 const cpuUsage = process.cpuUsage()
56
57 const userUsageMicros = cpuUsage.user - lastCpuUsage.user
58 const systemUsageMicros = cpuUsage.system - lastCpuUsage.system
59
60 lastCpuUsage = cpuUsage
61
62 observableResult.observe(cpuTotal, (userUsageMicros + systemUsageMicros) / 1e6)
63 observableResult.observe(cpuUser, userUsageMicros / 1e6)
64 observableResult.observe(cpuSystem, systemUsageMicros / 1e6)
65
66 }, [ cpuTotal, cpuUser, cpuSystem ])
67 }
68
69 private buildMemoryObserver () {
70 this.meter.createObservableGauge('nodejs_memory_usage_bytes', {
71 description: 'Memory'
72 }).addCallback(observableResult => {
73 const current = process.memoryUsage()
74
75 observableResult.observe(current.heapTotal, { memoryType: 'heapTotal' })
76 observableResult.observe(current.heapUsed, { memoryType: 'heapUsed' })
77 observableResult.observe(current.arrayBuffers, { memoryType: 'arrayBuffers' })
78 observableResult.observe(current.external, { memoryType: 'external' })
79 observableResult.observe(current.rss, { memoryType: 'rss' })
80 })
81 }
82
83 private buildHandlesObserver () {
84 if (typeof (process as any)._getActiveHandles !== 'function') return
85
86 this.meter.createObservableGauge('nodejs_active_handles_total', {
87 description: 'Total number of active handles.'
88 }).addCallback(observableResult => {
89 const handles = (process as any)._getActiveHandles()
90
91 observableResult.observe(handles.length)
92 })
93 }
94
95 private buildGCObserver () {
96 const kinds = {
97 [constants.NODE_PERFORMANCE_GC_MAJOR]: 'major',
98 [constants.NODE_PERFORMANCE_GC_MINOR]: 'minor',
99 [constants.NODE_PERFORMANCE_GC_INCREMENTAL]: 'incremental',
100 [constants.NODE_PERFORMANCE_GC_WEAKCB]: 'weakcb'
101 }
102
103 const histogram = this.meter.createHistogram('nodejs_gc_duration_seconds', {
104 description: 'Garbage collection duration by kind, one of major, minor, incremental or weakcb'
105 })
106
107 const obs = new PerformanceObserver(list => {
108 const entry = list.getEntries()[0]
109
110 // Node < 16 uses entry.kind
111 // Node >= 16 uses entry.detail.kind
112 // See: https://nodejs.org/docs/latest-v16.x/api/deprecations.html#deprecations_dep0152_extension_performanceentry_properties
113 const kind = entry.detail
114 ? kinds[(entry.detail as NodeGCPerformanceDetail).kind]
115 : kinds[(entry as any).kind]
116
117 // Convert duration from milliseconds to seconds
118 histogram.record(entry.duration / 1000, {
119 kind
120 })
121 })
122
123 obs.observe({ entryTypes: [ 'gc' ] })
124 }
125
126 private buildEventLoopLagObserver () {
127 const reportEventloopLag = (start: [ number, number ], observableResult: ObservableResult, res: () => void) => {
128 const delta = process.hrtime(start)
129 const nanosec = delta[0] * 1e9 + delta[1]
130 const seconds = nanosec / 1e9
131
132 observableResult.observe(seconds)
133
134 res()
135 }
136
137 this.meter.createObservableGauge('nodejs_eventloop_lag_seconds', {
138 description: 'Lag of event loop in seconds.'
139 }).addCallback(observableResult => {
140 return new Promise(res => {
141 const start = process.hrtime()
142
143 setImmediate(reportEventloopLag, start, observableResult, res)
144 })
145 })
146 }
147
148 private buildFileDescriptorsObserver () {
149 this.meter.createObservableGauge('process_open_fds', {
150 description: 'Number of open file descriptors.'
151 }).addCallback(async observableResult => {
152 try {
153 const fds = await readdir('/proc/self/fd')
154 observableResult.observe(fds.length - 1)
155 } catch (err) {
156 logger.debug('Cannot list file descriptors of current process for OpenTelemetry.', { err })
157 }
158 })
159 }
160
161 private buildLibUVActiveRequestsObserver () {
162 if (typeof (process as any)._getActiveRequests !== 'function') return
163
164 this.meter.createObservableGauge('nodejs_active_requests_total', {
165 description: 'Total number of active libuv requests.'
166 }).addCallback(observableResult => {
167 const requests = (process as any)._getActiveRequests()
168
169 observableResult.observe(requests.length)
170 })
171 }
172
173 private buildActiveResourcesObserver () {
174 if (typeof (process as any).getActiveResourcesInfo !== 'function') return
175
176 const grouped = this.meter.createObservableCounter('nodejs_active_resources', {
177 description: 'Number of active resources that are currently keeping the event loop alive, grouped by async resource type.'
178 })
179 const total = this.meter.createObservableCounter('nodejs_active_resources_total', {
180 description: 'Total number of active resources.'
181 })
182
183 this.meter.addBatchObservableCallback(observableResult => {
184 const resources = (process as any).getActiveResourcesInfo()
185
186 const data = {}
187
188 for (let i = 0; i < resources.length; i++) {
189 const resource = resources[i]
190
191 if (data[resource] === undefined) data[resource] = 0
192 data[resource] += 1
193 }
194
195 for (const type of Object.keys(data)) {
196 observableResult.observe(grouped, data[type], { type })
197 }
198
199 observableResult.observe(total, resources.length)
200 }, [ grouped, total ])
201 }
202}
diff --git a/server/lib/opentelemetry/metric-helpers/playback-metrics.ts b/server/lib/opentelemetry/metric-helpers/playback-metrics.ts
deleted file mode 100644
index 1eb08b5a6..000000000
--- a/server/lib/opentelemetry/metric-helpers/playback-metrics.ts
+++ /dev/null
@@ -1,85 +0,0 @@
1import { Counter, Meter } from '@opentelemetry/api'
2import { MVideoImmutable } from '@server/types/models'
3import { PlaybackMetricCreate } from '@shared/models'
4
5export class PlaybackMetrics {
6 private errorsCounter: Counter
7 private resolutionChangesCounter: Counter
8
9 private downloadedBytesP2PCounter: Counter
10 private uploadedBytesP2PCounter: Counter
11
12 private downloadedBytesHTTPCounter: Counter
13
14 private peersP2PPeersGaugeBuffer: {
15 value: number
16 attributes: any
17 }[] = []
18
19 constructor (private readonly meter: Meter) {
20
21 }
22
23 buildCounters () {
24 this.errorsCounter = this.meter.createCounter('peertube_playback_errors_count', {
25 description: 'Errors collected from PeerTube player.'
26 })
27
28 this.resolutionChangesCounter = this.meter.createCounter('peertube_playback_resolution_changes_count', {
29 description: 'Resolution changes collected from PeerTube player.'
30 })
31
32 this.downloadedBytesHTTPCounter = this.meter.createCounter('peertube_playback_http_downloaded_bytes', {
33 description: 'Downloaded bytes with HTTP by PeerTube player.'
34 })
35 this.downloadedBytesP2PCounter = this.meter.createCounter('peertube_playback_p2p_downloaded_bytes', {
36 description: 'Downloaded bytes with P2P by PeerTube player.'
37 })
38
39 this.uploadedBytesP2PCounter = this.meter.createCounter('peertube_playback_p2p_uploaded_bytes', {
40 description: 'Uploaded bytes with P2P by PeerTube player.'
41 })
42
43 this.meter.createObservableGauge('peertube_playback_p2p_peers', {
44 description: 'Total P2P peers connected to the PeerTube player.'
45 }).addCallback(observableResult => {
46 for (const gauge of this.peersP2PPeersGaugeBuffer) {
47 observableResult.observe(gauge.value, gauge.attributes)
48 }
49
50 this.peersP2PPeersGaugeBuffer = []
51 })
52 }
53
54 observe (video: MVideoImmutable, metrics: PlaybackMetricCreate) {
55 const attributes = {
56 videoOrigin: video.remote
57 ? 'remote'
58 : 'local',
59
60 playerMode: metrics.playerMode,
61
62 resolution: metrics.resolution + '',
63 fps: metrics.fps + '',
64
65 p2pEnabled: metrics.p2pEnabled,
66
67 videoUUID: video.uuid
68 }
69
70 this.errorsCounter.add(metrics.errors, attributes)
71 this.resolutionChangesCounter.add(metrics.resolutionChanges, attributes)
72
73 this.downloadedBytesHTTPCounter.add(metrics.downloadedBytesHTTP, attributes)
74 this.downloadedBytesP2PCounter.add(metrics.downloadedBytesP2P, attributes)
75
76 this.uploadedBytesP2PCounter.add(metrics.uploadedBytesP2P, attributes)
77
78 if (metrics.p2pPeers) {
79 this.peersP2PPeersGaugeBuffer.push({
80 value: metrics.p2pPeers,
81 attributes
82 })
83 }
84 }
85}
diff --git a/server/lib/opentelemetry/metric-helpers/stats-observers-builder.ts b/server/lib/opentelemetry/metric-helpers/stats-observers-builder.ts
deleted file mode 100644
index 9f5f22e1b..000000000
--- a/server/lib/opentelemetry/metric-helpers/stats-observers-builder.ts
+++ /dev/null
@@ -1,186 +0,0 @@
1import memoizee from 'memoizee'
2import { Meter } from '@opentelemetry/api'
3import { MEMOIZE_TTL } from '@server/initializers/constants'
4import { buildAvailableActivities } from '@server/lib/activitypub/activity'
5import { StatsManager } from '@server/lib/stat-manager'
6
7export class StatsObserversBuilder {
8
9 private readonly getInstanceStats = memoizee(() => {
10 return StatsManager.Instance.getStats()
11 }, { maxAge: MEMOIZE_TTL.GET_STATS_FOR_OPEN_TELEMETRY_METRICS })
12
13 constructor (private readonly meter: Meter) {
14
15 }
16
17 buildObservers () {
18 this.buildUserStatsObserver()
19 this.buildVideoStatsObserver()
20 this.buildCommentStatsObserver()
21 this.buildPlaylistStatsObserver()
22 this.buildChannelStatsObserver()
23 this.buildInstanceFollowsStatsObserver()
24 this.buildRedundancyStatsObserver()
25 this.buildActivityPubStatsObserver()
26 }
27
28 private buildUserStatsObserver () {
29 this.meter.createObservableGauge('peertube_users_total', {
30 description: 'Total users on the instance'
31 }).addCallback(async observableResult => {
32 const stats = await this.getInstanceStats()
33
34 observableResult.observe(stats.totalUsers)
35 })
36
37 this.meter.createObservableGauge('peertube_active_users_total', {
38 description: 'Total active users on the instance'
39 }).addCallback(async observableResult => {
40 const stats = await this.getInstanceStats()
41
42 observableResult.observe(stats.totalDailyActiveUsers, { activeInterval: 'daily' })
43 observableResult.observe(stats.totalWeeklyActiveUsers, { activeInterval: 'weekly' })
44 observableResult.observe(stats.totalMonthlyActiveUsers, { activeInterval: 'monthly' })
45 })
46 }
47
48 private buildChannelStatsObserver () {
49 this.meter.createObservableGauge('peertube_channels_total', {
50 description: 'Total channels on the instance'
51 }).addCallback(async observableResult => {
52 const stats = await this.getInstanceStats()
53
54 observableResult.observe(stats.totalLocalVideoChannels, { channelOrigin: 'local' })
55 })
56
57 this.meter.createObservableGauge('peertube_active_channels_total', {
58 description: 'Total active channels on the instance'
59 }).addCallback(async observableResult => {
60 const stats = await this.getInstanceStats()
61
62 observableResult.observe(stats.totalLocalDailyActiveVideoChannels, { channelOrigin: 'local', activeInterval: 'daily' })
63 observableResult.observe(stats.totalLocalWeeklyActiveVideoChannels, { channelOrigin: 'local', activeInterval: 'weekly' })
64 observableResult.observe(stats.totalLocalMonthlyActiveVideoChannels, { channelOrigin: 'local', activeInterval: 'monthly' })
65 })
66 }
67
68 private buildVideoStatsObserver () {
69 this.meter.createObservableGauge('peertube_videos_total', {
70 description: 'Total videos on the instance'
71 }).addCallback(async observableResult => {
72 const stats = await this.getInstanceStats()
73
74 observableResult.observe(stats.totalLocalVideos, { videoOrigin: 'local' })
75 observableResult.observe(stats.totalVideos - stats.totalLocalVideos, { videoOrigin: 'remote' })
76 })
77
78 this.meter.createObservableGauge('peertube_video_views_total', {
79 description: 'Total video views made on the instance'
80 }).addCallback(async observableResult => {
81 const stats = await this.getInstanceStats()
82
83 observableResult.observe(stats.totalLocalVideoViews, { viewOrigin: 'local' })
84 })
85
86 this.meter.createObservableGauge('peertube_video_bytes_total', {
87 description: 'Total bytes of videos'
88 }).addCallback(async observableResult => {
89 const stats = await this.getInstanceStats()
90
91 observableResult.observe(stats.totalLocalVideoFilesSize, { videoOrigin: 'local' })
92 })
93 }
94
95 private buildCommentStatsObserver () {
96 this.meter.createObservableGauge('peertube_comments_total', {
97 description: 'Total comments on the instance'
98 }).addCallback(async observableResult => {
99 const stats = await this.getInstanceStats()
100
101 observableResult.observe(stats.totalLocalVideoComments, { accountOrigin: 'local' })
102 })
103 }
104
105 private buildPlaylistStatsObserver () {
106 this.meter.createObservableGauge('peertube_playlists_total', {
107 description: 'Total playlists on the instance'
108 }).addCallback(async observableResult => {
109 const stats = await this.getInstanceStats()
110
111 observableResult.observe(stats.totalLocalPlaylists, { playlistOrigin: 'local' })
112 })
113 }
114
115 private buildInstanceFollowsStatsObserver () {
116 this.meter.createObservableGauge('peertube_instance_followers_total', {
117 description: 'Total followers of the instance'
118 }).addCallback(async observableResult => {
119 const stats = await this.getInstanceStats()
120
121 observableResult.observe(stats.totalInstanceFollowers)
122 })
123
124 this.meter.createObservableGauge('peertube_instance_following_total', {
125 description: 'Total following of the instance'
126 }).addCallback(async observableResult => {
127 const stats = await this.getInstanceStats()
128
129 observableResult.observe(stats.totalInstanceFollowing)
130 })
131 }
132
133 private buildRedundancyStatsObserver () {
134 this.meter.createObservableGauge('peertube_redundancy_used_bytes_total', {
135 description: 'Total redundancy used of the instance'
136 }).addCallback(async observableResult => {
137 const stats = await this.getInstanceStats()
138
139 for (const r of stats.videosRedundancy) {
140 observableResult.observe(r.totalUsed, { strategy: r.strategy })
141 }
142 })
143
144 this.meter.createObservableGauge('peertube_redundancy_available_bytes_total', {
145 description: 'Total redundancy available of the instance'
146 }).addCallback(async observableResult => {
147 const stats = await this.getInstanceStats()
148
149 for (const r of stats.videosRedundancy) {
150 observableResult.observe(r.totalSize, { strategy: r.strategy })
151 }
152 })
153 }
154
155 private buildActivityPubStatsObserver () {
156 const availableActivities = buildAvailableActivities()
157
158 this.meter.createObservableGauge('peertube_ap_inbox_success_total', {
159 description: 'Total inbox messages processed with success'
160 }).addCallback(async observableResult => {
161 const stats = await this.getInstanceStats()
162
163 for (const type of availableActivities) {
164 observableResult.observe(stats[`totalActivityPub${type}MessagesSuccesses`], { activityType: type })
165 }
166 })
167
168 this.meter.createObservableGauge('peertube_ap_inbox_error_total', {
169 description: 'Total inbox messages processed with error'
170 }).addCallback(async observableResult => {
171 const stats = await this.getInstanceStats()
172
173 for (const type of availableActivities) {
174 observableResult.observe(stats[`totalActivityPub${type}MessagesErrors`], { activityType: type })
175 }
176 })
177
178 this.meter.createObservableGauge('peertube_ap_inbox_waiting_total', {
179 description: 'Total inbox messages waiting for being processed'
180 }).addCallback(async observableResult => {
181 const stats = await this.getInstanceStats()
182
183 observableResult.observe(stats.totalActivityPubMessagesWaiting)
184 })
185 }
186}
diff --git a/server/lib/opentelemetry/metric-helpers/viewers-observers-builder.ts b/server/lib/opentelemetry/metric-helpers/viewers-observers-builder.ts
deleted file mode 100644
index c65f8ddae..000000000
--- a/server/lib/opentelemetry/metric-helpers/viewers-observers-builder.ts
+++ /dev/null
@@ -1,24 +0,0 @@
1import { Meter } from '@opentelemetry/api'
2import { VideoScope, ViewerScope } from '@server/lib/views/shared'
3import { VideoViewsManager } from '@server/lib/views/video-views-manager'
4
5export class ViewersObserversBuilder {
6
7 constructor (private readonly meter: Meter) {
8
9 }
10
11 buildObservers () {
12 this.meter.createObservableGauge('peertube_viewers_total', {
13 description: 'Total viewers on the instance'
14 }).addCallback(observableResult => {
15 for (const viewerScope of [ 'local', 'remote' ] as ViewerScope[]) {
16 for (const videoScope of [ 'local', 'remote' ] as VideoScope[]) {
17 const result = VideoViewsManager.Instance.getTotalViewers({ viewerScope, videoScope })
18
19 observableResult.observe(result, { viewerOrigin: viewerScope, videoOrigin: videoScope })
20 }
21 }
22 })
23 }
24}
diff --git a/server/lib/opentelemetry/metrics.ts b/server/lib/opentelemetry/metrics.ts
deleted file mode 100644
index bffe00840..000000000
--- a/server/lib/opentelemetry/metrics.ts
+++ /dev/null
@@ -1,123 +0,0 @@
1import { Application, Request, Response } from 'express'
2import { Meter, metrics } from '@opentelemetry/api'
3import { PrometheusExporter } from '@opentelemetry/exporter-prometheus'
4import { MeterProvider } from '@opentelemetry/sdk-metrics'
5import { logger } from '@server/helpers/logger'
6import { CONFIG } from '@server/initializers/config'
7import { MVideoImmutable } from '@server/types/models'
8import { PlaybackMetricCreate } from '@shared/models'
9import {
10 BittorrentTrackerObserversBuilder,
11 JobQueueObserversBuilder,
12 LivesObserversBuilder,
13 NodeJSObserversBuilder,
14 PlaybackMetrics,
15 StatsObserversBuilder,
16 ViewersObserversBuilder
17} from './metric-helpers'
18
19class OpenTelemetryMetrics {
20
21 private static instance: OpenTelemetryMetrics
22
23 private meter: Meter
24
25 private onRequestDuration: (req: Request, res: Response) => void
26
27 private playbackMetrics: PlaybackMetrics
28
29 private constructor () {}
30
31 init (app: Application) {
32 if (CONFIG.OPEN_TELEMETRY.METRICS.ENABLED !== true) return
33
34 app.use((req, res, next) => {
35 res.once('finish', () => {
36 if (!this.onRequestDuration) return
37
38 this.onRequestDuration(req as Request, res as Response)
39 })
40
41 next()
42 })
43 }
44
45 registerMetrics (options: { trackerServer: any }) {
46 if (CONFIG.OPEN_TELEMETRY.METRICS.ENABLED !== true) return
47
48 logger.info('Registering Open Telemetry metrics')
49
50 const provider = new MeterProvider({
51 views: [
52 ...NodeJSObserversBuilder.getViews()
53 ]
54 })
55
56 provider.addMetricReader(new PrometheusExporter({
57 host: CONFIG.OPEN_TELEMETRY.METRICS.PROMETHEUS_EXPORTER.HOSTNAME,
58 port: CONFIG.OPEN_TELEMETRY.METRICS.PROMETHEUS_EXPORTER.PORT
59 }))
60
61 metrics.setGlobalMeterProvider(provider)
62
63 this.meter = metrics.getMeter('default')
64
65 if (CONFIG.OPEN_TELEMETRY.METRICS.HTTP_REQUEST_DURATION.ENABLED === true) {
66 this.buildRequestObserver()
67 }
68
69 this.playbackMetrics = new PlaybackMetrics(this.meter)
70 this.playbackMetrics.buildCounters()
71
72 const nodeJSObserversBuilder = new NodeJSObserversBuilder(this.meter)
73 nodeJSObserversBuilder.buildObservers()
74
75 const jobQueueObserversBuilder = new JobQueueObserversBuilder(this.meter)
76 jobQueueObserversBuilder.buildObservers()
77
78 const statsObserversBuilder = new StatsObserversBuilder(this.meter)
79 statsObserversBuilder.buildObservers()
80
81 const livesObserversBuilder = new LivesObserversBuilder(this.meter)
82 livesObserversBuilder.buildObservers()
83
84 const viewersObserversBuilder = new ViewersObserversBuilder(this.meter)
85 viewersObserversBuilder.buildObservers()
86
87 const bittorrentTrackerObserversBuilder = new BittorrentTrackerObserversBuilder(this.meter, options.trackerServer)
88 bittorrentTrackerObserversBuilder.buildObservers()
89 }
90
91 observePlaybackMetric (video: MVideoImmutable, metrics: PlaybackMetricCreate) {
92 this.playbackMetrics.observe(video, metrics)
93 }
94
95 private buildRequestObserver () {
96 const requestDuration = this.meter.createHistogram('http_request_duration_ms', {
97 unit: 'milliseconds',
98 description: 'Duration of HTTP requests in ms'
99 })
100
101 this.onRequestDuration = (req: Request, res: Response) => {
102 const duration = Date.now() - res.locals.requestStart
103
104 requestDuration.record(duration, {
105 path: this.buildRequestPath(req.originalUrl),
106 method: req.method,
107 statusCode: res.statusCode + ''
108 })
109 }
110 }
111
112 private buildRequestPath (path: string) {
113 return path.split('?')[0]
114 }
115
116 static get Instance () {
117 return this.instance || (this.instance = new this())
118 }
119}
120
121export {
122 OpenTelemetryMetrics
123}
diff --git a/server/lib/opentelemetry/tracing.ts b/server/lib/opentelemetry/tracing.ts
deleted file mode 100644
index 9a81680b2..000000000
--- a/server/lib/opentelemetry/tracing.ts
+++ /dev/null
@@ -1,94 +0,0 @@
1import { SequelizeInstrumentation } from 'opentelemetry-instrumentation-sequelize'
2import { context, diag, DiagLogLevel, trace } from '@opentelemetry/api'
3import { JaegerExporter } from '@opentelemetry/exporter-jaeger'
4import { registerInstrumentations } from '@opentelemetry/instrumentation'
5import { DnsInstrumentation } from '@opentelemetry/instrumentation-dns'
6import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express'
7import FsInstrumentation from '@opentelemetry/instrumentation-fs'
8import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'
9import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis'
10import { PgInstrumentation } from '@opentelemetry/instrumentation-pg'
11import { Resource } from '@opentelemetry/resources'
12import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'
13import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'
14import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'
15import { logger } from '@server/helpers/logger'
16import { CONFIG } from '@server/initializers/config'
17
18const tracer = trace.getTracer('peertube')
19
20function registerOpentelemetryTracing () {
21 if (CONFIG.OPEN_TELEMETRY.TRACING.ENABLED !== true) return
22
23 logger.info('Registering Open Telemetry tracing')
24
25 const customLogger = (level: string) => {
26 return (message: string, ...args: unknown[]) => {
27 let fullMessage = message
28
29 for (const arg of args) {
30 if (typeof arg === 'string') fullMessage += arg
31 else break
32 }
33
34 logger[level](fullMessage)
35 }
36 }
37
38 diag.setLogger({
39 error: customLogger('error'),
40 warn: customLogger('warn'),
41 info: customLogger('info'),
42 debug: customLogger('debug'),
43 verbose: customLogger('verbose')
44 }, DiagLogLevel.INFO)
45
46 const tracerProvider = new NodeTracerProvider({
47 resource: new Resource({
48 [SemanticResourceAttributes.SERVICE_NAME]: 'peertube'
49 })
50 })
51
52 registerInstrumentations({
53 tracerProvider,
54 instrumentations: [
55 new PgInstrumentation({
56 enhancedDatabaseReporting: true
57 }),
58 new DnsInstrumentation(),
59 new HttpInstrumentation(),
60 new ExpressInstrumentation(),
61 new IORedisInstrumentation({
62 dbStatementSerializer: function (cmdName, cmdArgs) {
63 return [ cmdName, ...cmdArgs ].join(' ')
64 }
65 }),
66 new FsInstrumentation(),
67 new SequelizeInstrumentation()
68 ]
69 })
70
71 tracerProvider.addSpanProcessor(
72 new BatchSpanProcessor(
73 new JaegerExporter({ endpoint: CONFIG.OPEN_TELEMETRY.TRACING.JAEGER_EXPORTER.ENDPOINT })
74 )
75 )
76
77 tracerProvider.register()
78}
79
80async function wrapWithSpanAndContext <T> (spanName: string, cb: () => Promise<T>) {
81 const span = tracer.startSpan(spanName)
82 const activeContext = trace.setSpan(context.active(), span)
83
84 const result = await context.with(activeContext, () => cb())
85 span.end()
86
87 return result
88}
89
90export {
91 registerOpentelemetryTracing,
92 tracer,
93 wrapWithSpanAndContext
94}
diff --git a/server/lib/paths.ts b/server/lib/paths.ts
deleted file mode 100644
index db1cdede2..000000000
--- a/server/lib/paths.ts
+++ /dev/null
@@ -1,92 +0,0 @@
1import { join } from 'path'
2import { CONFIG } from '@server/initializers/config'
3import { DIRECTORIES, VIDEO_LIVE } from '@server/initializers/constants'
4import { isStreamingPlaylist, MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoUUID } from '@server/types/models'
5import { removeFragmentedMP4Ext } from '@shared/core-utils'
6import { buildUUID } from '@shared/extra-utils'
7import { isVideoInPrivateDirectory } from './video-privacy'
8
9// ################## Video file name ##################
10
11function generateWebVideoFilename (resolution: number, extname: string) {
12 return buildUUID() + '-' + resolution + extname
13}
14
15function generateHLSVideoFilename (resolution: number) {
16 return `${buildUUID()}-${resolution}-fragmented.mp4`
17}
18
19// ################## Streaming playlist ##################
20
21function getLiveDirectory (video: MVideo) {
22 return getHLSDirectory(video)
23}
24
25function getLiveReplayBaseDirectory (video: MVideo) {
26 return join(getLiveDirectory(video), VIDEO_LIVE.REPLAY_DIRECTORY)
27}
28
29function getHLSDirectory (video: MVideo) {
30 if (isVideoInPrivateDirectory(video.privacy)) {
31 return join(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, video.uuid)
32 }
33
34 return join(DIRECTORIES.HLS_STREAMING_PLAYLIST.PUBLIC, video.uuid)
35}
36
37function getHLSRedundancyDirectory (video: MVideoUUID) {
38 return join(DIRECTORIES.HLS_REDUNDANCY, video.uuid)
39}
40
41function getHlsResolutionPlaylistFilename (videoFilename: string) {
42 // Video file name already contain resolution
43 return removeFragmentedMP4Ext(videoFilename) + '.m3u8'
44}
45
46function generateHLSMasterPlaylistFilename (isLive = false) {
47 if (isLive) return 'master.m3u8'
48
49 return buildUUID() + '-master.m3u8'
50}
51
52function generateHlsSha256SegmentsFilename (isLive = false) {
53 if (isLive) return 'segments-sha256.json'
54
55 return buildUUID() + '-segments-sha256.json'
56}
57
58// ################## Torrents ##################
59
60function generateTorrentFileName (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, resolution: number) {
61 const extension = '.torrent'
62 const uuid = buildUUID()
63
64 if (isStreamingPlaylist(videoOrPlaylist)) {
65 return `${uuid}-${resolution}-${videoOrPlaylist.getStringType()}${extension}`
66 }
67
68 return uuid + '-' + resolution + extension
69}
70
71function getFSTorrentFilePath (videoFile: MVideoFile) {
72 return join(CONFIG.STORAGE.TORRENTS_DIR, videoFile.torrentFilename)
73}
74
75// ---------------------------------------------------------------------------
76
77export {
78 generateHLSVideoFilename,
79 generateWebVideoFilename,
80
81 generateTorrentFileName,
82 getFSTorrentFilePath,
83
84 getHLSDirectory,
85 getLiveDirectory,
86 getLiveReplayBaseDirectory,
87 getHLSRedundancyDirectory,
88
89 generateHLSMasterPlaylistFilename,
90 generateHlsSha256SegmentsFilename,
91 getHlsResolutionPlaylistFilename
92}
diff --git a/server/lib/peertube-socket.ts b/server/lib/peertube-socket.ts
deleted file mode 100644
index 3e41a2def..000000000
--- a/server/lib/peertube-socket.ts
+++ /dev/null
@@ -1,129 +0,0 @@
1import { Server as HTTPServer } from 'http'
2import { Namespace, Server as SocketServer, Socket } from 'socket.io'
3import { isIdValid } from '@server/helpers/custom-validators/misc'
4import { Debounce } from '@server/helpers/debounce'
5import { MVideo, MVideoImmutable } from '@server/types/models'
6import { MRunner } from '@server/types/models/runners'
7import { UserNotificationModelForApi } from '@server/types/models/user'
8import { LiveVideoEventPayload, LiveVideoEventType } from '@shared/models'
9import { logger } from '../helpers/logger'
10import { authenticateRunnerSocket, authenticateSocket } from '../middlewares'
11
12class PeerTubeSocket {
13
14 private static instance: PeerTubeSocket
15
16 private userNotificationSockets: { [ userId: number ]: Socket[] } = {}
17 private liveVideosNamespace: Namespace
18 private readonly runnerSockets = new Set<Socket>()
19
20 private constructor () {}
21
22 init (server: HTTPServer) {
23 const io = new SocketServer(server)
24
25 io.of('/user-notifications')
26 .use(authenticateSocket)
27 .on('connection', socket => {
28 const userId = socket.handshake.auth.user.id
29
30 logger.debug('User %d connected to the notification system.', userId)
31
32 if (!this.userNotificationSockets[userId]) this.userNotificationSockets[userId] = []
33
34 this.userNotificationSockets[userId].push(socket)
35
36 socket.on('disconnect', () => {
37 logger.debug('User %d disconnected from SocketIO notifications.', userId)
38
39 this.userNotificationSockets[userId] = this.userNotificationSockets[userId].filter(s => s !== socket)
40 })
41 })
42
43 this.liveVideosNamespace = io.of('/live-videos')
44 .on('connection', socket => {
45 socket.on('subscribe', ({ videoId }) => {
46 if (!isIdValid(videoId)) return
47
48 /* eslint-disable @typescript-eslint/no-floating-promises */
49 socket.join(videoId)
50 })
51
52 socket.on('unsubscribe', ({ videoId }) => {
53 if (!isIdValid(videoId)) return
54
55 /* eslint-disable @typescript-eslint/no-floating-promises */
56 socket.leave(videoId)
57 })
58 })
59
60 io.of('/runners')
61 .use(authenticateRunnerSocket)
62 .on('connection', socket => {
63 const runner: MRunner = socket.handshake.auth.runner
64
65 logger.debug(`New runner "${runner.name}" connected to the notification system.`)
66
67 this.runnerSockets.add(socket)
68
69 socket.on('disconnect', () => {
70 logger.debug(`Runner "${runner.name}" disconnected from the notification system.`)
71
72 this.runnerSockets.delete(socket)
73 })
74 })
75 }
76
77 sendNotification (userId: number, notification: UserNotificationModelForApi) {
78 const sockets = this.userNotificationSockets[userId]
79 if (!sockets) return
80
81 logger.debug('Sending user notification to user %d.', userId)
82
83 const notificationMessage = notification.toFormattedJSON()
84 for (const socket of sockets) {
85 socket.emit('new-notification', notificationMessage)
86 }
87 }
88
89 sendVideoLiveNewState (video: MVideo) {
90 const data: LiveVideoEventPayload = { state: video.state }
91 const type: LiveVideoEventType = 'state-change'
92
93 logger.debug('Sending video live new state notification of %s.', video.url, { state: video.state })
94
95 this.liveVideosNamespace
96 .in(video.id)
97 .emit(type, data)
98 }
99
100 sendVideoViewsUpdate (video: MVideoImmutable, numViewers: number) {
101 const data: LiveVideoEventPayload = { viewers: numViewers }
102 const type: LiveVideoEventType = 'views-change'
103
104 logger.debug('Sending video live views update notification of %s.', video.url, { viewers: numViewers })
105
106 this.liveVideosNamespace
107 .in(video.id)
108 .emit(type, data)
109 }
110
111 @Debounce({ timeoutMS: 1000 })
112 sendAvailableJobsPingToRunners () {
113 logger.debug(`Sending available-jobs notification to ${this.runnerSockets.size} runner sockets`)
114
115 for (const runners of this.runnerSockets) {
116 runners.emit('available-jobs')
117 }
118 }
119
120 static get Instance () {
121 return this.instance || (this.instance = new this())
122 }
123}
124
125// ---------------------------------------------------------------------------
126
127export {
128 PeerTubeSocket
129}
diff --git a/server/lib/plugins/hooks.ts b/server/lib/plugins/hooks.ts
deleted file mode 100644
index 694527c12..000000000
--- a/server/lib/plugins/hooks.ts
+++ /dev/null
@@ -1,35 +0,0 @@
1import Bluebird from 'bluebird'
2import { ServerActionHookName, ServerFilterHookName } from '../../../shared/models'
3import { logger } from '../../helpers/logger'
4import { PluginManager } from './plugin-manager'
5
6type PromiseFunction <U, T> = (params: U) => Promise<T> | Bluebird<T>
7type RawFunction <U, T> = (params: U) => T
8
9// Helpers to run hooks
10const Hooks = {
11 wrapObject: <T, U extends ServerFilterHookName>(result: T, hookName: U, context?: any) => {
12 return PluginManager.Instance.runHook(hookName, result, context)
13 },
14
15 wrapPromiseFun: async <U, T, V extends ServerFilterHookName>(fun: PromiseFunction<U, T>, params: U, hookName: V) => {
16 const result = await fun(params)
17
18 return PluginManager.Instance.runHook(hookName, result, params)
19 },
20
21 wrapFun: async <U, T, V extends ServerFilterHookName>(fun: RawFunction<U, T>, params: U, hookName: V) => {
22 const result = fun(params)
23
24 return PluginManager.Instance.runHook(hookName, result, params)
25 },
26
27 runAction: <T, U extends ServerActionHookName>(hookName: U, params?: T) => {
28 PluginManager.Instance.runHook(hookName, undefined, params)
29 .catch(err => logger.error('Fatal hook error.', { err }))
30 }
31}
32
33export {
34 Hooks
35}
diff --git a/server/lib/plugins/plugin-helpers-builder.ts b/server/lib/plugins/plugin-helpers-builder.ts
deleted file mode 100644
index b4e3eece4..000000000
--- a/server/lib/plugins/plugin-helpers-builder.ts
+++ /dev/null
@@ -1,262 +0,0 @@
1import express from 'express'
2import { Server } from 'http'
3import { join } from 'path'
4import { buildLogger } from '@server/helpers/logger'
5import { CONFIG } from '@server/initializers/config'
6import { WEBSERVER } from '@server/initializers/constants'
7import { sequelizeTypescript } from '@server/initializers/database'
8import { AccountModel } from '@server/models/account/account'
9import { AccountBlocklistModel } from '@server/models/account/account-blocklist'
10import { getServerActor } from '@server/models/application/application'
11import { ServerModel } from '@server/models/server/server'
12import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
13import { UserModel } from '@server/models/user/user'
14import { VideoModel } from '@server/models/video/video'
15import { VideoBlacklistModel } from '@server/models/video/video-blacklist'
16import { MPlugin, MVideo, UserNotificationModelForApi } from '@server/types/models'
17import { PeerTubeHelpers } from '@server/types/plugins'
18import { ffprobePromise } from '@shared/ffmpeg'
19import { VideoBlacklistCreate, VideoStorage } from '@shared/models'
20import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../blocklist'
21import { PeerTubeSocket } from '../peertube-socket'
22import { ServerConfigManager } from '../server-config-manager'
23import { blacklistVideo, unblacklistVideo } from '../video-blacklist'
24import { VideoPathManager } from '../video-path-manager'
25
26function buildPluginHelpers (httpServer: Server, pluginModel: MPlugin, npmName: string): PeerTubeHelpers {
27 const logger = buildPluginLogger(npmName)
28
29 const database = buildDatabaseHelpers()
30 const videos = buildVideosHelpers()
31
32 const config = buildConfigHelpers()
33
34 const server = buildServerHelpers(httpServer)
35
36 const moderation = buildModerationHelpers()
37
38 const plugin = buildPluginRelatedHelpers(pluginModel, npmName)
39
40 const socket = buildSocketHelpers()
41
42 const user = buildUserHelpers()
43
44 return {
45 logger,
46 database,
47 videos,
48 config,
49 moderation,
50 plugin,
51 server,
52 socket,
53 user
54 }
55}
56
57export {
58 buildPluginHelpers
59}
60
61// ---------------------------------------------------------------------------
62
63function buildPluginLogger (npmName: string) {
64 return buildLogger(npmName)
65}
66
67function buildDatabaseHelpers () {
68 return {
69 query: sequelizeTypescript.query.bind(sequelizeTypescript)
70 }
71}
72
73function buildServerHelpers (httpServer: Server) {
74 return {
75 getHTTPServer: () => httpServer,
76
77 getServerActor: () => getServerActor()
78 }
79}
80
81function buildVideosHelpers () {
82 return {
83 loadByUrl: (url: string) => {
84 return VideoModel.loadByUrl(url)
85 },
86
87 loadByIdOrUUID: (id: number | string) => {
88 return VideoModel.load(id)
89 },
90
91 removeVideo: (id: number) => {
92 return sequelizeTypescript.transaction(async t => {
93 const video = await VideoModel.loadFull(id, t)
94
95 await video.destroy({ transaction: t })
96 })
97 },
98
99 ffprobe: (path: string) => {
100 return ffprobePromise(path)
101 },
102
103 getFiles: async (id: number | string) => {
104 const video = await VideoModel.loadFull(id)
105 if (!video) return undefined
106
107 const webVideoFiles = (video.VideoFiles || []).map(f => ({
108 path: f.storage === VideoStorage.FILE_SYSTEM
109 ? VideoPathManager.Instance.getFSVideoFileOutputPath(video, f)
110 : null,
111 url: f.getFileUrl(video),
112
113 resolution: f.resolution,
114 size: f.size,
115 fps: f.fps
116 }))
117
118 const hls = video.getHLSPlaylist()
119
120 const hlsVideoFiles = hls
121 ? (video.getHLSPlaylist().VideoFiles || []).map(f => {
122 return {
123 path: f.storage === VideoStorage.FILE_SYSTEM
124 ? VideoPathManager.Instance.getFSVideoFileOutputPath(hls, f)
125 : null,
126 url: f.getFileUrl(video),
127 resolution: f.resolution,
128 size: f.size,
129 fps: f.fps
130 }
131 })
132 : []
133
134 const thumbnails = video.Thumbnails.map(t => ({
135 type: t.type,
136 url: t.getOriginFileUrl(video),
137 path: t.getPath()
138 }))
139
140 return {
141 webtorrent: { // TODO: remove in v7
142 videoFiles: webVideoFiles
143 },
144
145 webVideo: {
146 videoFiles: webVideoFiles
147 },
148
149 hls: {
150 videoFiles: hlsVideoFiles
151 },
152
153 thumbnails
154 }
155 }
156 }
157}
158
159function buildModerationHelpers () {
160 return {
161 blockServer: async (options: { byAccountId: number, hostToBlock: string }) => {
162 const serverToBlock = await ServerModel.loadOrCreateByHost(options.hostToBlock)
163
164 await addServerInBlocklist(options.byAccountId, serverToBlock.id)
165 },
166
167 unblockServer: async (options: { byAccountId: number, hostToUnblock: string }) => {
168 const serverBlock = await ServerBlocklistModel.loadByAccountAndHost(options.byAccountId, options.hostToUnblock)
169 if (!serverBlock) return
170
171 await removeServerFromBlocklist(serverBlock)
172 },
173
174 blockAccount: async (options: { byAccountId: number, handleToBlock: string }) => {
175 const accountToBlock = await AccountModel.loadByNameWithHost(options.handleToBlock)
176 if (!accountToBlock) return
177
178 await addAccountInBlocklist(options.byAccountId, accountToBlock.id)
179 },
180
181 unblockAccount: async (options: { byAccountId: number, handleToUnblock: string }) => {
182 const targetAccount = await AccountModel.loadByNameWithHost(options.handleToUnblock)
183 if (!targetAccount) return
184
185 const accountBlock = await AccountBlocklistModel.loadByAccountAndTarget(options.byAccountId, targetAccount.id)
186 if (!accountBlock) return
187
188 await removeAccountFromBlocklist(accountBlock)
189 },
190
191 blacklistVideo: async (options: { videoIdOrUUID: number | string, createOptions: VideoBlacklistCreate }) => {
192 const video = await VideoModel.loadFull(options.videoIdOrUUID)
193 if (!video) return
194
195 await blacklistVideo(video, options.createOptions)
196 },
197
198 unblacklistVideo: async (options: { videoIdOrUUID: number | string }) => {
199 const video = await VideoModel.loadFull(options.videoIdOrUUID)
200 if (!video) return
201
202 const videoBlacklist = await VideoBlacklistModel.loadByVideoId(video.id)
203 if (!videoBlacklist) return
204
205 await unblacklistVideo(videoBlacklist, video)
206 }
207 }
208}
209
210function buildConfigHelpers () {
211 return {
212 getWebserverUrl () {
213 return WEBSERVER.URL
214 },
215
216 getServerListeningConfig () {
217 return { hostname: CONFIG.LISTEN.HOSTNAME, port: CONFIG.LISTEN.PORT }
218 },
219
220 getServerConfig () {
221 return ServerConfigManager.Instance.getServerConfig()
222 }
223 }
224}
225
226function buildPluginRelatedHelpers (plugin: MPlugin, npmName: string) {
227 return {
228 getBaseStaticRoute: () => `/plugins/${plugin.name}/${plugin.version}/static/`,
229
230 getBaseRouterRoute: () => `/plugins/${plugin.name}/${plugin.version}/router/`,
231
232 getBaseWebSocketRoute: () => `/plugins/${plugin.name}/${plugin.version}/ws/`,
233
234 getDataDirectoryPath: () => join(CONFIG.STORAGE.PLUGINS_DIR, 'data', npmName)
235 }
236}
237
238function buildSocketHelpers () {
239 return {
240 sendNotification: (userId: number, notification: UserNotificationModelForApi) => {
241 PeerTubeSocket.Instance.sendNotification(userId, notification)
242 },
243 sendVideoLiveNewState: (video: MVideo) => {
244 PeerTubeSocket.Instance.sendVideoLiveNewState(video)
245 }
246 }
247}
248
249function buildUserHelpers () {
250 return {
251 loadById: (id: number) => {
252 return UserModel.loadByIdFull(id)
253 },
254
255 getAuthUser: (res: express.Response) => {
256 const user = res.locals.oauth?.token?.User || res.locals.videoFileToken?.user
257 if (!user) return undefined
258
259 return UserModel.loadByIdFull(user.id)
260 }
261 }
262}
diff --git a/server/lib/plugins/plugin-index.ts b/server/lib/plugins/plugin-index.ts
deleted file mode 100644
index 119cee8e0..000000000
--- a/server/lib/plugins/plugin-index.ts
+++ /dev/null
@@ -1,85 +0,0 @@
1import { sanitizeUrl } from '@server/helpers/core-utils'
2import { logger } from '@server/helpers/logger'
3import { doJSONRequest } from '@server/helpers/requests'
4import { CONFIG } from '@server/initializers/config'
5import { PEERTUBE_VERSION } from '@server/initializers/constants'
6import { PluginModel } from '@server/models/server/plugin'
7import {
8 PeerTubePluginIndex,
9 PeertubePluginIndexList,
10 PeertubePluginLatestVersionRequest,
11 PeertubePluginLatestVersionResponse,
12 ResultList
13} from '@shared/models'
14import { PluginManager } from './plugin-manager'
15
16async function listAvailablePluginsFromIndex (options: PeertubePluginIndexList) {
17 const { start = 0, count = 20, search, sort = 'npmName', pluginType } = options
18
19 const searchParams: PeertubePluginIndexList & Record<string, string | number> = {
20 start,
21 count,
22 sort,
23 pluginType,
24 search,
25 currentPeerTubeEngine: options.currentPeerTubeEngine || PEERTUBE_VERSION
26 }
27
28 const uri = CONFIG.PLUGINS.INDEX.URL + '/api/v1/plugins'
29
30 try {
31 const { body } = await doJSONRequest<any>(uri, { searchParams })
32
33 logger.debug('Got result from PeerTube index.', { body })
34
35 addInstanceInformation(body)
36
37 return body as ResultList<PeerTubePluginIndex>
38 } catch (err) {
39 logger.error('Cannot list available plugins from index %s.', uri, { err })
40 return undefined
41 }
42}
43
44function addInstanceInformation (result: ResultList<PeerTubePluginIndex>) {
45 for (const d of result.data) {
46 d.installed = PluginManager.Instance.isRegistered(d.npmName)
47 d.name = PluginModel.normalizePluginName(d.npmName)
48 }
49
50 return result
51}
52
53async function getLatestPluginsVersion (npmNames: string[]): Promise<PeertubePluginLatestVersionResponse> {
54 const bodyRequest: PeertubePluginLatestVersionRequest = {
55 npmNames,
56 currentPeerTubeEngine: PEERTUBE_VERSION
57 }
58
59 const uri = sanitizeUrl(CONFIG.PLUGINS.INDEX.URL) + '/api/v1/plugins/latest-version'
60
61 const options = {
62 json: bodyRequest,
63 method: 'POST' as 'POST'
64 }
65 const { body } = await doJSONRequest<PeertubePluginLatestVersionResponse>(uri, options)
66
67 return body
68}
69
70async function getLatestPluginVersion (npmName: string) {
71 const results = await getLatestPluginsVersion([ npmName ])
72
73 if (Array.isArray(results) === false || results.length !== 1) {
74 logger.warn('Cannot get latest supported plugin version of %s.', npmName)
75 return undefined
76 }
77
78 return results[0].latestVersion
79}
80
81export {
82 listAvailablePluginsFromIndex,
83 getLatestPluginVersion,
84 getLatestPluginsVersion
85}
diff --git a/server/lib/plugins/plugin-manager.ts b/server/lib/plugins/plugin-manager.ts
deleted file mode 100644
index 88c5b60d7..000000000
--- a/server/lib/plugins/plugin-manager.ts
+++ /dev/null
@@ -1,665 +0,0 @@
1import express from 'express'
2import { createReadStream, createWriteStream } from 'fs'
3import { ensureDir, outputFile, readJSON } from 'fs-extra'
4import { Server } from 'http'
5import { basename, join } from 'path'
6import { decachePlugin } from '@server/helpers/decache'
7import { ApplicationModel } from '@server/models/application/application'
8import { MOAuthTokenUser, MUser } from '@server/types/models'
9import { getCompleteLocale } from '@shared/core-utils'
10import {
11 ClientScriptJSON,
12 PluginPackageJSON,
13 PluginTranslation,
14 PluginTranslationPathsJSON,
15 RegisterServerHookOptions
16} from '@shared/models'
17import { getHookType, internalRunHook } from '../../../shared/core-utils/plugins/hooks'
18import { PluginType } from '../../../shared/models/plugins/plugin.type'
19import { ServerHook, ServerHookName } from '../../../shared/models/plugins/server/server-hook.model'
20import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins'
21import { logger } from '../../helpers/logger'
22import { CONFIG } from '../../initializers/config'
23import { PLUGIN_GLOBAL_CSS_PATH } from '../../initializers/constants'
24import { PluginModel } from '../../models/server/plugin'
25import { PluginLibrary, RegisterServerAuthExternalOptions, RegisterServerAuthPassOptions, RegisterServerOptions } from '../../types/plugins'
26import { ClientHtml } from '../client-html'
27import { RegisterHelpers } from './register-helpers'
28import { installNpmPlugin, installNpmPluginFromDisk, rebuildNativePlugins, removeNpmPlugin } from './yarn'
29
30export interface RegisteredPlugin {
31 npmName: string
32 name: string
33 version: string
34 description: string
35 peertubeEngine: string
36
37 type: PluginType
38
39 path: string
40
41 staticDirs: { [name: string]: string }
42 clientScripts: { [name: string]: ClientScriptJSON }
43
44 css: string[]
45
46 // Only if this is a plugin
47 registerHelpers?: RegisterHelpers
48 unregister?: Function
49}
50
51export interface HookInformationValue {
52 npmName: string
53 pluginName: string
54 handler: Function
55 priority: number
56}
57
58type PluginLocalesTranslations = {
59 [locale: string]: PluginTranslation
60}
61
62export class PluginManager implements ServerHook {
63
64 private static instance: PluginManager
65
66 private registeredPlugins: { [name: string]: RegisteredPlugin } = {}
67
68 private hooks: { [name: string]: HookInformationValue[] } = {}
69 private translations: PluginLocalesTranslations = {}
70
71 private server: Server
72
73 private constructor () {
74 }
75
76 init (server: Server) {
77 this.server = server
78 }
79
80 registerWebSocketRouter () {
81 this.server.on('upgrade', (request, socket, head) => {
82 // Check if it's a plugin websocket connection
83 // No need to destroy the stream when we abort the request
84 // Other handlers in PeerTube will catch this upgrade event too (socket.io, tracker etc)
85
86 const url = request.url
87
88 const matched = url.match(`/plugins/([^/]+)/([^/]+/)?ws(/.*)`)
89 if (!matched) return
90
91 const npmName = PluginModel.buildNpmName(matched[1], PluginType.PLUGIN)
92 const subRoute = matched[3]
93
94 const result = this.getRegisteredPluginOrTheme(npmName)
95 if (!result) return
96
97 const routes = result.registerHelpers.getWebSocketRoutes()
98
99 const wss = routes.find(r => r.route.startsWith(subRoute))
100 if (!wss) return
101
102 try {
103 wss.handler(request, socket, head)
104 } catch (err) {
105 logger.error('Exception in plugin handler ' + npmName, { err })
106 }
107 })
108 }
109
110 // ###################### Getters ######################
111
112 isRegistered (npmName: string) {
113 return !!this.getRegisteredPluginOrTheme(npmName)
114 }
115
116 getRegisteredPluginOrTheme (npmName: string) {
117 return this.registeredPlugins[npmName]
118 }
119
120 getRegisteredPluginByShortName (name: string) {
121 const npmName = PluginModel.buildNpmName(name, PluginType.PLUGIN)
122 const registered = this.getRegisteredPluginOrTheme(npmName)
123
124 if (!registered || registered.type !== PluginType.PLUGIN) return undefined
125
126 return registered
127 }
128
129 getRegisteredThemeByShortName (name: string) {
130 const npmName = PluginModel.buildNpmName(name, PluginType.THEME)
131 const registered = this.getRegisteredPluginOrTheme(npmName)
132
133 if (!registered || registered.type !== PluginType.THEME) return undefined
134
135 return registered
136 }
137
138 getRegisteredPlugins () {
139 return this.getRegisteredPluginsOrThemes(PluginType.PLUGIN)
140 }
141
142 getRegisteredThemes () {
143 return this.getRegisteredPluginsOrThemes(PluginType.THEME)
144 }
145
146 getIdAndPassAuths () {
147 return this.getRegisteredPlugins()
148 .map(p => ({
149 npmName: p.npmName,
150 name: p.name,
151 version: p.version,
152 idAndPassAuths: p.registerHelpers.getIdAndPassAuths()
153 }))
154 .filter(v => v.idAndPassAuths.length !== 0)
155 }
156
157 getExternalAuths () {
158 return this.getRegisteredPlugins()
159 .map(p => ({
160 npmName: p.npmName,
161 name: p.name,
162 version: p.version,
163 externalAuths: p.registerHelpers.getExternalAuths()
164 }))
165 .filter(v => v.externalAuths.length !== 0)
166 }
167
168 getRegisteredSettings (npmName: string) {
169 const result = this.getRegisteredPluginOrTheme(npmName)
170 if (!result || result.type !== PluginType.PLUGIN) return []
171
172 return result.registerHelpers.getSettings()
173 }
174
175 getRouter (npmName: string) {
176 const result = this.getRegisteredPluginOrTheme(npmName)
177 if (!result || result.type !== PluginType.PLUGIN) return null
178
179 return result.registerHelpers.getRouter()
180 }
181
182 getTranslations (locale: string) {
183 return this.translations[locale] || {}
184 }
185
186 async isTokenValid (token: MOAuthTokenUser, type: 'access' | 'refresh') {
187 const auth = this.getAuth(token.User.pluginAuth, token.authName)
188 if (!auth) return true
189
190 if (auth.hookTokenValidity) {
191 try {
192 const { valid } = await auth.hookTokenValidity({ token, type })
193
194 if (valid === false) {
195 logger.info('Rejecting %s token validity from auth %s of plugin %s', type, token.authName, token.User.pluginAuth)
196 }
197
198 return valid
199 } catch (err) {
200 logger.warn('Cannot run check token validity from auth %s of plugin %s.', token.authName, token.User.pluginAuth, { err })
201 return true
202 }
203 }
204
205 return true
206 }
207
208 // ###################### External events ######################
209
210 async onLogout (npmName: string, authName: string, user: MUser, req: express.Request) {
211 const auth = this.getAuth(npmName, authName)
212
213 if (auth?.onLogout) {
214 logger.info('Running onLogout function from auth %s of plugin %s', authName, npmName)
215
216 try {
217 // Force await, in case or onLogout returns a promise
218 const result = await auth.onLogout(user, req)
219
220 return typeof result === 'string'
221 ? result
222 : undefined
223 } catch (err) {
224 logger.warn('Cannot run onLogout function from auth %s of plugin %s.', authName, npmName, { err })
225 }
226 }
227
228 return undefined
229 }
230
231 async onSettingsChanged (name: string, settings: any) {
232 const registered = this.getRegisteredPluginByShortName(name)
233 if (!registered) {
234 logger.error('Cannot find plugin %s to call on settings changed.', name)
235 }
236
237 for (const cb of registered.registerHelpers.getOnSettingsChangedCallbacks()) {
238 try {
239 await cb(settings)
240 } catch (err) {
241 logger.error('Cannot run on settings changed callback for %s.', registered.npmName, { err })
242 }
243 }
244 }
245
246 // ###################### Hooks ######################
247
248 async runHook<T> (hookName: ServerHookName, result?: T, params?: any): Promise<T> {
249 if (!this.hooks[hookName]) return Promise.resolve(result)
250
251 const hookType = getHookType(hookName)
252
253 for (const hook of this.hooks[hookName]) {
254 logger.debug('Running hook %s of plugin %s.', hookName, hook.npmName)
255
256 result = await internalRunHook({
257 handler: hook.handler,
258 hookType,
259 result,
260 params,
261 onError: err => { logger.error('Cannot run hook %s of plugin %s.', hookName, hook.pluginName, { err }) }
262 })
263 }
264
265 return result
266 }
267
268 // ###################### Registration ######################
269
270 async registerPluginsAndThemes () {
271 await this.resetCSSGlobalFile()
272
273 const plugins = await PluginModel.listEnabledPluginsAndThemes()
274
275 for (const plugin of plugins) {
276 try {
277 await this.registerPluginOrTheme(plugin)
278 } catch (err) {
279 // Try to unregister the plugin
280 try {
281 await this.unregister(PluginModel.buildNpmName(plugin.name, plugin.type))
282 } catch {
283 // we don't care if we cannot unregister it
284 }
285
286 logger.error('Cannot register plugin %s, skipping.', plugin.name, { err })
287 }
288 }
289
290 this.sortHooksByPriority()
291 }
292
293 // Don't need the plugin type since themes cannot register server code
294 async unregister (npmName: string) {
295 logger.info('Unregister plugin %s.', npmName)
296
297 const plugin = this.getRegisteredPluginOrTheme(npmName)
298
299 if (!plugin) {
300 throw new Error(`Unknown plugin ${npmName} to unregister`)
301 }
302
303 delete this.registeredPlugins[plugin.npmName]
304
305 this.deleteTranslations(plugin.npmName)
306
307 if (plugin.type === PluginType.PLUGIN) {
308 await plugin.unregister()
309
310 // Remove hooks of this plugin
311 for (const key of Object.keys(this.hooks)) {
312 this.hooks[key] = this.hooks[key].filter(h => h.npmName !== npmName)
313 }
314
315 const store = plugin.registerHelpers
316 store.reinitVideoConstants(plugin.npmName)
317 store.reinitTranscodingProfilesAndEncoders(plugin.npmName)
318
319 logger.info('Regenerating registered plugin CSS to global file.')
320 await this.regeneratePluginGlobalCSS()
321 }
322
323 ClientHtml.invalidCache()
324 }
325
326 // ###################### Installation ######################
327
328 async install (options: {
329 toInstall: string
330 version?: string
331 fromDisk?: boolean // default false
332 register?: boolean // default true
333 }) {
334 const { toInstall, version, fromDisk = false, register = true } = options
335
336 let plugin: PluginModel
337 let npmName: string
338
339 logger.info('Installing plugin %s.', toInstall)
340
341 try {
342 fromDisk
343 ? await installNpmPluginFromDisk(toInstall)
344 : await installNpmPlugin(toInstall, version)
345
346 npmName = fromDisk ? basename(toInstall) : toInstall
347 const pluginType = PluginModel.getTypeFromNpmName(npmName)
348 const pluginName = PluginModel.normalizePluginName(npmName)
349
350 const packageJSON = await this.getPackageJSON(pluginName, pluginType)
351
352 this.sanitizeAndCheckPackageJSONOrThrow(packageJSON, pluginType);
353
354 [ plugin ] = await PluginModel.upsert({
355 name: pluginName,
356 description: packageJSON.description,
357 homepage: packageJSON.homepage,
358 type: pluginType,
359 version: packageJSON.version,
360 enabled: true,
361 uninstalled: false,
362 peertubeEngine: packageJSON.engine.peertube
363 }, { returning: true })
364
365 logger.info('Successful installation of plugin %s.', toInstall)
366
367 if (register) {
368 await this.registerPluginOrTheme(plugin)
369 }
370 } catch (rootErr) {
371 logger.error('Cannot install plugin %s, removing it...', toInstall, { err: rootErr })
372
373 if (npmName) {
374 try {
375 await this.uninstall({ npmName })
376 } catch (err) {
377 logger.error('Cannot uninstall plugin %s after failed installation.', toInstall, { err })
378
379 try {
380 await removeNpmPlugin(npmName)
381 } catch (err) {
382 logger.error('Cannot remove plugin %s after failed installation.', toInstall, { err })
383 }
384 }
385 }
386
387 throw rootErr
388 }
389
390 return plugin
391 }
392
393 async update (toUpdate: string, fromDisk = false) {
394 const npmName = fromDisk ? basename(toUpdate) : toUpdate
395
396 logger.info('Updating plugin %s.', npmName)
397
398 // Use the latest version from DB, to not upgrade to a version that does not support our PeerTube version
399 let version: string
400 if (!fromDisk) {
401 const plugin = await PluginModel.loadByNpmName(toUpdate)
402 version = plugin.latestVersion
403 }
404
405 // Unregister old hooks
406 await this.unregister(npmName)
407
408 return this.install({ toInstall: toUpdate, version, fromDisk })
409 }
410
411 async uninstall (options: {
412 npmName: string
413 unregister?: boolean // default true
414 }) {
415 const { npmName, unregister = true } = options
416
417 logger.info('Uninstalling plugin %s.', npmName)
418
419 if (unregister) {
420 try {
421 await this.unregister(npmName)
422 } catch (err) {
423 logger.warn('Cannot unregister plugin %s.', npmName, { err })
424 }
425 }
426
427 const plugin = await PluginModel.loadByNpmName(npmName)
428 if (!plugin || plugin.uninstalled === true) {
429 logger.error('Cannot uninstall plugin %s: it does not exist or is already uninstalled.', npmName)
430 return
431 }
432
433 plugin.enabled = false
434 plugin.uninstalled = true
435
436 await plugin.save()
437
438 await removeNpmPlugin(npmName)
439
440 logger.info('Plugin %s uninstalled.', npmName)
441 }
442
443 async rebuildNativePluginsIfNeeded () {
444 if (!await ApplicationModel.nodeABIChanged()) return
445
446 return rebuildNativePlugins()
447 }
448
449 // ###################### Private register ######################
450
451 private async registerPluginOrTheme (plugin: PluginModel) {
452 const npmName = PluginModel.buildNpmName(plugin.name, plugin.type)
453
454 logger.info('Registering plugin or theme %s.', npmName)
455
456 const packageJSON = await this.getPackageJSON(plugin.name, plugin.type)
457 const pluginPath = this.getPluginPath(plugin.name, plugin.type)
458
459 this.sanitizeAndCheckPackageJSONOrThrow(packageJSON, plugin.type)
460
461 let library: PluginLibrary
462 let registerHelpers: RegisterHelpers
463 if (plugin.type === PluginType.PLUGIN) {
464 const result = await this.registerPlugin(plugin, pluginPath, packageJSON)
465 library = result.library
466 registerHelpers = result.registerStore
467 }
468
469 const clientScripts: { [id: string]: ClientScriptJSON } = {}
470 for (const c of packageJSON.clientScripts) {
471 clientScripts[c.script] = c
472 }
473
474 this.registeredPlugins[npmName] = {
475 npmName,
476 name: plugin.name,
477 type: plugin.type,
478 version: plugin.version,
479 description: plugin.description,
480 peertubeEngine: plugin.peertubeEngine,
481 path: pluginPath,
482 staticDirs: packageJSON.staticDirs,
483 clientScripts,
484 css: packageJSON.css,
485 registerHelpers: registerHelpers || undefined,
486 unregister: library ? library.unregister : undefined
487 }
488
489 await this.addTranslations(plugin, npmName, packageJSON.translations)
490
491 ClientHtml.invalidCache()
492 }
493
494 private async registerPlugin (plugin: PluginModel, pluginPath: string, packageJSON: PluginPackageJSON) {
495 const npmName = PluginModel.buildNpmName(plugin.name, plugin.type)
496
497 // Delete cache if needed
498 const modulePath = join(pluginPath, packageJSON.library)
499 decachePlugin(modulePath)
500 const library: PluginLibrary = require(modulePath)
501
502 if (!isLibraryCodeValid(library)) {
503 throw new Error('Library code is not valid (miss register or unregister function)')
504 }
505
506 const { registerOptions, registerStore } = this.getRegisterHelpers(npmName, plugin)
507
508 await ensureDir(registerOptions.peertubeHelpers.plugin.getDataDirectoryPath())
509
510 await library.register(registerOptions)
511
512 logger.info('Add plugin %s CSS to global file.', npmName)
513
514 await this.addCSSToGlobalFile(pluginPath, packageJSON.css)
515
516 return { library, registerStore }
517 }
518
519 // ###################### Translations ######################
520
521 private async addTranslations (plugin: PluginModel, npmName: string, translationPaths: PluginTranslationPathsJSON) {
522 for (const locale of Object.keys(translationPaths)) {
523 const path = translationPaths[locale]
524 const json = await readJSON(join(this.getPluginPath(plugin.name, plugin.type), path))
525
526 const completeLocale = getCompleteLocale(locale)
527
528 if (!this.translations[completeLocale]) this.translations[completeLocale] = {}
529 this.translations[completeLocale][npmName] = json
530
531 logger.info('Added locale %s of plugin %s.', completeLocale, npmName)
532 }
533 }
534
535 private deleteTranslations (npmName: string) {
536 for (const locale of Object.keys(this.translations)) {
537 delete this.translations[locale][npmName]
538
539 logger.info('Deleted locale %s of plugin %s.', locale, npmName)
540 }
541 }
542
543 // ###################### CSS ######################
544
545 private resetCSSGlobalFile () {
546 return outputFile(PLUGIN_GLOBAL_CSS_PATH, '')
547 }
548
549 private async addCSSToGlobalFile (pluginPath: string, cssRelativePaths: string[]) {
550 for (const cssPath of cssRelativePaths) {
551 await this.concatFiles(join(pluginPath, cssPath), PLUGIN_GLOBAL_CSS_PATH)
552 }
553 }
554
555 private concatFiles (input: string, output: string) {
556 return new Promise<void>((res, rej) => {
557 const inputStream = createReadStream(input)
558 const outputStream = createWriteStream(output, { flags: 'a' })
559
560 inputStream.pipe(outputStream)
561
562 inputStream.on('end', () => res())
563 inputStream.on('error', err => rej(err))
564 })
565 }
566
567 private async regeneratePluginGlobalCSS () {
568 await this.resetCSSGlobalFile()
569
570 for (const plugin of this.getRegisteredPlugins()) {
571 await this.addCSSToGlobalFile(plugin.path, plugin.css)
572 }
573 }
574
575 // ###################### Utils ######################
576
577 private sortHooksByPriority () {
578 for (const hookName of Object.keys(this.hooks)) {
579 this.hooks[hookName].sort((a, b) => {
580 return b.priority - a.priority
581 })
582 }
583 }
584
585 private getPackageJSON (pluginName: string, pluginType: PluginType) {
586 const pluginPath = join(this.getPluginPath(pluginName, pluginType), 'package.json')
587
588 return readJSON(pluginPath) as Promise<PluginPackageJSON>
589 }
590
591 private getPluginPath (pluginName: string, pluginType: PluginType) {
592 const npmName = PluginModel.buildNpmName(pluginName, pluginType)
593
594 return join(CONFIG.STORAGE.PLUGINS_DIR, 'node_modules', npmName)
595 }
596
597 private getAuth (npmName: string, authName: string) {
598 const plugin = this.getRegisteredPluginOrTheme(npmName)
599 if (!plugin || plugin.type !== PluginType.PLUGIN) return null
600
601 let auths: (RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions)[] = plugin.registerHelpers.getIdAndPassAuths()
602 auths = auths.concat(plugin.registerHelpers.getExternalAuths())
603
604 return auths.find(a => a.authName === authName)
605 }
606
607 // ###################### Private getters ######################
608
609 private getRegisteredPluginsOrThemes (type: PluginType) {
610 const plugins: RegisteredPlugin[] = []
611
612 for (const npmName of Object.keys(this.registeredPlugins)) {
613 const plugin = this.registeredPlugins[npmName]
614 if (plugin.type !== type) continue
615
616 plugins.push(plugin)
617 }
618
619 return plugins
620 }
621
622 // ###################### Generate register helpers ######################
623
624 private getRegisterHelpers (
625 npmName: string,
626 plugin: PluginModel
627 ): { registerStore: RegisterHelpers, registerOptions: RegisterServerOptions } {
628 const onHookAdded = (options: RegisterServerHookOptions) => {
629 if (!this.hooks[options.target]) this.hooks[options.target] = []
630
631 this.hooks[options.target].push({
632 npmName,
633 pluginName: plugin.name,
634 handler: options.handler,
635 priority: options.priority || 0
636 })
637 }
638
639 const registerHelpers = new RegisterHelpers(npmName, plugin, this.server, onHookAdded.bind(this))
640
641 return {
642 registerStore: registerHelpers,
643 registerOptions: registerHelpers.buildRegisterHelpers()
644 }
645 }
646
647 private sanitizeAndCheckPackageJSONOrThrow (packageJSON: PluginPackageJSON, pluginType: PluginType) {
648 if (!packageJSON.staticDirs) packageJSON.staticDirs = {}
649 if (!packageJSON.css) packageJSON.css = []
650 if (!packageJSON.clientScripts) packageJSON.clientScripts = []
651 if (!packageJSON.translations) packageJSON.translations = {}
652
653 const { result: packageJSONValid, badFields } = isPackageJSONValid(packageJSON, pluginType)
654 if (!packageJSONValid) {
655 const formattedFields = badFields.map(f => `"${f}"`)
656 .join(', ')
657
658 throw new Error(`PackageJSON is invalid (invalid fields: ${formattedFields}).`)
659 }
660 }
661
662 static get Instance () {
663 return this.instance || (this.instance = new this())
664 }
665}
diff --git a/server/lib/plugins/register-helpers.ts b/server/lib/plugins/register-helpers.ts
deleted file mode 100644
index 1aaef3606..000000000
--- a/server/lib/plugins/register-helpers.ts
+++ /dev/null
@@ -1,340 +0,0 @@
1import express from 'express'
2import { Server } from 'http'
3import { logger } from '@server/helpers/logger'
4import { onExternalUserAuthenticated } from '@server/lib/auth/external-auth'
5import { VideoConstantManagerFactory } from '@server/lib/plugins/video-constant-manager-factory'
6import { PluginModel } from '@server/models/server/plugin'
7import {
8 RegisterServerAuthExternalOptions,
9 RegisterServerAuthExternalResult,
10 RegisterServerAuthPassOptions,
11 RegisterServerExternalAuthenticatedResult,
12 RegisterServerOptions,
13 RegisterServerWebSocketRouteOptions
14} from '@server/types/plugins'
15import {
16 EncoderOptionsBuilder,
17 PluginSettingsManager,
18 PluginStorageManager,
19 RegisterServerHookOptions,
20 RegisterServerSettingOptions,
21 serverHookObject,
22 SettingsChangeCallback,
23 VideoPlaylistPrivacy,
24 VideoPrivacy
25} from '@shared/models'
26import { VideoTranscodingProfilesManager } from '../transcoding/default-transcoding-profiles'
27import { buildPluginHelpers } from './plugin-helpers-builder'
28
29export class RegisterHelpers {
30 private readonly transcodingProfiles: {
31 [ npmName: string ]: {
32 type: 'vod' | 'live'
33 encoder: string
34 profile: string
35 }[]
36 } = {}
37
38 private readonly transcodingEncoders: {
39 [ npmName: string ]: {
40 type: 'vod' | 'live'
41 streamType: 'audio' | 'video'
42 encoder: string
43 priority: number
44 }[]
45 } = {}
46
47 private readonly settings: RegisterServerSettingOptions[] = []
48
49 private idAndPassAuths: RegisterServerAuthPassOptions[] = []
50 private externalAuths: RegisterServerAuthExternalOptions[] = []
51
52 private readonly onSettingsChangeCallbacks: SettingsChangeCallback[] = []
53
54 private readonly webSocketRoutes: RegisterServerWebSocketRouteOptions[] = []
55
56 private readonly router: express.Router
57 private readonly videoConstantManagerFactory: VideoConstantManagerFactory
58
59 constructor (
60 private readonly npmName: string,
61 private readonly plugin: PluginModel,
62 private readonly server: Server,
63 private readonly onHookAdded: (options: RegisterServerHookOptions) => void
64 ) {
65 this.router = express.Router()
66 this.videoConstantManagerFactory = new VideoConstantManagerFactory(this.npmName)
67 }
68
69 buildRegisterHelpers (): RegisterServerOptions {
70 const registerHook = this.buildRegisterHook()
71 const registerSetting = this.buildRegisterSetting()
72
73 const getRouter = this.buildGetRouter()
74 const registerWebSocketRoute = this.buildRegisterWebSocketRoute()
75
76 const settingsManager = this.buildSettingsManager()
77 const storageManager = this.buildStorageManager()
78
79 const videoLanguageManager = this.videoConstantManagerFactory.createVideoConstantManager<string>('language')
80
81 const videoLicenceManager = this.videoConstantManagerFactory.createVideoConstantManager<number>('licence')
82 const videoCategoryManager = this.videoConstantManagerFactory.createVideoConstantManager<number>('category')
83
84 const videoPrivacyManager = this.videoConstantManagerFactory.createVideoConstantManager<VideoPrivacy>('privacy')
85 const playlistPrivacyManager = this.videoConstantManagerFactory.createVideoConstantManager<VideoPlaylistPrivacy>('playlistPrivacy')
86
87 const transcodingManager = this.buildTranscodingManager()
88
89 const registerIdAndPassAuth = this.buildRegisterIdAndPassAuth()
90 const registerExternalAuth = this.buildRegisterExternalAuth()
91 const unregisterIdAndPassAuth = this.buildUnregisterIdAndPassAuth()
92 const unregisterExternalAuth = this.buildUnregisterExternalAuth()
93
94 const peertubeHelpers = buildPluginHelpers(this.server, this.plugin, this.npmName)
95
96 return {
97 registerHook,
98 registerSetting,
99
100 getRouter,
101 registerWebSocketRoute,
102
103 settingsManager,
104 storageManager,
105
106 videoLanguageManager: {
107 ...videoLanguageManager,
108 /** @deprecated use `addConstant` instead **/
109 addLanguage: videoLanguageManager.addConstant,
110 /** @deprecated use `deleteConstant` instead **/
111 deleteLanguage: videoLanguageManager.deleteConstant
112 },
113 videoCategoryManager: {
114 ...videoCategoryManager,
115 /** @deprecated use `addConstant` instead **/
116 addCategory: videoCategoryManager.addConstant,
117 /** @deprecated use `deleteConstant` instead **/
118 deleteCategory: videoCategoryManager.deleteConstant
119 },
120 videoLicenceManager: {
121 ...videoLicenceManager,
122 /** @deprecated use `addConstant` instead **/
123 addLicence: videoLicenceManager.addConstant,
124 /** @deprecated use `deleteConstant` instead **/
125 deleteLicence: videoLicenceManager.deleteConstant
126 },
127
128 videoPrivacyManager: {
129 ...videoPrivacyManager,
130 /** @deprecated use `deleteConstant` instead **/
131 deletePrivacy: videoPrivacyManager.deleteConstant
132 },
133 playlistPrivacyManager: {
134 ...playlistPrivacyManager,
135 /** @deprecated use `deleteConstant` instead **/
136 deletePlaylistPrivacy: playlistPrivacyManager.deleteConstant
137 },
138
139 transcodingManager,
140
141 registerIdAndPassAuth,
142 registerExternalAuth,
143 unregisterIdAndPassAuth,
144 unregisterExternalAuth,
145
146 peertubeHelpers
147 }
148 }
149
150 reinitVideoConstants (npmName: string) {
151 this.videoConstantManagerFactory.resetVideoConstants(npmName)
152 }
153
154 reinitTranscodingProfilesAndEncoders (npmName: string) {
155 const profiles = this.transcodingProfiles[npmName]
156 if (Array.isArray(profiles)) {
157 for (const profile of profiles) {
158 VideoTranscodingProfilesManager.Instance.removeProfile(profile)
159 }
160 }
161
162 const encoders = this.transcodingEncoders[npmName]
163 if (Array.isArray(encoders)) {
164 for (const o of encoders) {
165 VideoTranscodingProfilesManager.Instance.removeEncoderPriority(o.type, o.streamType, o.encoder, o.priority)
166 }
167 }
168 }
169
170 getSettings () {
171 return this.settings
172 }
173
174 getRouter () {
175 return this.router
176 }
177
178 getIdAndPassAuths () {
179 return this.idAndPassAuths
180 }
181
182 getExternalAuths () {
183 return this.externalAuths
184 }
185
186 getOnSettingsChangedCallbacks () {
187 return this.onSettingsChangeCallbacks
188 }
189
190 getWebSocketRoutes () {
191 return this.webSocketRoutes
192 }
193
194 private buildGetRouter () {
195 return () => this.router
196 }
197
198 private buildRegisterWebSocketRoute () {
199 return (options: RegisterServerWebSocketRouteOptions) => {
200 this.webSocketRoutes.push(options)
201 }
202 }
203
204 private buildRegisterSetting () {
205 return (options: RegisterServerSettingOptions) => {
206 this.settings.push(options)
207 }
208 }
209
210 private buildRegisterHook () {
211 return (options: RegisterServerHookOptions) => {
212 if (serverHookObject[options.target] !== true) {
213 logger.warn('Unknown hook %s of plugin %s. Skipping.', options.target, this.npmName)
214 return
215 }
216
217 return this.onHookAdded(options)
218 }
219 }
220
221 private buildRegisterIdAndPassAuth () {
222 return (options: RegisterServerAuthPassOptions) => {
223 if (!options.authName || typeof options.getWeight !== 'function' || typeof options.login !== 'function') {
224 logger.error('Cannot register auth plugin %s: authName, getWeight or login are not valid.', this.npmName, { options })
225 return
226 }
227
228 this.idAndPassAuths.push(options)
229 }
230 }
231
232 private buildRegisterExternalAuth () {
233 const self = this
234
235 return (options: RegisterServerAuthExternalOptions) => {
236 if (!options.authName || typeof options.authDisplayName !== 'function' || typeof options.onAuthRequest !== 'function') {
237 logger.error('Cannot register auth plugin %s: authName, authDisplayName or onAuthRequest are not valid.', this.npmName, { options })
238 return
239 }
240
241 this.externalAuths.push(options)
242
243 return {
244 userAuthenticated (result: RegisterServerExternalAuthenticatedResult): void {
245 onExternalUserAuthenticated({
246 npmName: self.npmName,
247 authName: options.authName,
248 authResult: result
249 }).catch(err => {
250 logger.error('Cannot execute onExternalUserAuthenticated.', { npmName: self.npmName, authName: options.authName, err })
251 })
252 }
253 } as RegisterServerAuthExternalResult
254 }
255 }
256
257 private buildUnregisterExternalAuth () {
258 return (authName: string) => {
259 this.externalAuths = this.externalAuths.filter(a => a.authName !== authName)
260 }
261 }
262
263 private buildUnregisterIdAndPassAuth () {
264 return (authName: string) => {
265 this.idAndPassAuths = this.idAndPassAuths.filter(a => a.authName !== authName)
266 }
267 }
268
269 private buildSettingsManager (): PluginSettingsManager {
270 return {
271 getSetting: (name: string) => PluginModel.getSetting(this.plugin.name, this.plugin.type, name, this.settings),
272
273 getSettings: (names: string[]) => PluginModel.getSettings(this.plugin.name, this.plugin.type, names, this.settings),
274
275 setSetting: (name: string, value: string) => PluginModel.setSetting(this.plugin.name, this.plugin.type, name, value),
276
277 onSettingsChange: (cb: SettingsChangeCallback) => this.onSettingsChangeCallbacks.push(cb)
278 }
279 }
280
281 private buildStorageManager (): PluginStorageManager {
282 return {
283 getData: (key: string) => PluginModel.getData(this.plugin.name, this.plugin.type, key),
284
285 storeData: (key: string, data: any) => PluginModel.storeData(this.plugin.name, this.plugin.type, key, data)
286 }
287 }
288
289 private buildTranscodingManager () {
290 const self = this
291
292 function addProfile (type: 'live' | 'vod', encoder: string, profile: string, builder: EncoderOptionsBuilder) {
293 if (profile === 'default') {
294 logger.error('A plugin cannot add a default live transcoding profile')
295 return false
296 }
297
298 VideoTranscodingProfilesManager.Instance.addProfile({
299 type,
300 encoder,
301 profile,
302 builder
303 })
304
305 if (!self.transcodingProfiles[self.npmName]) self.transcodingProfiles[self.npmName] = []
306 self.transcodingProfiles[self.npmName].push({ type, encoder, profile })
307
308 return true
309 }
310
311 function addEncoderPriority (type: 'live' | 'vod', streamType: 'audio' | 'video', encoder: string, priority: number) {
312 VideoTranscodingProfilesManager.Instance.addEncoderPriority(type, streamType, encoder, priority)
313
314 if (!self.transcodingEncoders[self.npmName]) self.transcodingEncoders[self.npmName] = []
315 self.transcodingEncoders[self.npmName].push({ type, streamType, encoder, priority })
316 }
317
318 return {
319 addLiveProfile (encoder: string, profile: string, builder: EncoderOptionsBuilder) {
320 return addProfile('live', encoder, profile, builder)
321 },
322
323 addVODProfile (encoder: string, profile: string, builder: EncoderOptionsBuilder) {
324 return addProfile('vod', encoder, profile, builder)
325 },
326
327 addLiveEncoderPriority (streamType: 'audio' | 'video', encoder: string, priority: number) {
328 return addEncoderPriority('live', streamType, encoder, priority)
329 },
330
331 addVODEncoderPriority (streamType: 'audio' | 'video', encoder: string, priority: number) {
332 return addEncoderPriority('vod', streamType, encoder, priority)
333 },
334
335 removeAllProfilesAndEncoderPriorities () {
336 return self.reinitTranscodingProfilesAndEncoders(self.npmName)
337 }
338 }
339 }
340}
diff --git a/server/lib/plugins/theme-utils.ts b/server/lib/plugins/theme-utils.ts
deleted file mode 100644
index 76c671f1c..000000000
--- a/server/lib/plugins/theme-utils.ts
+++ /dev/null
@@ -1,24 +0,0 @@
1import { DEFAULT_THEME_NAME, DEFAULT_USER_THEME_NAME } from '../../initializers/constants'
2import { PluginManager } from './plugin-manager'
3import { CONFIG } from '../../initializers/config'
4
5function getThemeOrDefault (name: string, defaultTheme: string) {
6 if (isThemeRegistered(name)) return name
7
8 // Fallback to admin default theme
9 if (name !== CONFIG.THEME.DEFAULT) return getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
10
11 return defaultTheme
12}
13
14function isThemeRegistered (name: string) {
15 if (name === DEFAULT_THEME_NAME || name === DEFAULT_USER_THEME_NAME) return true
16
17 return !!PluginManager.Instance.getRegisteredThemes()
18 .find(r => r.name === name)
19}
20
21export {
22 getThemeOrDefault,
23 isThemeRegistered
24}
diff --git a/server/lib/plugins/video-constant-manager-factory.ts b/server/lib/plugins/video-constant-manager-factory.ts
deleted file mode 100644
index 5f7edfbe2..000000000
--- a/server/lib/plugins/video-constant-manager-factory.ts
+++ /dev/null
@@ -1,139 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import {
3 VIDEO_CATEGORIES,
4 VIDEO_LANGUAGES,
5 VIDEO_LICENCES,
6 VIDEO_PLAYLIST_PRIVACIES,
7 VIDEO_PRIVACIES
8} from '@server/initializers/constants'
9import { ConstantManager } from '@shared/models/plugins/server/plugin-constant-manager.model'
10
11type AlterableVideoConstant = 'language' | 'licence' | 'category' | 'privacy' | 'playlistPrivacy'
12type VideoConstant = Record<number | string, string>
13
14type UpdatedVideoConstant = {
15 [name in AlterableVideoConstant]: {
16 [ npmName: string]: {
17 added: VideoConstant[]
18 deleted: VideoConstant[]
19 }
20 }
21}
22
23const constantsHash: { [key in AlterableVideoConstant]: VideoConstant } = {
24 language: VIDEO_LANGUAGES,
25 licence: VIDEO_LICENCES,
26 category: VIDEO_CATEGORIES,
27 privacy: VIDEO_PRIVACIES,
28 playlistPrivacy: VIDEO_PLAYLIST_PRIVACIES
29}
30
31export class VideoConstantManagerFactory {
32 private readonly updatedVideoConstants: UpdatedVideoConstant = {
33 playlistPrivacy: { },
34 privacy: { },
35 language: { },
36 licence: { },
37 category: { }
38 }
39
40 constructor (
41 private readonly npmName: string
42 ) {}
43
44 public resetVideoConstants (npmName: string) {
45 const types: AlterableVideoConstant[] = [ 'language', 'licence', 'category', 'privacy', 'playlistPrivacy' ]
46 for (const type of types) {
47 this.resetConstants({ npmName, type })
48 }
49 }
50
51 private resetConstants (parameters: { npmName: string, type: AlterableVideoConstant }) {
52 const { npmName, type } = parameters
53 const updatedConstants = this.updatedVideoConstants[type][npmName]
54
55 if (!updatedConstants) return
56
57 for (const added of updatedConstants.added) {
58 delete constantsHash[type][added.key]
59 }
60
61 for (const deleted of updatedConstants.deleted) {
62 constantsHash[type][deleted.key] = deleted.label
63 }
64
65 delete this.updatedVideoConstants[type][npmName]
66 }
67
68 public createVideoConstantManager<K extends number | string>(type: AlterableVideoConstant): ConstantManager<K> {
69 const { npmName } = this
70 return {
71 addConstant: (key: K, label: string) => this.addConstant({ npmName, type, key, label }),
72 deleteConstant: (key: K) => this.deleteConstant({ npmName, type, key }),
73 getConstantValue: (key: K) => constantsHash[type][key],
74 getConstants: () => constantsHash[type] as Record<K, string>,
75 resetConstants: () => this.resetConstants({ npmName, type })
76 }
77 }
78
79 private addConstant<T extends string | number> (parameters: {
80 npmName: string
81 type: AlterableVideoConstant
82 key: T
83 label: string
84 }) {
85 const { npmName, type, key, label } = parameters
86 const obj = constantsHash[type]
87
88 if (obj[key]) {
89 logger.warn('Cannot add %s %s by plugin %s: key already exists.', type, npmName, key)
90 return false
91 }
92
93 if (!this.updatedVideoConstants[type][npmName]) {
94 this.updatedVideoConstants[type][npmName] = {
95 added: [],
96 deleted: []
97 }
98 }
99
100 this.updatedVideoConstants[type][npmName].added.push({ key, label } as VideoConstant)
101 obj[key] = label
102
103 return true
104 }
105
106 private deleteConstant<T extends string | number> (parameters: {
107 npmName: string
108 type: AlterableVideoConstant
109 key: T
110 }) {
111 const { npmName, type, key } = parameters
112 const obj = constantsHash[type]
113
114 if (!obj[key]) {
115 logger.warn('Cannot delete %s by plugin %s: key %s does not exist.', type, npmName, key)
116 return false
117 }
118
119 if (!this.updatedVideoConstants[type][npmName]) {
120 this.updatedVideoConstants[type][npmName] = {
121 added: [],
122 deleted: []
123 }
124 }
125
126 const updatedConstants = this.updatedVideoConstants[type][npmName]
127
128 const alreadyAdded = updatedConstants.added.find(a => a.key === key)
129 if (alreadyAdded) {
130 updatedConstants.added.filter(a => a.key !== key)
131 } else if (obj[key]) {
132 updatedConstants.deleted.push({ key, label: obj[key] } as VideoConstant)
133 }
134
135 delete obj[key]
136
137 return true
138 }
139}
diff --git a/server/lib/plugins/yarn.ts b/server/lib/plugins/yarn.ts
deleted file mode 100644
index 9cf6ec9e9..000000000
--- a/server/lib/plugins/yarn.ts
+++ /dev/null
@@ -1,73 +0,0 @@
1import { outputJSON, pathExists } from 'fs-extra'
2import { join } from 'path'
3import { execShell } from '../../helpers/core-utils'
4import { isNpmPluginNameValid, isPluginStableOrUnstableVersionValid } from '../../helpers/custom-validators/plugins'
5import { logger } from '../../helpers/logger'
6import { CONFIG } from '../../initializers/config'
7import { getLatestPluginVersion } from './plugin-index'
8
9async function installNpmPlugin (npmName: string, versionArg?: string) {
10 // Security check
11 checkNpmPluginNameOrThrow(npmName)
12 if (versionArg) checkPluginVersionOrThrow(versionArg)
13
14 const version = versionArg || await getLatestPluginVersion(npmName)
15
16 let toInstall = npmName
17 if (version) toInstall += `@${version}`
18
19 const { stdout } = await execYarn('add ' + toInstall)
20
21 logger.debug('Added a yarn package.', { yarnStdout: stdout })
22}
23
24async function installNpmPluginFromDisk (path: string) {
25 await execYarn('add file:' + path)
26}
27
28async function removeNpmPlugin (name: string) {
29 checkNpmPluginNameOrThrow(name)
30
31 await execYarn('remove ' + name)
32}
33
34async function rebuildNativePlugins () {
35 await execYarn('install --pure-lockfile')
36}
37
38// ############################################################################
39
40export {
41 installNpmPlugin,
42 installNpmPluginFromDisk,
43 rebuildNativePlugins,
44 removeNpmPlugin
45}
46
47// ############################################################################
48
49async function execYarn (command: string) {
50 try {
51 const pluginDirectory = CONFIG.STORAGE.PLUGINS_DIR
52 const pluginPackageJSON = join(pluginDirectory, 'package.json')
53
54 // Create empty package.json file if needed
55 if (!await pathExists(pluginPackageJSON)) {
56 await outputJSON(pluginPackageJSON, {})
57 }
58
59 return execShell(`yarn ${command}`, { cwd: pluginDirectory })
60 } catch (result) {
61 logger.error('Cannot exec yarn.', { command, err: result.err, stderr: result.stderr })
62
63 throw result.err
64 }
65}
66
67function checkNpmPluginNameOrThrow (name: string) {
68 if (!isNpmPluginNameValid(name)) throw new Error('Invalid NPM plugin name to install')
69}
70
71function checkPluginVersionOrThrow (name: string) {
72 if (!isPluginStableOrUnstableVersionValid(name)) throw new Error('Invalid NPM plugin version to install')
73}
diff --git a/server/lib/redis.ts b/server/lib/redis.ts
deleted file mode 100644
index 48d9986b5..000000000
--- a/server/lib/redis.ts
+++ /dev/null
@@ -1,465 +0,0 @@
1import IoRedis, { RedisOptions } from 'ioredis'
2import { exists } from '@server/helpers/custom-validators/misc'
3import { sha256 } from '@shared/extra-utils'
4import { logger } from '../helpers/logger'
5import { generateRandomString } from '../helpers/utils'
6import { CONFIG } from '../initializers/config'
7import {
8 AP_CLEANER,
9 CONTACT_FORM_LIFETIME,
10 RESUMABLE_UPLOAD_SESSION_LIFETIME,
11 TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
12 EMAIL_VERIFY_LIFETIME,
13 USER_PASSWORD_CREATE_LIFETIME,
14 USER_PASSWORD_RESET_LIFETIME,
15 VIEW_LIFETIME,
16 WEBSERVER
17} from '../initializers/constants'
18
19class Redis {
20
21 private static instance: Redis
22 private initialized = false
23 private connected = false
24 private client: IoRedis
25 private prefix: string
26
27 private constructor () {
28 }
29
30 init () {
31 // Already initialized
32 if (this.initialized === true) return
33 this.initialized = true
34
35 const redisMode = CONFIG.REDIS.SENTINEL.ENABLED ? 'sentinel' : 'standalone'
36 logger.info('Connecting to redis ' + redisMode + '...')
37
38 this.client = new IoRedis(Redis.getRedisClientOptions('', { enableAutoPipelining: true }))
39 this.client.on('error', err => logger.error('Redis failed to connect', { err }))
40 this.client.on('connect', () => {
41 logger.info('Connected to redis.')
42
43 this.connected = true
44 })
45 this.client.on('reconnecting', (ms) => {
46 logger.error(`Reconnecting to redis in ${ms}.`)
47 })
48 this.client.on('close', () => {
49 logger.error('Connection to redis has closed.')
50 this.connected = false
51 })
52
53 this.client.on('end', () => {
54 logger.error('Connection to redis has closed and no more reconnects will be done.')
55 })
56
57 this.prefix = 'redis-' + WEBSERVER.HOST + '-'
58 }
59
60 static getRedisClientOptions (name?: string, options: RedisOptions = {}): RedisOptions {
61 const connectionName = [ 'PeerTube', name ].join('')
62 const connectTimeout = 20000 // Could be slow since node use sync call to compile PeerTube
63
64 if (CONFIG.REDIS.SENTINEL.ENABLED) {
65 return {
66 connectionName,
67 connectTimeout,
68 enableTLSForSentinelMode: CONFIG.REDIS.SENTINEL.ENABLE_TLS,
69 sentinelPassword: CONFIG.REDIS.AUTH,
70 sentinels: CONFIG.REDIS.SENTINEL.SENTINELS,
71 name: CONFIG.REDIS.SENTINEL.MASTER_NAME,
72 ...options
73 }
74 }
75
76 return {
77 connectionName,
78 connectTimeout,
79 password: CONFIG.REDIS.AUTH,
80 db: CONFIG.REDIS.DB,
81 host: CONFIG.REDIS.HOSTNAME,
82 port: CONFIG.REDIS.PORT,
83 path: CONFIG.REDIS.SOCKET,
84 showFriendlyErrorStack: true,
85 ...options
86 }
87 }
88
89 getClient () {
90 return this.client
91 }
92
93 getPrefix () {
94 return this.prefix
95 }
96
97 isConnected () {
98 return this.connected
99 }
100
101 /* ************ Forgot password ************ */
102
103 async setResetPasswordVerificationString (userId: number) {
104 const generatedString = await generateRandomString(32)
105
106 await this.setValue(this.generateResetPasswordKey(userId), generatedString, USER_PASSWORD_RESET_LIFETIME)
107
108 return generatedString
109 }
110
111 async setCreatePasswordVerificationString (userId: number) {
112 const generatedString = await generateRandomString(32)
113
114 await this.setValue(this.generateResetPasswordKey(userId), generatedString, USER_PASSWORD_CREATE_LIFETIME)
115
116 return generatedString
117 }
118
119 async removePasswordVerificationString (userId: number) {
120 return this.removeValue(this.generateResetPasswordKey(userId))
121 }
122
123 async getResetPasswordVerificationString (userId: number) {
124 return this.getValue(this.generateResetPasswordKey(userId))
125 }
126
127 /* ************ Two factor auth request ************ */
128
129 async setTwoFactorRequest (userId: number, otpSecret: string) {
130 const requestToken = await generateRandomString(32)
131
132 await this.setValue(this.generateTwoFactorRequestKey(userId, requestToken), otpSecret, TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME)
133
134 return requestToken
135 }
136
137 async getTwoFactorRequestToken (userId: number, requestToken: string) {
138 return this.getValue(this.generateTwoFactorRequestKey(userId, requestToken))
139 }
140
141 /* ************ Email verification ************ */
142
143 async setUserVerifyEmailVerificationString (userId: number) {
144 const generatedString = await generateRandomString(32)
145
146 await this.setValue(this.generateUserVerifyEmailKey(userId), generatedString, EMAIL_VERIFY_LIFETIME)
147
148 return generatedString
149 }
150
151 async getUserVerifyEmailLink (userId: number) {
152 return this.getValue(this.generateUserVerifyEmailKey(userId))
153 }
154
155 async setRegistrationVerifyEmailVerificationString (registrationId: number) {
156 const generatedString = await generateRandomString(32)
157
158 await this.setValue(this.generateRegistrationVerifyEmailKey(registrationId), generatedString, EMAIL_VERIFY_LIFETIME)
159
160 return generatedString
161 }
162
163 async getRegistrationVerifyEmailLink (registrationId: number) {
164 return this.getValue(this.generateRegistrationVerifyEmailKey(registrationId))
165 }
166
167 /* ************ Contact form per IP ************ */
168
169 async setContactFormIp (ip: string) {
170 return this.setValue(this.generateContactFormKey(ip), '1', CONTACT_FORM_LIFETIME)
171 }
172
173 async doesContactFormIpExist (ip: string) {
174 return this.exists(this.generateContactFormKey(ip))
175 }
176
177 /* ************ Views per IP ************ */
178
179 setIPVideoView (ip: string, videoUUID: string) {
180 return this.setValue(this.generateIPViewKey(ip, videoUUID), '1', VIEW_LIFETIME.VIEW)
181 }
182
183 async doesVideoIPViewExist (ip: string, videoUUID: string) {
184 return this.exists(this.generateIPViewKey(ip, videoUUID))
185 }
186
187 /* ************ Video views stats ************ */
188
189 addVideoViewStats (videoId: number) {
190 const { videoKey, setKey } = this.generateVideoViewStatsKeys({ videoId })
191
192 return Promise.all([
193 this.addToSet(setKey, videoId.toString()),
194 this.increment(videoKey)
195 ])
196 }
197
198 async getVideoViewsStats (videoId: number, hour: number) {
199 const { videoKey } = this.generateVideoViewStatsKeys({ videoId, hour })
200
201 const valueString = await this.getValue(videoKey)
202 const valueInt = parseInt(valueString, 10)
203
204 if (isNaN(valueInt)) {
205 logger.error('Cannot get videos views stats of video %d in hour %d: views number is NaN (%s).', videoId, hour, valueString)
206 return undefined
207 }
208
209 return valueInt
210 }
211
212 async listVideosViewedForStats (hour: number) {
213 const { setKey } = this.generateVideoViewStatsKeys({ hour })
214
215 const stringIds = await this.getSet(setKey)
216 return stringIds.map(s => parseInt(s, 10))
217 }
218
219 deleteVideoViewsStats (videoId: number, hour: number) {
220 const { setKey, videoKey } = this.generateVideoViewStatsKeys({ videoId, hour })
221
222 return Promise.all([
223 this.deleteFromSet(setKey, videoId.toString()),
224 this.deleteKey(videoKey)
225 ])
226 }
227
228 /* ************ Local video views buffer ************ */
229
230 addLocalVideoView (videoId: number) {
231 const { videoKey, setKey } = this.generateLocalVideoViewsKeys(videoId)
232
233 return Promise.all([
234 this.addToSet(setKey, videoId.toString()),
235 this.increment(videoKey)
236 ])
237 }
238
239 async getLocalVideoViews (videoId: number) {
240 const { videoKey } = this.generateLocalVideoViewsKeys(videoId)
241
242 const valueString = await this.getValue(videoKey)
243 const valueInt = parseInt(valueString, 10)
244
245 if (isNaN(valueInt)) {
246 logger.error('Cannot get videos views of video %d: views number is NaN (%s).', videoId, valueString)
247 return undefined
248 }
249
250 return valueInt
251 }
252
253 async listLocalVideosViewed () {
254 const { setKey } = this.generateLocalVideoViewsKeys()
255
256 const stringIds = await this.getSet(setKey)
257 return stringIds.map(s => parseInt(s, 10))
258 }
259
260 deleteLocalVideoViews (videoId: number) {
261 const { setKey, videoKey } = this.generateLocalVideoViewsKeys(videoId)
262
263 return Promise.all([
264 this.deleteFromSet(setKey, videoId.toString()),
265 this.deleteKey(videoKey)
266 ])
267 }
268
269 /* ************ Video viewers stats ************ */
270
271 getLocalVideoViewer (options: {
272 key?: string
273 // Or
274 ip?: string
275 videoId?: number
276 }) {
277 if (options.key) return this.getObject(options.key)
278
279 const { viewerKey } = this.generateLocalVideoViewerKeys(options.ip, options.videoId)
280
281 return this.getObject(viewerKey)
282 }
283
284 setLocalVideoViewer (ip: string, videoId: number, object: any) {
285 const { setKey, viewerKey } = this.generateLocalVideoViewerKeys(ip, videoId)
286
287 return Promise.all([
288 this.addToSet(setKey, viewerKey),
289 this.setObject(viewerKey, object)
290 ])
291 }
292
293 listLocalVideoViewerKeys () {
294 const { setKey } = this.generateLocalVideoViewerKeys()
295
296 return this.getSet(setKey)
297 }
298
299 deleteLocalVideoViewersKeys (key: string) {
300 const { setKey } = this.generateLocalVideoViewerKeys()
301
302 return Promise.all([
303 this.deleteFromSet(setKey, key),
304 this.deleteKey(key)
305 ])
306 }
307
308 /* ************ Resumable uploads final responses ************ */
309
310 setUploadSession (uploadId: string, response?: { video: { id: number, shortUUID: string, uuid: string } }) {
311 return this.setValue(
312 'resumable-upload-' + uploadId,
313 response
314 ? JSON.stringify(response)
315 : '',
316 RESUMABLE_UPLOAD_SESSION_LIFETIME
317 )
318 }
319
320 doesUploadSessionExist (uploadId: string) {
321 return this.exists('resumable-upload-' + uploadId)
322 }
323
324 async getUploadSession (uploadId: string) {
325 const value = await this.getValue('resumable-upload-' + uploadId)
326
327 return value
328 ? JSON.parse(value) as { video: { id: number, shortUUID: string, uuid: string } }
329 : undefined
330 }
331
332 deleteUploadSession (uploadId: string) {
333 return this.deleteKey('resumable-upload-' + uploadId)
334 }
335
336 /* ************ AP resource unavailability ************ */
337
338 async addAPUnavailability (url: string) {
339 const key = this.generateAPUnavailabilityKey(url)
340
341 const value = await this.increment(key)
342 await this.setExpiration(key, AP_CLEANER.PERIOD * 2)
343
344 return value
345 }
346
347 /* ************ Keys generation ************ */
348
349 private generateLocalVideoViewsKeys (videoId: number): { setKey: string, videoKey: string }
350 private generateLocalVideoViewsKeys (): { setKey: string }
351 private generateLocalVideoViewsKeys (videoId?: number) {
352 return { setKey: `local-video-views-buffer`, videoKey: `local-video-views-buffer-${videoId}` }
353 }
354
355 private generateLocalVideoViewerKeys (ip: string, videoId: number): { setKey: string, viewerKey: string }
356 private generateLocalVideoViewerKeys (): { setKey: string }
357 private generateLocalVideoViewerKeys (ip?: string, videoId?: number) {
358 return { setKey: `local-video-viewer-stats-keys`, viewerKey: `local-video-viewer-stats-${ip}-${videoId}` }
359 }
360
361 private generateVideoViewStatsKeys (options: { videoId?: number, hour?: number }) {
362 const hour = exists(options.hour)
363 ? options.hour
364 : new Date().getHours()
365
366 return { setKey: `videos-view-h${hour}`, videoKey: `video-view-${options.videoId}-h${hour}` }
367 }
368
369 private generateResetPasswordKey (userId: number) {
370 return 'reset-password-' + userId
371 }
372
373 private generateTwoFactorRequestKey (userId: number, token: string) {
374 return 'two-factor-request-' + userId + '-' + token
375 }
376
377 private generateUserVerifyEmailKey (userId: number) {
378 return 'verify-email-user-' + userId
379 }
380
381 private generateRegistrationVerifyEmailKey (registrationId: number) {
382 return 'verify-email-registration-' + registrationId
383 }
384
385 private generateIPViewKey (ip: string, videoUUID: string) {
386 return `views-${videoUUID}-${ip}`
387 }
388
389 private generateContactFormKey (ip: string) {
390 return 'contact-form-' + ip
391 }
392
393 private generateAPUnavailabilityKey (url: string) {
394 return 'ap-unavailability-' + sha256(url)
395 }
396
397 /* ************ Redis helpers ************ */
398
399 private getValue (key: string) {
400 return this.client.get(this.prefix + key)
401 }
402
403 private getSet (key: string) {
404 return this.client.smembers(this.prefix + key)
405 }
406
407 private addToSet (key: string, value: string) {
408 return this.client.sadd(this.prefix + key, value)
409 }
410
411 private deleteFromSet (key: string, value: string) {
412 return this.client.srem(this.prefix + key, value)
413 }
414
415 private deleteKey (key: string) {
416 return this.client.del(this.prefix + key)
417 }
418
419 private async getObject (key: string) {
420 const value = await this.getValue(key)
421 if (!value) return null
422
423 return JSON.parse(value)
424 }
425
426 private setObject (key: string, value: { [ id: string ]: number | string }, expirationMilliseconds?: number) {
427 return this.setValue(key, JSON.stringify(value), expirationMilliseconds)
428 }
429
430 private async setValue (key: string, value: string, expirationMilliseconds?: number) {
431 const result = expirationMilliseconds !== undefined
432 ? await this.client.set(this.prefix + key, value, 'PX', expirationMilliseconds)
433 : await this.client.set(this.prefix + key, value)
434
435 if (result !== 'OK') throw new Error('Redis set result is not OK.')
436 }
437
438 private removeValue (key: string) {
439 return this.client.del(this.prefix + key)
440 }
441
442 private increment (key: string) {
443 return this.client.incr(this.prefix + key)
444 }
445
446 private async exists (key: string) {
447 const result = await this.client.exists(this.prefix + key)
448
449 return result !== 0
450 }
451
452 private setExpiration (key: string, ms: number) {
453 return this.client.expire(this.prefix + key, ms / 1000)
454 }
455
456 static get Instance () {
457 return this.instance || (this.instance = new this())
458 }
459}
460
461// ---------------------------------------------------------------------------
462
463export {
464 Redis
465}
diff --git a/server/lib/redundancy.ts b/server/lib/redundancy.ts
deleted file mode 100644
index 2613d01be..000000000
--- a/server/lib/redundancy.ts
+++ /dev/null
@@ -1,59 +0,0 @@
1import { Transaction } from 'sequelize'
2import { logger, loggerTagsFactory } from '@server/helpers/logger'
3import { CONFIG } from '@server/initializers/config'
4import { ActorFollowModel } from '@server/models/actor/actor-follow'
5import { getServerActor } from '@server/models/application/application'
6import { MActorSignature, MVideoRedundancyVideo } from '@server/types/models'
7import { Activity } from '@shared/models'
8import { VideoRedundancyModel } from '../models/redundancy/video-redundancy'
9import { sendUndoCacheFile } from './activitypub/send'
10
11const lTags = loggerTagsFactory('redundancy')
12
13async function removeVideoRedundancy (videoRedundancy: MVideoRedundancyVideo, t?: Transaction) {
14 const serverActor = await getServerActor()
15
16 // Local cache, send undo to remote instances
17 if (videoRedundancy.actorId === serverActor.id) await sendUndoCacheFile(serverActor, videoRedundancy, t)
18
19 await videoRedundancy.destroy({ transaction: t })
20}
21
22async function removeRedundanciesOfServer (serverId: number) {
23 const redundancies = await VideoRedundancyModel.listLocalOfServer(serverId)
24
25 for (const redundancy of redundancies) {
26 await removeVideoRedundancy(redundancy)
27 }
28}
29
30async function isRedundancyAccepted (activity: Activity, byActor: MActorSignature) {
31 const configAcceptFrom = CONFIG.REMOTE_REDUNDANCY.VIDEOS.ACCEPT_FROM
32 if (configAcceptFrom === 'nobody') {
33 logger.info('Do not accept remote redundancy %s due instance accept policy.', activity.id, lTags())
34 return false
35 }
36
37 if (configAcceptFrom === 'followings') {
38 const serverActor = await getServerActor()
39 const allowed = await ActorFollowModel.isFollowedBy(byActor.id, serverActor.id)
40
41 if (allowed !== true) {
42 logger.info(
43 'Do not accept remote redundancy %s because actor %s is not followed by our instance.',
44 activity.id, byActor.url, lTags()
45 )
46 return false
47 }
48 }
49
50 return true
51}
52
53// ---------------------------------------------------------------------------
54
55export {
56 isRedundancyAccepted,
57 removeRedundanciesOfServer,
58 removeVideoRedundancy
59}
diff --git a/server/lib/runners/index.ts b/server/lib/runners/index.ts
deleted file mode 100644
index a737c7b59..000000000
--- a/server/lib/runners/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
1export * from './job-handlers'
2export * from './runner'
3export * from './runner-urls'
diff --git a/server/lib/runners/job-handlers/abstract-job-handler.ts b/server/lib/runners/job-handlers/abstract-job-handler.ts
deleted file mode 100644
index 329977de1..000000000
--- a/server/lib/runners/job-handlers/abstract-job-handler.ts
+++ /dev/null
@@ -1,269 +0,0 @@
1import { throttle } from 'lodash'
2import { saveInTransactionWithRetries } from '@server/helpers/database-utils'
3import { logger, loggerTagsFactory } from '@server/helpers/logger'
4import { RUNNER_JOBS } from '@server/initializers/constants'
5import { sequelizeTypescript } from '@server/initializers/database'
6import { PeerTubeSocket } from '@server/lib/peertube-socket'
7import { RunnerJobModel } from '@server/models/runner/runner-job'
8import { setAsUpdated } from '@server/models/shared'
9import { MRunnerJob } from '@server/types/models/runners'
10import { pick } from '@shared/core-utils'
11import {
12 RunnerJobLiveRTMPHLSTranscodingPayload,
13 RunnerJobLiveRTMPHLSTranscodingPrivatePayload,
14 RunnerJobState,
15 RunnerJobStudioTranscodingPayload,
16 RunnerJobSuccessPayload,
17 RunnerJobType,
18 RunnerJobUpdatePayload,
19 RunnerJobVideoStudioTranscodingPrivatePayload,
20 RunnerJobVODAudioMergeTranscodingPayload,
21 RunnerJobVODAudioMergeTranscodingPrivatePayload,
22 RunnerJobVODHLSTranscodingPayload,
23 RunnerJobVODHLSTranscodingPrivatePayload,
24 RunnerJobVODWebVideoTranscodingPayload,
25 RunnerJobVODWebVideoTranscodingPrivatePayload
26} from '@shared/models'
27
28type CreateRunnerJobArg =
29 {
30 type: Extract<RunnerJobType, 'vod-web-video-transcoding'>
31 payload: RunnerJobVODWebVideoTranscodingPayload
32 privatePayload: RunnerJobVODWebVideoTranscodingPrivatePayload
33 } |
34 {
35 type: Extract<RunnerJobType, 'vod-hls-transcoding'>
36 payload: RunnerJobVODHLSTranscodingPayload
37 privatePayload: RunnerJobVODHLSTranscodingPrivatePayload
38 } |
39 {
40 type: Extract<RunnerJobType, 'vod-audio-merge-transcoding'>
41 payload: RunnerJobVODAudioMergeTranscodingPayload
42 privatePayload: RunnerJobVODAudioMergeTranscodingPrivatePayload
43 } |
44 {
45 type: Extract<RunnerJobType, 'live-rtmp-hls-transcoding'>
46 payload: RunnerJobLiveRTMPHLSTranscodingPayload
47 privatePayload: RunnerJobLiveRTMPHLSTranscodingPrivatePayload
48 } |
49 {
50 type: Extract<RunnerJobType, 'video-studio-transcoding'>
51 payload: RunnerJobStudioTranscodingPayload
52 privatePayload: RunnerJobVideoStudioTranscodingPrivatePayload
53 }
54
55export abstract class AbstractJobHandler <C, U extends RunnerJobUpdatePayload, S extends RunnerJobSuccessPayload> {
56
57 protected readonly lTags = loggerTagsFactory('runner')
58
59 static setJobAsUpdatedThrottled = throttle(setAsUpdated, 2000)
60
61 // ---------------------------------------------------------------------------
62
63 abstract create (options: C): Promise<MRunnerJob>
64
65 protected async createRunnerJob (options: CreateRunnerJobArg & {
66 jobUUID: string
67 priority: number
68 dependsOnRunnerJob?: MRunnerJob
69 }): Promise<MRunnerJob> {
70 const { priority, dependsOnRunnerJob } = options
71
72 logger.debug('Creating runner job', { options, ...this.lTags(options.type) })
73
74 const runnerJob = new RunnerJobModel({
75 ...pick(options, [ 'type', 'payload', 'privatePayload' ]),
76
77 uuid: options.jobUUID,
78
79 state: dependsOnRunnerJob
80 ? RunnerJobState.WAITING_FOR_PARENT_JOB
81 : RunnerJobState.PENDING,
82
83 dependsOnRunnerJobId: dependsOnRunnerJob?.id,
84
85 priority
86 })
87
88 const job = await sequelizeTypescript.transaction(async transaction => {
89 return runnerJob.save({ transaction })
90 })
91
92 if (runnerJob.state === RunnerJobState.PENDING) {
93 PeerTubeSocket.Instance.sendAvailableJobsPingToRunners()
94 }
95
96 return job
97 }
98
99 // ---------------------------------------------------------------------------
100
101 protected abstract specificUpdate (options: {
102 runnerJob: MRunnerJob
103 updatePayload?: U
104 }): Promise<void> | void
105
106 async update (options: {
107 runnerJob: MRunnerJob
108 progress?: number
109 updatePayload?: U
110 }) {
111 const { runnerJob, progress } = options
112
113 await this.specificUpdate(options)
114
115 if (progress) runnerJob.progress = progress
116
117 if (!runnerJob.changed()) {
118 try {
119 await AbstractJobHandler.setJobAsUpdatedThrottled({ sequelize: sequelizeTypescript, table: 'runnerJob', id: runnerJob.id })
120 } catch (err) {
121 logger.warn('Cannot set remote job as updated', { err, ...this.lTags(runnerJob.id, runnerJob.type) })
122 }
123
124 return
125 }
126
127 await saveInTransactionWithRetries(runnerJob)
128 }
129
130 // ---------------------------------------------------------------------------
131
132 async complete (options: {
133 runnerJob: MRunnerJob
134 resultPayload: S
135 }) {
136 const { runnerJob } = options
137
138 runnerJob.state = RunnerJobState.COMPLETING
139 await saveInTransactionWithRetries(runnerJob)
140
141 try {
142 await this.specificComplete(options)
143
144 runnerJob.state = RunnerJobState.COMPLETED
145 } catch (err) {
146 logger.error('Cannot complete runner job', { err, ...this.lTags(runnerJob.id, runnerJob.type) })
147
148 runnerJob.state = RunnerJobState.ERRORED
149 runnerJob.error = err.message
150 }
151
152 runnerJob.progress = null
153 runnerJob.finishedAt = new Date()
154
155 await saveInTransactionWithRetries(runnerJob)
156
157 const [ affectedCount ] = await RunnerJobModel.updateDependantJobsOf(runnerJob)
158
159 if (affectedCount !== 0) PeerTubeSocket.Instance.sendAvailableJobsPingToRunners()
160 }
161
162 protected abstract specificComplete (options: {
163 runnerJob: MRunnerJob
164 resultPayload: S
165 }): Promise<void> | void
166
167 // ---------------------------------------------------------------------------
168
169 async cancel (options: {
170 runnerJob: MRunnerJob
171 fromParent?: boolean
172 }) {
173 const { runnerJob, fromParent } = options
174
175 await this.specificCancel(options)
176
177 const cancelState = fromParent
178 ? RunnerJobState.PARENT_CANCELLED
179 : RunnerJobState.CANCELLED
180
181 runnerJob.setToErrorOrCancel(cancelState)
182
183 await saveInTransactionWithRetries(runnerJob)
184
185 const children = await RunnerJobModel.listChildrenOf(runnerJob)
186 for (const child of children) {
187 logger.info(`Cancelling child job ${child.uuid} of ${runnerJob.uuid} because of parent cancel`, this.lTags(child.uuid))
188
189 await this.cancel({ runnerJob: child, fromParent: true })
190 }
191 }
192
193 protected abstract specificCancel (options: {
194 runnerJob: MRunnerJob
195 }): Promise<void> | void
196
197 // ---------------------------------------------------------------------------
198
199 protected abstract isAbortSupported (): boolean
200
201 async abort (options: {
202 runnerJob: MRunnerJob
203 }) {
204 const { runnerJob } = options
205
206 if (this.isAbortSupported() !== true) {
207 return this.error({ runnerJob, message: 'Job has been aborted but it is not supported by this job type' })
208 }
209
210 await this.specificAbort(options)
211
212 runnerJob.resetToPending()
213
214 await saveInTransactionWithRetries(runnerJob)
215 }
216
217 protected setAbortState (runnerJob: MRunnerJob) {
218 runnerJob.resetToPending()
219 }
220
221 protected abstract specificAbort (options: {
222 runnerJob: MRunnerJob
223 }): Promise<void> | void
224
225 // ---------------------------------------------------------------------------
226
227 async error (options: {
228 runnerJob: MRunnerJob
229 message: string
230 fromParent?: boolean
231 }) {
232 const { runnerJob, message, fromParent } = options
233
234 const errorState = fromParent
235 ? RunnerJobState.PARENT_ERRORED
236 : RunnerJobState.ERRORED
237
238 const nextState = errorState === RunnerJobState.ERRORED && this.isAbortSupported() && runnerJob.failures < RUNNER_JOBS.MAX_FAILURES
239 ? RunnerJobState.PENDING
240 : errorState
241
242 await this.specificError({ ...options, nextState })
243
244 if (nextState === errorState) {
245 runnerJob.setToErrorOrCancel(nextState)
246 runnerJob.error = message
247 } else {
248 runnerJob.resetToPending()
249 }
250
251 await saveInTransactionWithRetries(runnerJob)
252
253 if (runnerJob.state === errorState) {
254 const children = await RunnerJobModel.listChildrenOf(runnerJob)
255
256 for (const child of children) {
257 logger.info(`Erroring child job ${child.uuid} of ${runnerJob.uuid} because of parent error`, this.lTags(child.uuid))
258
259 await this.error({ runnerJob: child, message: 'Parent error', fromParent: true })
260 }
261 }
262 }
263
264 protected abstract specificError (options: {
265 runnerJob: MRunnerJob
266 message: string
267 nextState: RunnerJobState
268 }): Promise<void> | void
269}
diff --git a/server/lib/runners/job-handlers/abstract-vod-transcoding-job-handler.ts b/server/lib/runners/job-handlers/abstract-vod-transcoding-job-handler.ts
deleted file mode 100644
index f425828d9..000000000
--- a/server/lib/runners/job-handlers/abstract-vod-transcoding-job-handler.ts
+++ /dev/null
@@ -1,66 +0,0 @@
1
2import { retryTransactionWrapper } from '@server/helpers/database-utils'
3import { logger } from '@server/helpers/logger'
4import { moveToFailedTranscodingState, moveToNextState } from '@server/lib/video-state'
5import { VideoJobInfoModel } from '@server/models/video/video-job-info'
6import { MRunnerJob } from '@server/types/models/runners'
7import { RunnerJobState, RunnerJobSuccessPayload, RunnerJobUpdatePayload, RunnerJobVODPrivatePayload } from '@shared/models'
8import { AbstractJobHandler } from './abstract-job-handler'
9import { loadTranscodingRunnerVideo } from './shared'
10
11// eslint-disable-next-line max-len
12export abstract class AbstractVODTranscodingJobHandler <C, U extends RunnerJobUpdatePayload, S extends RunnerJobSuccessPayload> extends AbstractJobHandler<C, U, S> {
13
14 protected isAbortSupported () {
15 return true
16 }
17
18 protected specificUpdate (_options: {
19 runnerJob: MRunnerJob
20 }) {
21 // empty
22 }
23
24 protected specificAbort (_options: {
25 runnerJob: MRunnerJob
26 }) {
27 // empty
28 }
29
30 protected async specificError (options: {
31 runnerJob: MRunnerJob
32 nextState: RunnerJobState
33 }) {
34 if (options.nextState !== RunnerJobState.ERRORED) return
35
36 const video = await loadTranscodingRunnerVideo(options.runnerJob, this.lTags)
37 if (!video) return
38
39 await moveToFailedTranscodingState(video)
40
41 await VideoJobInfoModel.decrease(video.uuid, 'pendingTranscode')
42 }
43
44 protected async specificCancel (options: {
45 runnerJob: MRunnerJob
46 }) {
47 const { runnerJob } = options
48
49 const video = await loadTranscodingRunnerVideo(options.runnerJob, this.lTags)
50 if (!video) return
51
52 const pending = await VideoJobInfoModel.decrease(video.uuid, 'pendingTranscode')
53
54 logger.debug(`Pending transcode decreased to ${pending} after cancel`, this.lTags(video.uuid))
55
56 if (pending === 0) {
57 logger.info(
58 `All transcoding jobs of ${video.uuid} have been processed or canceled, moving it to its next state`,
59 this.lTags(video.uuid)
60 )
61
62 const privatePayload = runnerJob.privatePayload as RunnerJobVODPrivatePayload
63 await retryTransactionWrapper(moveToNextState, { video, isNewVideo: privatePayload.isNewVideo })
64 }
65 }
66}
diff --git a/server/lib/runners/job-handlers/index.ts b/server/lib/runners/job-handlers/index.ts
deleted file mode 100644
index 40ad2f97a..000000000
--- a/server/lib/runners/job-handlers/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
1export * from './abstract-job-handler'
2export * from './live-rtmp-hls-transcoding-job-handler'
3export * from './runner-job-handlers'
4export * from './video-studio-transcoding-job-handler'
5export * from './vod-audio-merge-transcoding-job-handler'
6export * from './vod-hls-transcoding-job-handler'
7export * from './vod-web-video-transcoding-job-handler'
diff --git a/server/lib/runners/job-handlers/live-rtmp-hls-transcoding-job-handler.ts b/server/lib/runners/job-handlers/live-rtmp-hls-transcoding-job-handler.ts
deleted file mode 100644
index 6b2894f8c..000000000
--- a/server/lib/runners/job-handlers/live-rtmp-hls-transcoding-job-handler.ts
+++ /dev/null
@@ -1,173 +0,0 @@
1import { move, remove } from 'fs-extra'
2import { join } from 'path'
3import { logger } from '@server/helpers/logger'
4import { JOB_PRIORITY } from '@server/initializers/constants'
5import { LiveManager } from '@server/lib/live'
6import { MStreamingPlaylist, MVideo } from '@server/types/models'
7import { MRunnerJob } from '@server/types/models/runners'
8import { buildUUID } from '@shared/extra-utils'
9import {
10 LiveRTMPHLSTranscodingSuccess,
11 LiveRTMPHLSTranscodingUpdatePayload,
12 LiveVideoError,
13 RunnerJobLiveRTMPHLSTranscodingPayload,
14 RunnerJobLiveRTMPHLSTranscodingPrivatePayload,
15 RunnerJobState
16} from '@shared/models'
17import { AbstractJobHandler } from './abstract-job-handler'
18
19type CreateOptions = {
20 video: MVideo
21 playlist: MStreamingPlaylist
22
23 sessionId: string
24 rtmpUrl: string
25
26 toTranscode: {
27 resolution: number
28 fps: number
29 }[]
30
31 segmentListSize: number
32 segmentDuration: number
33
34 outputDirectory: string
35}
36
37// eslint-disable-next-line max-len
38export class LiveRTMPHLSTranscodingJobHandler extends AbstractJobHandler<CreateOptions, LiveRTMPHLSTranscodingUpdatePayload, LiveRTMPHLSTranscodingSuccess> {
39
40 async create (options: CreateOptions) {
41 const { video, rtmpUrl, toTranscode, playlist, segmentDuration, segmentListSize, outputDirectory, sessionId } = options
42
43 const jobUUID = buildUUID()
44 const payload: RunnerJobLiveRTMPHLSTranscodingPayload = {
45 input: {
46 rtmpUrl
47 },
48 output: {
49 toTranscode,
50 segmentListSize,
51 segmentDuration
52 }
53 }
54
55 const privatePayload: RunnerJobLiveRTMPHLSTranscodingPrivatePayload = {
56 videoUUID: video.uuid,
57 masterPlaylistName: playlist.playlistFilename,
58 sessionId,
59 outputDirectory
60 }
61
62 const job = await this.createRunnerJob({
63 type: 'live-rtmp-hls-transcoding',
64 jobUUID,
65 payload,
66 privatePayload,
67 priority: JOB_PRIORITY.TRANSCODING
68 })
69
70 return job
71 }
72
73 // ---------------------------------------------------------------------------
74
75 protected async specificUpdate (options: {
76 runnerJob: MRunnerJob
77 updatePayload: LiveRTMPHLSTranscodingUpdatePayload
78 }) {
79 const { runnerJob, updatePayload } = options
80
81 const privatePayload = runnerJob.privatePayload as RunnerJobLiveRTMPHLSTranscodingPrivatePayload
82 const outputDirectory = privatePayload.outputDirectory
83 const videoUUID = privatePayload.videoUUID
84
85 // Always process the chunk first before moving m3u8 that references this chunk
86 if (updatePayload.type === 'add-chunk') {
87 await move(
88 updatePayload.videoChunkFile as string,
89 join(outputDirectory, updatePayload.videoChunkFilename),
90 { overwrite: true }
91 )
92 } else if (updatePayload.type === 'remove-chunk') {
93 await remove(join(outputDirectory, updatePayload.videoChunkFilename))
94 }
95
96 if (updatePayload.resolutionPlaylistFile && updatePayload.resolutionPlaylistFilename) {
97 await move(
98 updatePayload.resolutionPlaylistFile as string,
99 join(outputDirectory, updatePayload.resolutionPlaylistFilename),
100 { overwrite: true }
101 )
102 }
103
104 if (updatePayload.masterPlaylistFile) {
105 await move(updatePayload.masterPlaylistFile as string, join(outputDirectory, privatePayload.masterPlaylistName), { overwrite: true })
106 }
107
108 logger.debug(
109 'Runner live RTMP to HLS job %s for %s updated.',
110 runnerJob.uuid, videoUUID, { updatePayload, ...this.lTags(videoUUID, runnerJob.uuid) }
111 )
112 }
113
114 // ---------------------------------------------------------------------------
115
116 protected specificComplete (options: {
117 runnerJob: MRunnerJob
118 }) {
119 return this.stopLive({
120 runnerJob: options.runnerJob,
121 type: 'ended'
122 })
123 }
124
125 // ---------------------------------------------------------------------------
126
127 protected isAbortSupported () {
128 return false
129 }
130
131 protected specificAbort () {
132 throw new Error('Not implemented')
133 }
134
135 protected specificError (options: {
136 runnerJob: MRunnerJob
137 nextState: RunnerJobState
138 }) {
139 return this.stopLive({
140 runnerJob: options.runnerJob,
141 type: 'errored'
142 })
143 }
144
145 protected specificCancel (options: {
146 runnerJob: MRunnerJob
147 }) {
148 return this.stopLive({
149 runnerJob: options.runnerJob,
150 type: 'cancelled'
151 })
152 }
153
154 private stopLive (options: {
155 runnerJob: MRunnerJob
156 type: 'ended' | 'errored' | 'cancelled'
157 }) {
158 const { runnerJob, type } = options
159
160 const privatePayload = runnerJob.privatePayload as RunnerJobLiveRTMPHLSTranscodingPrivatePayload
161 const videoUUID = privatePayload.videoUUID
162
163 const errorType = {
164 ended: null,
165 errored: LiveVideoError.RUNNER_JOB_ERROR,
166 cancelled: LiveVideoError.RUNNER_JOB_CANCEL
167 }
168
169 LiveManager.Instance.stopSessionOf(privatePayload.videoUUID, errorType[type])
170
171 logger.info('Runner live RTMP to HLS job %s for video %s %s.', runnerJob.uuid, videoUUID, type, this.lTags(runnerJob.uuid, videoUUID))
172 }
173}
diff --git a/server/lib/runners/job-handlers/runner-job-handlers.ts b/server/lib/runners/job-handlers/runner-job-handlers.ts
deleted file mode 100644
index 85551c365..000000000
--- a/server/lib/runners/job-handlers/runner-job-handlers.ts
+++ /dev/null
@@ -1,20 +0,0 @@
1import { MRunnerJob } from '@server/types/models/runners'
2import { RunnerJobSuccessPayload, RunnerJobType, RunnerJobUpdatePayload } from '@shared/models'
3import { AbstractJobHandler } from './abstract-job-handler'
4import { LiveRTMPHLSTranscodingJobHandler } from './live-rtmp-hls-transcoding-job-handler'
5import { VideoStudioTranscodingJobHandler } from './video-studio-transcoding-job-handler'
6import { VODAudioMergeTranscodingJobHandler } from './vod-audio-merge-transcoding-job-handler'
7import { VODHLSTranscodingJobHandler } from './vod-hls-transcoding-job-handler'
8import { VODWebVideoTranscodingJobHandler } from './vod-web-video-transcoding-job-handler'
9
10const processors: Record<RunnerJobType, new() => AbstractJobHandler<unknown, RunnerJobUpdatePayload, RunnerJobSuccessPayload>> = {
11 'vod-web-video-transcoding': VODWebVideoTranscodingJobHandler,
12 'vod-hls-transcoding': VODHLSTranscodingJobHandler,
13 'vod-audio-merge-transcoding': VODAudioMergeTranscodingJobHandler,
14 'live-rtmp-hls-transcoding': LiveRTMPHLSTranscodingJobHandler,
15 'video-studio-transcoding': VideoStudioTranscodingJobHandler
16}
17
18export function getRunnerJobHandlerClass (job: MRunnerJob) {
19 return processors[job.type]
20}
diff --git a/server/lib/runners/job-handlers/shared/index.ts b/server/lib/runners/job-handlers/shared/index.ts
deleted file mode 100644
index 348273ae2..000000000
--- a/server/lib/runners/job-handlers/shared/index.ts
+++ /dev/null
@@ -1 +0,0 @@
1export * from './vod-helpers'
diff --git a/server/lib/runners/job-handlers/shared/vod-helpers.ts b/server/lib/runners/job-handlers/shared/vod-helpers.ts
deleted file mode 100644
index 1a2ad02ca..000000000
--- a/server/lib/runners/job-handlers/shared/vod-helpers.ts
+++ /dev/null
@@ -1,44 +0,0 @@
1import { move } from 'fs-extra'
2import { dirname, join } from 'path'
3import { logger, LoggerTagsFn } from '@server/helpers/logger'
4import { onTranscodingEnded } from '@server/lib/transcoding/ended-transcoding'
5import { onWebVideoFileTranscoding } from '@server/lib/transcoding/web-transcoding'
6import { buildNewFile } from '@server/lib/video-file'
7import { VideoModel } from '@server/models/video/video'
8import { MVideoFullLight } from '@server/types/models'
9import { MRunnerJob } from '@server/types/models/runners'
10import { RunnerJobVODAudioMergeTranscodingPrivatePayload, RunnerJobVODWebVideoTranscodingPrivatePayload } from '@shared/models'
11
12export async function onVODWebVideoOrAudioMergeTranscodingJob (options: {
13 video: MVideoFullLight
14 videoFilePath: string
15 privatePayload: RunnerJobVODWebVideoTranscodingPrivatePayload | RunnerJobVODAudioMergeTranscodingPrivatePayload
16}) {
17 const { video, videoFilePath, privatePayload } = options
18
19 const videoFile = await buildNewFile({ path: videoFilePath, mode: 'web-video' })
20 videoFile.videoId = video.id
21
22 const newVideoFilePath = join(dirname(videoFilePath), videoFile.filename)
23 await move(videoFilePath, newVideoFilePath)
24
25 await onWebVideoFileTranscoding({
26 video,
27 videoFile,
28 videoOutputPath: newVideoFilePath
29 })
30
31 await onTranscodingEnded({ isNewVideo: privatePayload.isNewVideo, moveVideoToNextState: true, video })
32}
33
34export async function loadTranscodingRunnerVideo (runnerJob: MRunnerJob, lTags: LoggerTagsFn) {
35 const videoUUID = runnerJob.privatePayload.videoUUID
36
37 const video = await VideoModel.loadFull(videoUUID)
38 if (!video) {
39 logger.info('Video %s does not exist anymore after transcoding runner job.', videoUUID, lTags(videoUUID))
40 return undefined
41 }
42
43 return video
44}
diff --git a/server/lib/runners/job-handlers/video-studio-transcoding-job-handler.ts b/server/lib/runners/job-handlers/video-studio-transcoding-job-handler.ts
deleted file mode 100644
index f604382b7..000000000
--- a/server/lib/runners/job-handlers/video-studio-transcoding-job-handler.ts
+++ /dev/null
@@ -1,157 +0,0 @@
1
2import { basename } from 'path'
3import { logger } from '@server/helpers/logger'
4import { onVideoStudioEnded, safeCleanupStudioTMPFiles } from '@server/lib/video-studio'
5import { MVideo } from '@server/types/models'
6import { MRunnerJob } from '@server/types/models/runners'
7import { buildUUID } from '@shared/extra-utils'
8import {
9 isVideoStudioTaskIntro,
10 isVideoStudioTaskOutro,
11 isVideoStudioTaskWatermark,
12 RunnerJobState,
13 RunnerJobUpdatePayload,
14 RunnerJobStudioTranscodingPayload,
15 RunnerJobVideoStudioTranscodingPrivatePayload,
16 VideoStudioTranscodingSuccess,
17 VideoState,
18 VideoStudioTaskPayload
19} from '@shared/models'
20import { generateRunnerEditionTranscodingVideoInputFileUrl, generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls'
21import { AbstractJobHandler } from './abstract-job-handler'
22import { loadTranscodingRunnerVideo } from './shared'
23
24type CreateOptions = {
25 video: MVideo
26 tasks: VideoStudioTaskPayload[]
27 priority: number
28}
29
30// eslint-disable-next-line max-len
31export class VideoStudioTranscodingJobHandler extends AbstractJobHandler<CreateOptions, RunnerJobUpdatePayload, VideoStudioTranscodingSuccess> {
32
33 async create (options: CreateOptions) {
34 const { video, priority, tasks } = options
35
36 const jobUUID = buildUUID()
37 const payload: RunnerJobStudioTranscodingPayload = {
38 input: {
39 videoFileUrl: generateRunnerTranscodingVideoInputFileUrl(jobUUID, video.uuid)
40 },
41 tasks: tasks.map(t => {
42 if (isVideoStudioTaskIntro(t) || isVideoStudioTaskOutro(t)) {
43 return {
44 ...t,
45
46 options: {
47 ...t.options,
48
49 file: generateRunnerEditionTranscodingVideoInputFileUrl(jobUUID, video.uuid, basename(t.options.file))
50 }
51 }
52 }
53
54 if (isVideoStudioTaskWatermark(t)) {
55 return {
56 ...t,
57
58 options: {
59 ...t.options,
60
61 file: generateRunnerEditionTranscodingVideoInputFileUrl(jobUUID, video.uuid, basename(t.options.file))
62 }
63 }
64 }
65
66 return t
67 })
68 }
69
70 const privatePayload: RunnerJobVideoStudioTranscodingPrivatePayload = {
71 videoUUID: video.uuid,
72 originalTasks: tasks
73 }
74
75 const job = await this.createRunnerJob({
76 type: 'video-studio-transcoding',
77 jobUUID,
78 payload,
79 privatePayload,
80 priority
81 })
82
83 return job
84 }
85
86 // ---------------------------------------------------------------------------
87
88 protected isAbortSupported () {
89 return true
90 }
91
92 protected specificUpdate (_options: {
93 runnerJob: MRunnerJob
94 }) {
95 // empty
96 }
97
98 protected specificAbort (_options: {
99 runnerJob: MRunnerJob
100 }) {
101 // empty
102 }
103
104 protected async specificComplete (options: {
105 runnerJob: MRunnerJob
106 resultPayload: VideoStudioTranscodingSuccess
107 }) {
108 const { runnerJob, resultPayload } = options
109 const privatePayload = runnerJob.privatePayload as RunnerJobVideoStudioTranscodingPrivatePayload
110
111 const video = await loadTranscodingRunnerVideo(runnerJob, this.lTags)
112 if (!video) {
113 await safeCleanupStudioTMPFiles(privatePayload.originalTasks)
114
115 }
116
117 const videoFilePath = resultPayload.videoFile as string
118
119 await onVideoStudioEnded({ video, editionResultPath: videoFilePath, tasks: privatePayload.originalTasks })
120
121 logger.info(
122 'Runner video edition transcoding job %s for %s ended.',
123 runnerJob.uuid, video.uuid, this.lTags(video.uuid, runnerJob.uuid)
124 )
125 }
126
127 protected specificError (options: {
128 runnerJob: MRunnerJob
129 nextState: RunnerJobState
130 }) {
131 if (options.nextState === RunnerJobState.ERRORED) {
132 return this.specificErrorOrCancel(options)
133 }
134
135 return Promise.resolve()
136 }
137
138 protected specificCancel (options: {
139 runnerJob: MRunnerJob
140 }) {
141 return this.specificErrorOrCancel(options)
142 }
143
144 private async specificErrorOrCancel (options: {
145 runnerJob: MRunnerJob
146 }) {
147 const { runnerJob } = options
148
149 const payload = runnerJob.privatePayload as RunnerJobVideoStudioTranscodingPrivatePayload
150 await safeCleanupStudioTMPFiles(payload.originalTasks)
151
152 const video = await loadTranscodingRunnerVideo(options.runnerJob, this.lTags)
153 if (!video) return
154
155 return video.setNewState(VideoState.PUBLISHED, false, undefined)
156 }
157}
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
deleted file mode 100644
index 137a94535..000000000
--- a/server/lib/runners/job-handlers/vod-audio-merge-transcoding-job-handler.ts
+++ /dev/null
@@ -1,97 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { VideoJobInfoModel } from '@server/models/video/video-job-info'
3import { MVideo } from '@server/types/models'
4import { MRunnerJob } from '@server/types/models/runners'
5import { pick } from '@shared/core-utils'
6import { buildUUID } from '@shared/extra-utils'
7import { getVideoStreamDuration } from '@shared/ffmpeg'
8import {
9 RunnerJobUpdatePayload,
10 RunnerJobVODAudioMergeTranscodingPayload,
11 RunnerJobVODWebVideoTranscodingPrivatePayload,
12 VODAudioMergeTranscodingSuccess
13} from '@shared/models'
14import { generateRunnerTranscodingVideoInputFileUrl, generateRunnerTranscodingVideoPreviewFileUrl } from '../runner-urls'
15import { AbstractVODTranscodingJobHandler } from './abstract-vod-transcoding-job-handler'
16import { loadTranscodingRunnerVideo, onVODWebVideoOrAudioMergeTranscodingJob } from './shared'
17
18type CreateOptions = {
19 video: MVideo
20 isNewVideo: boolean
21 resolution: number
22 fps: number
23 priority: number
24 dependsOnRunnerJob?: MRunnerJob
25}
26
27// eslint-disable-next-line max-len
28export class VODAudioMergeTranscodingJobHandler extends AbstractVODTranscodingJobHandler<CreateOptions, RunnerJobUpdatePayload, VODAudioMergeTranscodingSuccess> {
29
30 async create (options: CreateOptions) {
31 const { video, resolution, fps, priority, dependsOnRunnerJob } = options
32
33 const jobUUID = buildUUID()
34 const payload: RunnerJobVODAudioMergeTranscodingPayload = {
35 input: {
36 audioFileUrl: generateRunnerTranscodingVideoInputFileUrl(jobUUID, video.uuid),
37 previewFileUrl: generateRunnerTranscodingVideoPreviewFileUrl(jobUUID, video.uuid)
38 },
39 output: {
40 resolution,
41 fps
42 }
43 }
44
45 const privatePayload: RunnerJobVODWebVideoTranscodingPrivatePayload = {
46 ...pick(options, [ 'isNewVideo' ]),
47
48 videoUUID: video.uuid
49 }
50
51 const job = await this.createRunnerJob({
52 type: 'vod-audio-merge-transcoding',
53 jobUUID,
54 payload,
55 privatePayload,
56 priority,
57 dependsOnRunnerJob
58 })
59
60 await VideoJobInfoModel.increaseOrCreate(video.uuid, 'pendingTranscode')
61
62 return job
63 }
64
65 // ---------------------------------------------------------------------------
66
67 protected async specificComplete (options: {
68 runnerJob: MRunnerJob
69 resultPayload: VODAudioMergeTranscodingSuccess
70 }) {
71 const { runnerJob, resultPayload } = options
72 const privatePayload = runnerJob.privatePayload as RunnerJobVODWebVideoTranscodingPrivatePayload
73
74 const video = await loadTranscodingRunnerVideo(runnerJob, this.lTags)
75 if (!video) return
76
77 const videoFilePath = resultPayload.videoFile as string
78
79 // ffmpeg generated a new video file, so update the video duration
80 // See https://trac.ffmpeg.org/ticket/5456
81 video.duration = await getVideoStreamDuration(videoFilePath)
82 await video.save()
83
84 // We can remove the old audio file
85 const oldAudioFile = video.VideoFiles[0]
86 await video.removeWebVideoFile(oldAudioFile)
87 await oldAudioFile.destroy()
88 video.VideoFiles = []
89
90 await onVODWebVideoOrAudioMergeTranscodingJob({ video, videoFilePath, privatePayload })
91
92 logger.info(
93 'Runner VOD audio merge transcoding job %s for %s ended.',
94 runnerJob.uuid, video.uuid, this.lTags(video.uuid, runnerJob.uuid)
95 )
96 }
97}
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
deleted file mode 100644
index 02845952c..000000000
--- a/server/lib/runners/job-handlers/vod-hls-transcoding-job-handler.ts
+++ /dev/null
@@ -1,114 +0,0 @@
1import { move } from 'fs-extra'
2import { dirname, join } from 'path'
3import { logger } from '@server/helpers/logger'
4import { renameVideoFileInPlaylist } from '@server/lib/hls'
5import { getHlsResolutionPlaylistFilename } from '@server/lib/paths'
6import { onTranscodingEnded } from '@server/lib/transcoding/ended-transcoding'
7import { onHLSVideoFileTranscoding } from '@server/lib/transcoding/hls-transcoding'
8import { buildNewFile, removeAllWebVideoFiles } from '@server/lib/video-file'
9import { VideoJobInfoModel } from '@server/models/video/video-job-info'
10import { MVideo } from '@server/types/models'
11import { MRunnerJob } from '@server/types/models/runners'
12import { pick } from '@shared/core-utils'
13import { buildUUID } from '@shared/extra-utils'
14import {
15 RunnerJobUpdatePayload,
16 RunnerJobVODHLSTranscodingPayload,
17 RunnerJobVODHLSTranscodingPrivatePayload,
18 VODHLSTranscodingSuccess
19} from '@shared/models'
20import { generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls'
21import { AbstractVODTranscodingJobHandler } from './abstract-vod-transcoding-job-handler'
22import { loadTranscodingRunnerVideo } from './shared'
23
24type CreateOptions = {
25 video: MVideo
26 isNewVideo: boolean
27 deleteWebVideoFiles: boolean
28 resolution: number
29 fps: number
30 priority: number
31 dependsOnRunnerJob?: MRunnerJob
32}
33
34// eslint-disable-next-line max-len
35export class VODHLSTranscodingJobHandler extends AbstractVODTranscodingJobHandler<CreateOptions, RunnerJobUpdatePayload, VODHLSTranscodingSuccess> {
36
37 async create (options: CreateOptions) {
38 const { video, resolution, fps, dependsOnRunnerJob, priority } = options
39
40 const jobUUID = buildUUID()
41
42 const payload: RunnerJobVODHLSTranscodingPayload = {
43 input: {
44 videoFileUrl: generateRunnerTranscodingVideoInputFileUrl(jobUUID, video.uuid)
45 },
46 output: {
47 resolution,
48 fps
49 }
50 }
51
52 const privatePayload: RunnerJobVODHLSTranscodingPrivatePayload = {
53 ...pick(options, [ 'isNewVideo', 'deleteWebVideoFiles' ]),
54
55 videoUUID: video.uuid
56 }
57
58 const job = await this.createRunnerJob({
59 type: 'vod-hls-transcoding',
60 jobUUID,
61 payload,
62 privatePayload,
63 priority,
64 dependsOnRunnerJob
65 })
66
67 await VideoJobInfoModel.increaseOrCreate(video.uuid, 'pendingTranscode')
68
69 return job
70 }
71
72 // ---------------------------------------------------------------------------
73
74 protected async specificComplete (options: {
75 runnerJob: MRunnerJob
76 resultPayload: VODHLSTranscodingSuccess
77 }) {
78 const { runnerJob, resultPayload } = options
79 const privatePayload = runnerJob.privatePayload as RunnerJobVODHLSTranscodingPrivatePayload
80
81 const video = await loadTranscodingRunnerVideo(runnerJob, this.lTags)
82 if (!video) return
83
84 const videoFilePath = resultPayload.videoFile as string
85 const resolutionPlaylistFilePath = resultPayload.resolutionPlaylistFile as string
86
87 const videoFile = await buildNewFile({ path: videoFilePath, mode: 'hls' })
88 const newVideoFilePath = join(dirname(videoFilePath), videoFile.filename)
89 await move(videoFilePath, newVideoFilePath)
90
91 const resolutionPlaylistFilename = getHlsResolutionPlaylistFilename(videoFile.filename)
92 const newResolutionPlaylistFilePath = join(dirname(resolutionPlaylistFilePath), resolutionPlaylistFilename)
93 await move(resolutionPlaylistFilePath, newResolutionPlaylistFilePath)
94
95 await renameVideoFileInPlaylist(newResolutionPlaylistFilePath, videoFile.filename)
96
97 await onHLSVideoFileTranscoding({
98 video,
99 videoFile,
100 m3u8OutputPath: newResolutionPlaylistFilePath,
101 videoOutputPath: newVideoFilePath
102 })
103
104 await onTranscodingEnded({ isNewVideo: privatePayload.isNewVideo, moveVideoToNextState: true, video })
105
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))
108
109 await removeAllWebVideoFiles(video)
110 }
111
112 logger.info('Runner VOD HLS job %s for %s ended.', runnerJob.uuid, video.uuid, this.lTags(runnerJob.uuid, video.uuid))
113 }
114}
diff --git a/server/lib/runners/job-handlers/vod-web-video-transcoding-job-handler.ts b/server/lib/runners/job-handlers/vod-web-video-transcoding-job-handler.ts
deleted file mode 100644
index 9ee8ab88e..000000000
--- a/server/lib/runners/job-handlers/vod-web-video-transcoding-job-handler.ts
+++ /dev/null
@@ -1,84 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { VideoJobInfoModel } from '@server/models/video/video-job-info'
3import { MVideo } from '@server/types/models'
4import { MRunnerJob } from '@server/types/models/runners'
5import { pick } from '@shared/core-utils'
6import { buildUUID } from '@shared/extra-utils'
7import {
8 RunnerJobUpdatePayload,
9 RunnerJobVODWebVideoTranscodingPayload,
10 RunnerJobVODWebVideoTranscodingPrivatePayload,
11 VODWebVideoTranscodingSuccess
12} from '@shared/models'
13import { generateRunnerTranscodingVideoInputFileUrl } from '../runner-urls'
14import { AbstractVODTranscodingJobHandler } from './abstract-vod-transcoding-job-handler'
15import { loadTranscodingRunnerVideo, onVODWebVideoOrAudioMergeTranscodingJob } from './shared'
16
17type CreateOptions = {
18 video: MVideo
19 isNewVideo: boolean
20 resolution: number
21 fps: number
22 priority: number
23 dependsOnRunnerJob?: MRunnerJob
24}
25
26// eslint-disable-next-line max-len
27export class VODWebVideoTranscodingJobHandler extends AbstractVODTranscodingJobHandler<CreateOptions, RunnerJobUpdatePayload, VODWebVideoTranscodingSuccess> {
28
29 async create (options: CreateOptions) {
30 const { video, resolution, fps, priority, dependsOnRunnerJob } = options
31
32 const jobUUID = buildUUID()
33 const payload: RunnerJobVODWebVideoTranscodingPayload = {
34 input: {
35 videoFileUrl: generateRunnerTranscodingVideoInputFileUrl(jobUUID, video.uuid)
36 },
37 output: {
38 resolution,
39 fps
40 }
41 }
42
43 const privatePayload: RunnerJobVODWebVideoTranscodingPrivatePayload = {
44 ...pick(options, [ 'isNewVideo' ]),
45
46 videoUUID: video.uuid
47 }
48
49 const job = await this.createRunnerJob({
50 type: 'vod-web-video-transcoding',
51 jobUUID,
52 payload,
53 privatePayload,
54 dependsOnRunnerJob,
55 priority
56 })
57
58 await VideoJobInfoModel.increaseOrCreate(video.uuid, 'pendingTranscode')
59
60 return job
61 }
62
63 // ---------------------------------------------------------------------------
64
65 protected async specificComplete (options: {
66 runnerJob: MRunnerJob
67 resultPayload: VODWebVideoTranscodingSuccess
68 }) {
69 const { runnerJob, resultPayload } = options
70 const privatePayload = runnerJob.privatePayload as RunnerJobVODWebVideoTranscodingPrivatePayload
71
72 const video = await loadTranscodingRunnerVideo(runnerJob, this.lTags)
73 if (!video) return
74
75 const videoFilePath = resultPayload.videoFile as string
76
77 await onVODWebVideoOrAudioMergeTranscodingJob({ video, videoFilePath, privatePayload })
78
79 logger.info(
80 'Runner VOD web video transcoding job %s for %s ended.',
81 runnerJob.uuid, video.uuid, this.lTags(video.uuid, runnerJob.uuid)
82 )
83 }
84}
diff --git a/server/lib/runners/runner-urls.ts b/server/lib/runners/runner-urls.ts
deleted file mode 100644
index a27060b33..000000000
--- a/server/lib/runners/runner-urls.ts
+++ /dev/null
@@ -1,13 +0,0 @@
1import { WEBSERVER } from '@server/initializers/constants'
2
3export function generateRunnerTranscodingVideoInputFileUrl (jobUUID: string, videoUUID: string) {
4 return WEBSERVER.URL + '/api/v1/runners/jobs/' + jobUUID + '/files/videos/' + videoUUID + '/max-quality'
5}
6
7export function generateRunnerTranscodingVideoPreviewFileUrl (jobUUID: string, videoUUID: string) {
8 return WEBSERVER.URL + '/api/v1/runners/jobs/' + jobUUID + '/files/videos/' + videoUUID + '/previews/max-quality'
9}
10
11export function generateRunnerEditionTranscodingVideoInputFileUrl (jobUUID: string, videoUUID: string, filename: string) {
12 return WEBSERVER.URL + '/api/v1/runners/jobs/' + jobUUID + '/files/videos/' + videoUUID + '/studio/task-files/' + filename
13}
diff --git a/server/lib/runners/runner.ts b/server/lib/runners/runner.ts
deleted file mode 100644
index 947fdb3f0..000000000
--- a/server/lib/runners/runner.ts
+++ /dev/null
@@ -1,49 +0,0 @@
1import express from 'express'
2import { retryTransactionWrapper } from '@server/helpers/database-utils'
3import { logger, loggerTagsFactory } from '@server/helpers/logger'
4import { sequelizeTypescript } from '@server/initializers/database'
5import { MRunner, MRunnerJob } from '@server/types/models/runners'
6import { RUNNER_JOBS } from '@server/initializers/constants'
7import { RunnerJobState } from '@shared/models'
8
9const lTags = loggerTagsFactory('runner')
10
11const updatingRunner = new Set<number>()
12
13function updateLastRunnerContact (req: express.Request, runner: MRunner) {
14 const now = new Date()
15
16 // Don't update last runner contact too often
17 if (now.getTime() - runner.lastContact.getTime() < RUNNER_JOBS.LAST_CONTACT_UPDATE_INTERVAL) return
18 if (updatingRunner.has(runner.id)) return
19
20 updatingRunner.add(runner.id)
21
22 runner.lastContact = now
23 runner.ip = req.ip
24
25 logger.debug('Updating last runner contact for %s', runner.name, lTags(runner.name))
26
27 retryTransactionWrapper(() => {
28 return sequelizeTypescript.transaction(async transaction => {
29 return runner.save({ transaction })
30 })
31 })
32 .catch(err => logger.error('Cannot update last runner contact for %s', runner.name, { err, ...lTags(runner.name) }))
33 .finally(() => updatingRunner.delete(runner.id))
34}
35
36function runnerJobCanBeCancelled (runnerJob: MRunnerJob) {
37 const allowedStates = new Set<RunnerJobState>([
38 RunnerJobState.PENDING,
39 RunnerJobState.PROCESSING,
40 RunnerJobState.WAITING_FOR_PARENT_JOB
41 ])
42
43 return allowedStates.has(runnerJob.state)
44}
45
46export {
47 updateLastRunnerContact,
48 runnerJobCanBeCancelled
49}
diff --git a/server/lib/schedulers/abstract-scheduler.ts b/server/lib/schedulers/abstract-scheduler.ts
deleted file mode 100644
index f3d51a22e..000000000
--- a/server/lib/schedulers/abstract-scheduler.ts
+++ /dev/null
@@ -1,35 +0,0 @@
1import Bluebird from 'bluebird'
2import { logger } from '../../helpers/logger'
3
4export abstract class AbstractScheduler {
5
6 protected abstract schedulerIntervalMs: number
7
8 private interval: NodeJS.Timer
9 private isRunning = false
10
11 enable () {
12 if (!this.schedulerIntervalMs) throw new Error('Interval is not correctly set.')
13
14 this.interval = setInterval(() => this.execute(), this.schedulerIntervalMs)
15 }
16
17 disable () {
18 clearInterval(this.interval)
19 }
20
21 async execute () {
22 if (this.isRunning === true) return
23 this.isRunning = true
24
25 try {
26 await this.internalExecute()
27 } catch (err) {
28 logger.error('Cannot execute %s scheduler.', this.constructor.name, { err })
29 } finally {
30 this.isRunning = false
31 }
32 }
33
34 protected abstract internalExecute (): Promise<any> | Bluebird<any>
35}
diff --git a/server/lib/schedulers/actor-follow-scheduler.ts b/server/lib/schedulers/actor-follow-scheduler.ts
deleted file mode 100644
index e1c56c135..000000000
--- a/server/lib/schedulers/actor-follow-scheduler.ts
+++ /dev/null
@@ -1,54 +0,0 @@
1import { isTestOrDevInstance } from '../../helpers/core-utils'
2import { logger } from '../../helpers/logger'
3import { ACTOR_FOLLOW_SCORE, SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
4import { ActorFollowModel } from '../../models/actor/actor-follow'
5import { ActorFollowHealthCache } from '../actor-follow-health-cache'
6import { AbstractScheduler } from './abstract-scheduler'
7
8export class ActorFollowScheduler extends AbstractScheduler {
9
10 private static instance: AbstractScheduler
11
12 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.ACTOR_FOLLOW_SCORES
13
14 private constructor () {
15 super()
16 }
17
18 protected async internalExecute () {
19 await this.processPendingScores()
20
21 await this.removeBadActorFollows()
22 }
23
24 private async processPendingScores () {
25 const pendingScores = ActorFollowHealthCache.Instance.getPendingFollowsScore()
26 const badServerIds = ActorFollowHealthCache.Instance.getBadFollowingServerIds()
27 const goodServerIds = ActorFollowHealthCache.Instance.getGoodFollowingServerIds()
28
29 ActorFollowHealthCache.Instance.clearPendingFollowsScore()
30 ActorFollowHealthCache.Instance.clearBadFollowingServerIds()
31 ActorFollowHealthCache.Instance.clearGoodFollowingServerIds()
32
33 for (const inbox of Object.keys(pendingScores)) {
34 await ActorFollowModel.updateScore(inbox, pendingScores[inbox])
35 }
36
37 await ActorFollowModel.updateScoreByFollowingServers(badServerIds, ACTOR_FOLLOW_SCORE.PENALTY)
38 await ActorFollowModel.updateScoreByFollowingServers(goodServerIds, ACTOR_FOLLOW_SCORE.BONUS)
39 }
40
41 private async removeBadActorFollows () {
42 if (!isTestOrDevInstance()) logger.info('Removing bad actor follows (scheduler).')
43
44 try {
45 await ActorFollowModel.removeBadActorFollows()
46 } catch (err) {
47 logger.error('Error in bad actor follows scheduler.', { err })
48 }
49 }
50
51 static get Instance () {
52 return this.instance || (this.instance = new this())
53 }
54}
diff --git a/server/lib/schedulers/auto-follow-index-instances.ts b/server/lib/schedulers/auto-follow-index-instances.ts
deleted file mode 100644
index 956ece749..000000000
--- a/server/lib/schedulers/auto-follow-index-instances.ts
+++ /dev/null
@@ -1,75 +0,0 @@
1import { chunk } from 'lodash'
2import { doJSONRequest } from '@server/helpers/requests'
3import { JobQueue } from '@server/lib/job-queue'
4import { ActorFollowModel } from '@server/models/actor/actor-follow'
5import { getServerActor } from '@server/models/application/application'
6import { logger } from '../../helpers/logger'
7import { CONFIG } from '../../initializers/config'
8import { SCHEDULER_INTERVALS_MS, SERVER_ACTOR_NAME } from '../../initializers/constants'
9import { AbstractScheduler } from './abstract-scheduler'
10
11export class AutoFollowIndexInstances extends AbstractScheduler {
12
13 private static instance: AbstractScheduler
14
15 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.AUTO_FOLLOW_INDEX_INSTANCES
16
17 private lastCheck: Date
18
19 private constructor () {
20 super()
21 }
22
23 protected async internalExecute () {
24 return this.autoFollow()
25 }
26
27 private async autoFollow () {
28 if (CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.ENABLED === false) return
29
30 const indexUrl = CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL
31
32 logger.info('Auto follow instances of index %s.', indexUrl)
33
34 try {
35 const serverActor = await getServerActor()
36
37 const searchParams = { count: 1000 }
38 if (this.lastCheck) Object.assign(searchParams, { since: this.lastCheck.toISOString() })
39
40 this.lastCheck = new Date()
41
42 const { body } = await doJSONRequest<any>(indexUrl, { searchParams })
43 if (!body.data || Array.isArray(body.data) === false) {
44 logger.error('Cannot auto follow instances of index %s. Please check the auto follow URL.', indexUrl, { body })
45 return
46 }
47
48 const hosts: string[] = body.data.map(o => o.host)
49 const chunks = chunk(hosts, 20)
50
51 for (const chunk of chunks) {
52 const unfollowedHosts = await ActorFollowModel.keepUnfollowedInstance(chunk)
53
54 for (const unfollowedHost of unfollowedHosts) {
55 const payload = {
56 host: unfollowedHost,
57 name: SERVER_ACTOR_NAME,
58 followerActorId: serverActor.id,
59 isAutoFollow: true
60 }
61
62 JobQueue.Instance.createJobAsync({ type: 'activitypub-follow', payload })
63 }
64 }
65
66 } catch (err) {
67 logger.error('Cannot auto follow hosts of index %s.', indexUrl, { err })
68 }
69
70 }
71
72 static get Instance () {
73 return this.instance || (this.instance = new this())
74 }
75}
diff --git a/server/lib/schedulers/geo-ip-update-scheduler.ts b/server/lib/schedulers/geo-ip-update-scheduler.ts
deleted file mode 100644
index b06f5a9b5..000000000
--- a/server/lib/schedulers/geo-ip-update-scheduler.ts
+++ /dev/null
@@ -1,22 +0,0 @@
1import { GeoIP } from '@server/helpers/geo-ip'
2import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
3import { AbstractScheduler } from './abstract-scheduler'
4
5export class GeoIPUpdateScheduler extends AbstractScheduler {
6
7 private static instance: AbstractScheduler
8
9 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.GEO_IP_UPDATE
10
11 private constructor () {
12 super()
13 }
14
15 protected internalExecute () {
16 return GeoIP.Instance.updateDatabase()
17 }
18
19 static get Instance () {
20 return this.instance || (this.instance = new this())
21 }
22}
diff --git a/server/lib/schedulers/peertube-version-check-scheduler.ts b/server/lib/schedulers/peertube-version-check-scheduler.ts
deleted file mode 100644
index bc38ed49f..000000000
--- a/server/lib/schedulers/peertube-version-check-scheduler.ts
+++ /dev/null
@@ -1,55 +0,0 @@
1
2import { doJSONRequest } from '@server/helpers/requests'
3import { ApplicationModel } from '@server/models/application/application'
4import { compareSemVer } from '@shared/core-utils'
5import { JoinPeerTubeVersions } from '@shared/models'
6import { logger } from '../../helpers/logger'
7import { CONFIG } from '../../initializers/config'
8import { PEERTUBE_VERSION, SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
9import { Notifier } from '../notifier'
10import { AbstractScheduler } from './abstract-scheduler'
11
12export class PeerTubeVersionCheckScheduler extends AbstractScheduler {
13
14 private static instance: AbstractScheduler
15
16 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.CHECK_PEERTUBE_VERSION
17
18 private constructor () {
19 super()
20 }
21
22 protected async internalExecute () {
23 return this.checkLatestVersion()
24 }
25
26 private async checkLatestVersion () {
27 if (CONFIG.PEERTUBE.CHECK_LATEST_VERSION.ENABLED === false) return
28
29 logger.info('Checking latest PeerTube version.')
30
31 const { body } = await doJSONRequest<JoinPeerTubeVersions>(CONFIG.PEERTUBE.CHECK_LATEST_VERSION.URL)
32
33 if (!body?.peertube?.latestVersion) {
34 logger.warn('Cannot check latest PeerTube version: body is invalid.', { body })
35 return
36 }
37
38 const latestVersion = body.peertube.latestVersion
39 const application = await ApplicationModel.load()
40
41 // Already checked this version
42 if (application.latestPeerTubeVersion === latestVersion) return
43
44 if (compareSemVer(PEERTUBE_VERSION, latestVersion) < 0) {
45 application.latestPeerTubeVersion = latestVersion
46 await application.save()
47
48 Notifier.Instance.notifyOfNewPeerTubeVersion(application, latestVersion)
49 }
50 }
51
52 static get Instance () {
53 return this.instance || (this.instance = new this())
54 }
55}
diff --git a/server/lib/schedulers/plugins-check-scheduler.ts b/server/lib/schedulers/plugins-check-scheduler.ts
deleted file mode 100644
index 820c01693..000000000
--- a/server/lib/schedulers/plugins-check-scheduler.ts
+++ /dev/null
@@ -1,74 +0,0 @@
1import { chunk } from 'lodash'
2import { compareSemVer } from '@shared/core-utils'
3import { logger } from '../../helpers/logger'
4import { CONFIG } from '../../initializers/config'
5import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
6import { PluginModel } from '../../models/server/plugin'
7import { Notifier } from '../notifier'
8import { getLatestPluginsVersion } from '../plugins/plugin-index'
9import { AbstractScheduler } from './abstract-scheduler'
10
11export class PluginsCheckScheduler extends AbstractScheduler {
12
13 private static instance: AbstractScheduler
14
15 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.CHECK_PLUGINS
16
17 private constructor () {
18 super()
19 }
20
21 protected async internalExecute () {
22 return this.checkLatestPluginsVersion()
23 }
24
25 private async checkLatestPluginsVersion () {
26 if (CONFIG.PLUGINS.INDEX.ENABLED === false) return
27
28 logger.info('Checking latest plugins version.')
29
30 const plugins = await PluginModel.listInstalled()
31
32 // Process 10 plugins in 1 HTTP request
33 const chunks = chunk(plugins, 10)
34 for (const chunk of chunks) {
35 // Find plugins according to their npm name
36 const pluginIndex: { [npmName: string]: PluginModel } = {}
37 for (const plugin of chunk) {
38 pluginIndex[PluginModel.buildNpmName(plugin.name, plugin.type)] = plugin
39 }
40
41 const npmNames = Object.keys(pluginIndex)
42
43 try {
44 const results = await getLatestPluginsVersion(npmNames)
45
46 for (const result of results) {
47 const plugin = pluginIndex[result.npmName]
48 if (!result.latestVersion) continue
49
50 if (
51 !plugin.latestVersion ||
52 (plugin.latestVersion !== result.latestVersion && compareSemVer(plugin.latestVersion, result.latestVersion) < 0)
53 ) {
54 plugin.latestVersion = result.latestVersion
55 await plugin.save()
56
57 // Notify if there is an higher plugin version available
58 if (compareSemVer(plugin.version, result.latestVersion) < 0) {
59 Notifier.Instance.notifyOfNewPluginVersion(plugin)
60 }
61
62 logger.info('Plugin %s has a new latest version %s.', result.npmName, plugin.latestVersion)
63 }
64 }
65 } catch (err) {
66 logger.error('Cannot get latest plugins version.', { npmNames, err })
67 }
68 }
69 }
70
71 static get Instance () {
72 return this.instance || (this.instance = new this())
73 }
74}
diff --git a/server/lib/schedulers/remove-dangling-resumable-uploads-scheduler.ts b/server/lib/schedulers/remove-dangling-resumable-uploads-scheduler.ts
deleted file mode 100644
index 61e93eafa..000000000
--- a/server/lib/schedulers/remove-dangling-resumable-uploads-scheduler.ts
+++ /dev/null
@@ -1,40 +0,0 @@
1
2import { logger, loggerTagsFactory } from '@server/helpers/logger'
3import { SCHEDULER_INTERVALS_MS } from '@server/initializers/constants'
4import { uploadx } from '../uploadx'
5import { AbstractScheduler } from './abstract-scheduler'
6
7const lTags = loggerTagsFactory('scheduler', 'resumable-upload', 'cleaner')
8
9export class RemoveDanglingResumableUploadsScheduler extends AbstractScheduler {
10
11 private static instance: AbstractScheduler
12 private lastExecutionTimeMs: number
13
14 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.REMOVE_DANGLING_RESUMABLE_UPLOADS
15
16 private constructor () {
17 super()
18
19 this.lastExecutionTimeMs = new Date().getTime()
20 }
21
22 protected async internalExecute () {
23 logger.debug('Removing dangling resumable uploads', lTags())
24
25 const now = new Date().getTime()
26
27 try {
28 // Remove files that were not updated since the last execution
29 await uploadx.storage.purge(now - this.lastExecutionTimeMs)
30 } catch (error) {
31 logger.error('Failed to handle file during resumable video upload folder cleanup', { error, ...lTags() })
32 } finally {
33 this.lastExecutionTimeMs = now
34 }
35 }
36
37 static get Instance () {
38 return this.instance || (this.instance = new this())
39 }
40}
diff --git a/server/lib/schedulers/remove-old-history-scheduler.ts b/server/lib/schedulers/remove-old-history-scheduler.ts
deleted file mode 100644
index 34b160799..000000000
--- a/server/lib/schedulers/remove-old-history-scheduler.ts
+++ /dev/null
@@ -1,31 +0,0 @@
1import { logger } from '../../helpers/logger'
2import { AbstractScheduler } from './abstract-scheduler'
3import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
4import { UserVideoHistoryModel } from '../../models/user/user-video-history'
5import { CONFIG } from '../../initializers/config'
6
7export class RemoveOldHistoryScheduler extends AbstractScheduler {
8
9 private static instance: AbstractScheduler
10
11 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.REMOVE_OLD_HISTORY
12
13 private constructor () {
14 super()
15 }
16
17 protected internalExecute () {
18 if (CONFIG.HISTORY.VIDEOS.MAX_AGE === -1) return
19
20 logger.info('Removing old videos history.')
21
22 const now = new Date()
23 const beforeDate = new Date(now.getTime() - CONFIG.HISTORY.VIDEOS.MAX_AGE).toISOString()
24
25 return UserVideoHistoryModel.removeOldHistory(beforeDate)
26 }
27
28 static get Instance () {
29 return this.instance || (this.instance = new this())
30 }
31}
diff --git a/server/lib/schedulers/remove-old-views-scheduler.ts b/server/lib/schedulers/remove-old-views-scheduler.ts
deleted file mode 100644
index 8bc53a045..000000000
--- a/server/lib/schedulers/remove-old-views-scheduler.ts
+++ /dev/null
@@ -1,31 +0,0 @@
1import { VideoViewModel } from '@server/models/view/video-view'
2import { logger } from '../../helpers/logger'
3import { CONFIG } from '../../initializers/config'
4import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
5import { AbstractScheduler } from './abstract-scheduler'
6
7export class RemoveOldViewsScheduler extends AbstractScheduler {
8
9 private static instance: AbstractScheduler
10
11 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.REMOVE_OLD_VIEWS
12
13 private constructor () {
14 super()
15 }
16
17 protected internalExecute () {
18 if (CONFIG.VIEWS.VIDEOS.REMOTE.MAX_AGE === -1) return
19
20 logger.info('Removing old videos views.')
21
22 const now = new Date()
23 const beforeDate = new Date(now.getTime() - CONFIG.VIEWS.VIDEOS.REMOTE.MAX_AGE).toISOString()
24
25 return VideoViewModel.removeOldRemoteViewsHistory(beforeDate)
26 }
27
28 static get Instance () {
29 return this.instance || (this.instance = new this())
30 }
31}
diff --git a/server/lib/schedulers/runner-job-watch-dog-scheduler.ts b/server/lib/schedulers/runner-job-watch-dog-scheduler.ts
deleted file mode 100644
index f7a26d2bc..000000000
--- a/server/lib/schedulers/runner-job-watch-dog-scheduler.ts
+++ /dev/null
@@ -1,42 +0,0 @@
1import { CONFIG } from '@server/initializers/config'
2import { RunnerJobModel } from '@server/models/runner/runner-job'
3import { logger, loggerTagsFactory } from '../../helpers/logger'
4import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
5import { getRunnerJobHandlerClass } from '../runners'
6import { AbstractScheduler } from './abstract-scheduler'
7
8const lTags = loggerTagsFactory('runner')
9
10export class RunnerJobWatchDogScheduler extends AbstractScheduler {
11
12 private static instance: AbstractScheduler
13
14 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.RUNNER_JOB_WATCH_DOG
15
16 private constructor () {
17 super()
18 }
19
20 protected async internalExecute () {
21 const vodStalledJobs = await RunnerJobModel.listStalledJobs({
22 staleTimeMS: CONFIG.REMOTE_RUNNERS.STALLED_JOBS.VOD,
23 types: [ 'vod-audio-merge-transcoding', 'vod-hls-transcoding', 'vod-web-video-transcoding' ]
24 })
25
26 const liveStalledJobs = await RunnerJobModel.listStalledJobs({
27 staleTimeMS: CONFIG.REMOTE_RUNNERS.STALLED_JOBS.LIVE,
28 types: [ 'live-rtmp-hls-transcoding' ]
29 })
30
31 for (const stalled of [ ...vodStalledJobs, ...liveStalledJobs ]) {
32 logger.info('Abort stalled runner job %s (%s)', stalled.uuid, stalled.type, lTags(stalled.uuid, stalled.type))
33
34 const Handler = getRunnerJobHandlerClass(stalled)
35 await new Handler().abort({ runnerJob: stalled })
36 }
37 }
38
39 static get Instance () {
40 return this.instance || (this.instance = new this())
41 }
42}
diff --git a/server/lib/schedulers/update-videos-scheduler.ts b/server/lib/schedulers/update-videos-scheduler.ts
deleted file mode 100644
index e38685c04..000000000
--- a/server/lib/schedulers/update-videos-scheduler.ts
+++ /dev/null
@@ -1,89 +0,0 @@
1import { VideoModel } from '@server/models/video/video'
2import { MScheduleVideoUpdate } from '@server/types/models'
3import { VideoPrivacy, VideoState } from '@shared/models'
4import { logger } from '../../helpers/logger'
5import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
6import { sequelizeTypescript } from '../../initializers/database'
7import { ScheduleVideoUpdateModel } from '../../models/video/schedule-video-update'
8import { Notifier } from '../notifier'
9import { addVideoJobsAfterUpdate } from '../video'
10import { VideoPathManager } from '../video-path-manager'
11import { setVideoPrivacy } from '../video-privacy'
12import { AbstractScheduler } from './abstract-scheduler'
13
14export class UpdateVideosScheduler extends AbstractScheduler {
15
16 private static instance: AbstractScheduler
17
18 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.UPDATE_VIDEOS
19
20 private constructor () {
21 super()
22 }
23
24 protected async internalExecute () {
25 return this.updateVideos()
26 }
27
28 private async updateVideos () {
29 if (!await ScheduleVideoUpdateModel.areVideosToUpdate()) return undefined
30
31 const schedules = await ScheduleVideoUpdateModel.listVideosToUpdate()
32
33 for (const schedule of schedules) {
34 const videoOnly = await VideoModel.load(schedule.videoId)
35 const mutexReleaser = await VideoPathManager.Instance.lockFiles(videoOnly.uuid)
36
37 try {
38 const { video, published } = await this.updateAVideo(schedule)
39
40 if (published) Notifier.Instance.notifyOnVideoPublishedAfterScheduledUpdate(video)
41 } catch (err) {
42 logger.error('Cannot update video', { err })
43 }
44
45 mutexReleaser()
46 }
47 }
48
49 private async updateAVideo (schedule: MScheduleVideoUpdate) {
50 let oldPrivacy: VideoPrivacy
51 let isNewVideo: boolean
52 let published = false
53
54 const video = await sequelizeTypescript.transaction(async t => {
55 const video = await VideoModel.loadFull(schedule.videoId, t)
56 if (video.state === VideoState.TO_TRANSCODE) return null
57
58 logger.info('Executing scheduled video update on %s.', video.uuid)
59
60 if (schedule.privacy) {
61 isNewVideo = video.isNewVideo(schedule.privacy)
62 oldPrivacy = video.privacy
63
64 setVideoPrivacy(video, schedule.privacy)
65 await video.save({ transaction: t })
66
67 if (oldPrivacy === VideoPrivacy.PRIVATE) {
68 published = true
69 }
70 }
71
72 await schedule.destroy({ transaction: t })
73
74 return video
75 })
76
77 if (!video) {
78 return { video, published: false }
79 }
80
81 await addVideoJobsAfterUpdate({ video, oldPrivacy, isNewVideo, nameChanged: false })
82
83 return { video, published }
84 }
85
86 static get Instance () {
87 return this.instance || (this.instance = new this())
88 }
89}
diff --git a/server/lib/schedulers/video-channel-sync-latest-scheduler.ts b/server/lib/schedulers/video-channel-sync-latest-scheduler.ts
deleted file mode 100644
index efb957fac..000000000
--- a/server/lib/schedulers/video-channel-sync-latest-scheduler.ts
+++ /dev/null
@@ -1,50 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { CONFIG } from '@server/initializers/config'
3import { VideoChannelModel } from '@server/models/video/video-channel'
4import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync'
5import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
6import { synchronizeChannel } from '../sync-channel'
7import { AbstractScheduler } from './abstract-scheduler'
8
9export class VideoChannelSyncLatestScheduler extends AbstractScheduler {
10 private static instance: AbstractScheduler
11 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.CHANNEL_SYNC_CHECK_INTERVAL
12
13 private constructor () {
14 super()
15 }
16
17 protected async internalExecute () {
18 if (!CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.ENABLED) {
19 logger.debug('Discard channels synchronization as the feature is disabled')
20 return
21 }
22
23 logger.info('Checking channels to synchronize')
24
25 const channelSyncs = await VideoChannelSyncModel.listSyncs()
26
27 for (const sync of channelSyncs) {
28 const channel = await VideoChannelModel.loadAndPopulateAccount(sync.videoChannelId)
29
30 logger.info(
31 'Creating video import jobs for "%s" sync with external channel "%s"',
32 channel.Actor.preferredUsername, sync.externalChannelUrl
33 )
34
35 const onlyAfter = sync.lastSyncAt || sync.createdAt
36
37 await synchronizeChannel({
38 channel,
39 externalChannelUrl: sync.externalChannelUrl,
40 videosCountLimit: CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.VIDEOS_LIMIT_PER_SYNCHRONIZATION,
41 channelSync: sync,
42 onlyAfter
43 })
44 }
45 }
46
47 static get Instance () {
48 return this.instance || (this.instance = new this())
49 }
50}
diff --git a/server/lib/schedulers/video-views-buffer-scheduler.ts b/server/lib/schedulers/video-views-buffer-scheduler.ts
deleted file mode 100644
index 244a88b14..000000000
--- a/server/lib/schedulers/video-views-buffer-scheduler.ts
+++ /dev/null
@@ -1,52 +0,0 @@
1import { logger, loggerTagsFactory } from '@server/helpers/logger'
2import { VideoModel } from '@server/models/video/video'
3import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
4import { federateVideoIfNeeded } from '../activitypub/videos'
5import { Redis } from '../redis'
6import { AbstractScheduler } from './abstract-scheduler'
7
8const lTags = loggerTagsFactory('views')
9
10export class VideoViewsBufferScheduler extends AbstractScheduler {
11
12 private static instance: AbstractScheduler
13
14 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.VIDEO_VIEWS_BUFFER_UPDATE
15
16 private constructor () {
17 super()
18 }
19
20 protected async internalExecute () {
21 const videoIds = await Redis.Instance.listLocalVideosViewed()
22 if (videoIds.length === 0) return
23
24 for (const videoId of videoIds) {
25 try {
26 const views = await Redis.Instance.getLocalVideoViews(videoId)
27 await Redis.Instance.deleteLocalVideoViews(videoId)
28
29 const video = await VideoModel.loadFull(videoId)
30 if (!video) {
31 logger.debug('Video %d does not exist anymore, skipping videos view addition.', videoId, lTags())
32 continue
33 }
34
35 logger.info('Processing local video %s views buffer.', video.uuid, lTags(video.uuid))
36
37 // If this is a remote video, the origin instance will send us an update
38 await VideoModel.incrementViews(videoId, views)
39
40 // Send video update
41 video.views += views
42 await federateVideoIfNeeded(video, false)
43 } catch (err) {
44 logger.error('Cannot process local video views buffer of video %d.', videoId, { err, ...lTags() })
45 }
46 }
47 }
48
49 static get Instance () {
50 return this.instance || (this.instance = new this())
51 }
52}
diff --git a/server/lib/schedulers/videos-redundancy-scheduler.ts b/server/lib/schedulers/videos-redundancy-scheduler.ts
deleted file mode 100644
index 91625ccb5..000000000
--- a/server/lib/schedulers/videos-redundancy-scheduler.ts
+++ /dev/null
@@ -1,375 +0,0 @@
1import { move } from 'fs-extra'
2import { join } from 'path'
3import { getServerActor } from '@server/models/application/application'
4import { VideoModel } from '@server/models/video/video'
5import {
6 MStreamingPlaylistFiles,
7 MVideoAccountLight,
8 MVideoFile,
9 MVideoFileVideo,
10 MVideoRedundancyFileVideo,
11 MVideoRedundancyStreamingPlaylistVideo,
12 MVideoRedundancyVideo,
13 MVideoWithAllFiles
14} from '@server/types/models'
15import { VideosRedundancyStrategy } from '../../../shared/models/redundancy'
16import { logger, loggerTagsFactory } from '../../helpers/logger'
17import { downloadWebTorrentVideo } from '../../helpers/webtorrent'
18import { CONFIG } from '../../initializers/config'
19import { DIRECTORIES, REDUNDANCY, VIDEO_IMPORT_TIMEOUT } from '../../initializers/constants'
20import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
21import { sendCreateCacheFile, sendUpdateCacheFile } from '../activitypub/send'
22import { getLocalVideoCacheFileActivityPubUrl, getLocalVideoCacheStreamingPlaylistActivityPubUrl } from '../activitypub/url'
23import { getOrCreateAPVideo } from '../activitypub/videos'
24import { downloadPlaylistSegments } from '../hls'
25import { removeVideoRedundancy } from '../redundancy'
26import { generateHLSRedundancyUrl, generateWebVideoRedundancyUrl } from '../video-urls'
27import { AbstractScheduler } from './abstract-scheduler'
28
29const lTags = loggerTagsFactory('redundancy')
30
31type CandidateToDuplicate = {
32 redundancy: VideosRedundancyStrategy
33 video: MVideoWithAllFiles
34 files: MVideoFile[]
35 streamingPlaylists: MStreamingPlaylistFiles[]
36}
37
38function isMVideoRedundancyFileVideo (
39 o: MVideoRedundancyFileVideo | MVideoRedundancyStreamingPlaylistVideo
40): o is MVideoRedundancyFileVideo {
41 return !!(o as MVideoRedundancyFileVideo).VideoFile
42}
43
44export class VideosRedundancyScheduler extends AbstractScheduler {
45
46 private static instance: VideosRedundancyScheduler
47
48 protected schedulerIntervalMs = CONFIG.REDUNDANCY.VIDEOS.CHECK_INTERVAL
49
50 private constructor () {
51 super()
52 }
53
54 async createManualRedundancy (videoId: number) {
55 const videoToDuplicate = await VideoModel.loadWithFiles(videoId)
56
57 if (!videoToDuplicate) {
58 logger.warn('Video to manually duplicate %d does not exist anymore.', videoId, lTags())
59 return
60 }
61
62 return this.createVideoRedundancies({
63 video: videoToDuplicate,
64 redundancy: null,
65 files: videoToDuplicate.VideoFiles,
66 streamingPlaylists: videoToDuplicate.VideoStreamingPlaylists
67 })
68 }
69
70 protected async internalExecute () {
71 for (const redundancyConfig of CONFIG.REDUNDANCY.VIDEOS.STRATEGIES) {
72 logger.info('Running redundancy scheduler for strategy %s.', redundancyConfig.strategy, lTags())
73
74 try {
75 const videoToDuplicate = await this.findVideoToDuplicate(redundancyConfig)
76 if (!videoToDuplicate) continue
77
78 const candidateToDuplicate = {
79 video: videoToDuplicate,
80 redundancy: redundancyConfig,
81 files: videoToDuplicate.VideoFiles,
82 streamingPlaylists: videoToDuplicate.VideoStreamingPlaylists
83 }
84
85 await this.purgeCacheIfNeeded(candidateToDuplicate)
86
87 if (await this.isTooHeavy(candidateToDuplicate)) {
88 logger.info('Video %s is too big for our cache, skipping.', videoToDuplicate.url, lTags(videoToDuplicate.uuid))
89 continue
90 }
91
92 logger.info(
93 'Will duplicate video %s in redundancy scheduler "%s".',
94 videoToDuplicate.url, redundancyConfig.strategy, lTags(videoToDuplicate.uuid)
95 )
96
97 await this.createVideoRedundancies(candidateToDuplicate)
98 } catch (err) {
99 logger.error('Cannot run videos redundancy %s.', redundancyConfig.strategy, { err, ...lTags() })
100 }
101 }
102
103 await this.extendsLocalExpiration()
104
105 await this.purgeRemoteExpired()
106 }
107
108 static get Instance () {
109 return this.instance || (this.instance = new this())
110 }
111
112 private async extendsLocalExpiration () {
113 const expired = await VideoRedundancyModel.listLocalExpired()
114
115 for (const redundancyModel of expired) {
116 try {
117 const redundancyConfig = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.find(s => s.strategy === redundancyModel.strategy)
118
119 // If the admin disabled the redundancy, remove this redundancy instead of extending it
120 if (!redundancyConfig) {
121 logger.info(
122 'Destroying redundancy %s because the redundancy %s does not exist anymore.',
123 redundancyModel.url, redundancyModel.strategy
124 )
125
126 await removeVideoRedundancy(redundancyModel)
127 continue
128 }
129
130 const { totalUsed } = await VideoRedundancyModel.getStats(redundancyConfig.strategy)
131
132 // If the admin decreased the cache size, remove this redundancy instead of extending it
133 if (totalUsed > redundancyConfig.size) {
134 logger.info('Destroying redundancy %s because the cache size %s is too heavy.', redundancyModel.url, redundancyModel.strategy)
135
136 await removeVideoRedundancy(redundancyModel)
137 continue
138 }
139
140 await this.extendsRedundancy(redundancyModel)
141 } catch (err) {
142 logger.error(
143 'Cannot extend or remove expiration of %s video from our redundancy system.',
144 this.buildEntryLogId(redundancyModel), { err, ...lTags(redundancyModel.getVideoUUID()) }
145 )
146 }
147 }
148 }
149
150 private async extendsRedundancy (redundancyModel: MVideoRedundancyVideo) {
151 const redundancy = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.find(s => s.strategy === redundancyModel.strategy)
152 // Redundancy strategy disabled, remove our redundancy instead of extending expiration
153 if (!redundancy) {
154 await removeVideoRedundancy(redundancyModel)
155 return
156 }
157
158 await this.extendsExpirationOf(redundancyModel, redundancy.minLifetime)
159 }
160
161 private async purgeRemoteExpired () {
162 const expired = await VideoRedundancyModel.listRemoteExpired()
163
164 for (const redundancyModel of expired) {
165 try {
166 await removeVideoRedundancy(redundancyModel)
167 } catch (err) {
168 logger.error(
169 'Cannot remove redundancy %s from our redundancy system.',
170 this.buildEntryLogId(redundancyModel), lTags(redundancyModel.getVideoUUID())
171 )
172 }
173 }
174 }
175
176 private findVideoToDuplicate (cache: VideosRedundancyStrategy) {
177 if (cache.strategy === 'most-views') {
178 return VideoRedundancyModel.findMostViewToDuplicate(REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR)
179 }
180
181 if (cache.strategy === 'trending') {
182 return VideoRedundancyModel.findTrendingToDuplicate(REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR)
183 }
184
185 if (cache.strategy === 'recently-added') {
186 const minViews = cache.minViews
187 return VideoRedundancyModel.findRecentlyAddedToDuplicate(REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR, minViews)
188 }
189 }
190
191 private async createVideoRedundancies (data: CandidateToDuplicate) {
192 const video = await this.loadAndRefreshVideo(data.video.url)
193
194 if (!video) {
195 logger.info('Video %s we want to duplicate does not existing anymore, skipping.', data.video.url, lTags(data.video.uuid))
196
197 return
198 }
199
200 for (const file of data.files) {
201 const existingRedundancy = await VideoRedundancyModel.loadLocalByFileId(file.id)
202 if (existingRedundancy) {
203 await this.extendsRedundancy(existingRedundancy)
204
205 continue
206 }
207
208 await this.createVideoFileRedundancy(data.redundancy, video, file)
209 }
210
211 for (const streamingPlaylist of data.streamingPlaylists) {
212 const existingRedundancy = await VideoRedundancyModel.loadLocalByStreamingPlaylistId(streamingPlaylist.id)
213 if (existingRedundancy) {
214 await this.extendsRedundancy(existingRedundancy)
215
216 continue
217 }
218
219 await this.createStreamingPlaylistRedundancy(data.redundancy, video, streamingPlaylist)
220 }
221 }
222
223 private async createVideoFileRedundancy (redundancy: VideosRedundancyStrategy | null, video: MVideoAccountLight, fileArg: MVideoFile) {
224 let strategy = 'manual'
225 let expiresOn: Date = null
226
227 if (redundancy) {
228 strategy = redundancy.strategy
229 expiresOn = this.buildNewExpiration(redundancy.minLifetime)
230 }
231
232 const file = fileArg as MVideoFileVideo
233 file.Video = video
234
235 const serverActor = await getServerActor()
236
237 logger.info('Duplicating %s - %d in videos redundancy with "%s" strategy.', video.url, file.resolution, strategy, lTags(video.uuid))
238
239 const tmpPath = await downloadWebTorrentVideo({ uri: file.torrentUrl }, VIDEO_IMPORT_TIMEOUT)
240
241 const destPath = join(CONFIG.STORAGE.REDUNDANCY_DIR, file.filename)
242 await move(tmpPath, destPath, { overwrite: true })
243
244 const createdModel: MVideoRedundancyFileVideo = await VideoRedundancyModel.create({
245 expiresOn,
246 url: getLocalVideoCacheFileActivityPubUrl(file),
247 fileUrl: generateWebVideoRedundancyUrl(file),
248 strategy,
249 videoFileId: file.id,
250 actorId: serverActor.id
251 })
252
253 createdModel.VideoFile = file
254
255 await sendCreateCacheFile(serverActor, video, createdModel)
256
257 logger.info('Duplicated %s - %d -> %s.', video.url, file.resolution, createdModel.url, lTags(video.uuid))
258 }
259
260 private async createStreamingPlaylistRedundancy (
261 redundancy: VideosRedundancyStrategy,
262 video: MVideoAccountLight,
263 playlistArg: MStreamingPlaylistFiles
264 ) {
265 let strategy = 'manual'
266 let expiresOn: Date = null
267
268 if (redundancy) {
269 strategy = redundancy.strategy
270 expiresOn = this.buildNewExpiration(redundancy.minLifetime)
271 }
272
273 const playlist = Object.assign(playlistArg, { Video: video })
274 const serverActor = await getServerActor()
275
276 logger.info('Duplicating %s streaming playlist in videos redundancy with "%s" strategy.', video.url, strategy, lTags(video.uuid))
277
278 const destDirectory = join(DIRECTORIES.HLS_REDUNDANCY, video.uuid)
279 const masterPlaylistUrl = playlist.getMasterPlaylistUrl(video)
280
281 const maxSizeKB = this.getTotalFileSizes([], [ playlist ]) / 1000
282 const toleranceKB = maxSizeKB + ((5 * maxSizeKB) / 100) // 5% more tolerance
283 await downloadPlaylistSegments(masterPlaylistUrl, destDirectory, VIDEO_IMPORT_TIMEOUT, toleranceKB)
284
285 const createdModel: MVideoRedundancyStreamingPlaylistVideo = await VideoRedundancyModel.create({
286 expiresOn,
287 url: getLocalVideoCacheStreamingPlaylistActivityPubUrl(video, playlist),
288 fileUrl: generateHLSRedundancyUrl(video, playlistArg),
289 strategy,
290 videoStreamingPlaylistId: playlist.id,
291 actorId: serverActor.id
292 })
293
294 createdModel.VideoStreamingPlaylist = playlist
295
296 await sendCreateCacheFile(serverActor, video, createdModel)
297
298 logger.info('Duplicated playlist %s -> %s.', masterPlaylistUrl, createdModel.url, lTags(video.uuid))
299 }
300
301 private async extendsExpirationOf (redundancy: MVideoRedundancyVideo, expiresAfterMs: number) {
302 logger.info('Extending expiration of %s.', redundancy.url, lTags(redundancy.getVideoUUID()))
303
304 const serverActor = await getServerActor()
305
306 redundancy.expiresOn = this.buildNewExpiration(expiresAfterMs)
307 await redundancy.save()
308
309 await sendUpdateCacheFile(serverActor, redundancy)
310 }
311
312 private async purgeCacheIfNeeded (candidateToDuplicate: CandidateToDuplicate) {
313 while (await this.isTooHeavy(candidateToDuplicate)) {
314 const redundancy = candidateToDuplicate.redundancy
315 const toDelete = await VideoRedundancyModel.loadOldestLocalExpired(redundancy.strategy, redundancy.minLifetime)
316 if (!toDelete) return
317
318 const videoId = toDelete.VideoFile
319 ? toDelete.VideoFile.videoId
320 : toDelete.VideoStreamingPlaylist.videoId
321
322 const redundancies = await VideoRedundancyModel.listLocalByVideoId(videoId)
323
324 for (const redundancy of redundancies) {
325 await removeVideoRedundancy(redundancy)
326 }
327 }
328 }
329
330 private async isTooHeavy (candidateToDuplicate: CandidateToDuplicate) {
331 const maxSize = candidateToDuplicate.redundancy.size
332
333 const { totalUsed: alreadyUsed } = await VideoRedundancyModel.getStats(candidateToDuplicate.redundancy.strategy)
334
335 const videoSize = this.getTotalFileSizes(candidateToDuplicate.files, candidateToDuplicate.streamingPlaylists)
336 const willUse = alreadyUsed + videoSize
337
338 logger.debug('Checking candidate size.', { maxSize, alreadyUsed, videoSize, willUse, ...lTags(candidateToDuplicate.video.uuid) })
339
340 return willUse > maxSize
341 }
342
343 private buildNewExpiration (expiresAfterMs: number) {
344 return new Date(Date.now() + expiresAfterMs)
345 }
346
347 private buildEntryLogId (object: MVideoRedundancyFileVideo | MVideoRedundancyStreamingPlaylistVideo) {
348 if (isMVideoRedundancyFileVideo(object)) return `${object.VideoFile.Video.url}-${object.VideoFile.resolution}`
349
350 return `${object.VideoStreamingPlaylist.getMasterPlaylistUrl(object.VideoStreamingPlaylist.Video)}`
351 }
352
353 private getTotalFileSizes (files: MVideoFile[], playlists: MStreamingPlaylistFiles[]): number {
354 const fileReducer = (previous: number, current: MVideoFile) => previous + current.size
355
356 let allFiles = files
357 for (const p of playlists) {
358 allFiles = allFiles.concat(p.VideoFiles)
359 }
360
361 return allFiles.reduce(fileReducer, 0)
362 }
363
364 private async loadAndRefreshVideo (videoUrl: string) {
365 // We need more attributes and check if the video still exists
366 const getVideoOptions = {
367 videoObject: videoUrl,
368 syncParam: { rates: false, shares: false, comments: false, refreshVideo: true },
369 fetchType: 'all' as 'all'
370 }
371 const { video } = await getOrCreateAPVideo(getVideoOptions)
372
373 return video
374 }
375}
diff --git a/server/lib/schedulers/youtube-dl-update-scheduler.ts b/server/lib/schedulers/youtube-dl-update-scheduler.ts
deleted file mode 100644
index 1ee4ae1b2..000000000
--- a/server/lib/schedulers/youtube-dl-update-scheduler.ts
+++ /dev/null
@@ -1,22 +0,0 @@
1import { YoutubeDLCLI } from '@server/helpers/youtube-dl'
2import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
3import { AbstractScheduler } from './abstract-scheduler'
4
5export class YoutubeDlUpdateScheduler extends AbstractScheduler {
6
7 private static instance: AbstractScheduler
8
9 protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.YOUTUBE_DL_UPDATE
10
11 private constructor () {
12 super()
13 }
14
15 protected internalExecute () {
16 return YoutubeDLCLI.updateYoutubeDLBinary()
17 }
18
19 static get Instance () {
20 return this.instance || (this.instance = new this())
21 }
22}
diff --git a/server/lib/search.ts b/server/lib/search.ts
deleted file mode 100644
index b3363fbec..000000000
--- a/server/lib/search.ts
+++ /dev/null
@@ -1,49 +0,0 @@
1import express from 'express'
2import { CONFIG } from '@server/initializers/config'
3import { AccountBlocklistModel } from '@server/models/account/account-blocklist'
4import { getServerActor } from '@server/models/application/application'
5import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
6import { SearchTargetQuery } from '@shared/models'
7
8function isSearchIndexSearch (query: SearchTargetQuery) {
9 if (query.searchTarget === 'search-index') return true
10
11 const searchIndexConfig = CONFIG.SEARCH.SEARCH_INDEX
12
13 if (searchIndexConfig.ENABLED !== true) return false
14
15 if (searchIndexConfig.IS_DEFAULT_SEARCH && !query.searchTarget) return true
16
17 return false
18}
19
20async function buildMutedForSearchIndex (res: express.Response) {
21 const serverActor = await getServerActor()
22 const accountIds = [ serverActor.Account.id ]
23
24 if (res.locals.oauth) {
25 accountIds.push(res.locals.oauth.token.User.Account.id)
26 }
27
28 const [ blockedHosts, blockedAccounts ] = await Promise.all([
29 ServerBlocklistModel.listHostsBlockedBy(accountIds),
30 AccountBlocklistModel.listHandlesBlockedBy(accountIds)
31 ])
32
33 return {
34 blockedHosts,
35 blockedAccounts
36 }
37}
38
39function isURISearch (search: string) {
40 if (!search) return false
41
42 return search.startsWith('http://') || search.startsWith('https://')
43}
44
45export {
46 isSearchIndexSearch,
47 buildMutedForSearchIndex,
48 isURISearch
49}
diff --git a/server/lib/server-config-manager.ts b/server/lib/server-config-manager.ts
deleted file mode 100644
index beb5d4d82..000000000
--- a/server/lib/server-config-manager.ts
+++ /dev/null
@@ -1,384 +0,0 @@
1import { getServerCommit } from '@server/helpers/version'
2import { CONFIG, isEmailEnabled } from '@server/initializers/config'
3import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION } from '@server/initializers/constants'
4import { isSignupAllowed, isSignupAllowedForCurrentIP } from '@server/lib/signup'
5import { ActorCustomPageModel } from '@server/models/account/actor-custom-page'
6import { PluginModel } from '@server/models/server/plugin'
7import { HTMLServerConfig, RegisteredExternalAuthConfig, RegisteredIdAndPassAuthConfig, ServerConfig } from '@shared/models'
8import { Hooks } from './plugins/hooks'
9import { PluginManager } from './plugins/plugin-manager'
10import { getThemeOrDefault } from './plugins/theme-utils'
11import { VideoTranscodingProfilesManager } from './transcoding/default-transcoding-profiles'
12
13/**
14 *
15 * Used to send the server config to clients (using REST/API or plugins API)
16 * We need a singleton class to manage config state depending on external events (to build menu entries etc)
17 *
18 */
19
20class ServerConfigManager {
21
22 private static instance: ServerConfigManager
23
24 private serverCommit: string
25
26 private homepageEnabled = false
27
28 private constructor () {}
29
30 async init () {
31 const instanceHomepage = await ActorCustomPageModel.loadInstanceHomepage()
32
33 this.updateHomepageState(instanceHomepage?.content)
34 }
35
36 updateHomepageState (content: string) {
37 this.homepageEnabled = !!content
38 }
39
40 async getHTMLServerConfig (): Promise<HTMLServerConfig> {
41 if (this.serverCommit === undefined) this.serverCommit = await getServerCommit()
42
43 const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
44
45 return {
46 client: {
47 videos: {
48 miniature: {
49 displayAuthorAvatar: CONFIG.CLIENT.VIDEOS.MINIATURE.DISPLAY_AUTHOR_AVATAR,
50 preferAuthorDisplayName: CONFIG.CLIENT.VIDEOS.MINIATURE.PREFER_AUTHOR_DISPLAY_NAME
51 },
52 resumableUpload: {
53 maxChunkSize: CONFIG.CLIENT.VIDEOS.RESUMABLE_UPLOAD.MAX_CHUNK_SIZE
54 }
55 },
56 menu: {
57 login: {
58 redirectOnSingleExternalAuth: CONFIG.CLIENT.MENU.LOGIN.REDIRECT_ON_SINGLE_EXTERNAL_AUTH
59 }
60 }
61 },
62
63 defaults: {
64 publish: {
65 downloadEnabled: CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED,
66 commentsEnabled: CONFIG.DEFAULTS.PUBLISH.COMMENTS_ENABLED,
67 privacy: CONFIG.DEFAULTS.PUBLISH.PRIVACY,
68 licence: CONFIG.DEFAULTS.PUBLISH.LICENCE
69 },
70 p2p: {
71 webapp: {
72 enabled: CONFIG.DEFAULTS.P2P.WEBAPP.ENABLED
73 },
74 embed: {
75 enabled: CONFIG.DEFAULTS.P2P.EMBED.ENABLED
76 }
77 }
78 },
79
80 webadmin: {
81 configuration: {
82 edition: {
83 allowed: CONFIG.WEBADMIN.CONFIGURATION.EDITION.ALLOWED
84 }
85 }
86 },
87
88 instance: {
89 name: CONFIG.INSTANCE.NAME,
90 shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
91 isNSFW: CONFIG.INSTANCE.IS_NSFW,
92 defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
93 defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE,
94 customizations: {
95 javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT,
96 css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
97 }
98 },
99 search: {
100 remoteUri: {
101 users: CONFIG.SEARCH.REMOTE_URI.USERS,
102 anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
103 },
104 searchIndex: {
105 enabled: CONFIG.SEARCH.SEARCH_INDEX.ENABLED,
106 url: CONFIG.SEARCH.SEARCH_INDEX.URL,
107 disableLocalSearch: CONFIG.SEARCH.SEARCH_INDEX.DISABLE_LOCAL_SEARCH,
108 isDefaultSearch: CONFIG.SEARCH.SEARCH_INDEX.IS_DEFAULT_SEARCH
109 }
110 },
111 plugin: {
112 registered: this.getRegisteredPlugins(),
113 registeredExternalAuths: this.getExternalAuthsPlugins(),
114 registeredIdAndPassAuths: this.getIdAndPassAuthPlugins()
115 },
116 theme: {
117 registered: this.getRegisteredThemes(),
118 default: defaultTheme
119 },
120 email: {
121 enabled: isEmailEnabled()
122 },
123 contactForm: {
124 enabled: CONFIG.CONTACT_FORM.ENABLED
125 },
126 serverVersion: PEERTUBE_VERSION,
127 serverCommit: this.serverCommit,
128 transcoding: {
129 remoteRunners: {
130 enabled: CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.REMOTE_RUNNERS.ENABLED
131 },
132 hls: {
133 enabled: CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.HLS.ENABLED
134 },
135 web_videos: {
136 enabled: CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED
137 },
138 enabledResolutions: this.getEnabledResolutions('vod'),
139 profile: CONFIG.TRANSCODING.PROFILE,
140 availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('vod')
141 },
142 live: {
143 enabled: CONFIG.LIVE.ENABLED,
144
145 allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
146 latencySetting: {
147 enabled: CONFIG.LIVE.LATENCY_SETTING.ENABLED
148 },
149
150 maxDuration: CONFIG.LIVE.MAX_DURATION,
151 maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES,
152 maxUserLives: CONFIG.LIVE.MAX_USER_LIVES,
153
154 transcoding: {
155 enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
156 remoteRunners: {
157 enabled: CONFIG.LIVE.TRANSCODING.ENABLED && CONFIG.LIVE.TRANSCODING.REMOTE_RUNNERS.ENABLED
158 },
159 enabledResolutions: this.getEnabledResolutions('live'),
160 profile: CONFIG.LIVE.TRANSCODING.PROFILE,
161 availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('live')
162 },
163
164 rtmp: {
165 port: CONFIG.LIVE.RTMP.PORT
166 }
167 },
168 videoStudio: {
169 enabled: CONFIG.VIDEO_STUDIO.ENABLED,
170 remoteRunners: {
171 enabled: CONFIG.VIDEO_STUDIO.REMOTE_RUNNERS.ENABLED
172 }
173 },
174 videoFile: {
175 update: {
176 enabled: CONFIG.VIDEO_FILE.UPDATE.ENABLED
177 }
178 },
179 import: {
180 videos: {
181 http: {
182 enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED
183 },
184 torrent: {
185 enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED
186 }
187 },
188 videoChannelSynchronization: {
189 enabled: CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.ENABLED
190 }
191 },
192 autoBlacklist: {
193 videos: {
194 ofUsers: {
195 enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED
196 }
197 }
198 },
199 avatar: {
200 file: {
201 size: {
202 max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
203 },
204 extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
205 }
206 },
207 banner: {
208 file: {
209 size: {
210 max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
211 },
212 extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
213 }
214 },
215 video: {
216 image: {
217 extensions: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME,
218 size: {
219 max: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max
220 }
221 },
222 file: {
223 extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME
224 }
225 },
226 videoCaption: {
227 file: {
228 size: {
229 max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
230 },
231 extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
232 }
233 },
234 user: {
235 videoQuota: CONFIG.USER.VIDEO_QUOTA,
236 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
237 },
238 videoChannels: {
239 maxPerUser: CONFIG.VIDEO_CHANNELS.MAX_PER_USER
240 },
241 trending: {
242 videos: {
243 intervalDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS,
244 algorithms: {
245 enabled: CONFIG.TRENDING.VIDEOS.ALGORITHMS.ENABLED,
246 default: CONFIG.TRENDING.VIDEOS.ALGORITHMS.DEFAULT
247 }
248 }
249 },
250 tracker: {
251 enabled: CONFIG.TRACKER.ENABLED
252 },
253
254 followings: {
255 instance: {
256 autoFollowIndex: {
257 indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL
258 }
259 }
260 },
261
262 broadcastMessage: {
263 enabled: CONFIG.BROADCAST_MESSAGE.ENABLED,
264 message: CONFIG.BROADCAST_MESSAGE.MESSAGE,
265 level: CONFIG.BROADCAST_MESSAGE.LEVEL,
266 dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE
267 },
268
269 homepage: {
270 enabled: this.homepageEnabled
271 }
272 }
273 }
274
275 async getServerConfig (ip?: string): Promise<ServerConfig> {
276 const { allowed } = await Hooks.wrapPromiseFun(
277 isSignupAllowed,
278
279 {
280 ip,
281 signupMode: CONFIG.SIGNUP.REQUIRES_APPROVAL
282 ? 'request-registration'
283 : 'direct-registration'
284 },
285
286 CONFIG.SIGNUP.REQUIRES_APPROVAL
287 ? 'filter:api.user.request-signup.allowed.result'
288 : 'filter:api.user.signup.allowed.result'
289 )
290
291 const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip)
292
293 const signup = {
294 allowed,
295 allowedForCurrentIP,
296 minimumAge: CONFIG.SIGNUP.MINIMUM_AGE,
297 requiresApproval: CONFIG.SIGNUP.REQUIRES_APPROVAL,
298 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
299 }
300
301 const htmlConfig = await this.getHTMLServerConfig()
302
303 return { ...htmlConfig, signup }
304 }
305
306 getRegisteredThemes () {
307 return PluginManager.Instance.getRegisteredThemes()
308 .map(t => ({
309 npmName: PluginModel.buildNpmName(t.name, t.type),
310 name: t.name,
311 version: t.version,
312 description: t.description,
313 css: t.css,
314 clientScripts: t.clientScripts
315 }))
316 }
317
318 getRegisteredPlugins () {
319 return PluginManager.Instance.getRegisteredPlugins()
320 .map(p => ({
321 npmName: PluginModel.buildNpmName(p.name, p.type),
322 name: p.name,
323 version: p.version,
324 description: p.description,
325 clientScripts: p.clientScripts
326 }))
327 }
328
329 getEnabledResolutions (type: 'vod' | 'live') {
330 const transcoding = type === 'vod'
331 ? CONFIG.TRANSCODING
332 : CONFIG.LIVE.TRANSCODING
333
334 return Object.keys(transcoding.RESOLUTIONS)
335 .filter(key => transcoding.ENABLED && transcoding.RESOLUTIONS[key] === true)
336 .map(r => parseInt(r, 10))
337 }
338
339 private getIdAndPassAuthPlugins () {
340 const result: RegisteredIdAndPassAuthConfig[] = []
341
342 for (const p of PluginManager.Instance.getIdAndPassAuths()) {
343 for (const auth of p.idAndPassAuths) {
344 result.push({
345 npmName: p.npmName,
346 name: p.name,
347 version: p.version,
348 authName: auth.authName,
349 weight: auth.getWeight()
350 })
351 }
352 }
353
354 return result
355 }
356
357 private getExternalAuthsPlugins () {
358 const result: RegisteredExternalAuthConfig[] = []
359
360 for (const p of PluginManager.Instance.getExternalAuths()) {
361 for (const auth of p.externalAuths) {
362 result.push({
363 npmName: p.npmName,
364 name: p.name,
365 version: p.version,
366 authName: auth.authName,
367 authDisplayName: auth.authDisplayName()
368 })
369 }
370 }
371
372 return result
373 }
374
375 static get Instance () {
376 return this.instance || (this.instance = new this())
377 }
378}
379
380// ---------------------------------------------------------------------------
381
382export {
383 ServerConfigManager
384}
diff --git a/server/lib/signup.ts b/server/lib/signup.ts
deleted file mode 100644
index 6702c22cb..000000000
--- a/server/lib/signup.ts
+++ /dev/null
@@ -1,75 +0,0 @@
1import { IPv4, IPv6, parse, subnetMatch } from 'ipaddr.js'
2import { CONFIG } from '../initializers/config'
3import { UserModel } from '../models/user/user'
4
5const isCidr = require('is-cidr')
6
7export type SignupMode = 'direct-registration' | 'request-registration'
8
9async function isSignupAllowed (options: {
10 signupMode: SignupMode
11
12 ip: string // For plugins
13 body?: any
14}): Promise<{ allowed: boolean, errorMessage?: string }> {
15 const { signupMode } = options
16
17 if (CONFIG.SIGNUP.ENABLED === false) {
18 return { allowed: false, errorMessage: 'User registration is not allowed' }
19 }
20
21 if (signupMode === 'direct-registration' && CONFIG.SIGNUP.REQUIRES_APPROVAL === true) {
22 return { allowed: false, errorMessage: 'User registration requires approval' }
23 }
24
25 // No limit and signup is enabled
26 if (CONFIG.SIGNUP.LIMIT === -1) {
27 return { allowed: true }
28 }
29
30 const totalUsers = await UserModel.countTotal()
31
32 return { allowed: totalUsers < CONFIG.SIGNUP.LIMIT, errorMessage: 'User limit is reached on this instance' }
33}
34
35function isSignupAllowedForCurrentIP (ip: string) {
36 if (!ip) return false
37
38 const addr = parse(ip)
39 const excludeList = [ 'blacklist' ]
40 let matched = ''
41
42 // if there is a valid, non-empty whitelist, we exclude all unknown addresses too
43 if (CONFIG.SIGNUP.FILTERS.CIDR.WHITELIST.filter(cidr => isCidr(cidr)).length > 0) {
44 excludeList.push('unknown')
45 }
46
47 if (addr.kind() === 'ipv4') {
48 const addrV4 = IPv4.parse(ip)
49 const rangeList = {
50 whitelist: CONFIG.SIGNUP.FILTERS.CIDR.WHITELIST.filter(cidr => isCidr.v4(cidr))
51 .map(cidr => IPv4.parseCIDR(cidr)),
52 blacklist: CONFIG.SIGNUP.FILTERS.CIDR.BLACKLIST.filter(cidr => isCidr.v4(cidr))
53 .map(cidr => IPv4.parseCIDR(cidr))
54 }
55 matched = subnetMatch(addrV4, rangeList, 'unknown')
56 } else if (addr.kind() === 'ipv6') {
57 const addrV6 = IPv6.parse(ip)
58 const rangeList = {
59 whitelist: CONFIG.SIGNUP.FILTERS.CIDR.WHITELIST.filter(cidr => isCidr.v6(cidr))
60 .map(cidr => IPv6.parseCIDR(cidr)),
61 blacklist: CONFIG.SIGNUP.FILTERS.CIDR.BLACKLIST.filter(cidr => isCidr.v6(cidr))
62 .map(cidr => IPv6.parseCIDR(cidr))
63 }
64 matched = subnetMatch(addrV6, rangeList, 'unknown')
65 }
66
67 return !excludeList.includes(matched)
68}
69
70// ---------------------------------------------------------------------------
71
72export {
73 isSignupAllowed,
74 isSignupAllowedForCurrentIP
75}
diff --git a/server/lib/stat-manager.ts b/server/lib/stat-manager.ts
deleted file mode 100644
index 0516e7f1a..000000000
--- a/server/lib/stat-manager.ts
+++ /dev/null
@@ -1,182 +0,0 @@
1import { mapSeries } from 'bluebird'
2import { CONFIG } from '@server/initializers/config'
3import { ActorFollowModel } from '@server/models/actor/actor-follow'
4import { VideoRedundancyModel } from '@server/models/redundancy/video-redundancy'
5import { UserModel } from '@server/models/user/user'
6import { VideoModel } from '@server/models/video/video'
7import { VideoChannelModel } from '@server/models/video/video-channel'
8import { VideoCommentModel } from '@server/models/video/video-comment'
9import { VideoFileModel } from '@server/models/video/video-file'
10import { VideoPlaylistModel } from '@server/models/video/video-playlist'
11import { ActivityType, ServerStats, VideoRedundancyStrategyWithManual } from '@shared/models'
12
13class StatsManager {
14
15 private static instance: StatsManager
16
17 private readonly instanceStartDate = new Date()
18
19 private readonly inboxMessages = {
20 processed: 0,
21 errors: 0,
22 successes: 0,
23 waiting: 0,
24 errorsPerType: this.buildAPPerType(),
25 successesPerType: this.buildAPPerType()
26 }
27
28 private constructor () {}
29
30 updateInboxWaiting (inboxMessagesWaiting: number) {
31 this.inboxMessages.waiting = inboxMessagesWaiting
32 }
33
34 addInboxProcessedSuccess (type: ActivityType) {
35 this.inboxMessages.processed++
36 this.inboxMessages.successes++
37 this.inboxMessages.successesPerType[type]++
38 }
39
40 addInboxProcessedError (type: ActivityType) {
41 this.inboxMessages.processed++
42 this.inboxMessages.errors++
43 this.inboxMessages.errorsPerType[type]++
44 }
45
46 async getStats () {
47 const { totalLocalVideos, totalLocalVideoViews, totalVideos } = await VideoModel.getStats()
48 const { totalLocalVideoComments, totalVideoComments } = await VideoCommentModel.getStats()
49 const { totalUsers, totalDailyActiveUsers, totalWeeklyActiveUsers, totalMonthlyActiveUsers } = await UserModel.getStats()
50 const { totalInstanceFollowers, totalInstanceFollowing } = await ActorFollowModel.getStats()
51 const { totalLocalVideoFilesSize } = await VideoFileModel.getStats()
52 const {
53 totalLocalVideoChannels,
54 totalLocalDailyActiveVideoChannels,
55 totalLocalWeeklyActiveVideoChannels,
56 totalLocalMonthlyActiveVideoChannels
57 } = await VideoChannelModel.getStats()
58 const { totalLocalPlaylists } = await VideoPlaylistModel.getStats()
59
60 const videosRedundancyStats = await this.buildRedundancyStats()
61
62 const data: ServerStats = {
63 totalUsers,
64 totalDailyActiveUsers,
65 totalWeeklyActiveUsers,
66 totalMonthlyActiveUsers,
67
68 totalLocalVideos,
69 totalLocalVideoViews,
70 totalLocalVideoComments,
71 totalLocalVideoFilesSize,
72
73 totalVideos,
74 totalVideoComments,
75
76 totalLocalVideoChannels,
77 totalLocalDailyActiveVideoChannels,
78 totalLocalWeeklyActiveVideoChannels,
79 totalLocalMonthlyActiveVideoChannels,
80
81 totalLocalPlaylists,
82
83 totalInstanceFollowers,
84 totalInstanceFollowing,
85
86 videosRedundancy: videosRedundancyStats,
87
88 ...this.buildAPStats()
89 }
90
91 return data
92 }
93
94 private buildActivityPubMessagesProcessedPerSecond () {
95 const now = new Date()
96 const startedSeconds = (now.getTime() - this.instanceStartDate.getTime()) / 1000
97
98 return this.inboxMessages.processed / startedSeconds
99 }
100
101 private buildRedundancyStats () {
102 const strategies = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES
103 .map(r => ({
104 strategy: r.strategy as VideoRedundancyStrategyWithManual,
105 size: r.size
106 }))
107
108 strategies.push({ strategy: 'manual', size: null })
109
110 return mapSeries(strategies, r => {
111 return VideoRedundancyModel.getStats(r.strategy)
112 .then(stats => Object.assign(stats, { strategy: r.strategy, totalSize: r.size }))
113 })
114 }
115
116 private buildAPPerType () {
117 return {
118 Create: 0,
119 Update: 0,
120 Delete: 0,
121 Follow: 0,
122 Accept: 0,
123 Reject: 0,
124 Announce: 0,
125 Undo: 0,
126 Like: 0,
127 Dislike: 0,
128 Flag: 0,
129 View: 0
130 }
131 }
132
133 private buildAPStats () {
134 return {
135 totalActivityPubMessagesProcessed: this.inboxMessages.processed,
136
137 totalActivityPubMessagesSuccesses: this.inboxMessages.successes,
138
139 // Dirty, but simpler and with type checking
140 totalActivityPubCreateMessagesSuccesses: this.inboxMessages.successesPerType.Create,
141 totalActivityPubUpdateMessagesSuccesses: this.inboxMessages.successesPerType.Update,
142 totalActivityPubDeleteMessagesSuccesses: this.inboxMessages.successesPerType.Delete,
143 totalActivityPubFollowMessagesSuccesses: this.inboxMessages.successesPerType.Follow,
144 totalActivityPubAcceptMessagesSuccesses: this.inboxMessages.successesPerType.Accept,
145 totalActivityPubRejectMessagesSuccesses: this.inboxMessages.successesPerType.Reject,
146 totalActivityPubAnnounceMessagesSuccesses: this.inboxMessages.successesPerType.Announce,
147 totalActivityPubUndoMessagesSuccesses: this.inboxMessages.successesPerType.Undo,
148 totalActivityPubLikeMessagesSuccesses: this.inboxMessages.successesPerType.Like,
149 totalActivityPubDislikeMessagesSuccesses: this.inboxMessages.successesPerType.Dislike,
150 totalActivityPubFlagMessagesSuccesses: this.inboxMessages.successesPerType.Flag,
151 totalActivityPubViewMessagesSuccesses: this.inboxMessages.successesPerType.View,
152
153 totalActivityPubCreateMessagesErrors: this.inboxMessages.errorsPerType.Create,
154 totalActivityPubUpdateMessagesErrors: this.inboxMessages.errorsPerType.Update,
155 totalActivityPubDeleteMessagesErrors: this.inboxMessages.errorsPerType.Delete,
156 totalActivityPubFollowMessagesErrors: this.inboxMessages.errorsPerType.Follow,
157 totalActivityPubAcceptMessagesErrors: this.inboxMessages.errorsPerType.Accept,
158 totalActivityPubRejectMessagesErrors: this.inboxMessages.errorsPerType.Reject,
159 totalActivityPubAnnounceMessagesErrors: this.inboxMessages.errorsPerType.Announce,
160 totalActivityPubUndoMessagesErrors: this.inboxMessages.errorsPerType.Undo,
161 totalActivityPubLikeMessagesErrors: this.inboxMessages.errorsPerType.Like,
162 totalActivityPubDislikeMessagesErrors: this.inboxMessages.errorsPerType.Dislike,
163 totalActivityPubFlagMessagesErrors: this.inboxMessages.errorsPerType.Flag,
164 totalActivityPubViewMessagesErrors: this.inboxMessages.errorsPerType.View,
165
166 totalActivityPubMessagesErrors: this.inboxMessages.errors,
167
168 activityPubMessagesProcessedPerSecond: this.buildActivityPubMessagesProcessedPerSecond(),
169 totalActivityPubMessagesWaiting: this.inboxMessages.waiting
170 }
171 }
172
173 static get Instance () {
174 return this.instance || (this.instance = new this())
175 }
176}
177
178// ---------------------------------------------------------------------------
179
180export {
181 StatsManager
182}
diff --git a/server/lib/sync-channel.ts b/server/lib/sync-channel.ts
deleted file mode 100644
index 3a805a943..000000000
--- a/server/lib/sync-channel.ts
+++ /dev/null
@@ -1,111 +0,0 @@
1import { logger } from '@server/helpers/logger'
2import { YoutubeDLWrapper } from '@server/helpers/youtube-dl'
3import { CONFIG } from '@server/initializers/config'
4import { buildYoutubeDLImport } from '@server/lib/video-pre-import'
5import { UserModel } from '@server/models/user/user'
6import { VideoImportModel } from '@server/models/video/video-import'
7import { MChannel, MChannelAccountDefault, MChannelSync } from '@server/types/models'
8import { VideoChannelSyncState, VideoPrivacy } from '@shared/models'
9import { CreateJobArgument, JobQueue } from './job-queue'
10import { ServerConfigManager } from './server-config-manager'
11
12export async function synchronizeChannel (options: {
13 channel: MChannelAccountDefault
14 externalChannelUrl: string
15 videosCountLimit: number
16 channelSync?: MChannelSync
17 onlyAfter?: Date
18}) {
19 const { channel, externalChannelUrl, videosCountLimit, onlyAfter, channelSync } = options
20
21 if (channelSync) {
22 channelSync.state = VideoChannelSyncState.PROCESSING
23 channelSync.lastSyncAt = new Date()
24 await channelSync.save()
25 }
26
27 try {
28 const user = await UserModel.loadByChannelActorId(channel.actorId)
29 const youtubeDL = new YoutubeDLWrapper(
30 externalChannelUrl,
31 ServerConfigManager.Instance.getEnabledResolutions('vod'),
32 CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION
33 )
34
35 const targetUrls = await youtubeDL.getInfoForListImport({ latestVideosCount: videosCountLimit })
36
37 logger.info(
38 'Fetched %d candidate URLs for sync channel %s.',
39 targetUrls.length, channel.Actor.preferredUsername, { targetUrls }
40 )
41
42 if (targetUrls.length === 0) {
43 if (channelSync) {
44 channelSync.state = VideoChannelSyncState.SYNCED
45 await channelSync.save()
46 }
47
48 return
49 }
50
51 const children: CreateJobArgument[] = []
52
53 for (const targetUrl of targetUrls) {
54 if (await skipImport(channel, targetUrl, onlyAfter)) continue
55
56 const { job } = await buildYoutubeDLImport({
57 user,
58 channel,
59 targetUrl,
60 channelSync,
61 importDataOverride: {
62 privacy: VideoPrivacy.PUBLIC
63 }
64 })
65
66 children.push(job)
67 }
68
69 // Will update the channel sync status
70 const parent: CreateJobArgument = {
71 type: 'after-video-channel-import',
72 payload: {
73 channelSyncId: channelSync?.id
74 }
75 }
76
77 await JobQueue.Instance.createJobWithChildren(parent, children)
78 } catch (err) {
79 logger.error(`Failed to import ${externalChannelUrl} in channel ${channel.name}`, { err })
80 channelSync.state = VideoChannelSyncState.FAILED
81 await channelSync.save()
82 }
83}
84
85// ---------------------------------------------------------------------------
86
87async function skipImport (channel: MChannel, targetUrl: string, onlyAfter?: Date) {
88 if (await VideoImportModel.urlAlreadyImported(channel.id, targetUrl)) {
89 logger.debug('%s is already imported for channel %s, skipping video channel synchronization.', targetUrl, channel.name)
90 return true
91 }
92
93 if (onlyAfter) {
94 const youtubeDL = new YoutubeDLWrapper(
95 targetUrl,
96 ServerConfigManager.Instance.getEnabledResolutions('vod'),
97 CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION
98 )
99
100 const videoInfo = await youtubeDL.getInfoForDownload()
101
102 const onlyAfterWithoutTime = new Date(onlyAfter)
103 onlyAfterWithoutTime.setHours(0, 0, 0, 0)
104
105 if (videoInfo.originallyPublishedAtWithoutTime.getTime() < onlyAfterWithoutTime.getTime()) {
106 return true
107 }
108 }
109
110 return false
111}
diff --git a/server/lib/thumbnail.ts b/server/lib/thumbnail.ts
deleted file mode 100644
index 0b98da14f..000000000
--- a/server/lib/thumbnail.ts
+++ /dev/null
@@ -1,327 +0,0 @@
1import { join } from 'path'
2import { ThumbnailType } from '@shared/models'
3import { generateImageFilename, generateImageFromVideoFile } from '../helpers/image-utils'
4import { CONFIG } from '../initializers/config'
5import { ASSETS_PATH, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants'
6import { ThumbnailModel } from '../models/video/thumbnail'
7import { MVideoFile, MVideoThumbnail, MVideoUUID, MVideoWithAllFiles } from '../types/models'
8import { MThumbnail } from '../types/models/video/thumbnail'
9import { MVideoPlaylistThumbnail } from '../types/models/video/video-playlist'
10import { VideoPathManager } from './video-path-manager'
11import { downloadImageFromWorker, processImageFromWorker } from './worker/parent-process'
12
13type ImageSize = { height?: number, width?: number }
14
15function updateLocalPlaylistMiniatureFromExisting (options: {
16 inputPath: string
17 playlist: MVideoPlaylistThumbnail
18 automaticallyGenerated: boolean
19 keepOriginal?: boolean // default to false
20 size?: ImageSize
21}) {
22 const { inputPath, playlist, automaticallyGenerated, keepOriginal = false, size } = options
23 const { filename, outputPath, height, width, existingThumbnail } = buildMetadataFromPlaylist(playlist, size)
24 const type = ThumbnailType.MINIATURE
25
26 const thumbnailCreator = () => {
27 return processImageFromWorker({ path: inputPath, destination: outputPath, newSize: { width, height }, keepOriginal })
28 }
29
30 return updateThumbnailFromFunction({
31 thumbnailCreator,
32 filename,
33 height,
34 width,
35 type,
36 automaticallyGenerated,
37 onDisk: true,
38 existingThumbnail
39 })
40}
41
42function updateRemotePlaylistMiniatureFromUrl (options: {
43 downloadUrl: string
44 playlist: MVideoPlaylistThumbnail
45 size?: ImageSize
46}) {
47 const { downloadUrl, playlist, size } = options
48 const { filename, basePath, height, width, existingThumbnail } = buildMetadataFromPlaylist(playlist, size)
49 const type = ThumbnailType.MINIATURE
50
51 // Only save the file URL if it is a remote playlist
52 const fileUrl = playlist.isOwned()
53 ? null
54 : downloadUrl
55
56 const thumbnailCreator = () => {
57 return downloadImageFromWorker({ url: downloadUrl, destDir: basePath, destName: filename, size: { width, height } })
58 }
59
60 return updateThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl, onDisk: true })
61}
62
63function updateLocalVideoMiniatureFromExisting (options: {
64 inputPath: string
65 video: MVideoThumbnail
66 type: ThumbnailType
67 automaticallyGenerated: boolean
68 size?: ImageSize
69 keepOriginal?: boolean // default to false
70}) {
71 const { inputPath, video, type, automaticallyGenerated, size, keepOriginal = false } = options
72
73 const { filename, outputPath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size)
74
75 const thumbnailCreator = () => {
76 return processImageFromWorker({ path: inputPath, destination: outputPath, newSize: { width, height }, keepOriginal })
77 }
78
79 return updateThumbnailFromFunction({
80 thumbnailCreator,
81 filename,
82 height,
83 width,
84 type,
85 automaticallyGenerated,
86 existingThumbnail,
87 onDisk: true
88 })
89}
90
91function generateLocalVideoMiniature (options: {
92 video: MVideoThumbnail
93 videoFile: MVideoFile
94 type: ThumbnailType
95}) {
96 const { video, videoFile, type } = options
97
98 return VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), input => {
99 const { filename, basePath, height, width, existingThumbnail, outputPath } = buildMetadataFromVideo(video, type)
100
101 const thumbnailCreator = videoFile.isAudio()
102 ? () => processImageFromWorker({
103 path: ASSETS_PATH.DEFAULT_AUDIO_BACKGROUND,
104 destination: outputPath,
105 newSize: { width, height },
106 keepOriginal: true
107 })
108 : () => generateImageFromVideoFile({
109 fromPath: input,
110 folder: basePath,
111 imageName: filename,
112 size: { height, width }
113 })
114
115 return updateThumbnailFromFunction({
116 thumbnailCreator,
117 filename,
118 height,
119 width,
120 type,
121 automaticallyGenerated: true,
122 onDisk: true,
123 existingThumbnail
124 })
125 })
126}
127
128// ---------------------------------------------------------------------------
129
130function updateLocalVideoMiniatureFromUrl (options: {
131 downloadUrl: string
132 video: MVideoThumbnail
133 type: ThumbnailType
134 size?: ImageSize
135}) {
136 const { downloadUrl, video, type, size } = options
137 const { filename: updatedFilename, basePath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size)
138
139 // Only save the file URL if it is a remote video
140 const fileUrl = video.isOwned()
141 ? null
142 : downloadUrl
143
144 const thumbnailUrlChanged = hasThumbnailUrlChanged(existingThumbnail, downloadUrl, video)
145
146 // Do not change the thumbnail filename if the file did not change
147 const filename = thumbnailUrlChanged
148 ? updatedFilename
149 : existingThumbnail.filename
150
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
179 thumbnail.height = height
180 thumbnail.width = width
181 thumbnail.type = type
182 thumbnail.fileUrl = fileUrl
183 thumbnail.onDisk = onDisk
184
185 return thumbnail
186}
187
188// ---------------------------------------------------------------------------
189
190async function regenerateMiniaturesIfNeeded (video: MVideoWithAllFiles) {
191 if (video.getMiniature().automaticallyGenerated === true) {
192 const miniature = await generateLocalVideoMiniature({
193 video,
194 videoFile: video.getMaxQualityFile(),
195 type: ThumbnailType.MINIATURE
196 })
197 await video.addAndSaveThumbnail(miniature)
198 }
199
200 if (video.getPreview().automaticallyGenerated === true) {
201 const preview = await generateLocalVideoMiniature({
202 video,
203 videoFile: video.getMaxQualityFile(),
204 type: ThumbnailType.PREVIEW
205 })
206 await video.addAndSaveThumbnail(preview)
207 }
208}
209
210// ---------------------------------------------------------------------------
211
212export {
213 generateLocalVideoMiniature,
214 regenerateMiniaturesIfNeeded,
215 updateLocalVideoMiniatureFromUrl,
216 updateLocalVideoMiniatureFromExisting,
217 updateRemoteVideoThumbnail,
218 updateRemotePlaylistMiniatureFromUrl,
219 updateLocalPlaylistMiniatureFromExisting
220}
221
222// ---------------------------------------------------------------------------
223// Private
224// ---------------------------------------------------------------------------
225
226function hasThumbnailUrlChanged (existingThumbnail: MThumbnail, downloadUrl: string, video: MVideoUUID) {
227 const existingUrl = existingThumbnail
228 ? existingThumbnail.fileUrl
229 : null
230
231 // If the thumbnail URL did not change and has a unique filename (introduced in 3.1), avoid thumbnail processing
232 return !existingUrl || existingUrl !== downloadUrl || downloadUrl.endsWith(`${video.uuid}.jpg`)
233}
234
235function buildMetadataFromPlaylist (playlist: MVideoPlaylistThumbnail, size: ImageSize) {
236 const filename = playlist.generateThumbnailName()
237 const basePath = CONFIG.STORAGE.THUMBNAILS_DIR
238
239 return {
240 filename,
241 basePath,
242 existingThumbnail: playlist.Thumbnail,
243 outputPath: join(basePath, filename),
244 height: size ? size.height : THUMBNAILS_SIZE.height,
245 width: size ? size.width : THUMBNAILS_SIZE.width
246 }
247}
248
249function buildMetadataFromVideo (video: MVideoThumbnail, type: ThumbnailType, size?: ImageSize) {
250 const existingThumbnail = Array.isArray(video.Thumbnails)
251 ? video.Thumbnails.find(t => t.type === type)
252 : undefined
253
254 if (type === ThumbnailType.MINIATURE) {
255 const filename = generateImageFilename()
256 const basePath = CONFIG.STORAGE.THUMBNAILS_DIR
257
258 return {
259 filename,
260 basePath,
261 existingThumbnail,
262 outputPath: join(basePath, filename),
263 height: size ? size.height : THUMBNAILS_SIZE.height,
264 width: size ? size.width : THUMBNAILS_SIZE.width
265 }
266 }
267
268 if (type === ThumbnailType.PREVIEW) {
269 const filename = generateImageFilename()
270 const basePath = CONFIG.STORAGE.PREVIEWS_DIR
271
272 return {
273 filename,
274 basePath,
275 existingThumbnail,
276 outputPath: join(basePath, filename),
277 height: size ? size.height : PREVIEWS_SIZE.height,
278 width: size ? size.width : PREVIEWS_SIZE.width
279 }
280 }
281
282 return undefined
283}
284
285async function updateThumbnailFromFunction (parameters: {
286 thumbnailCreator: () => Promise<any>
287 filename: string
288 height: number
289 width: number
290 type: ThumbnailType
291 onDisk: boolean
292 automaticallyGenerated?: boolean
293 fileUrl?: string
294 existingThumbnail?: MThumbnail
295}) {
296 const {
297 thumbnailCreator,
298 filename,
299 width,
300 height,
301 type,
302 existingThumbnail,
303 onDisk,
304 automaticallyGenerated = null,
305 fileUrl = null
306 } = parameters
307
308 const oldFilename = existingThumbnail && existingThumbnail.filename !== filename
309 ? existingThumbnail.filename
310 : undefined
311
312 const thumbnail: MThumbnail = existingThumbnail || new ThumbnailModel()
313
314 thumbnail.filename = filename
315 thumbnail.height = height
316 thumbnail.width = width
317 thumbnail.type = type
318 thumbnail.fileUrl = fileUrl
319 thumbnail.automaticallyGenerated = automaticallyGenerated
320 thumbnail.onDisk = onDisk
321
322 if (oldFilename) thumbnail.previousThumbnailFilename = oldFilename
323
324 await thumbnailCreator()
325
326 return thumbnail
327}
diff --git a/server/lib/timeserie.ts b/server/lib/timeserie.ts
deleted file mode 100644
index 08b12129a..000000000
--- a/server/lib/timeserie.ts
+++ /dev/null
@@ -1,61 +0,0 @@
1import { logger } from '@server/helpers/logger'
2
3function buildGroupByAndBoundaries (startDateString: string, endDateString: string) {
4 const startDate = new Date(startDateString)
5 const endDate = new Date(endDateString)
6
7 const groupInterval = buildGroupInterval(startDate, endDate)
8
9 logger.debug('Found "%s" group interval.', groupInterval, { startDate, endDate })
10
11 // Remove parts of the date we don't need
12 if (groupInterval.endsWith(' month') || groupInterval.endsWith(' months')) {
13 startDate.setDate(1)
14 startDate.setHours(0, 0, 0, 0)
15 } else if (groupInterval.endsWith(' day') || groupInterval.endsWith(' days')) {
16 startDate.setHours(0, 0, 0, 0)
17 } else if (groupInterval.endsWith(' hour') || groupInterval.endsWith(' hours')) {
18 startDate.setMinutes(0, 0, 0)
19 } else {
20 startDate.setSeconds(0, 0)
21 }
22
23 return {
24 groupInterval,
25 startDate,
26 endDate
27 }
28}
29
30// ---------------------------------------------------------------------------
31
32export {
33 buildGroupByAndBoundaries
34}
35
36// ---------------------------------------------------------------------------
37
38function buildGroupInterval (startDate: Date, endDate: Date): string {
39 const aYear = 31536000
40 const aMonth = 2678400
41 const aDay = 86400
42 const anHour = 3600
43 const aMinute = 60
44
45 const diffSeconds = (endDate.getTime() - startDate.getTime()) / 1000
46
47 if (diffSeconds >= 6 * aYear) return '6 months'
48 if (diffSeconds >= 2 * aYear) return '1 month'
49 if (diffSeconds >= 6 * aMonth) return '7 days'
50 if (diffSeconds >= 2 * aMonth) return '2 days'
51
52 if (diffSeconds >= 15 * aDay) return '1 day'
53 if (diffSeconds >= 8 * aDay) return '12 hours'
54 if (diffSeconds >= 4 * aDay) return '6 hours'
55
56 if (diffSeconds >= 15 * anHour) return '1 hour'
57
58 if (diffSeconds >= 180 * aMinute) return '10 minutes'
59
60 return '1 minute'
61}
diff --git a/server/lib/transcoding/create-transcoding-job.ts b/server/lib/transcoding/create-transcoding-job.ts
deleted file mode 100644
index d78e68b87..000000000
--- a/server/lib/transcoding/create-transcoding-job.ts
+++ /dev/null
@@ -1,37 +0,0 @@
1import { CONFIG } from '@server/initializers/config'
2import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models'
3import { TranscodingJobQueueBuilder, TranscodingRunnerJobBuilder } from './shared'
4
5export function createOptimizeOrMergeAudioJobs (options: {
6 video: MVideoFullLight
7 videoFile: MVideoFile
8 isNewVideo: boolean
9 user: MUserId
10 videoFileAlreadyLocked: boolean
11}) {
12 return getJobBuilder().createOptimizeOrMergeAudioJobs(options)
13}
14
15// ---------------------------------------------------------------------------
16
17export function createTranscodingJobs (options: {
18 transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7
19 video: MVideoFullLight
20 resolutions: number[]
21 isNewVideo: boolean
22 user: MUserId
23}) {
24 return getJobBuilder().createTranscodingJobs(options)
25}
26
27// ---------------------------------------------------------------------------
28// Private
29// ---------------------------------------------------------------------------
30
31function getJobBuilder () {
32 if (CONFIG.TRANSCODING.REMOTE_RUNNERS.ENABLED === true) {
33 return new TranscodingRunnerJobBuilder()
34 }
35
36 return new TranscodingJobQueueBuilder()
37}
diff --git a/server/lib/transcoding/default-transcoding-profiles.ts b/server/lib/transcoding/default-transcoding-profiles.ts
deleted file mode 100644
index 8f8fdd026..000000000
--- a/server/lib/transcoding/default-transcoding-profiles.ts
+++ /dev/null
@@ -1,143 +0,0 @@
1
2import { logger } from '@server/helpers/logger'
3import { FFmpegCommandWrapper, getDefaultAvailableEncoders } from '@shared/ffmpeg'
4import { AvailableEncoders, EncoderOptionsBuilder } from '@shared/models'
5
6// ---------------------------------------------------------------------------
7// Profile manager to get and change default profiles
8// ---------------------------------------------------------------------------
9
10class VideoTranscodingProfilesManager {
11 private static instance: VideoTranscodingProfilesManager
12
13 // 1 === less priority
14 private readonly encodersPriorities = {
15 vod: this.buildDefaultEncodersPriorities(),
16 live: this.buildDefaultEncodersPriorities()
17 }
18
19 private readonly availableEncoders = getDefaultAvailableEncoders()
20
21 private availableProfiles = {
22 vod: [] as string[],
23 live: [] as string[]
24 }
25
26 private constructor () {
27 this.buildAvailableProfiles()
28 }
29
30 getAvailableEncoders (): AvailableEncoders {
31 return {
32 available: this.availableEncoders,
33 encodersToTry: {
34 vod: {
35 video: this.getEncodersByPriority('vod', 'video'),
36 audio: this.getEncodersByPriority('vod', 'audio')
37 },
38 live: {
39 video: this.getEncodersByPriority('live', 'video'),
40 audio: this.getEncodersByPriority('live', 'audio')
41 }
42 }
43 }
44 }
45
46 getAvailableProfiles (type: 'vod' | 'live') {
47 return this.availableProfiles[type]
48 }
49
50 addProfile (options: {
51 type: 'vod' | 'live'
52 encoder: string
53 profile: string
54 builder: EncoderOptionsBuilder
55 }) {
56 const { type, encoder, profile, builder } = options
57
58 const encoders = this.availableEncoders[type]
59
60 if (!encoders[encoder]) encoders[encoder] = {}
61 encoders[encoder][profile] = builder
62
63 this.buildAvailableProfiles()
64 }
65
66 removeProfile (options: {
67 type: 'vod' | 'live'
68 encoder: string
69 profile: string
70 }) {
71 const { type, encoder, profile } = options
72
73 delete this.availableEncoders[type][encoder][profile]
74 this.buildAvailableProfiles()
75 }
76
77 addEncoderPriority (type: 'vod' | 'live', streamType: 'audio' | 'video', encoder: string, priority: number) {
78 this.encodersPriorities[type][streamType].push({ name: encoder, priority })
79
80 FFmpegCommandWrapper.resetSupportedEncoders()
81 }
82
83 removeEncoderPriority (type: 'vod' | 'live', streamType: 'audio' | 'video', encoder: string, priority: number) {
84 this.encodersPriorities[type][streamType] = this.encodersPriorities[type][streamType]
85 .filter(o => o.name !== encoder && o.priority !== priority)
86
87 FFmpegCommandWrapper.resetSupportedEncoders()
88 }
89
90 private getEncodersByPriority (type: 'vod' | 'live', streamType: 'audio' | 'video') {
91 return this.encodersPriorities[type][streamType]
92 .sort((e1, e2) => {
93 if (e1.priority > e2.priority) return -1
94 else if (e1.priority === e2.priority) return 0
95
96 return 1
97 })
98 .map(e => e.name)
99 }
100
101 private buildAvailableProfiles () {
102 for (const type of [ 'vod', 'live' ]) {
103 const result = new Set()
104
105 const encoders = this.availableEncoders[type]
106
107 for (const encoderName of Object.keys(encoders)) {
108 for (const profile of Object.keys(encoders[encoderName])) {
109 result.add(profile)
110 }
111 }
112
113 this.availableProfiles[type] = Array.from(result)
114 }
115
116 logger.debug('Available transcoding profiles built.', { availableProfiles: this.availableProfiles })
117 }
118
119 private buildDefaultEncodersPriorities () {
120 return {
121 video: [
122 { name: 'libx264', priority: 100 }
123 ],
124
125 // Try the first one, if not available try the second one etc
126 audio: [
127 // we favor VBR, if a good AAC encoder is available
128 { name: 'libfdk_aac', priority: 200 },
129 { name: 'aac', priority: 100 }
130 ]
131 }
132 }
133
134 static get Instance () {
135 return this.instance || (this.instance = new this())
136 }
137}
138
139// ---------------------------------------------------------------------------
140
141export {
142 VideoTranscodingProfilesManager
143}
diff --git a/server/lib/transcoding/ended-transcoding.ts b/server/lib/transcoding/ended-transcoding.ts
deleted file mode 100644
index d31674ede..000000000
--- a/server/lib/transcoding/ended-transcoding.ts
+++ /dev/null
@@ -1,18 +0,0 @@
1import { retryTransactionWrapper } from '@server/helpers/database-utils'
2import { VideoJobInfoModel } from '@server/models/video/video-job-info'
3import { MVideo } from '@server/types/models'
4import { moveToNextState } from '../video-state'
5
6export async function onTranscodingEnded (options: {
7 video: MVideo
8 isNewVideo: boolean
9 moveVideoToNextState: boolean
10}) {
11 const { video, isNewVideo, moveVideoToNextState } = options
12
13 await VideoJobInfoModel.decrease(video.uuid, 'pendingTranscode')
14
15 if (moveVideoToNextState) {
16 await retryTransactionWrapper(moveToNextState, { video, isNewVideo })
17 }
18}
diff --git a/server/lib/transcoding/hls-transcoding.ts b/server/lib/transcoding/hls-transcoding.ts
deleted file mode 100644
index 2c325d9ee..000000000
--- a/server/lib/transcoding/hls-transcoding.ts
+++ /dev/null
@@ -1,180 +0,0 @@
1import { MutexInterface } from 'async-mutex'
2import { Job } from 'bullmq'
3import { ensureDir, move, stat } from 'fs-extra'
4import { basename, extname as extnameUtil, join } from 'path'
5import { retryTransactionWrapper } from '@server/helpers/database-utils'
6import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
7import { sequelizeTypescript } from '@server/initializers/database'
8import { MVideo, MVideoFile } from '@server/types/models'
9import { pick } from '@shared/core-utils'
10import { getVideoStreamDuration, getVideoStreamFPS } from '@shared/ffmpeg'
11import { VideoResolution } from '@shared/models'
12import { CONFIG } from '../../initializers/config'
13import { VideoFileModel } from '../../models/video/video-file'
14import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
15import { updatePlaylistAfterFileChange } from '../hls'
16import { generateHLSVideoFilename, getHlsResolutionPlaylistFilename } from '../paths'
17import { buildFileMetadata } from '../video-file'
18import { VideoPathManager } from '../video-path-manager'
19import { buildFFmpegVOD } from './shared'
20
21// Concat TS segments from a live video to a fragmented mp4 HLS playlist
22export async function generateHlsPlaylistResolutionFromTS (options: {
23 video: MVideo
24 concatenatedTsFilePath: string
25 resolution: VideoResolution
26 fps: number
27 isAAC: boolean
28 inputFileMutexReleaser: MutexInterface.Releaser
29}) {
30 return generateHlsPlaylistCommon({
31 type: 'hls-from-ts' as 'hls-from-ts',
32 inputPath: options.concatenatedTsFilePath,
33
34 ...pick(options, [ 'video', 'resolution', 'fps', 'inputFileMutexReleaser', 'isAAC' ])
35 })
36}
37
38// Generate an HLS playlist from an input file, and update the master playlist
39export function generateHlsPlaylistResolution (options: {
40 video: MVideo
41 videoInputPath: string
42 resolution: VideoResolution
43 fps: number
44 copyCodecs: boolean
45 inputFileMutexReleaser: MutexInterface.Releaser
46 job?: Job
47}) {
48 return generateHlsPlaylistCommon({
49 type: 'hls' as 'hls',
50 inputPath: options.videoInputPath,
51
52 ...pick(options, [ 'video', 'resolution', 'fps', 'copyCodecs', 'inputFileMutexReleaser', 'job' ])
53 })
54}
55
56export async function onHLSVideoFileTranscoding (options: {
57 video: MVideo
58 videoFile: MVideoFile
59 videoOutputPath: string
60 m3u8OutputPath: string
61}) {
62 const { video, videoFile, videoOutputPath, m3u8OutputPath } = options
63
64 // Create or update the playlist
65 const playlist = await retryTransactionWrapper(() => {
66 return sequelizeTypescript.transaction(async transaction => {
67 return VideoStreamingPlaylistModel.loadOrGenerate(video, transaction)
68 })
69 })
70 videoFile.videoStreamingPlaylistId = playlist.id
71
72 const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
73
74 try {
75 await video.reload()
76
77 const videoFilePath = VideoPathManager.Instance.getFSVideoFileOutputPath(playlist, videoFile)
78 await ensureDir(VideoPathManager.Instance.getFSHLSOutputPath(video))
79
80 // Move playlist file
81 const resolutionPlaylistPath = VideoPathManager.Instance.getFSHLSOutputPath(video, basename(m3u8OutputPath))
82 await move(m3u8OutputPath, resolutionPlaylistPath, { overwrite: true })
83 // Move video file
84 await move(videoOutputPath, videoFilePath, { overwrite: true })
85
86 // Update video duration if it was not set (in case of a live for example)
87 if (!video.duration) {
88 video.duration = await getVideoStreamDuration(videoFilePath)
89 await video.save()
90 }
91
92 const stats = await stat(videoFilePath)
93
94 videoFile.size = stats.size
95 videoFile.fps = await getVideoStreamFPS(videoFilePath)
96 videoFile.metadata = await buildFileMetadata(videoFilePath)
97
98 await createTorrentAndSetInfoHash(playlist, videoFile)
99
100 const oldFile = await VideoFileModel.loadHLSFile({
101 playlistId: playlist.id,
102 fps: videoFile.fps,
103 resolution: videoFile.resolution
104 })
105
106 if (oldFile) {
107 await video.removeStreamingPlaylistVideoFile(playlist, oldFile)
108 await oldFile.destroy()
109 }
110
111 const savedVideoFile = await VideoFileModel.customUpsert(videoFile, 'streaming-playlist', undefined)
112
113 await updatePlaylistAfterFileChange(video, playlist)
114
115 return { resolutionPlaylistPath, videoFile: savedVideoFile }
116 } finally {
117 mutexReleaser()
118 }
119}
120
121// ---------------------------------------------------------------------------
122
123async function generateHlsPlaylistCommon (options: {
124 type: 'hls' | 'hls-from-ts'
125 video: MVideo
126 inputPath: string
127
128 resolution: VideoResolution
129 fps: number
130
131 inputFileMutexReleaser: MutexInterface.Releaser
132
133 copyCodecs?: boolean
134 isAAC?: boolean
135
136 job?: Job
137}) {
138 const { type, video, inputPath, resolution, fps, copyCodecs, isAAC, job, inputFileMutexReleaser } = options
139 const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
140
141 const videoTranscodedBasePath = join(transcodeDirectory, type)
142 await ensureDir(videoTranscodedBasePath)
143
144 const videoFilename = generateHLSVideoFilename(resolution)
145 const videoOutputPath = join(videoTranscodedBasePath, videoFilename)
146
147 const resolutionPlaylistFilename = getHlsResolutionPlaylistFilename(videoFilename)
148 const m3u8OutputPath = join(videoTranscodedBasePath, resolutionPlaylistFilename)
149
150 const transcodeOptions = {
151 type,
152
153 inputPath,
154 outputPath: m3u8OutputPath,
155
156 resolution,
157 fps,
158 copyCodecs,
159
160 isAAC,
161
162 inputFileMutexReleaser,
163
164 hlsPlaylist: {
165 videoFilename
166 }
167 }
168
169 await buildFFmpegVOD(job).transcode(transcodeOptions)
170
171 const newVideoFile = new VideoFileModel({
172 resolution,
173 extname: extnameUtil(videoFilename),
174 size: 0,
175 filename: videoFilename,
176 fps: -1
177 })
178
179 await onHLSVideoFileTranscoding({ video, videoFile: newVideoFile, videoOutputPath, m3u8OutputPath })
180}
diff --git a/server/lib/transcoding/shared/ffmpeg-builder.ts b/server/lib/transcoding/shared/ffmpeg-builder.ts
deleted file mode 100644
index 441445ec4..000000000
--- a/server/lib/transcoding/shared/ffmpeg-builder.ts
+++ /dev/null
@@ -1,18 +0,0 @@
1import { Job } from 'bullmq'
2import { getFFmpegCommandWrapperOptions } from '@server/helpers/ffmpeg'
3import { logger } from '@server/helpers/logger'
4import { FFmpegVOD } from '@shared/ffmpeg'
5import { VideoTranscodingProfilesManager } from '../default-transcoding-profiles'
6
7export function buildFFmpegVOD (job?: Job) {
8 return new FFmpegVOD({
9 ...getFFmpegCommandWrapperOptions('vod', VideoTranscodingProfilesManager.Instance.getAvailableEncoders()),
10
11 updateJobProgress: progress => {
12 if (!job) return
13
14 job.updateProgress(progress)
15 .catch(err => logger.error('Cannot update ffmpeg job progress', { err }))
16 }
17 })
18}
diff --git a/server/lib/transcoding/shared/index.ts b/server/lib/transcoding/shared/index.ts
deleted file mode 100644
index f0b45bcbb..000000000
--- a/server/lib/transcoding/shared/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
1export * from './job-builders'
2export * from './ffmpeg-builder'
diff --git a/server/lib/transcoding/shared/job-builders/abstract-job-builder.ts b/server/lib/transcoding/shared/job-builders/abstract-job-builder.ts
deleted file mode 100644
index 15fc814ae..000000000
--- a/server/lib/transcoding/shared/job-builders/abstract-job-builder.ts
+++ /dev/null
@@ -1,21 +0,0 @@
1
2import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models'
3
4export abstract class AbstractJobBuilder {
5
6 abstract createOptimizeOrMergeAudioJobs (options: {
7 video: MVideoFullLight
8 videoFile: MVideoFile
9 isNewVideo: boolean
10 user: MUserId
11 videoFileAlreadyLocked: boolean
12 }): Promise<any>
13
14 abstract createTranscodingJobs (options: {
15 transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7
16 video: MVideoFullLight
17 resolutions: number[]
18 isNewVideo: boolean
19 user: MUserId | null
20 }): Promise<any>
21}
diff --git a/server/lib/transcoding/shared/job-builders/index.ts b/server/lib/transcoding/shared/job-builders/index.ts
deleted file mode 100644
index 9b1c82adf..000000000
--- a/server/lib/transcoding/shared/job-builders/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
1export * from './transcoding-job-queue-builder'
2export * from './transcoding-runner-job-builder'
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
deleted file mode 100644
index 0505c2b2f..000000000
--- a/server/lib/transcoding/shared/job-builders/transcoding-job-queue-builder.ts
+++ /dev/null
@@ -1,322 +0,0 @@
1import Bluebird from 'bluebird'
2import { computeOutputFPS } from '@server/helpers/ffmpeg'
3import { logger } from '@server/helpers/logger'
4import { CONFIG } from '@server/initializers/config'
5import { DEFAULT_AUDIO_RESOLUTION, VIDEO_TRANSCODING_FPS } from '@server/initializers/constants'
6import { CreateJobArgument, JobQueue } from '@server/lib/job-queue'
7import { Hooks } from '@server/lib/plugins/hooks'
8import { VideoPathManager } from '@server/lib/video-path-manager'
9import { VideoJobInfoModel } from '@server/models/video/video-job-info'
10import { MUserId, MVideoFile, MVideoFullLight, MVideoWithFileThumbnail } from '@server/types/models'
11import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream, isAudioFile } from '@shared/ffmpeg'
12import {
13 HLSTranscodingPayload,
14 MergeAudioTranscodingPayload,
15 NewWebVideoResolutionTranscodingPayload,
16 OptimizeTranscodingPayload,
17 VideoTranscodingPayload
18} from '@shared/models'
19import { getTranscodingJobPriority } from '../../transcoding-priority'
20import { canDoQuickTranscode } from '../../transcoding-quick-transcode'
21import { buildOriginalFileResolution, computeResolutionsToTranscode } from '../../transcoding-resolutions'
22import { AbstractJobBuilder } from './abstract-job-builder'
23
24export class TranscodingJobQueueBuilder extends AbstractJobBuilder {
25
26 async createOptimizeOrMergeAudioJobs (options: {
27 video: MVideoFullLight
28 videoFile: MVideoFile
29 isNewVideo: boolean
30 user: MUserId
31 videoFileAlreadyLocked: boolean
32 }) {
33 const { video, videoFile, isNewVideo, user, videoFileAlreadyLocked } = options
34
35 let mergeOrOptimizePayload: MergeAudioTranscodingPayload | OptimizeTranscodingPayload
36 let nextTranscodingSequentialJobPayloads: (NewWebVideoResolutionTranscodingPayload | HLSTranscodingPayload)[][] = []
37
38 const mutexReleaser = videoFileAlreadyLocked
39 ? () => {}
40 : await VideoPathManager.Instance.lockFiles(video.uuid)
41
42 try {
43 await video.reload()
44 await videoFile.reload()
45
46 await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), async videoFilePath => {
47 const probe = await ffprobePromise(videoFilePath)
48
49 const { resolution } = await getVideoStreamDimensionsInfo(videoFilePath, probe)
50 const hasAudio = await hasAudioStream(videoFilePath, probe)
51 const quickTranscode = await canDoQuickTranscode(videoFilePath, probe)
52 const inputFPS = videoFile.isAudio()
53 ? VIDEO_TRANSCODING_FPS.AUDIO_MERGE // The first transcoding job will transcode to this FPS value
54 : await getVideoStreamFPS(videoFilePath, probe)
55
56 const maxResolution = await isAudioFile(videoFilePath, probe)
57 ? DEFAULT_AUDIO_RESOLUTION
58 : buildOriginalFileResolution(resolution)
59
60 if (CONFIG.TRANSCODING.HLS.ENABLED === true) {
61 nextTranscodingSequentialJobPayloads.push([
62 this.buildHLSJobPayload({
63 deleteWebVideoFiles: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED === false,
64
65 // We had some issues with a web video quick transcoded while producing a HLS version of it
66 copyCodecs: !quickTranscode,
67
68 resolution: maxResolution,
69 fps: computeOutputFPS({ inputFPS, resolution: maxResolution }),
70 videoUUID: video.uuid,
71 isNewVideo
72 })
73 ])
74 }
75
76 const lowerResolutionJobPayloads = await this.buildLowerResolutionJobPayloads({
77 video,
78 inputVideoResolution: maxResolution,
79 inputVideoFPS: inputFPS,
80 hasAudio,
81 isNewVideo
82 })
83
84 nextTranscodingSequentialJobPayloads = [ ...nextTranscodingSequentialJobPayloads, ...lowerResolutionJobPayloads ]
85
86 const hasChildren = nextTranscodingSequentialJobPayloads.length !== 0
87 mergeOrOptimizePayload = videoFile.isAudio()
88 ? this.buildMergeAudioPayload({ videoUUID: video.uuid, isNewVideo, hasChildren })
89 : this.buildOptimizePayload({ videoUUID: video.uuid, isNewVideo, quickTranscode, hasChildren })
90 })
91 } finally {
92 mutexReleaser()
93 }
94
95 const nextTranscodingSequentialJobs = await Bluebird.mapSeries(nextTranscodingSequentialJobPayloads, payloads => {
96 return Bluebird.mapSeries(payloads, payload => {
97 return this.buildTranscodingJob({ payload, user })
98 })
99 })
100
101 const transcodingJobBuilderJob: CreateJobArgument = {
102 type: 'transcoding-job-builder',
103 payload: {
104 videoUUID: video.uuid,
105 sequentialJobs: nextTranscodingSequentialJobs
106 }
107 }
108
109 const mergeOrOptimizeJob = await this.buildTranscodingJob({ payload: mergeOrOptimizePayload, user })
110
111 await JobQueue.Instance.createSequentialJobFlow(...[ mergeOrOptimizeJob, transcodingJobBuilderJob ])
112
113 await VideoJobInfoModel.increaseOrCreate(video.uuid, 'pendingTranscode')
114 }
115
116 // ---------------------------------------------------------------------------
117
118 async createTranscodingJobs (options: {
119 transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7
120 video: MVideoFullLight
121 resolutions: number[]
122 isNewVideo: boolean
123 user: MUserId | null
124 }) {
125 const { video, transcodingType, resolutions, isNewVideo } = options
126
127 const maxResolution = Math.max(...resolutions)
128 const childrenResolutions = resolutions.filter(r => r !== maxResolution)
129
130 logger.info('Manually creating transcoding jobs for %s.', transcodingType, { childrenResolutions, maxResolution })
131
132 const { fps: inputFPS } = await video.probeMaxQualityFile()
133
134 const children = childrenResolutions.map(resolution => {
135 const fps = computeOutputFPS({ inputFPS, resolution })
136
137 if (transcodingType === 'hls') {
138 return this.buildHLSJobPayload({ videoUUID: video.uuid, resolution, fps, isNewVideo })
139 }
140
141 if (transcodingType === 'webtorrent' || transcodingType === 'web-video') {
142 return this.buildWebVideoJobPayload({ videoUUID: video.uuid, resolution, fps, isNewVideo })
143 }
144
145 throw new Error('Unknown transcoding type')
146 })
147
148 const fps = computeOutputFPS({ inputFPS, resolution: maxResolution })
149
150 const parent = transcodingType === 'hls'
151 ? this.buildHLSJobPayload({ videoUUID: video.uuid, resolution: maxResolution, fps, isNewVideo })
152 : this.buildWebVideoJobPayload({ videoUUID: video.uuid, resolution: maxResolution, fps, isNewVideo })
153
154 // Process the last resolution after the other ones to prevent concurrency issue
155 // Because low resolutions use the biggest one as ffmpeg input
156 await this.createTranscodingJobsWithChildren({ videoUUID: video.uuid, parent, children, user: null })
157 }
158
159 // ---------------------------------------------------------------------------
160
161 private async createTranscodingJobsWithChildren (options: {
162 videoUUID: string
163 parent: (HLSTranscodingPayload | NewWebVideoResolutionTranscodingPayload)
164 children: (HLSTranscodingPayload | NewWebVideoResolutionTranscodingPayload)[]
165 user: MUserId | null
166 }) {
167 const { videoUUID, parent, children, user } = options
168
169 const parentJob = await this.buildTranscodingJob({ payload: parent, user })
170 const childrenJobs = await Bluebird.mapSeries(children, c => this.buildTranscodingJob({ payload: c, user }))
171
172 await JobQueue.Instance.createJobWithChildren(parentJob, childrenJobs)
173
174 await VideoJobInfoModel.increaseOrCreate(videoUUID, 'pendingTranscode', 1 + children.length)
175 }
176
177 private async buildTranscodingJob (options: {
178 payload: VideoTranscodingPayload
179 user: MUserId | null // null means we don't want priority
180 }) {
181 const { user, payload } = options
182
183 return {
184 type: 'video-transcoding' as 'video-transcoding',
185 priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: undefined }),
186 payload
187 }
188 }
189
190 private async buildLowerResolutionJobPayloads (options: {
191 video: MVideoWithFileThumbnail
192 inputVideoResolution: number
193 inputVideoFPS: number
194 hasAudio: boolean
195 isNewVideo: boolean
196 }) {
197 const { video, inputVideoResolution, inputVideoFPS, isNewVideo, hasAudio } = options
198
199 // Create transcoding jobs if there are enabled resolutions
200 const resolutionsEnabled = await Hooks.wrapObject(
201 computeResolutionsToTranscode({ input: inputVideoResolution, type: 'vod', includeInput: false, strictLower: true, hasAudio }),
202 'filter:transcoding.auto.resolutions-to-transcode.result',
203 options
204 )
205
206 const sequentialPayloads: (NewWebVideoResolutionTranscodingPayload | HLSTranscodingPayload)[][] = []
207
208 for (const resolution of resolutionsEnabled) {
209 const fps = computeOutputFPS({ inputFPS: inputVideoFPS, resolution })
210
211 if (CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED) {
212 const payloads: (NewWebVideoResolutionTranscodingPayload | HLSTranscodingPayload)[] = [
213 this.buildWebVideoJobPayload({
214 videoUUID: video.uuid,
215 resolution,
216 fps,
217 isNewVideo
218 })
219 ]
220
221 // Create a subsequent job to create HLS resolution that will just copy web video codecs
222 if (CONFIG.TRANSCODING.HLS.ENABLED) {
223 payloads.push(
224 this.buildHLSJobPayload({
225 videoUUID: video.uuid,
226 resolution,
227 fps,
228 isNewVideo,
229 copyCodecs: true
230 })
231 )
232 }
233
234 sequentialPayloads.push(payloads)
235 } else if (CONFIG.TRANSCODING.HLS.ENABLED) {
236 sequentialPayloads.push([
237 this.buildHLSJobPayload({
238 videoUUID: video.uuid,
239 resolution,
240 fps,
241 copyCodecs: false,
242 isNewVideo
243 })
244 ])
245 }
246 }
247
248 return sequentialPayloads
249 }
250
251 private buildHLSJobPayload (options: {
252 videoUUID: string
253 resolution: number
254 fps: number
255 isNewVideo: boolean
256 deleteWebVideoFiles?: boolean // default false
257 copyCodecs?: boolean // default false
258 }): HLSTranscodingPayload {
259 const { videoUUID, resolution, fps, isNewVideo, deleteWebVideoFiles = false, copyCodecs = false } = options
260
261 return {
262 type: 'new-resolution-to-hls',
263 videoUUID,
264 resolution,
265 fps,
266 copyCodecs,
267 isNewVideo,
268 deleteWebVideoFiles
269 }
270 }
271
272 private buildWebVideoJobPayload (options: {
273 videoUUID: string
274 resolution: number
275 fps: number
276 isNewVideo: boolean
277 }): NewWebVideoResolutionTranscodingPayload {
278 const { videoUUID, resolution, fps, isNewVideo } = options
279
280 return {
281 type: 'new-resolution-to-web-video',
282 videoUUID,
283 isNewVideo,
284 resolution,
285 fps
286 }
287 }
288
289 private buildMergeAudioPayload (options: {
290 videoUUID: string
291 isNewVideo: boolean
292 hasChildren: boolean
293 }): MergeAudioTranscodingPayload {
294 const { videoUUID, isNewVideo, hasChildren } = options
295
296 return {
297 type: 'merge-audio-to-web-video',
298 resolution: DEFAULT_AUDIO_RESOLUTION,
299 fps: VIDEO_TRANSCODING_FPS.AUDIO_MERGE,
300 videoUUID,
301 isNewVideo,
302 hasChildren
303 }
304 }
305
306 private buildOptimizePayload (options: {
307 videoUUID: string
308 quickTranscode: boolean
309 isNewVideo: boolean
310 hasChildren: boolean
311 }): OptimizeTranscodingPayload {
312 const { videoUUID, quickTranscode, isNewVideo, hasChildren } = options
313
314 return {
315 type: 'optimize-to-web-video',
316 videoUUID,
317 isNewVideo,
318 hasChildren,
319 quickTranscode
320 }
321 }
322}
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
deleted file mode 100644
index f0671bd7a..000000000
--- a/server/lib/transcoding/shared/job-builders/transcoding-runner-job-builder.ts
+++ /dev/null
@@ -1,196 +0,0 @@
1import { computeOutputFPS } from '@server/helpers/ffmpeg'
2import { logger, loggerTagsFactory } from '@server/helpers/logger'
3import { CONFIG } from '@server/initializers/config'
4import { DEFAULT_AUDIO_RESOLUTION, VIDEO_TRANSCODING_FPS } from '@server/initializers/constants'
5import { Hooks } from '@server/lib/plugins/hooks'
6import { VODAudioMergeTranscodingJobHandler, VODHLSTranscodingJobHandler, VODWebVideoTranscodingJobHandler } from '@server/lib/runners'
7import { VideoPathManager } from '@server/lib/video-path-manager'
8import { MUserId, MVideoFile, MVideoFullLight, MVideoWithFileThumbnail } from '@server/types/models'
9import { MRunnerJob } from '@server/types/models/runners'
10import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream, isAudioFile } from '@shared/ffmpeg'
11import { getTranscodingJobPriority } from '../../transcoding-priority'
12import { computeResolutionsToTranscode } from '../../transcoding-resolutions'
13import { AbstractJobBuilder } from './abstract-job-builder'
14
15/**
16 *
17 * Class to build transcoding job in the local job queue
18 *
19 */
20
21const lTags = loggerTagsFactory('transcoding')
22
23export class TranscodingRunnerJobBuilder extends AbstractJobBuilder {
24
25 async createOptimizeOrMergeAudioJobs (options: {
26 video: MVideoFullLight
27 videoFile: MVideoFile
28 isNewVideo: boolean
29 user: MUserId
30 videoFileAlreadyLocked: boolean
31 }) {
32 const { video, videoFile, isNewVideo, user, videoFileAlreadyLocked } = options
33
34 const mutexReleaser = videoFileAlreadyLocked
35 ? () => {}
36 : await VideoPathManager.Instance.lockFiles(video.uuid)
37
38 try {
39 await video.reload()
40 await videoFile.reload()
41
42 await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), async videoFilePath => {
43 const probe = await ffprobePromise(videoFilePath)
44
45 const { resolution } = await getVideoStreamDimensionsInfo(videoFilePath, probe)
46 const hasAudio = await hasAudioStream(videoFilePath, probe)
47 const inputFPS = videoFile.isAudio()
48 ? VIDEO_TRANSCODING_FPS.AUDIO_MERGE // The first transcoding job will transcode to this FPS value
49 : await getVideoStreamFPS(videoFilePath, probe)
50
51 const maxResolution = await isAudioFile(videoFilePath, probe)
52 ? DEFAULT_AUDIO_RESOLUTION
53 : resolution
54
55 const fps = computeOutputFPS({ inputFPS, resolution: maxResolution })
56 const priority = await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
57
58 const mainRunnerJob = videoFile.isAudio()
59 ? await new VODAudioMergeTranscodingJobHandler().create({ video, resolution: maxResolution, fps, isNewVideo, priority })
60 : await new VODWebVideoTranscodingJobHandler().create({ video, resolution: maxResolution, fps, isNewVideo, priority })
61
62 if (CONFIG.TRANSCODING.HLS.ENABLED === true) {
63 await new VODHLSTranscodingJobHandler().create({
64 video,
65 deleteWebVideoFiles: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED === false,
66 resolution: maxResolution,
67 fps,
68 isNewVideo,
69 dependsOnRunnerJob: mainRunnerJob,
70 priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
71 })
72 }
73
74 await this.buildLowerResolutionJobPayloads({
75 video,
76 inputVideoResolution: maxResolution,
77 inputVideoFPS: inputFPS,
78 hasAudio,
79 isNewVideo,
80 mainRunnerJob,
81 user
82 })
83 })
84 } finally {
85 mutexReleaser()
86 }
87 }
88
89 // ---------------------------------------------------------------------------
90
91 async createTranscodingJobs (options: {
92 transcodingType: 'hls' | 'webtorrent' | 'web-video' // TODO: remove webtorrent in v7
93 video: MVideoFullLight
94 resolutions: number[]
95 isNewVideo: boolean
96 user: MUserId | null
97 }) {
98 const { video, transcodingType, resolutions, isNewVideo, user } = options
99
100 const maxResolution = Math.max(...resolutions)
101 const { fps: inputFPS } = await video.probeMaxQualityFile()
102 const maxFPS = computeOutputFPS({ inputFPS, resolution: maxResolution })
103 const priority = await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
104
105 const childrenResolutions = resolutions.filter(r => r !== maxResolution)
106
107 logger.info('Manually creating transcoding jobs for %s.', transcodingType, { childrenResolutions, maxResolution })
108
109 // Process the last resolution before the other ones to prevent concurrency issue
110 // Because low resolutions use the biggest one as ffmpeg input
111 const mainJob = transcodingType === 'hls'
112 // eslint-disable-next-line max-len
113 ? await new VODHLSTranscodingJobHandler().create({ video, resolution: maxResolution, fps: maxFPS, isNewVideo, deleteWebVideoFiles: false, priority })
114 : await new VODWebVideoTranscodingJobHandler().create({ video, resolution: maxResolution, fps: maxFPS, isNewVideo, priority })
115
116 for (const resolution of childrenResolutions) {
117 const dependsOnRunnerJob = mainJob
118 const fps = computeOutputFPS({ inputFPS, resolution })
119
120 if (transcodingType === 'hls') {
121 await new VODHLSTranscodingJobHandler().create({
122 video,
123 resolution,
124 fps,
125 isNewVideo,
126 deleteWebVideoFiles: false,
127 dependsOnRunnerJob,
128 priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
129 })
130 continue
131 }
132
133 if (transcodingType === 'webtorrent' || transcodingType === 'web-video') {
134 await new VODWebVideoTranscodingJobHandler().create({
135 video,
136 resolution,
137 fps,
138 isNewVideo,
139 dependsOnRunnerJob,
140 priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
141 })
142 continue
143 }
144
145 throw new Error('Unknown transcoding type')
146 }
147 }
148
149 private async buildLowerResolutionJobPayloads (options: {
150 mainRunnerJob: MRunnerJob
151 video: MVideoWithFileThumbnail
152 inputVideoResolution: number
153 inputVideoFPS: number
154 hasAudio: boolean
155 isNewVideo: boolean
156 user: MUserId
157 }) {
158 const { video, inputVideoResolution, inputVideoFPS, isNewVideo, hasAudio, mainRunnerJob, user } = options
159
160 // Create transcoding jobs if there are enabled resolutions
161 const resolutionsEnabled = await Hooks.wrapObject(
162 computeResolutionsToTranscode({ input: inputVideoResolution, type: 'vod', includeInput: false, strictLower: true, hasAudio }),
163 'filter:transcoding.auto.resolutions-to-transcode.result',
164 options
165 )
166
167 logger.debug('Lower resolutions build for %s.', video.uuid, { resolutionsEnabled, ...lTags(video.uuid) })
168
169 for (const resolution of resolutionsEnabled) {
170 const fps = computeOutputFPS({ inputFPS: inputVideoFPS, resolution })
171
172 if (CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED) {
173 await new VODWebVideoTranscodingJobHandler().create({
174 video,
175 resolution,
176 fps,
177 isNewVideo,
178 dependsOnRunnerJob: mainRunnerJob,
179 priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
180 })
181 }
182
183 if (CONFIG.TRANSCODING.HLS.ENABLED) {
184 await new VODHLSTranscodingJobHandler().create({
185 video,
186 resolution,
187 fps,
188 isNewVideo,
189 deleteWebVideoFiles: false,
190 dependsOnRunnerJob: mainRunnerJob,
191 priority: await getTranscodingJobPriority({ user, type: 'vod', fallback: 0 })
192 })
193 }
194 }
195 }
196}
diff --git a/server/lib/transcoding/transcoding-priority.ts b/server/lib/transcoding/transcoding-priority.ts
deleted file mode 100644
index 82ab6f2f1..000000000
--- a/server/lib/transcoding/transcoding-priority.ts
+++ /dev/null
@@ -1,24 +0,0 @@
1import { JOB_PRIORITY } from '@server/initializers/constants'
2import { VideoModel } from '@server/models/video/video'
3import { MUserId } from '@server/types/models'
4
5export async function getTranscodingJobPriority (options: {
6 user: MUserId
7 fallback: number
8 type: 'vod' | 'studio'
9}) {
10 const { user, fallback, type } = options
11
12 if (!user) return fallback
13
14 const now = new Date()
15 const lastWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
16
17 const videoUploadedByUser = await VideoModel.countVideosUploadedByUserSince(user.id, lastWeek)
18
19 const base = type === 'vod'
20 ? JOB_PRIORITY.TRANSCODING
21 : JOB_PRIORITY.VIDEO_STUDIO
22
23 return base + videoUploadedByUser
24}
diff --git a/server/lib/transcoding/transcoding-quick-transcode.ts b/server/lib/transcoding/transcoding-quick-transcode.ts
deleted file mode 100644
index 53f12cd06..000000000
--- a/server/lib/transcoding/transcoding-quick-transcode.ts
+++ /dev/null
@@ -1,12 +0,0 @@
1import { FfprobeData } from 'fluent-ffmpeg'
2import { CONFIG } from '@server/initializers/config'
3import { canDoQuickAudioTranscode, canDoQuickVideoTranscode, ffprobePromise } from '@shared/ffmpeg'
4
5export async function canDoQuickTranscode (path: string, existingProbe?: FfprobeData): Promise<boolean> {
6 if (CONFIG.TRANSCODING.PROFILE !== 'default') return false
7
8 const probe = existingProbe || await ffprobePromise(path)
9
10 return await canDoQuickVideoTranscode(path, probe) &&
11 await canDoQuickAudioTranscode(path, probe)
12}
diff --git a/server/lib/transcoding/transcoding-resolutions.ts b/server/lib/transcoding/transcoding-resolutions.ts
deleted file mode 100644
index 9a6bf5722..000000000
--- a/server/lib/transcoding/transcoding-resolutions.ts
+++ /dev/null
@@ -1,73 +0,0 @@
1import { CONFIG } from '@server/initializers/config'
2import { toEven } from '@shared/core-utils'
3import { VideoResolution } from '@shared/models'
4
5export function buildOriginalFileResolution (inputResolution: number) {
6 if (CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION === true) {
7 return toEven(inputResolution)
8 }
9
10 const resolutions = computeResolutionsToTranscode({
11 input: inputResolution,
12 type: 'vod',
13 includeInput: false,
14 strictLower: false,
15 // We don't really care about the audio resolution in this context
16 hasAudio: true
17 })
18
19 if (resolutions.length === 0) {
20 return toEven(inputResolution)
21 }
22
23 return Math.max(...resolutions)
24}
25
26export function computeResolutionsToTranscode (options: {
27 input: number
28 type: 'vod' | 'live'
29 includeInput: boolean
30 strictLower: boolean
31 hasAudio: boolean
32}) {
33 const { input, type, includeInput, strictLower, hasAudio } = options
34
35 const configResolutions = type === 'vod'
36 ? CONFIG.TRANSCODING.RESOLUTIONS
37 : CONFIG.LIVE.TRANSCODING.RESOLUTIONS
38
39 const resolutionsEnabled = new Set<number>()
40
41 // Put in the order we want to proceed jobs
42 const availableResolutions: VideoResolution[] = [
43 VideoResolution.H_NOVIDEO,
44 VideoResolution.H_480P,
45 VideoResolution.H_360P,
46 VideoResolution.H_720P,
47 VideoResolution.H_240P,
48 VideoResolution.H_144P,
49 VideoResolution.H_1080P,
50 VideoResolution.H_1440P,
51 VideoResolution.H_4K
52 ]
53
54 for (const resolution of availableResolutions) {
55 // Resolution not enabled
56 if (configResolutions[resolution + 'p'] !== true) continue
57 // Too big resolution for input file
58 if (input < resolution) continue
59 // We only want lower resolutions than input file
60 if (strictLower && input === resolution) continue
61 // Audio resolutio but no audio in the video
62 if (resolution === VideoResolution.H_NOVIDEO && !hasAudio) continue
63
64 resolutionsEnabled.add(resolution)
65 }
66
67 if (includeInput) {
68 // Always use an even resolution to avoid issues with ffmpeg
69 resolutionsEnabled.add(toEven(input))
70 }
71
72 return Array.from(resolutionsEnabled)
73}
diff --git a/server/lib/transcoding/web-transcoding.ts b/server/lib/transcoding/web-transcoding.ts
deleted file mode 100644
index f92d457a0..000000000
--- a/server/lib/transcoding/web-transcoding.ts
+++ /dev/null
@@ -1,263 +0,0 @@
1import { Job } from 'bullmq'
2import { copyFile, move, remove, stat } from 'fs-extra'
3import { basename, join } from 'path'
4import { computeOutputFPS } from '@server/helpers/ffmpeg'
5import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
6import { VideoModel } from '@server/models/video/video'
7import { MVideoFile, MVideoFullLight } from '@server/types/models'
8import { ffprobePromise, getVideoStreamDuration, getVideoStreamFPS, TranscodeVODOptionsType } from '@shared/ffmpeg'
9import { VideoResolution, VideoStorage } from '@shared/models'
10import { CONFIG } from '../../initializers/config'
11import { VideoFileModel } from '../../models/video/video-file'
12import { JobQueue } from '../job-queue'
13import { generateWebVideoFilename } from '../paths'
14import { buildFileMetadata } from '../video-file'
15import { VideoPathManager } from '../video-path-manager'
16import { buildFFmpegVOD } from './shared'
17import { buildOriginalFileResolution } from './transcoding-resolutions'
18
19// Optimize the original video file and replace it. The resolution is not changed.
20export async function optimizeOriginalVideofile (options: {
21 video: MVideoFullLight
22 inputVideoFile: MVideoFile
23 quickTranscode: boolean
24 job: Job
25}) {
26 const { video, inputVideoFile, quickTranscode, job } = options
27
28 const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
29 const newExtname = '.mp4'
30
31 // Will be released by our transcodeVOD function once ffmpeg is ran
32 const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
33
34 try {
35 await video.reload()
36 await inputVideoFile.reload()
37
38 const fileWithVideoOrPlaylist = inputVideoFile.withVideoOrPlaylist(video)
39
40 const result = await VideoPathManager.Instance.makeAvailableVideoFile(fileWithVideoOrPlaylist, async videoInputPath => {
41 const videoOutputPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
42
43 const transcodeType: TranscodeVODOptionsType = quickTranscode
44 ? 'quick-transcode'
45 : 'video'
46
47 const resolution = buildOriginalFileResolution(inputVideoFile.resolution)
48 const fps = computeOutputFPS({ inputFPS: inputVideoFile.fps, resolution })
49
50 // Could be very long!
51 await buildFFmpegVOD(job).transcode({
52 type: transcodeType,
53
54 inputPath: videoInputPath,
55 outputPath: videoOutputPath,
56
57 inputFileMutexReleaser,
58
59 resolution,
60 fps
61 })
62
63 // Important to do this before getVideoFilename() to take in account the new filename
64 inputVideoFile.resolution = resolution
65 inputVideoFile.extname = newExtname
66 inputVideoFile.filename = generateWebVideoFilename(resolution, newExtname)
67 inputVideoFile.storage = VideoStorage.FILE_SYSTEM
68
69 const { videoFile } = await onWebVideoFileTranscoding({
70 video,
71 videoFile: inputVideoFile,
72 videoOutputPath
73 })
74
75 await remove(videoInputPath)
76
77 return { transcodeType, videoFile }
78 })
79
80 return result
81 } finally {
82 inputFileMutexReleaser()
83 }
84}
85
86// Transcode the original video file to a lower resolution compatible with web browsers
87export async function transcodeNewWebVideoResolution (options: {
88 video: MVideoFullLight
89 resolution: VideoResolution
90 fps: number
91 job: Job
92}) {
93 const { video: videoArg, resolution, fps, job } = options
94
95 const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
96 const newExtname = '.mp4'
97
98 const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(videoArg.uuid)
99
100 try {
101 const video = await VideoModel.loadFull(videoArg.uuid)
102 const file = video.getMaxQualityFile().withVideoOrPlaylist(video)
103
104 const result = await VideoPathManager.Instance.makeAvailableVideoFile(file, async videoInputPath => {
105 const newVideoFile = new VideoFileModel({
106 resolution,
107 extname: newExtname,
108 filename: generateWebVideoFilename(resolution, newExtname),
109 size: 0,
110 videoId: video.id
111 })
112
113 const videoOutputPath = join(transcodeDirectory, newVideoFile.filename)
114
115 const transcodeOptions = {
116 type: 'video' as 'video',
117
118 inputPath: videoInputPath,
119 outputPath: videoOutputPath,
120
121 inputFileMutexReleaser,
122
123 resolution,
124 fps
125 }
126
127 await buildFFmpegVOD(job).transcode(transcodeOptions)
128
129 return onWebVideoFileTranscoding({ video, videoFile: newVideoFile, videoOutputPath })
130 })
131
132 return result
133 } finally {
134 inputFileMutexReleaser()
135 }
136}
137
138// Merge an image with an audio file to create a video
139export async function mergeAudioVideofile (options: {
140 video: MVideoFullLight
141 resolution: VideoResolution
142 fps: number
143 job: Job
144}) {
145 const { video: videoArg, resolution, fps, job } = options
146
147 const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
148 const newExtname = '.mp4'
149
150 const inputFileMutexReleaser = await VideoPathManager.Instance.lockFiles(videoArg.uuid)
151
152 try {
153 const video = await VideoModel.loadFull(videoArg.uuid)
154 const inputVideoFile = video.getMinQualityFile()
155
156 const fileWithVideoOrPlaylist = inputVideoFile.withVideoOrPlaylist(video)
157
158 const result = await VideoPathManager.Instance.makeAvailableVideoFile(fileWithVideoOrPlaylist, async audioInputPath => {
159 const videoOutputPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
160
161 // If the user updates the video preview during transcoding
162 const previewPath = video.getPreview().getPath()
163 const tmpPreviewPath = join(CONFIG.STORAGE.TMP_DIR, basename(previewPath))
164 await copyFile(previewPath, tmpPreviewPath)
165
166 const transcodeOptions = {
167 type: 'merge-audio' as 'merge-audio',
168
169 inputPath: tmpPreviewPath,
170 outputPath: videoOutputPath,
171
172 inputFileMutexReleaser,
173
174 audioPath: audioInputPath,
175 resolution,
176 fps
177 }
178
179 try {
180 await buildFFmpegVOD(job).transcode(transcodeOptions)
181
182 await remove(audioInputPath)
183 await remove(tmpPreviewPath)
184 } catch (err) {
185 await remove(tmpPreviewPath)
186 throw err
187 }
188
189 // Important to do this before getVideoFilename() to take in account the new file extension
190 inputVideoFile.extname = newExtname
191 inputVideoFile.resolution = resolution
192 inputVideoFile.filename = generateWebVideoFilename(inputVideoFile.resolution, newExtname)
193
194 // ffmpeg generated a new video file, so update the video duration
195 // See https://trac.ffmpeg.org/ticket/5456
196 video.duration = await getVideoStreamDuration(videoOutputPath)
197 await video.save()
198
199 return onWebVideoFileTranscoding({
200 video,
201 videoFile: inputVideoFile,
202 videoOutputPath,
203 wasAudioFile: true
204 })
205 })
206
207 return result
208 } finally {
209 inputFileMutexReleaser()
210 }
211}
212
213export async function onWebVideoFileTranscoding (options: {
214 video: MVideoFullLight
215 videoFile: MVideoFile
216 videoOutputPath: string
217 wasAudioFile?: boolean // default false
218}) {
219 const { video, videoFile, videoOutputPath, wasAudioFile } = options
220
221 const mutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
222
223 try {
224 await video.reload()
225
226 const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile)
227
228 const stats = await stat(videoOutputPath)
229
230 const probe = await ffprobePromise(videoOutputPath)
231 const fps = await getVideoStreamFPS(videoOutputPath, probe)
232 const metadata = await buildFileMetadata(videoOutputPath, probe)
233
234 await move(videoOutputPath, outputPath, { overwrite: true })
235
236 videoFile.size = stats.size
237 videoFile.fps = fps
238 videoFile.metadata = metadata
239
240 await createTorrentAndSetInfoHash(video, videoFile)
241
242 const oldFile = await VideoFileModel.loadWebVideoFile({ videoId: video.id, fps: videoFile.fps, resolution: videoFile.resolution })
243 if (oldFile) await video.removeWebVideoFile(oldFile)
244
245 await VideoFileModel.customUpsert(videoFile, 'video', undefined)
246 video.VideoFiles = await video.$get('VideoFiles')
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
259 return { video, videoFile }
260 } finally {
261 mutexReleaser()
262 }
263}
diff --git a/server/lib/uploadx.ts b/server/lib/uploadx.ts
deleted file mode 100644
index c7e0eb414..000000000
--- a/server/lib/uploadx.ts
+++ /dev/null
@@ -1,37 +0,0 @@
1import express from 'express'
2import { buildLogger } from '@server/helpers/logger'
3import { getResumableUploadPath } from '@server/helpers/upload'
4import { CONFIG } from '@server/initializers/config'
5import { LogLevel, Uploadx } from '@uploadx/core'
6import { extname } from 'path'
7
8const logger = buildLogger('uploadx')
9
10const uploadx = new Uploadx({
11 directory: getResumableUploadPath(),
12
13 expiration: { maxAge: undefined, rolling: true },
14
15 // Could be big with thumbnails/previews
16 maxMetadataSize: '10MB',
17
18 logger: {
19 logLevel: CONFIG.LOG.LEVEL as LogLevel,
20 debug: logger.debug.bind(logger),
21 info: logger.info.bind(logger),
22 warn: logger.warn.bind(logger),
23 error: logger.error.bind(logger)
24 },
25
26 userIdentifier: (_, res: express.Response) => {
27 if (!res.locals.oauth) return undefined
28
29 return res.locals.oauth.token.user.id + ''
30 },
31
32 filename: file => `${file.userId}-${file.id}${extname(file.metadata.filename)}`
33})
34
35export {
36 uploadx
37}
diff --git a/server/lib/user.ts b/server/lib/user.ts
deleted file mode 100644
index 56995cca3..000000000
--- a/server/lib/user.ts
+++ /dev/null
@@ -1,301 +0,0 @@
1import { Transaction } from 'sequelize/types'
2import { logger } from '@server/helpers/logger'
3import { CONFIG } from '@server/initializers/config'
4import { UserModel } from '@server/models/user/user'
5import { MActorDefault } from '@server/types/models/actor'
6import { ActivityPubActorType } from '../../shared/models/activitypub'
7import { UserAdminFlag, UserNotificationSetting, UserNotificationSettingValue, UserRole } from '../../shared/models/users'
8import { SERVER_ACTOR_NAME, WEBSERVER } from '../initializers/constants'
9import { sequelizeTypescript } from '../initializers/database'
10import { AccountModel } from '../models/account/account'
11import { UserNotificationSettingModel } from '../models/user/user-notification-setting'
12import { MAccountDefault, MChannelActor } from '../types/models'
13import { MRegistration, MUser, MUserDefault, MUserId } from '../types/models/user'
14import { generateAndSaveActorKeys } from './activitypub/actors'
15import { getLocalAccountActivityPubUrl } from './activitypub/url'
16import { Emailer } from './emailer'
17import { LiveQuotaStore } from './live/live-quota-store'
18import { buildActorInstance, findAvailableLocalActorName } from './local-actor'
19import { Redis } from './redis'
20import { createLocalVideoChannel } from './video-channel'
21import { createWatchLaterPlaylist } from './video-playlist'
22
23type ChannelNames = { name: string, displayName: string }
24
25function buildUser (options: {
26 username: string
27 password: string
28 email: string
29
30 role?: UserRole // Default to UserRole.User
31 adminFlags?: UserAdminFlag // Default to UserAdminFlag.NONE
32
33 emailVerified: boolean | null
34
35 videoQuota?: number // Default to CONFIG.USER.VIDEO_QUOTA
36 videoQuotaDaily?: number // Default to CONFIG.USER.VIDEO_QUOTA_DAILY
37
38 pluginAuth?: string
39}): MUser {
40 const {
41 username,
42 password,
43 email,
44 role = UserRole.USER,
45 emailVerified,
46 videoQuota = CONFIG.USER.VIDEO_QUOTA,
47 videoQuotaDaily = CONFIG.USER.VIDEO_QUOTA_DAILY,
48 adminFlags = UserAdminFlag.NONE,
49 pluginAuth
50 } = options
51
52 return new UserModel({
53 username,
54 password,
55 email,
56
57 nsfwPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
58 p2pEnabled: CONFIG.DEFAULTS.P2P.WEBAPP.ENABLED,
59 videosHistoryEnabled: CONFIG.USER.HISTORY.VIDEOS.ENABLED,
60
61 autoPlayVideo: true,
62
63 role,
64 emailVerified,
65 adminFlags,
66
67 videoQuota,
68 videoQuotaDaily,
69
70 pluginAuth
71 })
72}
73
74// ---------------------------------------------------------------------------
75
76async function createUserAccountAndChannelAndPlaylist (parameters: {
77 userToCreate: MUser
78 userDisplayName?: string
79 channelNames?: ChannelNames
80 validateUser?: boolean
81}): Promise<{ user: MUserDefault, account: MAccountDefault, videoChannel: MChannelActor }> {
82 const { userToCreate, userDisplayName, channelNames, validateUser = true } = parameters
83
84 const { user, account, videoChannel } = await sequelizeTypescript.transaction(async t => {
85 const userOptions = {
86 transaction: t,
87 validate: validateUser
88 }
89
90 const userCreated: MUserDefault = await userToCreate.save(userOptions)
91 userCreated.NotificationSetting = await createDefaultUserNotificationSettings(userCreated, t)
92
93 const accountCreated = await createLocalAccountWithoutKeys({
94 name: userCreated.username,
95 displayName: userDisplayName,
96 userId: userCreated.id,
97 applicationId: null,
98 t
99 })
100 userCreated.Account = accountCreated
101
102 const channelAttributes = await buildChannelAttributes({ user: userCreated, transaction: t, channelNames })
103 const videoChannel = await createLocalVideoChannel(channelAttributes, accountCreated, t)
104
105 const videoPlaylist = await createWatchLaterPlaylist(accountCreated, t)
106
107 return { user: userCreated, account: accountCreated, videoChannel, videoPlaylist }
108 })
109
110 const [ accountActorWithKeys, channelActorWithKeys ] = await Promise.all([
111 generateAndSaveActorKeys(account.Actor),
112 generateAndSaveActorKeys(videoChannel.Actor)
113 ])
114
115 account.Actor = accountActorWithKeys
116 videoChannel.Actor = channelActorWithKeys
117
118 return { user, account, videoChannel }
119}
120
121async function createLocalAccountWithoutKeys (parameters: {
122 name: string
123 displayName?: string
124 userId: number | null
125 applicationId: number | null
126 t: Transaction | undefined
127 type?: ActivityPubActorType
128}) {
129 const { name, displayName, userId, applicationId, t, type = 'Person' } = parameters
130 const url = getLocalAccountActivityPubUrl(name)
131
132 const actorInstance = buildActorInstance(type, url, name)
133 const actorInstanceCreated: MActorDefault = await actorInstance.save({ transaction: t })
134
135 const accountInstance = new AccountModel({
136 name: displayName || name,
137 userId,
138 applicationId,
139 actorId: actorInstanceCreated.id
140 })
141
142 const accountInstanceCreated: MAccountDefault = await accountInstance.save({ transaction: t })
143 accountInstanceCreated.Actor = actorInstanceCreated
144
145 return accountInstanceCreated
146}
147
148async function createApplicationActor (applicationId: number) {
149 const accountCreated = await createLocalAccountWithoutKeys({
150 name: SERVER_ACTOR_NAME,
151 userId: null,
152 applicationId,
153 t: undefined,
154 type: 'Application'
155 })
156
157 accountCreated.Actor = await generateAndSaveActorKeys(accountCreated.Actor)
158
159 return accountCreated
160}
161
162// ---------------------------------------------------------------------------
163
164async function sendVerifyUserEmail (user: MUser, isPendingEmail = false) {
165 const verificationString = await Redis.Instance.setUserVerifyEmailVerificationString(user.id)
166 let verifyEmailUrl = `${WEBSERVER.URL}/verify-account/email?userId=${user.id}&verificationString=${verificationString}`
167
168 if (isPendingEmail) verifyEmailUrl += '&isPendingEmail=true'
169
170 const to = isPendingEmail
171 ? user.pendingEmail
172 : user.email
173
174 const username = user.username
175
176 Emailer.Instance.addVerifyEmailJob({ username, to, verifyEmailUrl, isRegistrationRequest: false })
177}
178
179async function sendVerifyRegistrationEmail (registration: MRegistration) {
180 const verificationString = await Redis.Instance.setRegistrationVerifyEmailVerificationString(registration.id)
181 const verifyEmailUrl = `${WEBSERVER.URL}/verify-account/email?registrationId=${registration.id}&verificationString=${verificationString}`
182
183 const to = registration.email
184 const username = registration.username
185
186 Emailer.Instance.addVerifyEmailJob({ username, to, verifyEmailUrl, isRegistrationRequest: true })
187}
188
189// ---------------------------------------------------------------------------
190
191async function getOriginalVideoFileTotalFromUser (user: MUserId) {
192 // Don't use sequelize because we need to use a sub query
193 const query = UserModel.generateUserQuotaBaseSQL({
194 withSelect: true,
195 whereUserId: '$userId',
196 daily: false
197 })
198
199 const base = await UserModel.getTotalRawQuery(query, user.id)
200
201 return base + LiveQuotaStore.Instance.getLiveQuotaOf(user.id)
202}
203
204// Returns cumulative size of all video files uploaded in the last 24 hours.
205async function getOriginalVideoFileTotalDailyFromUser (user: MUserId) {
206 // Don't use sequelize because we need to use a sub query
207 const query = UserModel.generateUserQuotaBaseSQL({
208 withSelect: true,
209 whereUserId: '$userId',
210 daily: true
211 })
212
213 const base = await UserModel.getTotalRawQuery(query, user.id)
214
215 return base + LiveQuotaStore.Instance.getLiveQuotaOf(user.id)
216}
217
218async function isAbleToUploadVideo (userId: number, newVideoSize: number) {
219 const user = await UserModel.loadById(userId)
220
221 if (user.videoQuota === -1 && user.videoQuotaDaily === -1) return Promise.resolve(true)
222
223 const [ totalBytes, totalBytesDaily ] = await Promise.all([
224 getOriginalVideoFileTotalFromUser(user),
225 getOriginalVideoFileTotalDailyFromUser(user)
226 ])
227
228 const uploadedTotal = newVideoSize + totalBytes
229 const uploadedDaily = newVideoSize + totalBytesDaily
230
231 logger.debug(
232 'Check user %d quota to upload another video.', userId,
233 { totalBytes, totalBytesDaily, videoQuota: user.videoQuota, videoQuotaDaily: user.videoQuotaDaily, newVideoSize }
234 )
235
236 if (user.videoQuotaDaily === -1) return uploadedTotal < user.videoQuota
237 if (user.videoQuota === -1) return uploadedDaily < user.videoQuotaDaily
238
239 return uploadedTotal < user.videoQuota && uploadedDaily < user.videoQuotaDaily
240}
241
242// ---------------------------------------------------------------------------
243
244export {
245 getOriginalVideoFileTotalFromUser,
246 getOriginalVideoFileTotalDailyFromUser,
247 createApplicationActor,
248 createUserAccountAndChannelAndPlaylist,
249 createLocalAccountWithoutKeys,
250
251 sendVerifyUserEmail,
252 sendVerifyRegistrationEmail,
253
254 isAbleToUploadVideo,
255 buildUser
256}
257
258// ---------------------------------------------------------------------------
259
260function createDefaultUserNotificationSettings (user: MUserId, t: Transaction | undefined) {
261 const values: UserNotificationSetting & { userId: number } = {
262 userId: user.id,
263 newVideoFromSubscription: UserNotificationSettingValue.WEB,
264 newCommentOnMyVideo: UserNotificationSettingValue.WEB,
265 myVideoImportFinished: UserNotificationSettingValue.WEB,
266 myVideoPublished: UserNotificationSettingValue.WEB,
267 abuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
268 videoAutoBlacklistAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
269 blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
270 newUserRegistration: UserNotificationSettingValue.WEB,
271 commentMention: UserNotificationSettingValue.WEB,
272 newFollow: UserNotificationSettingValue.WEB,
273 newInstanceFollower: UserNotificationSettingValue.WEB,
274 abuseNewMessage: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
275 abuseStateChange: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
276 autoInstanceFollowing: UserNotificationSettingValue.WEB,
277 newPeerTubeVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL,
278 newPluginVersion: UserNotificationSettingValue.WEB,
279 myVideoStudioEditionFinished: UserNotificationSettingValue.WEB
280 }
281
282 return UserNotificationSettingModel.create(values, { transaction: t })
283}
284
285async function buildChannelAttributes (options: {
286 user: MUser
287 transaction?: Transaction
288 channelNames?: ChannelNames
289}) {
290 const { user, transaction, channelNames } = options
291
292 if (channelNames) return channelNames
293
294 const channelName = await findAvailableLocalActorName(user.username + '_channel', transaction)
295 const videoChannelDisplayName = `Main ${user.username} channel`
296
297 return {
298 name: channelName,
299 displayName: videoChannelDisplayName
300 }
301}
diff --git a/server/lib/video-blacklist.ts b/server/lib/video-blacklist.ts
deleted file mode 100644
index d5664a1b9..000000000
--- a/server/lib/video-blacklist.ts
+++ /dev/null
@@ -1,145 +0,0 @@
1import { Transaction } from 'sequelize'
2import { afterCommitIfTransaction } from '@server/helpers/database-utils'
3import { sequelizeTypescript } from '@server/initializers/database'
4import {
5 MUser,
6 MVideoAccountLight,
7 MVideoBlacklist,
8 MVideoBlacklistVideo,
9 MVideoFullLight,
10 MVideoWithBlacklistLight
11} from '@server/types/models'
12import { LiveVideoError, UserRight, VideoBlacklistCreate, VideoBlacklistType } from '../../shared/models'
13import { UserAdminFlag } from '../../shared/models/users/user-flag.model'
14import { logger, loggerTagsFactory } from '../helpers/logger'
15import { CONFIG } from '../initializers/config'
16import { VideoBlacklistModel } from '../models/video/video-blacklist'
17import { sendDeleteVideo } from './activitypub/send'
18import { federateVideoIfNeeded } from './activitypub/videos'
19import { LiveManager } from './live/live-manager'
20import { Notifier } from './notifier'
21import { Hooks } from './plugins/hooks'
22
23const lTags = loggerTagsFactory('blacklist')
24
25async function autoBlacklistVideoIfNeeded (parameters: {
26 video: MVideoWithBlacklistLight
27 user?: MUser
28 isRemote: boolean
29 isNew: boolean
30 isNewFile: boolean
31 notify?: boolean
32 transaction?: Transaction
33}) {
34 const { video, user, isRemote, isNew, isNewFile, notify = true, transaction } = parameters
35 const doAutoBlacklist = await Hooks.wrapFun(
36 autoBlacklistNeeded,
37 { video, user, isRemote, isNew, isNewFile },
38 'filter:video.auto-blacklist.result'
39 )
40
41 if (!doAutoBlacklist) return false
42
43 const videoBlacklistToCreate = {
44 videoId: video.id,
45 unfederated: true,
46 reason: 'Auto-blacklisted. Moderator review required.',
47 type: VideoBlacklistType.AUTO_BEFORE_PUBLISHED
48 }
49 const [ videoBlacklist ] = await VideoBlacklistModel.findOrCreate<MVideoBlacklistVideo>({
50 where: {
51 videoId: video.id
52 },
53 defaults: videoBlacklistToCreate,
54 transaction
55 })
56 video.VideoBlacklist = videoBlacklist
57
58 videoBlacklist.Video = video
59
60 if (notify) {
61 afterCommitIfTransaction(transaction, () => {
62 Notifier.Instance.notifyOnVideoAutoBlacklist(videoBlacklist)
63 })
64 }
65
66 logger.info('Video %s auto-blacklisted.', video.uuid, lTags(video.uuid))
67
68 return true
69}
70
71async function blacklistVideo (videoInstance: MVideoAccountLight, options: VideoBlacklistCreate) {
72 const blacklist: MVideoBlacklistVideo = await VideoBlacklistModel.create({
73 videoId: videoInstance.id,
74 unfederated: options.unfederate === true,
75 reason: options.reason,
76 type: VideoBlacklistType.MANUAL
77 })
78 blacklist.Video = videoInstance
79
80 if (options.unfederate === true) {
81 await sendDeleteVideo(videoInstance, undefined)
82 }
83
84 if (videoInstance.isLive) {
85 LiveManager.Instance.stopSessionOf(videoInstance.uuid, LiveVideoError.BLACKLISTED)
86 }
87
88 Notifier.Instance.notifyOnVideoBlacklist(blacklist)
89}
90
91async function unblacklistVideo (videoBlacklist: MVideoBlacklist, video: MVideoFullLight) {
92 const videoBlacklistType = await sequelizeTypescript.transaction(async t => {
93 const unfederated = videoBlacklist.unfederated
94 const videoBlacklistType = videoBlacklist.type
95
96 await videoBlacklist.destroy({ transaction: t })
97 video.VideoBlacklist = undefined
98
99 // Re federate the video
100 if (unfederated === true) {
101 await federateVideoIfNeeded(video, true, t)
102 }
103
104 return videoBlacklistType
105 })
106
107 Notifier.Instance.notifyOnVideoUnblacklist(video)
108
109 if (videoBlacklistType === VideoBlacklistType.AUTO_BEFORE_PUBLISHED) {
110 Notifier.Instance.notifyOnVideoPublishedAfterRemovedFromAutoBlacklist(video)
111
112 // Delete on object so new video notifications will send
113 delete video.VideoBlacklist
114 Notifier.Instance.notifyOnNewVideoIfNeeded(video)
115 }
116}
117
118// ---------------------------------------------------------------------------
119
120export {
121 autoBlacklistVideoIfNeeded,
122 blacklistVideo,
123 unblacklistVideo
124}
125
126// ---------------------------------------------------------------------------
127
128function autoBlacklistNeeded (parameters: {
129 video: MVideoWithBlacklistLight
130 isRemote: boolean
131 isNew: boolean
132 isNewFile: boolean
133 user?: MUser
134}) {
135 const { user, video, isRemote, isNew, isNewFile } = parameters
136
137 // Already blacklisted
138 if (video.VideoBlacklist) return false
139 if (!CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED || !user) return false
140 if (isRemote || (isNew === false && isNewFile === false)) return false
141
142 if (user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) || user.hasAdminFlag(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST)) return false
143
144 return true
145}
diff --git a/server/lib/video-channel.ts b/server/lib/video-channel.ts
deleted file mode 100644
index 8322c9ad2..000000000
--- a/server/lib/video-channel.ts
+++ /dev/null
@@ -1,50 +0,0 @@
1import * as Sequelize from 'sequelize'
2import { VideoChannelCreate } from '../../shared/models'
3import { VideoModel } from '../models/video/video'
4import { VideoChannelModel } from '../models/video/video-channel'
5import { MAccountId, MChannelId } from '../types/models'
6import { getLocalVideoChannelActivityPubUrl } from './activitypub/url'
7import { federateVideoIfNeeded } from './activitypub/videos'
8import { buildActorInstance } from './local-actor'
9
10async function createLocalVideoChannel (videoChannelInfo: VideoChannelCreate, account: MAccountId, t: Sequelize.Transaction) {
11 const url = getLocalVideoChannelActivityPubUrl(videoChannelInfo.name)
12 const actorInstance = buildActorInstance('Group', url, videoChannelInfo.name)
13
14 const actorInstanceCreated = await actorInstance.save({ transaction: t })
15
16 const videoChannelData = {
17 name: videoChannelInfo.displayName,
18 description: videoChannelInfo.description,
19 support: videoChannelInfo.support,
20 accountId: account.id,
21 actorId: actorInstanceCreated.id
22 }
23
24 const videoChannel = new VideoChannelModel(videoChannelData)
25
26 const options = { transaction: t }
27 const videoChannelCreated = await videoChannel.save(options)
28
29 videoChannelCreated.Actor = actorInstanceCreated
30
31 // No need to send this empty video channel to followers
32 return videoChannelCreated
33}
34
35async function federateAllVideosOfChannel (videoChannel: MChannelId) {
36 const videoIds = await VideoModel.getAllIdsFromChannel(videoChannel)
37
38 for (const videoId of videoIds) {
39 const video = await VideoModel.loadFull(videoId)
40
41 await federateVideoIfNeeded(video, false)
42 }
43}
44
45// ---------------------------------------------------------------------------
46
47export {
48 createLocalVideoChannel,
49 federateAllVideosOfChannel
50}
diff --git a/server/lib/video-comment.ts b/server/lib/video-comment.ts
deleted file mode 100644
index 6eb865f7f..000000000
--- a/server/lib/video-comment.ts
+++ /dev/null
@@ -1,116 +0,0 @@
1import express from 'express'
2import { cloneDeep } from 'lodash'
3import * as Sequelize from 'sequelize'
4import { logger } from '@server/helpers/logger'
5import { sequelizeTypescript } from '@server/initializers/database'
6import { ResultList } from '../../shared/models'
7import { VideoCommentThreadTree } from '../../shared/models/videos/comment/video-comment.model'
8import { VideoCommentModel } from '../models/video/video-comment'
9import {
10 MAccountDefault,
11 MComment,
12 MCommentFormattable,
13 MCommentOwnerVideo,
14 MCommentOwnerVideoReply,
15 MVideoFullLight
16} from '../types/models'
17import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send'
18import { getLocalVideoCommentActivityPubUrl } from './activitypub/url'
19import { Hooks } from './plugins/hooks'
20
21async function removeComment (commentArg: MComment, req: express.Request, res: express.Response) {
22 let videoCommentInstanceBefore: MCommentOwnerVideo
23
24 await sequelizeTypescript.transaction(async t => {
25 const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(commentArg.url, t)
26
27 videoCommentInstanceBefore = cloneDeep(comment)
28
29 if (comment.isOwned() || comment.Video.isOwned()) {
30 await sendDeleteVideoComment(comment, t)
31 }
32
33 comment.markAsDeleted()
34
35 await comment.save({ transaction: t })
36
37 logger.info('Video comment %d deleted.', comment.id)
38 })
39
40 Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore, req, res })
41}
42
43async function createVideoComment (obj: {
44 text: string
45 inReplyToComment: MComment | null
46 video: MVideoFullLight
47 account: MAccountDefault
48}, t: Sequelize.Transaction) {
49 let originCommentId: number | null = null
50 let inReplyToCommentId: number | null = null
51
52 if (obj.inReplyToComment && obj.inReplyToComment !== null) {
53 originCommentId = obj.inReplyToComment.originCommentId || obj.inReplyToComment.id
54 inReplyToCommentId = obj.inReplyToComment.id
55 }
56
57 const comment = await VideoCommentModel.create({
58 text: obj.text,
59 originCommentId,
60 inReplyToCommentId,
61 videoId: obj.video.id,
62 accountId: obj.account.id,
63 url: new Date().toISOString()
64 }, { transaction: t, validate: false })
65
66 comment.url = getLocalVideoCommentActivityPubUrl(obj.video, comment)
67
68 const savedComment: MCommentOwnerVideoReply = await comment.save({ transaction: t })
69 savedComment.InReplyToVideoComment = obj.inReplyToComment
70 savedComment.Video = obj.video
71 savedComment.Account = obj.account
72
73 await sendCreateVideoComment(savedComment, t)
74
75 return savedComment
76}
77
78function buildFormattedCommentTree (resultList: ResultList<MCommentFormattable>): VideoCommentThreadTree {
79 // Comments are sorted by id ASC
80 const comments = resultList.data
81
82 const comment = comments.shift()
83 const thread: VideoCommentThreadTree = {
84 comment: comment.toFormattedJSON(),
85 children: []
86 }
87 const idx = {
88 [comment.id]: thread
89 }
90
91 while (comments.length !== 0) {
92 const childComment = comments.shift()
93
94 const childCommentThread: VideoCommentThreadTree = {
95 comment: childComment.toFormattedJSON(),
96 children: []
97 }
98
99 const parentCommentThread = idx[childComment.inReplyToCommentId]
100 // Maybe the parent comment was blocked by the admin/user
101 if (!parentCommentThread) continue
102
103 parentCommentThread.children.push(childCommentThread)
104 idx[childComment.id] = childCommentThread
105 }
106
107 return thread
108}
109
110// ---------------------------------------------------------------------------
111
112export {
113 removeComment,
114 createVideoComment,
115 buildFormattedCommentTree
116}
diff --git a/server/lib/video-file.ts b/server/lib/video-file.ts
deleted file mode 100644
index 46af67ccd..000000000
--- a/server/lib/video-file.ts
+++ /dev/null
@@ -1,145 +0,0 @@
1import { FfprobeData } from 'fluent-ffmpeg'
2import { logger } from '@server/helpers/logger'
3import { VideoFileModel } from '@server/models/video/video-file'
4import { MVideoWithAllFiles } from '@server/types/models'
5import { getLowercaseExtension } from '@shared/core-utils'
6import { getFileSize } from '@shared/extra-utils'
7import { ffprobePromise, getVideoStreamDimensionsInfo, getVideoStreamFPS, isAudioFile } from '@shared/ffmpeg'
8import { VideoFileMetadata, VideoResolution } from '@shared/models'
9import { lTags } from './object-storage/shared'
10import { generateHLSVideoFilename, generateWebVideoFilename } from './paths'
11import { VideoPathManager } from './video-path-manager'
12
13async function buildNewFile (options: {
14 path: string
15 mode: 'web-video' | 'hls'
16}) {
17 const { path, mode } = options
18
19 const probe = await ffprobePromise(path)
20 const size = await getFileSize(path)
21
22 const videoFile = new VideoFileModel({
23 extname: getLowercaseExtension(path),
24 size,
25 metadata: await buildFileMetadata(path, probe)
26 })
27
28 if (await isAudioFile(path, probe)) {
29 videoFile.resolution = VideoResolution.H_NOVIDEO
30 } else {
31 videoFile.fps = await getVideoStreamFPS(path, probe)
32 videoFile.resolution = (await getVideoStreamDimensionsInfo(path, probe)).resolution
33 }
34
35 videoFile.filename = mode === 'web-video'
36 ? generateWebVideoFilename(videoFile.resolution, videoFile.extname)
37 : generateHLSVideoFilename(videoFile.resolution)
38
39 return videoFile
40}
41
42// ---------------------------------------------------------------------------
43
44async function removeHLSPlaylist (video: MVideoWithAllFiles) {
45 const hls = video.getHLSPlaylist()
46 if (!hls) return
47
48 const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
49
50 try {
51 await video.removeStreamingPlaylistFiles(hls)
52 await hls.destroy()
53
54 video.VideoStreamingPlaylists = video.VideoStreamingPlaylists.filter(p => p.id !== hls.id)
55 } finally {
56 videoFileMutexReleaser()
57 }
58}
59
60async function removeHLSFile (video: MVideoWithAllFiles, fileToDeleteId: number) {
61 logger.info('Deleting HLS file %d of %s.', fileToDeleteId, video.url, lTags(video.uuid))
62
63 const hls = video.getHLSPlaylist()
64 const files = hls.VideoFiles
65
66 if (files.length === 1) {
67 await removeHLSPlaylist(video)
68 return undefined
69 }
70
71 const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
72
73 try {
74 const toDelete = files.find(f => f.id === fileToDeleteId)
75 await video.removeStreamingPlaylistVideoFile(video.getHLSPlaylist(), toDelete)
76 await toDelete.destroy()
77
78 hls.VideoFiles = hls.VideoFiles.filter(f => f.id !== toDelete.id)
79 } finally {
80 videoFileMutexReleaser()
81 }
82
83 return hls
84}
85
86// ---------------------------------------------------------------------------
87
88async function removeAllWebVideoFiles (video: MVideoWithAllFiles) {
89 const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
90
91 try {
92 for (const file of video.VideoFiles) {
93 await video.removeWebVideoFile(file)
94 await file.destroy()
95 }
96
97 video.VideoFiles = []
98 } finally {
99 videoFileMutexReleaser()
100 }
101
102 return video
103}
104
105async function removeWebVideoFile (video: MVideoWithAllFiles, fileToDeleteId: number) {
106 const files = video.VideoFiles
107
108 if (files.length === 1) {
109 return removeAllWebVideoFiles(video)
110 }
111
112 const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(video.uuid)
113 try {
114 const toDelete = files.find(f => f.id === fileToDeleteId)
115 await video.removeWebVideoFile(toDelete)
116 await toDelete.destroy()
117
118 video.VideoFiles = files.filter(f => f.id !== toDelete.id)
119 } finally {
120 videoFileMutexReleaser()
121 }
122
123 return video
124}
125
126// ---------------------------------------------------------------------------
127
128async function buildFileMetadata (path: string, existingProbe?: FfprobeData) {
129 const metadata = existingProbe || await ffprobePromise(path)
130
131 return new VideoFileMetadata(metadata)
132}
133
134// ---------------------------------------------------------------------------
135
136export {
137 buildNewFile,
138
139 removeHLSPlaylist,
140 removeHLSFile,
141 removeAllWebVideoFiles,
142 removeWebVideoFile,
143
144 buildFileMetadata
145}
diff --git a/server/lib/video-path-manager.ts b/server/lib/video-path-manager.ts
deleted file mode 100644
index 133544bb2..000000000
--- a/server/lib/video-path-manager.ts
+++ /dev/null
@@ -1,174 +0,0 @@
1import { Mutex } from 'async-mutex'
2import { remove } from 'fs-extra'
3import { extname, join } from 'path'
4import { logger, loggerTagsFactory } from '@server/helpers/logger'
5import { extractVideo } from '@server/helpers/video'
6import { CONFIG } from '@server/initializers/config'
7import { DIRECTORIES } from '@server/initializers/constants'
8import { MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '@server/types/models'
9import { buildUUID } from '@shared/extra-utils'
10import { VideoStorage } from '@shared/models'
11import { makeHLSFileAvailable, makeWebVideoFileAvailable } from './object-storage'
12import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from './paths'
13import { isVideoInPrivateDirectory } from './video-privacy'
14
15type MakeAvailableCB <T> = (path: string) => Promise<T> | T
16
17const lTags = loggerTagsFactory('video-path-manager')
18
19class VideoPathManager {
20
21 private static instance: VideoPathManager
22
23 // Key is a video UUID
24 private readonly videoFileMutexStore = new Map<string, Mutex>()
25
26 private constructor () {}
27
28 getFSHLSOutputPath (video: MVideo, filename?: string) {
29 const base = getHLSDirectory(video)
30 if (!filename) return base
31
32 return join(base, filename)
33 }
34
35 getFSRedundancyVideoFilePath (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) {
36 if (videoFile.isHLS()) {
37 const video = extractVideo(videoOrPlaylist)
38
39 return join(getHLSRedundancyDirectory(video), videoFile.filename)
40 }
41
42 return join(CONFIG.STORAGE.REDUNDANCY_DIR, videoFile.filename)
43 }
44
45 getFSVideoFileOutputPath (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile) {
46 const video = extractVideo(videoOrPlaylist)
47
48 if (videoFile.isHLS()) {
49 return join(getHLSDirectory(video), videoFile.filename)
50 }
51
52 if (isVideoInPrivateDirectory(video.privacy)) {
53 return join(DIRECTORIES.VIDEOS.PRIVATE, videoFile.filename)
54 }
55
56 return join(DIRECTORIES.VIDEOS.PUBLIC, videoFile.filename)
57 }
58
59 async makeAvailableVideoFile <T> (videoFile: MVideoFileVideo | MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB<T>) {
60 if (videoFile.storage === VideoStorage.FILE_SYSTEM) {
61 return this.makeAvailableFactory(
62 () => this.getFSVideoFileOutputPath(videoFile.getVideoOrStreamingPlaylist(), videoFile),
63 false,
64 cb
65 )
66 }
67
68 const destination = this.buildTMPDestination(videoFile.filename)
69
70 if (videoFile.isHLS()) {
71 const playlist = (videoFile as MVideoFileStreamingPlaylistVideo).VideoStreamingPlaylist
72
73 return this.makeAvailableFactory(
74 () => makeHLSFileAvailable(playlist, videoFile.filename, destination),
75 true,
76 cb
77 )
78 }
79
80 return this.makeAvailableFactory(
81 () => makeWebVideoFileAvailable(videoFile.filename, destination),
82 true,
83 cb
84 )
85 }
86
87 async makeAvailableResolutionPlaylistFile <T> (videoFile: MVideoFileStreamingPlaylistVideo, cb: MakeAvailableCB<T>) {
88 const filename = getHlsResolutionPlaylistFilename(videoFile.filename)
89
90 if (videoFile.storage === VideoStorage.FILE_SYSTEM) {
91 return this.makeAvailableFactory(
92 () => join(getHLSDirectory(videoFile.getVideo()), filename),
93 false,
94 cb
95 )
96 }
97
98 const playlist = videoFile.VideoStreamingPlaylist
99 return this.makeAvailableFactory(
100 () => makeHLSFileAvailable(playlist, filename, this.buildTMPDestination(filename)),
101 true,
102 cb
103 )
104 }
105
106 async makeAvailablePlaylistFile <T> (playlist: MStreamingPlaylistVideo, filename: string, cb: MakeAvailableCB<T>) {
107 if (playlist.storage === VideoStorage.FILE_SYSTEM) {
108 return this.makeAvailableFactory(
109 () => join(getHLSDirectory(playlist.Video), filename),
110 false,
111 cb
112 )
113 }
114
115 return this.makeAvailableFactory(
116 () => makeHLSFileAvailable(playlist, filename, this.buildTMPDestination(filename)),
117 true,
118 cb
119 )
120 }
121
122 async lockFiles (videoUUID: string) {
123 if (!this.videoFileMutexStore.has(videoUUID)) {
124 this.videoFileMutexStore.set(videoUUID, new Mutex())
125 }
126
127 const mutex = this.videoFileMutexStore.get(videoUUID)
128 const releaser = await mutex.acquire()
129
130 logger.debug('Locked files of %s.', videoUUID, lTags(videoUUID))
131
132 return releaser
133 }
134
135 unlockFiles (videoUUID: string) {
136 const mutex = this.videoFileMutexStore.get(videoUUID)
137
138 mutex.release()
139
140 logger.debug('Released lockfiles of %s.', videoUUID, lTags(videoUUID))
141 }
142
143 private async makeAvailableFactory <T> (method: () => Promise<string> | string, clean: boolean, cb: MakeAvailableCB<T>) {
144 let result: T
145
146 const destination = await method()
147
148 try {
149 result = await cb(destination)
150 } catch (err) {
151 if (destination && clean) await remove(destination)
152 throw err
153 }
154
155 if (clean) await remove(destination)
156
157 return result
158 }
159
160 private buildTMPDestination (filename: string) {
161 return join(CONFIG.STORAGE.TMP_DIR, buildUUID() + extname(filename))
162
163 }
164
165 static get Instance () {
166 return this.instance || (this.instance = new this())
167 }
168}
169
170// ---------------------------------------------------------------------------
171
172export {
173 VideoPathManager
174}
diff --git a/server/lib/video-playlist.ts b/server/lib/video-playlist.ts
deleted file mode 100644
index a1af2e1af..000000000
--- a/server/lib/video-playlist.ts
+++ /dev/null
@@ -1,30 +0,0 @@
1import * as Sequelize from 'sequelize'
2import { VideoPlaylistPrivacy } from '../../shared/models/videos/playlist/video-playlist-privacy.model'
3import { VideoPlaylistType } from '../../shared/models/videos/playlist/video-playlist-type.model'
4import { VideoPlaylistModel } from '../models/video/video-playlist'
5import { MAccount } from '../types/models'
6import { MVideoPlaylistOwner } from '../types/models/video/video-playlist'
7import { getLocalVideoPlaylistActivityPubUrl } from './activitypub/url'
8
9async function createWatchLaterPlaylist (account: MAccount, t: Sequelize.Transaction) {
10 const videoPlaylist: MVideoPlaylistOwner = new VideoPlaylistModel({
11 name: 'Watch later',
12 privacy: VideoPlaylistPrivacy.PRIVATE,
13 type: VideoPlaylistType.WATCH_LATER,
14 ownerAccountId: account.id
15 })
16
17 videoPlaylist.url = getLocalVideoPlaylistActivityPubUrl(videoPlaylist) // We use the UUID, so set the URL after building the object
18
19 await videoPlaylist.save({ transaction: t })
20
21 videoPlaylist.OwnerAccount = account
22
23 return videoPlaylist
24}
25
26// ---------------------------------------------------------------------------
27
28export {
29 createWatchLaterPlaylist
30}
diff --git a/server/lib/video-pre-import.ts b/server/lib/video-pre-import.ts
deleted file mode 100644
index fcb9f77d7..000000000
--- a/server/lib/video-pre-import.ts
+++ /dev/null
@@ -1,323 +0,0 @@
1import { remove } from 'fs-extra'
2import { moveAndProcessCaptionFile } from '@server/helpers/captions-utils'
3import { isVTTFileValid } from '@server/helpers/custom-validators/video-captions'
4import { isVideoFileExtnameValid } from '@server/helpers/custom-validators/videos'
5import { isResolvingToUnicastOnly } from '@server/helpers/dns'
6import { logger } from '@server/helpers/logger'
7import { YoutubeDLInfo, YoutubeDLWrapper } from '@server/helpers/youtube-dl'
8import { CONFIG } from '@server/initializers/config'
9import { sequelizeTypescript } from '@server/initializers/database'
10import { Hooks } from '@server/lib/plugins/hooks'
11import { ServerConfigManager } from '@server/lib/server-config-manager'
12import { setVideoTags } from '@server/lib/video'
13import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist'
14import { VideoModel } from '@server/models/video/video'
15import { VideoCaptionModel } from '@server/models/video/video-caption'
16import { VideoImportModel } from '@server/models/video/video-import'
17import { FilteredModelAttributes } from '@server/types'
18import {
19 MChannelAccountDefault,
20 MChannelSync,
21 MThumbnail,
22 MUser,
23 MVideoAccountDefault,
24 MVideoCaption,
25 MVideoImportFormattable,
26 MVideoTag,
27 MVideoThumbnail,
28 MVideoWithBlacklistLight
29} from '@server/types/models'
30import { ThumbnailType, VideoImportCreate, VideoImportPayload, VideoImportState, VideoPrivacy, VideoState } from '@shared/models'
31import { getLocalVideoActivityPubUrl } from './activitypub/url'
32import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail'
33import { VideoPasswordModel } from '@server/models/video/video-password'
34
35class YoutubeDlImportError extends Error {
36 code: YoutubeDlImportError.CODE
37 cause?: Error // Property to remove once ES2022 is used
38 constructor ({ message, code }) {
39 super(message)
40 this.code = code
41 }
42
43 static fromError (err: Error, code: YoutubeDlImportError.CODE, message?: string) {
44 const ytDlErr = new this({ message: message ?? err.message, code })
45 ytDlErr.cause = err
46 ytDlErr.stack = err.stack // Useless once ES2022 is used
47 return ytDlErr
48 }
49}
50
51namespace YoutubeDlImportError {
52 export enum CODE {
53 FETCH_ERROR,
54 NOT_ONLY_UNICAST_URL
55 }
56}
57
58// ---------------------------------------------------------------------------
59
60async function insertFromImportIntoDB (parameters: {
61 video: MVideoThumbnail
62 thumbnailModel: MThumbnail
63 previewModel: MThumbnail
64 videoChannel: MChannelAccountDefault
65 tags: string[]
66 videoImportAttributes: FilteredModelAttributes<VideoImportModel>
67 user: MUser
68 videoPasswords?: string[]
69}): Promise<MVideoImportFormattable> {
70 const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user, videoPasswords } = parameters
71
72 const videoImport = await sequelizeTypescript.transaction(async t => {
73 const sequelizeOptions = { transaction: t }
74
75 // Save video object in database
76 const videoCreated = await video.save(sequelizeOptions) as (MVideoAccountDefault & MVideoWithBlacklistLight & MVideoTag)
77 videoCreated.VideoChannel = videoChannel
78
79 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
80 if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t)
81
82 if (videoCreated.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
83 await VideoPasswordModel.addPasswords(videoPasswords, video.id, t)
84 }
85
86 await autoBlacklistVideoIfNeeded({
87 video: videoCreated,
88 user,
89 notify: false,
90 isRemote: false,
91 isNew: true,
92 isNewFile: true,
93 transaction: t
94 })
95
96 await setVideoTags({ video: videoCreated, tags, transaction: t })
97
98 // Create video import object in database
99 const videoImport = await VideoImportModel.create(
100 Object.assign({ videoId: videoCreated.id }, videoImportAttributes),
101 sequelizeOptions
102 ) as MVideoImportFormattable
103 videoImport.Video = videoCreated
104
105 return videoImport
106 })
107
108 return videoImport
109}
110
111async function buildVideoFromImport ({ channelId, importData, importDataOverride, importType }: {
112 channelId: number
113 importData: YoutubeDLInfo
114 importDataOverride?: Partial<VideoImportCreate>
115 importType: 'url' | 'torrent'
116}): Promise<MVideoThumbnail> {
117 let videoData = {
118 name: importDataOverride?.name || importData.name || 'Unknown name',
119 remote: false,
120 category: importDataOverride?.category || importData.category,
121 licence: importDataOverride?.licence ?? importData.licence ?? CONFIG.DEFAULTS.PUBLISH.LICENCE,
122 language: importDataOverride?.language || importData.language,
123 commentsEnabled: importDataOverride?.commentsEnabled ?? CONFIG.DEFAULTS.PUBLISH.COMMENTS_ENABLED,
124 downloadEnabled: importDataOverride?.downloadEnabled ?? CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED,
125 waitTranscoding: importDataOverride?.waitTranscoding ?? true,
126 state: VideoState.TO_IMPORT,
127 nsfw: importDataOverride?.nsfw || importData.nsfw || false,
128 description: importDataOverride?.description || importData.description,
129 support: importDataOverride?.support || null,
130 privacy: importDataOverride?.privacy || VideoPrivacy.PRIVATE,
131 duration: 0, // duration will be set by the import job
132 channelId,
133 originallyPublishedAt: importDataOverride?.originallyPublishedAt
134 ? new Date(importDataOverride?.originallyPublishedAt)
135 : importData.originallyPublishedAtWithoutTime
136 }
137
138 videoData = await Hooks.wrapObject(
139 videoData,
140 importType === 'url'
141 ? 'filter:api.video.import-url.video-attribute.result'
142 : 'filter:api.video.import-torrent.video-attribute.result'
143 )
144
145 const video = new VideoModel(videoData)
146 video.url = getLocalVideoActivityPubUrl(video)
147
148 return video
149}
150
151async function buildYoutubeDLImport (options: {
152 targetUrl: string
153 channel: MChannelAccountDefault
154 user: MUser
155 channelSync?: MChannelSync
156 importDataOverride?: Partial<VideoImportCreate>
157 thumbnailFilePath?: string
158 previewFilePath?: string
159}) {
160 const { targetUrl, channel, channelSync, importDataOverride, thumbnailFilePath, previewFilePath, user } = options
161
162 const youtubeDL = new YoutubeDLWrapper(
163 targetUrl,
164 ServerConfigManager.Instance.getEnabledResolutions('vod'),
165 CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION
166 )
167
168 // Get video infos
169 let youtubeDLInfo: YoutubeDLInfo
170 try {
171 youtubeDLInfo = await youtubeDL.getInfoForDownload()
172 } catch (err) {
173 throw YoutubeDlImportError.fromError(
174 err, YoutubeDlImportError.CODE.FETCH_ERROR, `Cannot fetch information from import for URL ${targetUrl}`
175 )
176 }
177
178 if (!await hasUnicastURLsOnly(youtubeDLInfo)) {
179 throw new YoutubeDlImportError({
180 message: 'Cannot use non unicast IP as targetUrl.',
181 code: YoutubeDlImportError.CODE.NOT_ONLY_UNICAST_URL
182 })
183 }
184
185 const video = await buildVideoFromImport({
186 channelId: channel.id,
187 importData: youtubeDLInfo,
188 importDataOverride,
189 importType: 'url'
190 })
191
192 const thumbnailModel = await forgeThumbnail({
193 inputPath: thumbnailFilePath,
194 downloadUrl: youtubeDLInfo.thumbnailUrl,
195 video,
196 type: ThumbnailType.MINIATURE
197 })
198
199 const previewModel = await forgeThumbnail({
200 inputPath: previewFilePath,
201 downloadUrl: youtubeDLInfo.thumbnailUrl,
202 video,
203 type: ThumbnailType.PREVIEW
204 })
205
206 const videoImport = await insertFromImportIntoDB({
207 video,
208 thumbnailModel,
209 previewModel,
210 videoChannel: channel,
211 tags: importDataOverride?.tags || youtubeDLInfo.tags,
212 user,
213 videoImportAttributes: {
214 targetUrl,
215 state: VideoImportState.PENDING,
216 userId: user.id,
217 videoChannelSyncId: channelSync?.id
218 },
219 videoPasswords: importDataOverride.videoPasswords
220 })
221
222 // Get video subtitles
223 await processYoutubeSubtitles(youtubeDL, targetUrl, video.id)
224
225 let fileExt = `.${youtubeDLInfo.ext}`
226 if (!isVideoFileExtnameValid(fileExt)) fileExt = '.mp4'
227
228 const payload: VideoImportPayload = {
229 type: 'youtube-dl' as 'youtube-dl',
230 videoImportId: videoImport.id,
231 fileExt,
232 // If part of a sync process, there is a parent job that will aggregate children results
233 preventException: !!channelSync
234 }
235
236 return {
237 videoImport,
238 job: { type: 'video-import' as 'video-import', payload }
239 }
240}
241
242// ---------------------------------------------------------------------------
243
244export {
245 buildYoutubeDLImport,
246 YoutubeDlImportError,
247 insertFromImportIntoDB,
248 buildVideoFromImport
249}
250
251// ---------------------------------------------------------------------------
252
253async function forgeThumbnail ({ inputPath, video, downloadUrl, type }: {
254 inputPath?: string
255 downloadUrl?: string
256 video: MVideoThumbnail
257 type: ThumbnailType
258}): Promise<MThumbnail> {
259 if (inputPath) {
260 return updateLocalVideoMiniatureFromExisting({
261 inputPath,
262 video,
263 type,
264 automaticallyGenerated: false
265 })
266 }
267
268 if (downloadUrl) {
269 try {
270 return await updateLocalVideoMiniatureFromUrl({ downloadUrl, video, type })
271 } catch (err) {
272 logger.warn('Cannot process thumbnail %s from youtube-dl.', downloadUrl, { err })
273 }
274 }
275
276 return null
277}
278
279async function processYoutubeSubtitles (youtubeDL: YoutubeDLWrapper, targetUrl: string, videoId: number) {
280 try {
281 const subtitles = await youtubeDL.getSubtitles()
282
283 logger.info('Found %s subtitles candidates from youtube-dl import %s.', subtitles.length, targetUrl)
284
285 for (const subtitle of subtitles) {
286 if (!await isVTTFileValid(subtitle.path)) {
287 logger.info('%s is not a valid youtube-dl subtitle, skipping', subtitle.path)
288 await remove(subtitle.path)
289 continue
290 }
291
292 const videoCaption = new VideoCaptionModel({
293 videoId,
294 language: subtitle.language,
295 filename: VideoCaptionModel.generateCaptionName(subtitle.language)
296 }) as MVideoCaption
297
298 // Move physical file
299 await moveAndProcessCaptionFile(subtitle, videoCaption)
300
301 await sequelizeTypescript.transaction(async t => {
302 await VideoCaptionModel.insertOrReplaceLanguage(videoCaption, t)
303 })
304
305 logger.info('Added %s youtube-dl subtitle', subtitle.path)
306 }
307 } catch (err) {
308 logger.warn('Cannot get video subtitles.', { err })
309 }
310}
311
312async function hasUnicastURLsOnly (youtubeDLInfo: YoutubeDLInfo) {
313 const hosts = youtubeDLInfo.urls.map(u => new URL(u).hostname)
314 const uniqHosts = new Set(hosts)
315
316 for (const h of uniqHosts) {
317 if (await isResolvingToUnicastOnly(h) !== true) {
318 return false
319 }
320 }
321
322 return true
323}
diff --git a/server/lib/video-privacy.ts b/server/lib/video-privacy.ts
deleted file mode 100644
index 5dd4d9781..000000000
--- a/server/lib/video-privacy.ts
+++ /dev/null
@@ -1,133 +0,0 @@
1import { move } from 'fs-extra'
2import { join } from 'path'
3import { logger } from '@server/helpers/logger'
4import { DIRECTORIES } from '@server/initializers/constants'
5import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
6import { VideoPrivacy, VideoStorage } from '@shared/models'
7import { updateHLSFilesACL, updateWebVideoFileACL } from './object-storage'
8
9const validPrivacySet = new Set([
10 VideoPrivacy.PRIVATE,
11 VideoPrivacy.INTERNAL,
12 VideoPrivacy.PASSWORD_PROTECTED
13])
14
15function setVideoPrivacy (video: MVideo, newPrivacy: VideoPrivacy) {
16 if (video.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
17 video.publishedAt = new Date()
18 }
19
20 video.privacy = newPrivacy
21}
22
23function isVideoInPrivateDirectory (privacy) {
24 return validPrivacySet.has(privacy)
25}
26
27function isVideoInPublicDirectory (privacy: VideoPrivacy) {
28 return !isVideoInPrivateDirectory(privacy)
29}
30
31async function moveFilesIfPrivacyChanged (video: MVideoFullLight, oldPrivacy: VideoPrivacy) {
32 // Now public, previously private
33 if (isVideoInPublicDirectory(video.privacy) && isVideoInPrivateDirectory(oldPrivacy)) {
34 await moveFiles({ type: 'private-to-public', video })
35
36 return true
37 }
38
39 // Now private, previously public
40 if (isVideoInPrivateDirectory(video.privacy) && isVideoInPublicDirectory(oldPrivacy)) {
41 await moveFiles({ type: 'public-to-private', video })
42
43 return true
44 }
45
46 return false
47}
48
49export {
50 setVideoPrivacy,
51
52 isVideoInPrivateDirectory,
53 isVideoInPublicDirectory,
54
55 moveFilesIfPrivacyChanged
56}
57
58// ---------------------------------------------------------------------------
59
60type MoveType = 'private-to-public' | 'public-to-private'
61
62async function moveFiles (options: {
63 type: MoveType
64 video: MVideoFullLight
65}) {
66 const { type, video } = options
67
68 for (const file of video.VideoFiles) {
69 if (file.storage === VideoStorage.FILE_SYSTEM) {
70 await moveWebVideoFileOnFS(type, video, file)
71 } else {
72 await updateWebVideoFileACL(video, file)
73 }
74 }
75
76 const hls = video.getHLSPlaylist()
77
78 if (hls) {
79 if (hls.storage === VideoStorage.FILE_SYSTEM) {
80 await moveHLSFilesOnFS(type, video)
81 } else {
82 await updateHLSFilesACL(hls)
83 }
84 }
85}
86
87async function moveWebVideoFileOnFS (type: MoveType, video: MVideo, file: MVideoFile) {
88 const directories = getWebVideoDirectories(type)
89
90 const source = join(directories.old, file.filename)
91 const destination = join(directories.new, file.filename)
92
93 try {
94 logger.info('Moving web video files of %s after privacy change (%s -> %s).', video.uuid, source, destination)
95
96 await move(source, destination)
97 } catch (err) {
98 logger.error('Cannot move web video file %s to %s after privacy change', source, destination, { err })
99 }
100}
101
102function getWebVideoDirectories (moveType: MoveType) {
103 if (moveType === 'private-to-public') {
104 return { old: DIRECTORIES.VIDEOS.PRIVATE, new: DIRECTORIES.VIDEOS.PUBLIC }
105 }
106
107 return { old: DIRECTORIES.VIDEOS.PUBLIC, new: DIRECTORIES.VIDEOS.PRIVATE }
108}
109
110// ---------------------------------------------------------------------------
111
112async function moveHLSFilesOnFS (type: MoveType, video: MVideo) {
113 const directories = getHLSDirectories(type)
114
115 const source = join(directories.old, video.uuid)
116 const destination = join(directories.new, video.uuid)
117
118 try {
119 logger.info('Moving HLS files of %s after privacy change (%s -> %s).', video.uuid, source, destination)
120
121 await move(source, destination)
122 } catch (err) {
123 logger.error('Cannot move HLS file %s to %s after privacy change', source, destination, { err })
124 }
125}
126
127function getHLSDirectories (moveType: MoveType) {
128 if (moveType === 'private-to-public') {
129 return { old: DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, new: DIRECTORIES.HLS_STREAMING_PLAYLIST.PUBLIC }
130 }
131
132 return { old: DIRECTORIES.HLS_STREAMING_PLAYLIST.PUBLIC, new: DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE }
133}
diff --git a/server/lib/video-state.ts b/server/lib/video-state.ts
deleted file mode 100644
index 893725d85..000000000
--- a/server/lib/video-state.ts
+++ /dev/null
@@ -1,154 +0,0 @@
1import { Transaction } from 'sequelize'
2import { retryTransactionWrapper } from '@server/helpers/database-utils'
3import { logger } from '@server/helpers/logger'
4import { CONFIG } from '@server/initializers/config'
5import { sequelizeTypescript } from '@server/initializers/database'
6import { VideoModel } from '@server/models/video/video'
7import { VideoJobInfoModel } from '@server/models/video/video-job-info'
8import { MVideo, MVideoFullLight, MVideoUUID } from '@server/types/models'
9import { VideoState } from '@shared/models'
10import { federateVideoIfNeeded } from './activitypub/videos'
11import { JobQueue } from './job-queue'
12import { Notifier } from './notifier'
13import { buildMoveToObjectStorageJob } from './video'
14
15function buildNextVideoState (currentState?: VideoState) {
16 if (currentState === VideoState.PUBLISHED) {
17 throw new Error('Video is already in its final state')
18 }
19
20 if (
21 currentState !== VideoState.TO_EDIT &&
22 currentState !== VideoState.TO_TRANSCODE &&
23 currentState !== VideoState.TO_MOVE_TO_EXTERNAL_STORAGE &&
24 CONFIG.TRANSCODING.ENABLED
25 ) {
26 return VideoState.TO_TRANSCODE
27 }
28
29 if (
30 currentState !== VideoState.TO_MOVE_TO_EXTERNAL_STORAGE &&
31 CONFIG.OBJECT_STORAGE.ENABLED
32 ) {
33 return VideoState.TO_MOVE_TO_EXTERNAL_STORAGE
34 }
35
36 return VideoState.PUBLISHED
37}
38
39function moveToNextState (options: {
40 video: MVideoUUID
41 previousVideoState?: VideoState
42 isNewVideo?: boolean // Default true
43}) {
44 const { video, previousVideoState, isNewVideo = true } = options
45
46 return retryTransactionWrapper(() => {
47 return sequelizeTypescript.transaction(async t => {
48 // Maybe the video changed in database, refresh it
49 const videoDatabase = await VideoModel.loadFull(video.uuid, t)
50 // Video does not exist anymore
51 if (!videoDatabase) return undefined
52
53 // Already in its final state
54 if (videoDatabase.state === VideoState.PUBLISHED) {
55 return federateVideoIfNeeded(videoDatabase, false, t)
56 }
57
58 const newState = buildNextVideoState(videoDatabase.state)
59
60 if (newState === VideoState.PUBLISHED) {
61 return moveToPublishedState({ video: videoDatabase, previousVideoState, isNewVideo, transaction: t })
62 }
63
64 if (newState === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
65 return moveToExternalStorageState({ video: videoDatabase, isNewVideo, transaction: t })
66 }
67 })
68 })
69}
70
71async function moveToExternalStorageState (options: {
72 video: MVideoFullLight
73 isNewVideo: boolean
74 transaction: Transaction
75}) {
76 const { video, isNewVideo, transaction } = options
77
78 const videoJobInfo = await VideoJobInfoModel.load(video.id, transaction)
79 const pendingTranscode = videoJobInfo?.pendingTranscode || 0
80
81 // We want to wait all transcoding jobs before moving the video on an external storage
82 if (pendingTranscode !== 0) return false
83
84 const previousVideoState = video.state
85
86 if (video.state !== VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
87 await video.setNewState(VideoState.TO_MOVE_TO_EXTERNAL_STORAGE, isNewVideo, transaction)
88 }
89
90 logger.info('Creating external storage move job for video %s.', video.uuid, { tags: [ video.uuid ] })
91
92 try {
93 await JobQueue.Instance.createJob(await buildMoveToObjectStorageJob({ video, previousVideoState, isNewVideo }))
94
95 return true
96 } catch (err) {
97 logger.error('Cannot add move to object storage job', { err })
98
99 return false
100 }
101}
102
103function moveToFailedTranscodingState (video: MVideo) {
104 if (video.state === VideoState.TRANSCODING_FAILED) return
105
106 return video.setNewState(VideoState.TRANSCODING_FAILED, false, undefined)
107}
108
109function moveToFailedMoveToObjectStorageState (video: MVideo) {
110 if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED) return
111
112 return video.setNewState(VideoState.TO_MOVE_TO_EXTERNAL_STORAGE_FAILED, false, undefined)
113}
114
115// ---------------------------------------------------------------------------
116
117export {
118 buildNextVideoState,
119 moveToExternalStorageState,
120 moveToFailedTranscodingState,
121 moveToFailedMoveToObjectStorageState,
122 moveToNextState
123}
124
125// ---------------------------------------------------------------------------
126
127async function moveToPublishedState (options: {
128 video: MVideoFullLight
129 isNewVideo: boolean
130 transaction: Transaction
131 previousVideoState?: VideoState
132}) {
133 const { video, isNewVideo, transaction, previousVideoState } = options
134 const previousState = previousVideoState ?? video.state
135
136 logger.info('Publishing video %s.', video.uuid, { isNewVideo, previousState, tags: [ video.uuid ] })
137
138 await video.setNewState(VideoState.PUBLISHED, isNewVideo, transaction)
139
140 await federateVideoIfNeeded(video, isNewVideo, transaction)
141
142 if (previousState === VideoState.TO_EDIT) {
143 Notifier.Instance.notifyOfFinishedVideoStudioEdition(video)
144 return
145 }
146
147 if (isNewVideo) {
148 Notifier.Instance.notifyOnNewVideoIfNeeded(video)
149
150 if (previousState === VideoState.TO_TRANSCODE) {
151 Notifier.Instance.notifyOnVideoPublishedAfterTranscoding(video)
152 }
153 }
154}
diff --git a/server/lib/video-studio.ts b/server/lib/video-studio.ts
deleted file mode 100644
index f549a7084..000000000
--- a/server/lib/video-studio.ts
+++ /dev/null
@@ -1,130 +0,0 @@
1import { move, remove } from 'fs-extra'
2import { join } from 'path'
3import { logger, loggerTagsFactory } from '@server/helpers/logger'
4import { createTorrentAndSetInfoHashFromPath } from '@server/helpers/webtorrent'
5import { CONFIG } from '@server/initializers/config'
6import { UserModel } from '@server/models/user/user'
7import { MUser, MVideo, MVideoFile, MVideoFullLight, MVideoWithAllFiles } from '@server/types/models'
8import { getVideoStreamDuration } from '@shared/ffmpeg'
9import { VideoStudioEditionPayload, VideoStudioTask, VideoStudioTaskPayload } from '@shared/models'
10import { federateVideoIfNeeded } from './activitypub/videos'
11import { JobQueue } from './job-queue'
12import { VideoStudioTranscodingJobHandler } from './runners'
13import { createOptimizeOrMergeAudioJobs } from './transcoding/create-transcoding-job'
14import { getTranscodingJobPriority } from './transcoding/transcoding-priority'
15import { buildNewFile, removeHLSPlaylist, removeWebVideoFile } from './video-file'
16import { VideoPathManager } from './video-path-manager'
17
18const lTags = loggerTagsFactory('video-studio')
19
20export function buildTaskFileFieldname (indice: number, fieldName = 'file') {
21 return `tasks[${indice}][options][${fieldName}]`
22}
23
24export function getTaskFileFromReq (files: Express.Multer.File[], indice: number, fieldName = 'file') {
25 return files.find(f => f.fieldname === buildTaskFileFieldname(indice, fieldName))
26}
27
28export function getStudioTaskFilePath (filename: string) {
29 return join(CONFIG.STORAGE.TMP_PERSISTENT_DIR, filename)
30}
31
32export async function safeCleanupStudioTMPFiles (tasks: VideoStudioTaskPayload[]) {
33 logger.info('Removing studio task files', { tasks, ...lTags() })
34
35 for (const task of tasks) {
36 try {
37 if (task.name === 'add-intro' || task.name === 'add-outro') {
38 await remove(task.options.file)
39 } else if (task.name === 'add-watermark') {
40 await remove(task.options.file)
41 }
42 } catch (err) {
43 logger.error('Cannot remove studio file', { err })
44 }
45 }
46}
47
48// ---------------------------------------------------------------------------
49
50export async function approximateIntroOutroAdditionalSize (
51 video: MVideoFullLight,
52 tasks: VideoStudioTask[],
53 fileFinder: (i: number) => string
54) {
55 let additionalDuration = 0
56
57 for (let i = 0; i < tasks.length; i++) {
58 const task = tasks[i]
59
60 if (task.name !== 'add-intro' && task.name !== 'add-outro') continue
61
62 const filePath = fileFinder(i)
63 additionalDuration += await getVideoStreamDuration(filePath)
64 }
65
66 return (video.getMaxQualityFile().size / video.duration) * additionalDuration
67}
68
69// ---------------------------------------------------------------------------
70
71export async function createVideoStudioJob (options: {
72 video: MVideo
73 user: MUser
74 payload: VideoStudioEditionPayload
75}) {
76 const { video, user, payload } = options
77
78 const priority = await getTranscodingJobPriority({ user, type: 'studio', fallback: 0 })
79
80 if (CONFIG.VIDEO_STUDIO.REMOTE_RUNNERS.ENABLED) {
81 await new VideoStudioTranscodingJobHandler().create({ video, tasks: payload.tasks, priority })
82 return
83 }
84
85 await JobQueue.Instance.createJob({ type: 'video-studio-edition', payload, priority })
86}
87
88export async function onVideoStudioEnded (options: {
89 editionResultPath: string
90 tasks: VideoStudioTaskPayload[]
91 video: MVideoFullLight
92}) {
93 const { video, tasks, editionResultPath } = options
94
95 const newFile = await buildNewFile({ path: editionResultPath, mode: 'web-video' })
96 newFile.videoId = video.id
97
98 const outputPath = VideoPathManager.Instance.getFSVideoFileOutputPath(video, newFile)
99 await move(editionResultPath, outputPath)
100
101 await safeCleanupStudioTMPFiles(tasks)
102
103 await createTorrentAndSetInfoHashFromPath(video, newFile, outputPath)
104 await removeAllFiles(video, newFile)
105
106 await newFile.save()
107
108 video.duration = await getVideoStreamDuration(outputPath)
109 await video.save()
110
111 await federateVideoIfNeeded(video, false, undefined)
112
113 const user = await UserModel.loadByVideoId(video.id)
114
115 await createOptimizeOrMergeAudioJobs({ video, videoFile: newFile, isNewVideo: false, user, videoFileAlreadyLocked: false })
116}
117
118// ---------------------------------------------------------------------------
119// Private
120// ---------------------------------------------------------------------------
121
122async function removeAllFiles (video: MVideoWithAllFiles, webVideoFileException: MVideoFile) {
123 await removeHLSPlaylist(video)
124
125 for (const file of video.VideoFiles) {
126 if (file.id === webVideoFileException.id) continue
127
128 await removeWebVideoFile(video, file.id)
129 }
130}
diff --git a/server/lib/video-tokens-manager.ts b/server/lib/video-tokens-manager.ts
deleted file mode 100644
index e28e55cf7..000000000
--- a/server/lib/video-tokens-manager.ts
+++ /dev/null
@@ -1,78 +0,0 @@
1import { LRUCache } from 'lru-cache'
2import { LRU_CACHE } from '@server/initializers/constants'
3import { MUserAccountUrl } from '@server/types/models'
4import { pick } from '@shared/core-utils'
5import { buildUUID } from '@shared/extra-utils'
6
7// ---------------------------------------------------------------------------
8// Create temporary tokens that can be used as URL query parameters to access video static files
9// ---------------------------------------------------------------------------
10
11class VideoTokensManager {
12
13 private static instance: VideoTokensManager
14
15 private readonly lruCache = new LRUCache<string, { videoUUID: string, user?: MUserAccountUrl }>({
16 max: LRU_CACHE.VIDEO_TOKENS.MAX_SIZE,
17 ttl: LRU_CACHE.VIDEO_TOKENS.TTL
18 })
19
20 private constructor () {}
21
22 createForAuthUser (options: {
23 user: MUserAccountUrl
24 videoUUID: string
25 }) {
26 const { token, expires } = this.generateVideoToken()
27
28 this.lruCache.set(token, pick(options, [ 'user', 'videoUUID' ]))
29
30 return { token, expires }
31 }
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
43 hasToken (options: {
44 token: string
45 videoUUID: string
46 }) {
47 const value = this.lruCache.get(options.token)
48 if (!value) return false
49
50 return value.videoUUID === options.videoUUID
51 }
52
53 getUserFromToken (options: {
54 token: string
55 }) {
56 const value = this.lruCache.get(options.token)
57 if (!value) return undefined
58
59 return value.user
60 }
61
62 static get Instance () {
63 return this.instance || (this.instance = new this())
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 }
72}
73
74// ---------------------------------------------------------------------------
75
76export {
77 VideoTokensManager
78}
diff --git a/server/lib/video-urls.ts b/server/lib/video-urls.ts
deleted file mode 100644
index 0597488ad..000000000
--- a/server/lib/video-urls.ts
+++ /dev/null
@@ -1,31 +0,0 @@
1
2import { STATIC_PATHS, WEBSERVER } from '@server/initializers/constants'
3import { MStreamingPlaylist, MVideo, MVideoFile, MVideoUUID } from '@server/types/models'
4
5// ################## Redundancy ##################
6
7function generateHLSRedundancyUrl (video: MVideo, playlist: MStreamingPlaylist) {
8 // Base URL used by our HLS player
9 return WEBSERVER.URL + STATIC_PATHS.REDUNDANCY + playlist.getStringType() + '/' + video.uuid
10}
11
12function generateWebVideoRedundancyUrl (file: MVideoFile) {
13 return WEBSERVER.URL + STATIC_PATHS.REDUNDANCY + file.filename
14}
15
16// ################## Meta data ##################
17
18function getLocalVideoFileMetadataUrl (video: MVideoUUID, videoFile: MVideoFile) {
19 const path = '/api/v1/videos/'
20
21 return WEBSERVER.URL + path + video.uuid + '/metadata/' + videoFile.id
22}
23
24// ---------------------------------------------------------------------------
25
26export {
27 getLocalVideoFileMetadataUrl,
28
29 generateWebVideoRedundancyUrl,
30 generateHLSRedundancyUrl
31}
diff --git a/server/lib/video.ts b/server/lib/video.ts
deleted file mode 100644
index 362c861a5..000000000
--- a/server/lib/video.ts
+++ /dev/null
@@ -1,189 +0,0 @@
1import { UploadFiles } from 'express'
2import memoizee from 'memoizee'
3import { Transaction } from 'sequelize/types'
4import { CONFIG } from '@server/initializers/config'
5import { MEMOIZE_LENGTH, MEMOIZE_TTL } from '@server/initializers/constants'
6import { TagModel } from '@server/models/video/tag'
7import { VideoModel } from '@server/models/video/video'
8import { VideoJobInfoModel } from '@server/models/video/video-job-info'
9import { FilteredModelAttributes } from '@server/types'
10import { MThumbnail, MVideoFullLight, MVideoTag, MVideoThumbnail, MVideoUUID } from '@server/types/models'
11import { ManageVideoTorrentPayload, ThumbnailType, VideoCreate, VideoPrivacy, VideoState } from '@shared/models'
12import { CreateJobArgument, JobQueue } from './job-queue/job-queue'
13import { updateLocalVideoMiniatureFromExisting } from './thumbnail'
14import { moveFilesIfPrivacyChanged } from './video-privacy'
15
16function buildLocalVideoFromReq (videoInfo: VideoCreate, channelId: number): FilteredModelAttributes<VideoModel> {
17 return {
18 name: videoInfo.name,
19 remote: false,
20 category: videoInfo.category,
21 licence: videoInfo.licence ?? CONFIG.DEFAULTS.PUBLISH.LICENCE,
22 language: videoInfo.language,
23 commentsEnabled: videoInfo.commentsEnabled ?? CONFIG.DEFAULTS.PUBLISH.COMMENTS_ENABLED,
24 downloadEnabled: videoInfo.downloadEnabled ?? CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED,
25 waitTranscoding: videoInfo.waitTranscoding || false,
26 nsfw: videoInfo.nsfw || false,
27 description: videoInfo.description,
28 support: videoInfo.support,
29 privacy: videoInfo.privacy || VideoPrivacy.PRIVATE,
30 channelId,
31 originallyPublishedAt: videoInfo.originallyPublishedAt
32 ? new Date(videoInfo.originallyPublishedAt)
33 : null
34 }
35}
36
37async function buildVideoThumbnailsFromReq (options: {
38 video: MVideoThumbnail
39 files: UploadFiles
40 fallback: (type: ThumbnailType) => Promise<MThumbnail>
41 automaticallyGenerated?: boolean
42}) {
43 const { video, files, fallback, automaticallyGenerated } = options
44
45 const promises = [
46 {
47 type: ThumbnailType.MINIATURE,
48 fieldName: 'thumbnailfile'
49 },
50 {
51 type: ThumbnailType.PREVIEW,
52 fieldName: 'previewfile'
53 }
54 ].map(p => {
55 const fields = files?.[p.fieldName]
56
57 if (fields) {
58 return updateLocalVideoMiniatureFromExisting({
59 inputPath: fields[0].path,
60 video,
61 type: p.type,
62 automaticallyGenerated: automaticallyGenerated || false
63 })
64 }
65
66 return fallback(p.type)
67 })
68
69 return Promise.all(promises)
70}
71
72// ---------------------------------------------------------------------------
73
74async function setVideoTags (options: {
75 video: MVideoTag
76 tags: string[]
77 transaction?: Transaction
78}) {
79 const { video, tags, transaction } = options
80
81 const internalTags = tags || []
82 const tagInstances = await TagModel.findOrCreateTags(internalTags, transaction)
83
84 await video.$set('Tags', tagInstances, { transaction })
85 video.Tags = tagInstances
86}
87
88// ---------------------------------------------------------------------------
89
90async function buildMoveToObjectStorageJob (options: {
91 video: MVideoUUID
92 previousVideoState: VideoState
93 isNewVideo?: boolean // Default true
94}) {
95 const { video, previousVideoState, isNewVideo = true } = options
96
97 await VideoJobInfoModel.increaseOrCreate(video.uuid, 'pendingMove')
98
99 return {
100 type: 'move-to-object-storage' as 'move-to-object-storage',
101 payload: {
102 videoUUID: video.uuid,
103 isNewVideo,
104 previousVideoState
105 }
106 }
107}
108
109// ---------------------------------------------------------------------------
110
111async function getVideoDuration (videoId: number | string) {
112 const video = await VideoModel.load(videoId)
113
114 const duration = video.isLive
115 ? undefined
116 : video.duration
117
118 return { duration, isLive: video.isLive }
119}
120
121const getCachedVideoDuration = memoizee(getVideoDuration, {
122 promise: true,
123 max: MEMOIZE_LENGTH.VIDEO_DURATION,
124 maxAge: MEMOIZE_TTL.VIDEO_DURATION
125})
126
127// ---------------------------------------------------------------------------
128
129async function addVideoJobsAfterUpdate (options: {
130 video: MVideoFullLight
131 isNewVideo: boolean
132
133 nameChanged: boolean
134 oldPrivacy: VideoPrivacy
135}) {
136 const { video, nameChanged, oldPrivacy, isNewVideo } = options
137 const jobs: CreateJobArgument[] = []
138
139 const filePathChanged = await moveFilesIfPrivacyChanged(video, oldPrivacy)
140
141 if (!video.isLive && (nameChanged || filePathChanged)) {
142 for (const file of (video.VideoFiles || [])) {
143 const payload: ManageVideoTorrentPayload = { action: 'update-metadata', videoId: video.id, videoFileId: file.id }
144
145 jobs.push({ type: 'manage-video-torrent', payload })
146 }
147
148 const hls = video.getHLSPlaylist()
149
150 for (const file of (hls?.VideoFiles || [])) {
151 const payload: ManageVideoTorrentPayload = { action: 'update-metadata', streamingPlaylistId: hls.id, videoFileId: file.id }
152
153 jobs.push({ type: 'manage-video-torrent', payload })
154 }
155 }
156
157 jobs.push({
158 type: 'federate-video',
159 payload: {
160 videoUUID: video.uuid,
161 isNewVideo
162 }
163 })
164
165 const wasConfidentialVideo = new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.UNLISTED, VideoPrivacy.INTERNAL ]).has(oldPrivacy)
166
167 if (wasConfidentialVideo) {
168 jobs.push({
169 type: 'notify',
170 payload: {
171 action: 'new-video',
172 videoUUID: video.uuid
173 }
174 })
175 }
176
177 return JobQueue.Instance.createSequentialJobFlow(...jobs)
178}
179
180// ---------------------------------------------------------------------------
181
182export {
183 buildLocalVideoFromReq,
184 buildVideoThumbnailsFromReq,
185 setVideoTags,
186 buildMoveToObjectStorageJob,
187 addVideoJobsAfterUpdate,
188 getCachedVideoDuration
189}
diff --git a/server/lib/views/shared/index.ts b/server/lib/views/shared/index.ts
deleted file mode 100644
index 139471183..000000000
--- a/server/lib/views/shared/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
1export * from './video-viewer-counters'
2export * from './video-viewer-stats'
3export * from './video-views'
diff --git a/server/lib/views/shared/video-viewer-counters.ts b/server/lib/views/shared/video-viewer-counters.ts
deleted file mode 100644
index f5b83130e..000000000
--- a/server/lib/views/shared/video-viewer-counters.ts
+++ /dev/null
@@ -1,198 +0,0 @@
1import { isTestOrDevInstance } from '@server/helpers/core-utils'
2import { logger, loggerTagsFactory } from '@server/helpers/logger'
3import { VIEW_LIFETIME } from '@server/initializers/constants'
4import { sendView } from '@server/lib/activitypub/send/send-view'
5import { PeerTubeSocket } from '@server/lib/peertube-socket'
6import { getServerActor } from '@server/models/application/application'
7import { VideoModel } from '@server/models/video/video'
8import { MVideo, MVideoImmutable } from '@server/types/models'
9import { buildUUID, sha256 } from '@shared/extra-utils'
10
11const lTags = loggerTagsFactory('views')
12
13export type ViewerScope = 'local' | 'remote'
14export type VideoScope = 'local' | 'remote'
15
16type Viewer = {
17 expires: number
18 id: string
19 viewerScope: ViewerScope
20 videoScope: VideoScope
21 lastFederation?: number
22}
23
24export class VideoViewerCounters {
25
26 // expires is new Date().getTime()
27 private readonly viewersPerVideo = new Map<number, Viewer[]>()
28 private readonly idToViewer = new Map<string, Viewer>()
29
30 private readonly salt = buildUUID()
31
32 private processingViewerCounters = false
33
34 constructor () {
35 setInterval(() => this.cleanViewerCounters(), VIEW_LIFETIME.VIEWER_COUNTER)
36 }
37
38 // ---------------------------------------------------------------------------
39
40 async addLocalViewer (options: {
41 video: MVideoImmutable
42 ip: string
43 }) {
44 const { video, ip } = options
45
46 logger.debug('Adding local viewer to video viewers counter %s.', video.uuid, { ...lTags(video.uuid) })
47
48 const viewerId = this.generateViewerId(ip, video.uuid)
49 const viewer = this.idToViewer.get(viewerId)
50
51 if (viewer) {
52 viewer.expires = this.buildViewerExpireTime()
53 await this.federateViewerIfNeeded(video, viewer)
54
55 return false
56 }
57
58 const newViewer = await this.addViewerToVideo({ viewerId, video, viewerScope: 'local' })
59 await this.federateViewerIfNeeded(video, newViewer)
60
61 return true
62 }
63
64 async addRemoteViewer (options: {
65 video: MVideo
66 viewerId: string
67 viewerExpires: Date
68 }) {
69 const { video, viewerExpires, viewerId } = options
70
71 logger.debug('Adding remote viewer to video %s.', video.uuid, { ...lTags(video.uuid) })
72
73 await this.addViewerToVideo({ video, viewerExpires, viewerId, viewerScope: 'remote' })
74
75 return true
76 }
77
78 // ---------------------------------------------------------------------------
79
80 getTotalViewers (options: {
81 viewerScope: ViewerScope
82 videoScope: VideoScope
83 }) {
84 let total = 0
85
86 for (const viewers of this.viewersPerVideo.values()) {
87 total += viewers.filter(v => v.viewerScope === options.viewerScope && v.videoScope === options.videoScope).length
88 }
89
90 return total
91 }
92
93 getViewers (video: MVideo) {
94 const viewers = this.viewersPerVideo.get(video.id)
95 if (!viewers) return 0
96
97 return viewers.length
98 }
99
100 buildViewerExpireTime () {
101 return new Date().getTime() + VIEW_LIFETIME.VIEWER_COUNTER
102 }
103
104 // ---------------------------------------------------------------------------
105
106 private async addViewerToVideo (options: {
107 video: MVideoImmutable
108 viewerId: string
109 viewerScope: ViewerScope
110 viewerExpires?: Date
111 }) {
112 const { video, viewerExpires, viewerId, viewerScope } = options
113
114 let watchers = this.viewersPerVideo.get(video.id)
115
116 if (!watchers) {
117 watchers = []
118 this.viewersPerVideo.set(video.id, watchers)
119 }
120
121 const expires = viewerExpires
122 ? viewerExpires.getTime()
123 : this.buildViewerExpireTime()
124
125 const videoScope: VideoScope = video.remote
126 ? 'remote'
127 : 'local'
128
129 const viewer = { id: viewerId, expires, videoScope, viewerScope }
130 watchers.push(viewer)
131
132 this.idToViewer.set(viewerId, viewer)
133
134 await this.notifyClients(video.id, watchers.length)
135
136 return viewer
137 }
138
139 private async cleanViewerCounters () {
140 if (this.processingViewerCounters) return
141 this.processingViewerCounters = true
142
143 if (!isTestOrDevInstance()) logger.info('Cleaning video viewers.', lTags())
144
145 try {
146 for (const videoId of this.viewersPerVideo.keys()) {
147 const notBefore = new Date().getTime()
148
149 const viewers = this.viewersPerVideo.get(videoId)
150
151 // Only keep not expired viewers
152 const newViewers: Viewer[] = []
153
154 // Filter new viewers
155 for (const viewer of viewers) {
156 if (viewer.expires > notBefore) {
157 newViewers.push(viewer)
158 } else {
159 this.idToViewer.delete(viewer.id)
160 }
161 }
162
163 if (newViewers.length === 0) this.viewersPerVideo.delete(videoId)
164 else this.viewersPerVideo.set(videoId, newViewers)
165
166 await this.notifyClients(videoId, newViewers.length)
167 }
168 } catch (err) {
169 logger.error('Error in video clean viewers scheduler.', { err, ...lTags() })
170 }
171
172 this.processingViewerCounters = false
173 }
174
175 private async notifyClients (videoId: string | number, viewersLength: number) {
176 const video = await VideoModel.loadImmutableAttributes(videoId)
177 if (!video) return
178
179 PeerTubeSocket.Instance.sendVideoViewsUpdate(video, viewersLength)
180
181 logger.debug('Video viewers update for %s is %d.', video.url, viewersLength, lTags())
182 }
183
184 private generateViewerId (ip: string, videoUUID: string) {
185 return sha256(this.salt + '-' + ip + '-' + videoUUID)
186 }
187
188 private async federateViewerIfNeeded (video: MVideoImmutable, viewer: Viewer) {
189 // Federate the viewer if it's been a "long" time we did not
190 const now = new Date().getTime()
191 const federationLimit = now - (VIEW_LIFETIME.VIEWER_COUNTER * 0.75)
192
193 if (viewer.lastFederation && viewer.lastFederation > federationLimit) return
194
195 await sendView({ byActor: await getServerActor(), video, type: 'viewer', viewerIdentifier: viewer.id })
196 viewer.lastFederation = now
197 }
198}
diff --git a/server/lib/views/shared/video-viewer-stats.ts b/server/lib/views/shared/video-viewer-stats.ts
deleted file mode 100644
index ebd963e59..000000000
--- a/server/lib/views/shared/video-viewer-stats.ts
+++ /dev/null
@@ -1,196 +0,0 @@
1import { Transaction } from 'sequelize/types'
2import { isTestOrDevInstance } from '@server/helpers/core-utils'
3import { GeoIP } from '@server/helpers/geo-ip'
4import { logger, loggerTagsFactory } from '@server/helpers/logger'
5import { MAX_LOCAL_VIEWER_WATCH_SECTIONS, VIEW_LIFETIME } from '@server/initializers/constants'
6import { sequelizeTypescript } from '@server/initializers/database'
7import { sendCreateWatchAction } from '@server/lib/activitypub/send'
8import { getLocalVideoViewerActivityPubUrl } from '@server/lib/activitypub/url'
9import { Redis } from '@server/lib/redis'
10import { VideoModel } from '@server/models/video/video'
11import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer'
12import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section'
13import { MVideo, MVideoImmutable } from '@server/types/models'
14import { VideoViewEvent } from '@shared/models'
15
16const lTags = loggerTagsFactory('views')
17
18type LocalViewerStats = {
19 firstUpdated: number // Date.getTime()
20 lastUpdated: number // Date.getTime()
21
22 watchSections: {
23 start: number
24 end: number
25 }[]
26
27 watchTime: number
28
29 country: string
30
31 videoId: number
32}
33
34export class VideoViewerStats {
35 private processingViewersStats = false
36
37 constructor () {
38 setInterval(() => this.processViewerStats(), VIEW_LIFETIME.VIEWER_STATS)
39 }
40
41 // ---------------------------------------------------------------------------
42
43 async addLocalViewer (options: {
44 video: MVideoImmutable
45 currentTime: number
46 ip: string
47 viewEvent?: VideoViewEvent
48 }) {
49 const { video, ip, viewEvent, currentTime } = options
50
51 logger.debug('Adding local viewer to video stats %s.', video.uuid, { currentTime, viewEvent, ...lTags(video.uuid) })
52
53 return this.updateLocalViewerStats({ video, viewEvent, currentTime, ip })
54 }
55
56 // ---------------------------------------------------------------------------
57
58 async getWatchTime (videoId: number, ip: string) {
59 const stats: LocalViewerStats = await Redis.Instance.getLocalVideoViewer({ ip, videoId })
60
61 return stats?.watchTime || 0
62 }
63
64 // ---------------------------------------------------------------------------
65
66 private async updateLocalViewerStats (options: {
67 video: MVideoImmutable
68 ip: string
69 currentTime: number
70 viewEvent?: VideoViewEvent
71 }) {
72 const { video, ip, viewEvent, currentTime } = options
73 const nowMs = new Date().getTime()
74
75 let stats: LocalViewerStats = await Redis.Instance.getLocalVideoViewer({ ip, videoId: video.id })
76
77 if (stats && stats.watchSections.length >= MAX_LOCAL_VIEWER_WATCH_SECTIONS) {
78 logger.warn('Too much watch section to store for a viewer, skipping this one', { currentTime, viewEvent, ...lTags(video.uuid) })
79 return
80 }
81
82 if (!stats) {
83 const country = await GeoIP.Instance.safeCountryISOLookup(ip)
84
85 stats = {
86 firstUpdated: nowMs,
87 lastUpdated: nowMs,
88
89 watchSections: [],
90
91 watchTime: 0,
92
93 country,
94 videoId: video.id
95 }
96 }
97
98 stats.lastUpdated = nowMs
99
100 if (viewEvent === 'seek' || stats.watchSections.length === 0) {
101 stats.watchSections.push({
102 start: currentTime,
103 end: currentTime
104 })
105 } else {
106 const lastSection = stats.watchSections[stats.watchSections.length - 1]
107
108 if (lastSection.start > currentTime) {
109 logger.debug('Invalid end watch section %d. Last start record was at %d. Starting a new section.', currentTime, lastSection.start)
110
111 stats.watchSections.push({
112 start: currentTime,
113 end: currentTime
114 })
115 } else {
116 lastSection.end = currentTime
117 }
118 }
119
120 stats.watchTime = this.buildWatchTimeFromSections(stats.watchSections)
121
122 logger.debug('Set local video viewer stats for video %s.', video.uuid, { stats, ...lTags(video.uuid) })
123
124 await Redis.Instance.setLocalVideoViewer(ip, video.id, stats)
125 }
126
127 async processViewerStats () {
128 if (this.processingViewersStats) return
129 this.processingViewersStats = true
130
131 if (!isTestOrDevInstance()) logger.info('Processing viewer statistics.', lTags())
132
133 const now = new Date().getTime()
134
135 try {
136 const allKeys = await Redis.Instance.listLocalVideoViewerKeys()
137
138 for (const key of allKeys) {
139 const stats: LocalViewerStats = await Redis.Instance.getLocalVideoViewer({ key })
140
141 // Process expired stats
142 if (stats.lastUpdated > now - VIEW_LIFETIME.VIEWER_STATS) {
143 continue
144 }
145
146 try {
147 await sequelizeTypescript.transaction(async t => {
148 const video = await VideoModel.load(stats.videoId, t)
149 if (!video) return
150
151 const statsModel = await this.saveViewerStats(video, stats, t)
152
153 if (video.remote) {
154 await sendCreateWatchAction(statsModel, t)
155 }
156 })
157
158 await Redis.Instance.deleteLocalVideoViewersKeys(key)
159 } catch (err) {
160 logger.error('Cannot process viewer stats for Redis key %s.', key, { err, ...lTags() })
161 }
162 }
163 } catch (err) {
164 logger.error('Error in video save viewers stats scheduler.', { err, ...lTags() })
165 }
166
167 this.processingViewersStats = false
168 }
169
170 private async saveViewerStats (video: MVideo, stats: LocalViewerStats, transaction: Transaction) {
171 const statsModel = new LocalVideoViewerModel({
172 startDate: new Date(stats.firstUpdated),
173 endDate: new Date(stats.lastUpdated),
174 watchTime: stats.watchTime,
175 country: stats.country,
176 videoId: video.id
177 })
178
179 statsModel.url = getLocalVideoViewerActivityPubUrl(statsModel)
180 statsModel.Video = video as VideoModel
181
182 await statsModel.save({ transaction })
183
184 statsModel.WatchSections = await LocalVideoViewerWatchSectionModel.bulkCreateSections({
185 localVideoViewerId: statsModel.id,
186 watchSections: stats.watchSections,
187 transaction
188 })
189
190 return statsModel
191 }
192
193 private buildWatchTimeFromSections (sections: { start: number, end: number }[]) {
194 return sections.reduce((p, current) => p + (current.end - current.start), 0)
195 }
196}
diff --git a/server/lib/views/shared/video-views.ts b/server/lib/views/shared/video-views.ts
deleted file mode 100644
index e563287e1..000000000
--- a/server/lib/views/shared/video-views.ts
+++ /dev/null
@@ -1,70 +0,0 @@
1import { logger, loggerTagsFactory } from '@server/helpers/logger'
2import { sendView } from '@server/lib/activitypub/send/send-view'
3import { getCachedVideoDuration } from '@server/lib/video'
4import { getServerActor } from '@server/models/application/application'
5import { MVideo, MVideoImmutable } from '@server/types/models'
6import { buildUUID } from '@shared/extra-utils'
7import { Redis } from '../../redis'
8
9const lTags = loggerTagsFactory('views')
10
11export class VideoViews {
12
13 async addLocalView (options: {
14 video: MVideoImmutable
15 ip: string
16 watchTime: number
17 }) {
18 const { video, ip, watchTime } = options
19
20 logger.debug('Adding local view to video %s.', video.uuid, { watchTime, ...lTags(video.uuid) })
21
22 if (!await this.hasEnoughWatchTime(video, watchTime)) return false
23
24 const viewExists = await Redis.Instance.doesVideoIPViewExist(ip, video.uuid)
25 if (viewExists) return false
26
27 await Redis.Instance.setIPVideoView(ip, video.uuid)
28
29 await this.addView(video)
30
31 await sendView({ byActor: await getServerActor(), video, type: 'view', viewerIdentifier: buildUUID() })
32
33 return true
34 }
35
36 async addRemoteView (options: {
37 video: MVideo
38 }) {
39 const { video } = options
40
41 logger.debug('Adding remote view to video %s.', video.uuid, { ...lTags(video.uuid) })
42
43 await this.addView(video)
44
45 return true
46 }
47
48 // ---------------------------------------------------------------------------
49
50 private async addView (video: MVideoImmutable) {
51 const promises: Promise<any>[] = []
52
53 if (video.isOwned()) {
54 promises.push(Redis.Instance.addLocalVideoView(video.id))
55 }
56
57 promises.push(Redis.Instance.addVideoViewStats(video.id))
58
59 await Promise.all(promises)
60 }
61
62 private async hasEnoughWatchTime (video: MVideoImmutable, watchTime: number) {
63 const { duration, isLive } = await getCachedVideoDuration(video.id)
64
65 if (isLive || duration >= 30) return watchTime >= 30
66
67 // Check more than 50% of the video is watched
68 return duration / watchTime < 2
69 }
70}
diff --git a/server/lib/views/video-views-manager.ts b/server/lib/views/video-views-manager.ts
deleted file mode 100644
index c088dad5e..000000000
--- a/server/lib/views/video-views-manager.ts
+++ /dev/null
@@ -1,100 +0,0 @@
1import { logger, loggerTagsFactory } from '@server/helpers/logger'
2import { MVideo, MVideoImmutable } from '@server/types/models'
3import { VideoViewEvent } from '@shared/models'
4import { VideoScope, VideoViewerCounters, VideoViewerStats, VideoViews, ViewerScope } from './shared'
5
6/**
7 * If processing a local view:
8 * - We update viewer information (segments watched, watch time etc)
9 * - We add +1 to video viewers counter if this is a new viewer
10 * - We add +1 to video views counter if this is a new view and if the user watched enough seconds
11 * - We send AP message to notify about this viewer and this view
12 * - We update last video time for the user if authenticated
13 *
14 * If processing a remote view:
15 * - We add +1 to video viewers counter
16 * - We add +1 to video views counter
17 *
18 * A viewer is a someone that watched one or multiple sections of a video
19 * A viewer that watched only a few seconds of a video may not increment the video views counter
20 * Viewers statistics are sent to origin instance using the `WatchAction` ActivityPub object
21 *
22 */
23
24const lTags = loggerTagsFactory('views')
25
26export class VideoViewsManager {
27
28 private static instance: VideoViewsManager
29
30 private videoViewerStats: VideoViewerStats
31 private videoViewerCounters: VideoViewerCounters
32 private videoViews: VideoViews
33
34 private constructor () {
35 }
36
37 init () {
38 this.videoViewerStats = new VideoViewerStats()
39 this.videoViewerCounters = new VideoViewerCounters()
40 this.videoViews = new VideoViews()
41 }
42
43 async processLocalView (options: {
44 video: MVideoImmutable
45 currentTime: number
46 ip: string | null
47 viewEvent?: VideoViewEvent
48 }) {
49 const { video, ip, viewEvent, currentTime } = options
50
51 logger.debug('Processing local view for %s and ip %s.', video.url, ip, lTags())
52
53 await this.videoViewerStats.addLocalViewer({ video, ip, viewEvent, currentTime })
54
55 const successViewer = await this.videoViewerCounters.addLocalViewer({ video, ip })
56
57 // Do it after added local viewer to fetch updated information
58 const watchTime = await this.videoViewerStats.getWatchTime(video.id, ip)
59
60 const successView = await this.videoViews.addLocalView({ video, watchTime, ip })
61
62 return { successView, successViewer }
63 }
64
65 async processRemoteView (options: {
66 video: MVideo
67 viewerId: string | null
68 viewerExpires?: Date
69 }) {
70 const { video, viewerId, viewerExpires } = options
71
72 logger.debug('Processing remote view for %s.', video.url, { viewerExpires, viewerId, ...lTags() })
73
74 if (viewerExpires) await this.videoViewerCounters.addRemoteViewer({ video, viewerId, viewerExpires })
75 else await this.videoViews.addRemoteView({ video })
76 }
77
78 getViewers (video: MVideo) {
79 return this.videoViewerCounters.getViewers(video)
80 }
81
82 getTotalViewers (options: {
83 viewerScope: ViewerScope
84 videoScope: VideoScope
85 }) {
86 return this.videoViewerCounters.getTotalViewers(options)
87 }
88
89 buildViewerExpireTime () {
90 return this.videoViewerCounters.buildViewerExpireTime()
91 }
92
93 processViewerStats () {
94 return this.videoViewerStats.processViewerStats()
95 }
96
97 static get Instance () {
98 return this.instance || (this.instance = new this())
99 }
100}
diff --git a/server/lib/worker/parent-process.ts b/server/lib/worker/parent-process.ts
deleted file mode 100644
index 48b6c682b..000000000
--- a/server/lib/worker/parent-process.ts
+++ /dev/null
@@ -1,77 +0,0 @@
1import { join } from 'path'
2import Piscina from 'piscina'
3import { processImage } from '@server/helpers/image-utils'
4import { JOB_CONCURRENCY, WORKER_THREADS } from '@server/initializers/constants'
5import { httpBroadcast } from './workers/http-broadcast'
6import { downloadImage } from './workers/image-downloader'
7
8let downloadImageWorker: Piscina
9
10function downloadImageFromWorker (options: Parameters<typeof downloadImage>[0]): Promise<ReturnType<typeof downloadImage>> {
11 if (!downloadImageWorker) {
12 downloadImageWorker = new Piscina({
13 filename: join(__dirname, 'workers', 'image-downloader.js'),
14 concurrentTasksPerWorker: WORKER_THREADS.DOWNLOAD_IMAGE.CONCURRENCY,
15 maxThreads: WORKER_THREADS.DOWNLOAD_IMAGE.MAX_THREADS
16 })
17 }
18
19 return downloadImageWorker.run(options)
20}
21
22// ---------------------------------------------------------------------------
23
24let processImageWorker: Piscina
25
26function processImageFromWorker (options: Parameters<typeof processImage>[0]): Promise<ReturnType<typeof processImage>> {
27 if (!processImageWorker) {
28 processImageWorker = new Piscina({
29 filename: join(__dirname, 'workers', 'image-processor.js'),
30 concurrentTasksPerWorker: WORKER_THREADS.PROCESS_IMAGE.CONCURRENCY,
31 maxThreads: WORKER_THREADS.PROCESS_IMAGE.MAX_THREADS
32 })
33 }
34
35 return processImageWorker.run(options)
36}
37
38// ---------------------------------------------------------------------------
39
40let parallelHTTPBroadcastWorker: Piscina
41
42function parallelHTTPBroadcastFromWorker (options: Parameters<typeof httpBroadcast>[0]): Promise<ReturnType<typeof httpBroadcast>> {
43 if (!parallelHTTPBroadcastWorker) {
44 parallelHTTPBroadcastWorker = new Piscina({
45 filename: join(__dirname, 'workers', 'http-broadcast.js'),
46 // Keep it sync with job concurrency so the worker will accept all the requests sent by the parallelized jobs
47 concurrentTasksPerWorker: JOB_CONCURRENCY['activitypub-http-broadcast-parallel'],
48 maxThreads: 1
49 })
50 }
51
52 return parallelHTTPBroadcastWorker.run(options)
53}
54
55// ---------------------------------------------------------------------------
56
57let sequentialHTTPBroadcastWorker: Piscina
58
59function sequentialHTTPBroadcastFromWorker (options: Parameters<typeof httpBroadcast>[0]): Promise<ReturnType<typeof httpBroadcast>> {
60 if (!sequentialHTTPBroadcastWorker) {
61 sequentialHTTPBroadcastWorker = new Piscina({
62 filename: join(__dirname, 'workers', 'http-broadcast.js'),
63 // Keep it sync with job concurrency so the worker will accept all the requests sent by the parallelized jobs
64 concurrentTasksPerWorker: JOB_CONCURRENCY['activitypub-http-broadcast'],
65 maxThreads: 1
66 })
67 }
68
69 return sequentialHTTPBroadcastWorker.run(options)
70}
71
72export {
73 downloadImageFromWorker,
74 processImageFromWorker,
75 parallelHTTPBroadcastFromWorker,
76 sequentialHTTPBroadcastFromWorker
77}
diff --git a/server/lib/worker/workers/http-broadcast.ts b/server/lib/worker/workers/http-broadcast.ts
deleted file mode 100644
index 8c9c6b8ca..000000000
--- a/server/lib/worker/workers/http-broadcast.ts
+++ /dev/null
@@ -1,32 +0,0 @@
1import { map } from 'bluebird'
2import { logger } from '@server/helpers/logger'
3import { doRequest, PeerTubeRequestOptions } from '@server/helpers/requests'
4import { BROADCAST_CONCURRENCY } from '@server/initializers/constants'
5
6async function httpBroadcast (payload: {
7 uris: string[]
8 requestOptions: PeerTubeRequestOptions
9}) {
10 const { uris, requestOptions } = payload
11
12 const badUrls: string[] = []
13 const goodUrls: string[] = []
14
15 await map(uris, async uri => {
16 try {
17 await doRequest(uri, requestOptions)
18 goodUrls.push(uri)
19 } catch (err) {
20 logger.debug('HTTP broadcast to %s failed.', uri, { err })
21 badUrls.push(uri)
22 }
23 }, { concurrency: BROADCAST_CONCURRENCY })
24
25 return { goodUrls, badUrls }
26}
27
28module.exports = httpBroadcast
29
30export {
31 httpBroadcast
32}
diff --git a/server/lib/worker/workers/image-downloader.ts b/server/lib/worker/workers/image-downloader.ts
deleted file mode 100644
index 209594589..000000000
--- a/server/lib/worker/workers/image-downloader.ts
+++ /dev/null
@@ -1,35 +0,0 @@
1import { remove } from 'fs-extra'
2import { join } from 'path'
3import { processImage } from '@server/helpers/image-utils'
4import { doRequestAndSaveToFile } from '@server/helpers/requests'
5import { CONFIG } from '@server/initializers/config'
6
7async function downloadImage (options: {
8 url: string
9 destDir: string
10 destName: string
11 size: { width: number, height: number }
12}) {
13 const { url, destDir, destName, size } = options
14
15 const tmpPath = join(CONFIG.STORAGE.TMP_DIR, 'pending-' + destName)
16 await doRequestAndSaveToFile(url, tmpPath)
17
18 const destPath = join(destDir, destName)
19
20 try {
21 await processImage({ path: tmpPath, destination: destPath, newSize: size })
22 } catch (err) {
23 await remove(tmpPath)
24
25 throw err
26 }
27
28 return destPath
29}
30
31module.exports = downloadImage
32
33export {
34 downloadImage
35}
diff --git a/server/lib/worker/workers/image-processor.ts b/server/lib/worker/workers/image-processor.ts
deleted file mode 100644
index 0ab41a5a0..000000000
--- a/server/lib/worker/workers/image-processor.ts
+++ /dev/null
@@ -1,7 +0,0 @@
1import { processImage } from '@server/helpers/image-utils'
2
3module.exports = processImage
4
5export {
6 processImage
7}