From 29329d6c459f69ce9168f20cfb84c020e41c6e7a Mon Sep 17 00:00:00 2001
From: Chocobozzz <me@florianbigard.com>
Date: Fri, 29 Mar 2024 14:25:03 +0100
Subject: [PATCH] Implement auto tag on comments and videos

 * Comments and videos can be automatically tagged using core rules or
   watched word lists
 * These tags can be used to automatically filter videos and comments
 * Introduce a new video comment policy where comments must be approved
   first
 * Comments may have to be approved if the user auto block them using
   core rules or watched word lists
 * Implement FEP-5624 to federate reply control policies
---
 apps/peertube-cli/src/peertube-upload.ts      |  51 +-
 apps/peertube-cli/src/shared/cli.ts           |   2 +-
 client/src/app/+admin/admin.component.ts      |  12 +
 .../following-list/follow-modal.component.ts  |   9 +-
 .../+admin/moderation/moderation.routes.ts    |  13 +
 .../watched-words-list-admin.component.html   |  10 +
 .../watched-words-list-admin.component.ts     |  13 +
 .../video-comment-list.component.html         | 107 +--
 .../video-comment-list.component.scss         |  51 --
 .../comments/video-comment-list.component.ts  | 198 +-----
 .../overview/videos/video-admin.service.ts    |   7 +-
 .../overview/videos/video-list.component.html |  34 +-
 .../overview/videos/video-list.component.ts   |  11 +-
 client/src/app/+admin/routes.ts               |   4 +-
 .../automatic-tag.service.ts                  |  45 ++
 ...y-account-auto-tag-policies.component.html |  15 +
 .../my-account-auto-tag-policies.component.ts |  71 ++
 .../comments-on-my-videos.component.html      |   7 +
 .../comments-on-my-videos.component.ts        |  14 +
 ...-account-watched-words-list.component.html |  10 +
 ...my-account-watched-words-list.component.ts |  19 +
 .../app/+my-account/my-account.component.ts   |  57 +-
 client/src/app/+my-account/routes.ts          |  52 +-
 .../shared/video-edit.component.html          |  12 +-
 .../shared/video-edit.component.ts            |  71 +-
 .../comment/video-comment.component.html      |   3 +
 .../shared/comment/video-comment.component.ts |  41 +-
 .../comment/video-comments.component.html     |  12 +-
 .../comment/video-comments.component.ts       |  71 +-
 client/src/app/core/server/server.service.ts  |  19 +
 .../shared/form-validators/host-validators.ts |  24 +-
 .../form-validators/shared/validator-utils.ts |  18 +
 .../watched-words-list-validators.ts          |  51 ++
 .../misc/top-menu-dropdown.component.html     |  54 +-
 .../misc/top-menu-dropdown.component.ts       |   8 +-
 .../users/user-notification.model.ts          |   7 +
 .../shared-main/video/video-details.model.ts  |   7 +-
 .../shared-main/video/video-edit.model.ts     |  15 +-
 .../shared-main/video/video-import.service.ts |   2 +-
 .../shared/shared-main/video/video.model.ts   |   4 +
 .../shared/shared-main/video/video.service.ts |   2 +-
 .../batch-domains-modal.component.ts          |   7 +-
 .../user-moderation-dropdown.component.ts     |   2 +-
 ...eo-comment-list-admin-owner.component.html | 122 ++++
 ...eo-comment-list-admin-owner.component.scss |  60 ++
 ...ideo-comment-list-admin-owner.component.ts | 260 ++++++++
 .../video-comment.model.ts                    |  16 +-
 .../video-comment.service.ts                  |  76 ++-
 .../video-playlist.service.ts                 |   2 +-
 .../user-notifications.component.html         |   1 +
 ...ched-words-list-admin-owner.component.html |  86 +++
 ...atched-words-list-admin-owner.component.ts | 138 ++++
 ...tched-words-list-save-modal.component.html |  49 ++
 ...tched-words-list-save-modal.component.scss |   6 +
 ...watched-words-list-save-modal.component.ts |  95 +++
 .../watched-words-list.service.ts             |  86 +++
 client/src/root-helpers/string.ts             |   6 +
 config/default.yaml                           |   3 +-
 config/production.yaml.example                |   3 +-
 package.json                                  |   2 +
 packages/core-utils/src/users/user-role.ts    |   6 +-
 packages/models/src/activitypub/activity.ts   |  20 +-
 packages/models/src/activitypub/context.ts    |   4 +-
 .../objects/video-comment-object.ts           |   5 +
 .../src/activitypub/objects/video-object.ts   |   7 +-
 .../auto-tag-policies-export.ts               |   5 +
 .../peertube-export-format/index.ts           |   2 +
 .../user-video-history-export.ts              |   4 +-
 .../video-export.model.ts                     |   6 +-
 .../watched-words-lists-export.ts             |  10 +
 .../import-export/user-import-result.model.ts |   3 +
 .../automatic-tag-available.model.ts          |   8 +
 .../moderation/automatic-tag-policy.enum.ts   |   6 +
 ...ent-automatic-tag-policies-update.model.ts |   3 +
 .../comment-automatic-tag-policies.model.ts   |   3 +
 packages/models/src/moderation/index.ts       |   7 +-
 .../moderation/watched-words-list.model.ts    |   9 +
 .../src/search/videos-common-query.model.ts   |   6 +-
 .../models/src/server/server-config.model.ts  |   6 +-
 .../src/users/user-notification.model.ts      |   1 +
 packages/models/src/users/user-right.enum.ts  |   7 +-
 packages/models/src/videos/comment/index.ts   |   1 +
 .../comment/video-comment-policy.enum.ts      |   7 +
 .../src/videos/comment/video-comment.model.ts |  12 +-
 .../models/src/videos/video-create.model.ts   |   5 +
 .../models/src/videos/video-include.enum.ts   |   3 +-
 .../models/src/videos/video-update.model.ts   |   5 +
 packages/models/src/videos/video.model.ts     |  12 +-
 .../src/moderation/automatic-tags-command.ts  |  68 ++
 .../server-commands/src/moderation/index.ts   |   2 +
 .../src/moderation/watched-words-command.ts   |  87 +++
 packages/server-commands/src/server/server.ts |  26 +-
 .../src/videos/comments-command.ts            | 110 +++-
 .../src/videos/videos-command.ts              |  11 +-
 .../tests/src/api/check-params/auto-tags.ts   | 137 ++++
 packages/tests/src/api/check-params/index.ts  |   8 +-
 packages/tests/src/api/check-params/live.ts   |  19 +-
 .../api/check-params/video-channel-syncs.ts   |   2 +-
 .../src/api/check-params/video-comments.ts    | 162 +++--
 .../src/api/check-params/video-imports.ts     |  18 +-
 .../src/api/check-params/video-passwords.ts   |   7 +-
 .../api/check-params/videos-common-filters.ts |  18 +
 packages/tests/src/api/check-params/videos.ts |  62 +-
 packages/tests/src/api/check-params/views.ts  |   2 +-
 .../src/api/check-params/watched-words.ts     | 254 ++++++++
 packages/tests/src/api/live/live.ts           |   3 +-
 .../src/api/moderation/automatic-tags.ts      | 489 ++++++++++++++
 .../src/api/moderation/comment-approval.ts    | 552 ++++++++++++++++
 packages/tests/src/api/moderation/index.ts    |   2 +
 .../tests/src/api/moderation/watched-words.ts | 189 ++++++
 .../notifications/comments-notifications.ts   | 111 +++-
 .../tests/src/api/server/config-defaults.ts   |   8 +-
 packages/tests/src/api/server/follows.ts      |   8 +-
 packages/tests/src/api/server/handle-down.ts  |  10 +-
 packages/tests/src/api/users/user-export.ts   |  30 +-
 packages/tests/src/api/users/user-import.ts   |  34 +-
 .../tests/src/api/videos/multiple-servers.ts  |  18 +-
 .../tests/src/api/videos/single-server.ts     |   8 +-
 .../tests/src/api/videos/video-comments.ts    | 112 +++-
 packages/tests/src/feeds/feeds.ts             | 109 +++-
 packages/tests/src/server-helpers/index.ts    |   1 +
 packages/tests/src/server-helpers/regexp.ts   |  60 ++
 packages/tests/src/shared/import-export.ts    |  24 +-
 packages/tests/src/shared/notifications.ts    |  15 +-
 packages/tests/src/shared/videos.ts           |  16 +-
 .../user-import-completed/html.pug            |   6 +
 .../video-comment-new/html.pug                |   5 +
 server/core/controllers/activitypub/client.ts |  40 +-
 server/core/controllers/api/automatic-tags.ts |  82 +++
 server/core/controllers/api/index.ts          |   8 +-
 server/core/controllers/api/users/me.ts       |  58 +-
 server/core/controllers/api/videos/comment.ts | 104 +--
 server/core/controllers/api/videos/update.ts  |  20 +-
 server/core/controllers/api/watched-words.ts  | 162 +++++
 .../core/controllers/feeds/comment-feeds.ts   |  12 +-
 server/core/helpers/activity-pub-utils.ts     |  24 +-
 server/core/helpers/audit-logger.ts           |   2 +-
 .../custom-validators/activitypub/activity.ts |  61 +-
 .../activitypub/video-comments.ts             |   4 +-
 .../custom-validators/activitypub/videos.ts   |  25 +-
 .../core/helpers/custom-validators/videos.ts  |   9 +-
 .../custom-validators/watched-words.ts        |  17 +
 server/core/helpers/query.ts                  |   3 +-
 server/core/helpers/regexp.ts                 |  20 +-
 .../core/initializers/checker-before-init.ts  |   2 +-
 server/core/initializers/config.ts            |   3 +-
 server/core/initializers/constants.ts         |  20 +
 server/core/initializers/database.ts          |  28 +-
 .../initializers/migrations/0840-auto-tags.ts | 122 ++++
 .../lib/activitypub/process/process-create.ts |  30 +-
 .../lib/activitypub/process/process-delete.ts |   2 +-
 .../lib/activitypub/process/process-flag.ts   |   2 +-
 .../process/process-reply-approval.ts         |  45 ++
 .../core/lib/activitypub/process/process.ts   |   7 +-
 server/core/lib/activitypub/send/http.ts      |  19 +-
 server/core/lib/activitypub/send/index.ts     |   1 +
 .../core/lib/activitypub/send/send-create.ts  |  16 +-
 .../core/lib/activitypub/send/send-delete.ts  |   4 +-
 .../activitypub/send/send-reply-approval.ts   |  36 +
 .../activitypub/send/shared/audience-utils.ts |   2 +-
 server/core/lib/activitypub/url.ts            | 101 +--
 server/core/lib/activitypub/video-comments.ts |  66 +-
 .../videos/shared/abstract-builder.ts         |  28 +-
 .../lib/activitypub/videos/shared/creator.ts  |   4 +-
 .../shared/object-to-model-attributes.ts      |   4 +-
 server/core/lib/activitypub/videos/updater.ts |   5 +-
 .../lib/automatic-tags/automatic-tagger.ts    | 142 ++++
 .../core/lib/automatic-tags/automatic-tags.ts |  99 +++
 .../job-queue/handlers/activitypub-cleaner.ts |   4 +-
 .../lib/job-queue/handlers/video-import.ts    |  29 +-
 .../job-queue/handlers/video-live-ending.ts   |   2 +-
 server/core/lib/local-video-creator.ts        |   9 +-
 server/core/lib/notifier/notifier.ts          |  10 +
 .../shared/comment/comment-mention.ts         |   6 +-
 .../comment/new-comment-for-video-owner.ts    |  18 +-
 server/core/lib/server-config-manager.ts      |   7 +-
 server/core/lib/stat-manager.ts               |   8 +-
 .../exporters/auto-tag-policies.ts            |  18 +
 .../exporters/comments-exporter.ts            |   4 +-
 .../lib/user-import-export/exporters/index.ts |   4 +-
 .../exporters/videos-exporter.ts              |   7 +-
 .../exporters/watched-words-lists-exporter.ts |  23 +
 .../review-comments-tag-policies-importer.ts  |  31 +
 .../importers/user-video-history-importer.ts  |  10 +-
 .../importers/videos-importer.ts              |  32 +-
 .../importers/watched-words-lists-importer.ts |  35 +
 .../lib/user-import-export/user-exporter.ts   |  45 +-
 .../lib/user-import-export/user-importer.ts   |  38 +-
 server/core/lib/video-comment.ts              | 135 +++-
 server/core/lib/video-pre-import.ts           |  13 +-
 server/core/lib/video.ts                      |  22 +-
 .../middlewares/validators/automatic-tags.ts  |  45 ++
 server/core/middlewares/validators/bulk.ts    |   2 +-
 .../middlewares/validators/shared/users.ts    |  47 +-
 .../middlewares/validators/shared/videos.ts   |   4 -
 server/core/middlewares/validators/sort.ts    |   2 +
 .../middlewares/validators/users/users.ts     |  20 +-
 .../validators/videos/video-comments.ts       | 172 +++--
 .../middlewares/validators/videos/videos.ts   |  16 +-
 .../middlewares/validators/watched-words.ts   | 128 ++++
 server/core/models/account/account.ts         |  35 +-
 server/core/models/actor/actor-follow.ts      |   2 +-
 .../account-automatic-tag-policy.ts           |  96 +++
 .../models/automatic-tag/automatic-tag.ts     |  69 ++
 .../automatic-tag/comment-automatic-tag.ts    |  76 +++
 .../automatic-tag/video-automatic-tag.ts      |  76 +++
 server/core/models/shared/model-builder.ts    |   7 +-
 server/core/models/shared/query.ts            |  34 +-
 server/core/models/shared/sql.ts              |  33 +-
 .../user-notitication-list-query-builder.ts   |   1 +
 server/core/models/user/user-notification.ts  |   3 +-
 .../formatter/video-activity-pub-format.ts    |  20 +-
 .../video/formatter/video-api-format.ts       |  27 +-
 .../video-comment-list-query-builder.ts       |  77 ++-
 .../comment/video-comment-table-attributes.ts |  24 +-
 .../shared/abstract-video-query-builder.ts    |  18 +
 .../sql/video/shared/video-model-builder.ts   |  26 +
 .../video/shared/video-table-attributes.ts    |  11 +-
 .../sql/video/videos-id-list-query-builder.ts |  22 +
 .../video/videos-model-list-query-builder.ts  |  12 +-
 server/core/models/video/tag.ts               |  63 +-
 server/core/models/video/video-comment.ts     | 205 ++++--
 server/core/models/video/video-file.ts        |   6 +-
 .../models/video/video-streaming-playlist.ts  |   4 +-
 server/core/models/video/video.ts             |  16 +-
 .../watched-words/watched-words-list.ts       | 206 ++++++
 server/core/types/express.d.ts                |   5 +-
 .../account-automatic-tag-policy.ts           |   3 +
 .../models/automatic-tag/automatic-tag.ts     |   3 +
 .../automatic-tag/comment-automatic-tag.ts    |  15 +
 .../core/types/models/automatic-tag/index.ts  |   4 +
 .../automatic-tag/video-automatic-tag.ts      |  15 +
 server/core/types/models/index.ts             |   2 +
 .../types/models/user/user-notification.ts    |   2 +-
 .../core/types/models/video/video-channel.ts  |   9 +-
 .../core/types/models/video/video-comment.ts  |  22 +-
 server/core/types/models/video/video.ts       |   3 +-
 .../core/types/models/watched-words/index.ts  |   1 +
 .../watched-words/watched-words-list.ts       |   3 +
 support/doc/api/openapi.yaml                  | 614 +++++++++++++++++-
 yarn.lock                                     |   5 +
 241 files changed, 8090 insertions(+), 1399 deletions(-)
 create mode 100644 client/src/app/+admin/moderation/watched-words-list/watched-words-list-admin.component.html
 create mode 100644 client/src/app/+admin/moderation/watched-words-list/watched-words-list-admin.component.ts
 create mode 100644 client/src/app/+my-account/my-account-auto-tag-policies/automatic-tag.service.ts
 create mode 100644 client/src/app/+my-account/my-account-auto-tag-policies/my-account-auto-tag-policies.component.html
 create mode 100644 client/src/app/+my-account/my-account-auto-tag-policies/my-account-auto-tag-policies.component.ts
 create mode 100644 client/src/app/+my-account/my-account-comments-on-my-videos/comments-on-my-videos.component.html
 create mode 100644 client/src/app/+my-account/my-account-comments-on-my-videos/comments-on-my-videos.component.ts
 create mode 100644 client/src/app/+my-account/my-account-watched-words-list/my-account-watched-words-list.component.html
 create mode 100644 client/src/app/+my-account/my-account-watched-words-list/my-account-watched-words-list.component.ts
 create mode 100644 client/src/app/shared/form-validators/shared/validator-utils.ts
 create mode 100644 client/src/app/shared/form-validators/watched-words-list-validators.ts
 create mode 100644 client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.html
 create mode 100644 client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.scss
 create mode 100644 client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.ts
 create mode 100644 client/src/app/shared/standalone-watched-words/watched-words-list-admin-owner.component.html
 create mode 100644 client/src/app/shared/standalone-watched-words/watched-words-list-admin-owner.component.ts
 create mode 100644 client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.html
 create mode 100644 client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.scss
 create mode 100644 client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.ts
 create mode 100644 client/src/app/shared/standalone-watched-words/watched-words-list.service.ts
 create mode 100644 packages/models/src/import-export/peertube-export-format/auto-tag-policies-export.ts
 create mode 100644 packages/models/src/import-export/peertube-export-format/watched-words-lists-export.ts
 create mode 100644 packages/models/src/moderation/automatic-tag-available.model.ts
 create mode 100644 packages/models/src/moderation/automatic-tag-policy.enum.ts
 create mode 100644 packages/models/src/moderation/comment-automatic-tag-policies-update.model.ts
 create mode 100644 packages/models/src/moderation/comment-automatic-tag-policies.model.ts
 create mode 100644 packages/models/src/moderation/watched-words-list.model.ts
 create mode 100644 packages/models/src/videos/comment/video-comment-policy.enum.ts
 create mode 100644 packages/server-commands/src/moderation/automatic-tags-command.ts
 create mode 100644 packages/server-commands/src/moderation/watched-words-command.ts
 create mode 100644 packages/tests/src/api/check-params/auto-tags.ts
 create mode 100644 packages/tests/src/api/check-params/watched-words.ts
 create mode 100644 packages/tests/src/api/moderation/automatic-tags.ts
 create mode 100644 packages/tests/src/api/moderation/comment-approval.ts
 create mode 100644 packages/tests/src/api/moderation/watched-words.ts
 create mode 100644 packages/tests/src/server-helpers/regexp.ts
 create mode 100644 server/core/controllers/api/automatic-tags.ts
 create mode 100644 server/core/controllers/api/watched-words.ts
 create mode 100644 server/core/helpers/custom-validators/watched-words.ts
 create mode 100644 server/core/initializers/migrations/0840-auto-tags.ts
 create mode 100644 server/core/lib/activitypub/process/process-reply-approval.ts
 create mode 100644 server/core/lib/activitypub/send/send-reply-approval.ts
 create mode 100644 server/core/lib/automatic-tags/automatic-tagger.ts
 create mode 100644 server/core/lib/automatic-tags/automatic-tags.ts
 create mode 100644 server/core/lib/user-import-export/exporters/auto-tag-policies.ts
 create mode 100644 server/core/lib/user-import-export/exporters/watched-words-lists-exporter.ts
 create mode 100644 server/core/lib/user-import-export/importers/review-comments-tag-policies-importer.ts
 create mode 100644 server/core/lib/user-import-export/importers/watched-words-lists-importer.ts
 create mode 100644 server/core/middlewares/validators/automatic-tags.ts
 create mode 100644 server/core/middlewares/validators/watched-words.ts
 create mode 100644 server/core/models/automatic-tag/account-automatic-tag-policy.ts
 create mode 100644 server/core/models/automatic-tag/automatic-tag.ts
 create mode 100644 server/core/models/automatic-tag/comment-automatic-tag.ts
 create mode 100644 server/core/models/automatic-tag/video-automatic-tag.ts
 create mode 100644 server/core/models/watched-words/watched-words-list.ts
 create mode 100644 server/core/types/models/automatic-tag/account-automatic-tag-policy.ts
 create mode 100644 server/core/types/models/automatic-tag/automatic-tag.ts
 create mode 100644 server/core/types/models/automatic-tag/comment-automatic-tag.ts
 create mode 100644 server/core/types/models/automatic-tag/index.ts
 create mode 100644 server/core/types/models/automatic-tag/video-automatic-tag.ts
 create mode 100644 server/core/types/models/watched-words/index.ts
 create mode 100644 server/core/types/models/watched-words/watched-words-list.ts

diff --git a/apps/peertube-cli/src/peertube-upload.ts b/apps/peertube-cli/src/peertube-upload.ts
index 6a5950883..357870449 100644
--- a/apps/peertube-cli/src/peertube-upload.ts
+++ b/apps/peertube-cli/src/peertube-upload.ts
@@ -1,9 +1,9 @@
+import { Command } from '@commander-js/extra-typings'
+import { VideoCommentPolicy, VideoPrivacy, VideoPrivacyType } from '@peertube/peertube-models'
+import { PeerTubeServer } from '@peertube/peertube-server-commands'
 import { access, constants } from 'fs/promises'
 import { isAbsolute } from 'path'
 import { inspect } from 'util'
-import { Command } from '@commander-js/extra-typings'
-import { VideoPrivacy } from '@peertube/peertube-models'
-import { PeerTubeServer } from '@peertube/peertube-server-commands'
 import { assignToken, buildServer, getServerCredentials, listOptions } from './shared/index.js'
 
 type UploadOptions = {
@@ -14,13 +14,13 @@ type UploadOptions = {
   preview?: string
   file?: string
   videoName?: string
-  category?: string
-  licence?: string
+  category?: number
+  licence?: number
   language?: string
-  tags?: string
+  tags?: string[]
   nsfw?: true
   videoDescription?: string
-  privacy?: number
+  privacy?: VideoPrivacyType
   channelName?: string
   noCommentsEnabled?: true
   support?: string
@@ -41,13 +41,13 @@ export function defineUploadProgram () {
     .option('--preview <previewPath>', 'Preview path')
     .option('-f, --file <file>', 'Video absolute file path')
     .option('-n, --video-name <name>', 'Video name')
-    .option('-c, --category <category_number>', 'Category number')
-    .option('-l, --licence <licence_number>', 'Licence number')
+    .option('-c, --category <category_number>', 'Category number', parseInt)
+    .option('-l, --licence <licence_number>', 'Licence number', parseInt)
     .option('-L, --language <language_code>', 'Language ISO 639 code (fr or en...)')
     .option('-t, --tags <tags>', 'Video tags', listOptions)
     .option('-N, --nsfw', 'Video is Not Safe For Work')
     .option('-d, --video-description <description>', 'Video description')
-    .option('-P, --privacy <privacy_number>', 'Privacy', parseInt)
+    .option('-P, --privacy <privacy_number>', 'Privacy', v => parseInt(v) as VideoPrivacyType)
     .option('-C, --channel-name <channel_name>', 'Channel name')
     .option('--no-comments-enabled', 'Disable video comments')
     .option('-s, --support <support>', 'Video support text')
@@ -120,10 +120,9 @@ async function run (options: UploadOptions) {
   }
 }
 
-async function buildVideoAttributesFromCommander (server: PeerTubeServer, options: UploadOptions, defaultAttributes: any = {}) {
+async function buildVideoAttributesFromCommander (server: PeerTubeServer, options: UploadOptions) {
   const defaultBooleanAttributes = {
     nsfw: false,
-    commentsEnabled: true,
     downloadEnabled: true,
     waitTranscoding: true
   }
@@ -133,25 +132,29 @@ async function buildVideoAttributesFromCommander (server: PeerTubeServer, option
   for (const key of Object.keys(defaultBooleanAttributes)) {
     if (options[key] !== undefined) {
       booleanAttributes[key] = options[key]
-    } else if (defaultAttributes[key] !== undefined) {
-      booleanAttributes[key] = defaultAttributes[key]
     } else {
       booleanAttributes[key] = defaultBooleanAttributes[key]
     }
   }
 
   const videoAttributes = {
-    name: options.videoName || defaultAttributes.name,
-    category: options.category || defaultAttributes.category || undefined,
-    licence: options.licence || defaultAttributes.licence || undefined,
-    language: options.language || defaultAttributes.language || undefined,
-    privacy: options.privacy || defaultAttributes.privacy || VideoPrivacy.PUBLIC,
-    support: options.support || defaultAttributes.support || undefined,
-    description: options.videoDescription || defaultAttributes.description || undefined,
-    tags: options.tags || defaultAttributes.tags || undefined
-  }
+    name: options.videoName,
+    category: options.category || undefined,
+    licence: options.licence || undefined,
+    language: options.language || undefined,
+    privacy: options.privacy || VideoPrivacy.PUBLIC,
+    support: options.support || undefined,
+    description: options.videoDescription || undefined,
+    tags: options.tags || undefined,
 
-  Object.assign(videoAttributes, booleanAttributes)
+    commentsPolicy: options.noCommentsEnabled !== undefined
+      ? options.noCommentsEnabled === true
+        ? VideoCommentPolicy.DISABLED
+        : VideoCommentPolicy.ENABLED
+      : undefined,
+
+    ...booleanAttributes
+  }
 
   if (options.channelName) {
     const videoChannel = await server.channels.get({ channelName: options.channelName })
diff --git a/apps/peertube-cli/src/shared/cli.ts b/apps/peertube-cli/src/shared/cli.ts
index 080eb8237..256f4bcdb 100644
--- a/apps/peertube-cli/src/shared/cli.ts
+++ b/apps/peertube-cli/src/shared/cli.ts
@@ -120,7 +120,7 @@ function getRemoteObjectOrDie (
   return { url, username, password }
 }
 
-function listOptions (val: any) {
+function listOptions (val: string) {
   return val.split(',')
 }
 
diff --git a/client/src/app/+admin/admin.component.ts b/client/src/app/+admin/admin.component.ts
index 766a4ec52..cb3b0a262 100644
--- a/client/src/app/+admin/admin.component.ts
+++ b/client/src/app/+admin/admin.component.ts
@@ -153,6 +153,14 @@ export class AdminComponent implements OnInit {
       })
     }
 
+    if (this.hasServerWatchedWordsRight()) {
+      moderationItems.children.push({
+        label: $localize`Watched words`,
+        routerLink: '/admin/moderation/watched-words/list',
+        iconName: 'eye-open'
+      })
+    }
+
     if (moderationItems.children.length !== 0) this.menuEntries.push(moderationItems)
   }
 
@@ -241,6 +249,10 @@ export class AdminComponent implements OnInit {
     return this.auth.getUser().hasRight(UserRight.MANAGE_SERVERS_BLOCKLIST)
   }
 
+  private hasServerWatchedWordsRight () {
+    return this.auth.getUser().hasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS)
+  }
+
   private hasConfigRight () {
     return this.auth.getUser().hasRight(UserRight.MANAGE_CONFIGURATION)
   }
diff --git a/client/src/app/+admin/follows/following-list/follow-modal.component.ts b/client/src/app/+admin/follows/following-list/follow-modal.component.ts
index c642d2a3a..d57a49f7d 100644
--- a/client/src/app/+admin/follows/following-list/follow-modal.component.ts
+++ b/client/src/app/+admin/follows/following-list/follow-modal.component.ts
@@ -1,15 +1,16 @@
+import { NgClass, NgIf } from '@angular/common'
 import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
 import { Notifier } from '@app/core'
 import { formatICU } from '@app/helpers'
-import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators'
+import { UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators'
 import { FormReactive } from '@app/shared/shared-forms/form-reactive'
 import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
+import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
-import { NgClass, NgIf } from '@angular/common'
-import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { splitAndGetNotEmpty } from '@root-helpers/string'
 import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
-import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service'
 
 @Component({
   selector: 'my-follow-modal',
diff --git a/client/src/app/+admin/moderation/moderation.routes.ts b/client/src/app/+admin/moderation/moderation.routes.ts
index f0494de7b..d354f8dcc 100644
--- a/client/src/app/+admin/moderation/moderation.routes.ts
+++ b/client/src/app/+admin/moderation/moderation.routes.ts
@@ -5,6 +5,7 @@ import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list
 import { UserRightGuard } from '@app/core'
 import { UserRight } from '@peertube/peertube-models'
 import { RegistrationListComponent } from './registration-list'
+import { WatchedWordsListAdminComponent } from './watched-words-list/watched-words-list-admin.component'
 
 export const ModerationRoutes: Routes = [
   {
@@ -114,6 +115,18 @@ export const ModerationRoutes: Routes = [
             title: $localize`Muted instances`
           }
         }
+      },
+
+      {
+        path: 'watched-words/list',
+        component: WatchedWordsListAdminComponent,
+        canActivate: [ UserRightGuard ],
+        data: {
+          userRight: UserRight.MANAGE_INSTANCE_WATCHED_WORDS,
+          meta: {
+            title: $localize`Watched words`
+          }
+        }
       }
     ]
   }
diff --git a/client/src/app/+admin/moderation/watched-words-list/watched-words-list-admin.component.html b/client/src/app/+admin/moderation/watched-words-list/watched-words-list-admin.component.html
new file mode 100644
index 000000000..230cd365f
--- /dev/null
+++ b/client/src/app/+admin/moderation/watched-words-list/watched-words-list-admin.component.html
@@ -0,0 +1,10 @@
+<h1>
+  <my-global-icon iconName="eye-open" aria-hidden="true"></my-global-icon>
+  <ng-container i18n>Instance watched words lists</ng-container>
+</h1>
+
+<em class="d-block" i18n>Video name/description and comments that contain any of the watched words are automatically tagged with the name of the list.</em>
+<em class="d-block mb-3" i18n>These automatic tags can be used to filter comments and videos.</em>
+
+<my-watched-words-list-admin-owner mode="admin"></my-watched-words-list-admin-owner>
+
diff --git a/client/src/app/+admin/moderation/watched-words-list/watched-words-list-admin.component.ts b/client/src/app/+admin/moderation/watched-words-list/watched-words-list-admin.component.ts
new file mode 100644
index 000000000..2c15c3840
--- /dev/null
+++ b/client/src/app/+admin/moderation/watched-words-list/watched-words-list-admin.component.ts
@@ -0,0 +1,13 @@
+import { Component } from '@angular/core'
+import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
+import { WatchedWordsListAdminOwnerComponent } from '@app/shared/standalone-watched-words/watched-words-list-admin-owner.component'
+
+@Component({
+  templateUrl: './watched-words-list-admin.component.html',
+  standalone: true,
+  imports: [
+    GlobalIconComponent,
+    WatchedWordsListAdminOwnerComponent
+  ]
+})
+export class WatchedWordsListAdminComponent { }
diff --git a/client/src/app/+admin/overview/comments/video-comment-list.component.html b/client/src/app/+admin/overview/comments/video-comment-list.component.html
index 183854af7..0b87f0f38 100644
--- a/client/src/app/+admin/overview/comments/video-comment-list.component.html
+++ b/client/src/app/+admin/overview/comments/video-comment-list.component.html
@@ -7,110 +7,5 @@
 
 <em i18n>This view also shows comments from muted accounts.</em>
 
-<p-table
-  [value]="comments" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
-  [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id"
-  [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
-  [showCurrentPageReport]="true" [currentPageReportTemplate]="getPaginationTemplate()"
-  [expandedRowKeys]="expandedRows" [(selection)]="selectedRows"
->
-  <ng-template pTemplate="caption">
-    <div class="caption">
-      <div>
-        <my-action-dropdown
-          *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
-          [actions]="bulkActions" [entry]="selectedRows"
-        >
-        </my-action-dropdown>
-      </div>
-
-      <div class="ms-auto right-form">
-        <my-advanced-input-filter [filters]="inputFilters" (search)="onSearch($event)"></my-advanced-input-filter>
-
-        <my-button i18n-label label="Refresh" icon="refresh" (click)="reloadData()"></my-button>
-      </div>
-    </div>
-  </ng-template>
-
-  <ng-template pTemplate="header">
-    <tr>
-      <th scope="col" style="width: 40px;">
-        <p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
-      </th>
-      <th scope="col" style="width: 40px;">
-        <span i18n class="visually-hidden">More information</span>
-      </th>
-      <th scope="col" style="width: 150px;">
-        <span i18n class="visually-hidden">Actions</span>
-      </th>
-      <th scope="col" style="width: 300px;" i18n>Account</th>
-      <th scope="col" style="width: 300px;" i18n>Video</th>
-      <th scope="col" i18n>Comment</th>
-      <th scope="col" style="width: 150px;" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="createdAt">Date <p-sortIcon field="createdAt"></p-sortIcon></th>
-    </tr>
-  </ng-template>
-
-  <ng-template pTemplate="body" let-videoComment let-expanded="expanded">
-    <tr [pSelectableRow]="videoComment">
-
-      <td class="checkbox-cell">
-        <p-tableCheckbox [value]="videoComment" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox>
-      </td>
-
-      <td class="expand-cell">
-        <my-table-expander-icon [pRowToggler]="videoComment" i18n-tooltip tooltip="See full comment" [expanded]="expanded"></my-table-expander-icon>
-      </td>
-
-      <td class="action-cell">
-        <my-action-dropdown
-          [ngClass]="{ 'show': expanded }" placement="bottom-right" container="body"
-          i18n-label label="Actions" [actions]="videoCommentActions" [entry]="videoComment"
-        ></my-action-dropdown>
-      </td>
-
-      <td>
-        <a [href]="videoComment.account.localUrl" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
-          <div class="chip two-lines">
-            <my-actor-avatar [actor]="videoComment.account" actorType="account" size="32"></my-actor-avatar>
-          <div>
-              {{ videoComment.account.displayName }}
-              <span>{{ videoComment.by }}</span>
-            </div>
-          </div>
-        </a>
-      </td>
-
-      <td class="video">
-        <em i18n>Commented video</em>
-
-        <a [href]="videoComment.localUrl" target="_blank" rel="noopener noreferrer">{{ videoComment.video.name }}</a>
-      </td>
-
-      <td class="comment-html c-hand" [pRowToggler]="videoComment">
-        <div [innerHTML]="videoComment.textHtml"></div>
-      </td>
-
-      <td class="c-hand" [pRowToggler]="videoComment">{{ videoComment.createdAt | date: 'short' }}</td>
-    </tr>
-  </ng-template>
-
-  <ng-template pTemplate="rowexpansion" let-videoComment>
-    <tr>
-      <td class="expand-cell" myAutoColspan>
-        <div [innerHTML]="videoComment.textHtml"></div>
-      </td>
-    </tr>
-  </ng-template>
-
-  <ng-template pTemplate="emptymessage">
-    <tr>
-      <td myAutoColspan>
-        <div class="no-results">
-          <ng-container *ngIf="search" i18n>No comments found matching current filters.</ng-container>
-          <ng-container *ngIf="!search" i18n>No comments found.</ng-container>
-        </div>
-      </td>
-    </tr>
-  </ng-template>
-</p-table>
+<my-video-comment-list-admin-owner mode="admin"></my-video-comment-list-admin-owner>
 
diff --git a/client/src/app/+admin/overview/comments/video-comment-list.component.scss b/client/src/app/+admin/overview/comments/video-comment-list.component.scss
index 2777bf6d1..d7deffa29 100644
--- a/client/src/app/+admin/overview/comments/video-comment-list.component.scss
+++ b/client/src/app/+admin/overview/comments/video-comment-list.component.scss
@@ -7,54 +7,3 @@ my-feed {
   display: inline-block;
   width: 15px;
 }
-
-my-global-icon {
-  width: 24px;
-  height: 24px;
-}
-
-.video {
-  display: flex;
-  flex-direction: column;
-
-  em {
-    font-size: 11px;
-  }
-
-  a {
-    @include ellipsis;
-
-    color: pvar(--mainForegroundColor);
-  }
-}
-
-.comment-html {
-  ::ng-deep {
-    > div {
-      max-height: 22px;
-    }
-
-    div,
-    p {
-      @include ellipsis;
-    }
-
-    p {
-      margin: 0;
-    }
-  }
-}
-
-.right-form {
-  display: flex;
-
-  > *:not(:last-child) {
-    @include margin-right(10px);
-  }
-}
-
-@media screen and (max-width: $primeng-breakpoint) {
-  .video {
-    align-items: flex-start !important;
-  }
-}
diff --git a/client/src/app/+admin/overview/comments/video-comment-list.component.ts b/client/src/app/+admin/overview/comments/video-comment-list.component.ts
index f0fe8880e..5d5e7b89d 100644
--- a/client/src/app/+admin/overview/comments/video-comment-list.component.ts
+++ b/client/src/app/+admin/overview/comments/video-comment-list.component.ts
@@ -1,54 +1,22 @@
-import { SortMeta, SharedModule } from 'primeng/api'
-import { Component, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
-import { FeedFormat, UserRight } from '@peertube/peertube-models'
-import { formatICU } from '@app/helpers'
-import { AutoColspanDirective } from '../../../shared/shared-main/angular/auto-colspan.directive'
-import { ActorAvatarComponent } from '../../../shared/shared-actor-image/actor-avatar.component'
-import { TableExpanderIconComponent } from '../../../shared/shared-tables/table-expander-icon.component'
-import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
-import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component'
-import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../../../shared/shared-forms/advanced-input-filter.component'
-import { ActionDropdownComponent, DropdownAction } from '../../../shared/shared-main/buttons/action-dropdown.component'
-import { NgIf, NgClass, DatePipe } from '@angular/common'
-import { TableModule } from 'primeng/table'
-import { FeedComponent } from '../../../shared/shared-main/feeds/feed.component'
-import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
-import { VideoCommentAdmin } from '@app/shared/shared-video-comment/video-comment.model'
-import { BulkService } from '@app/shared/shared-moderation/bulk.service'
+import { Component } from '@angular/core'
 import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
+import { FeedFormat } from '@peertube/peertube-models'
+import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
+import { FeedComponent } from '../../../shared/shared-main/feeds/feed.component'
+import { VideoCommentListAdminOwnerComponent } from '../../../shared/shared-video-comment/video-comment-list-admin-owner.component'
 
 @Component({
   selector: 'my-video-comment-list',
   templateUrl: './video-comment-list.component.html',
-  styleUrls: [ '../../../shared/shared-moderation/moderation.scss', './video-comment-list.component.scss' ],
+  styleUrls: [ './video-comment-list.component.scss' ],
   standalone: true,
   imports: [
     GlobalIconComponent,
     FeedComponent,
-    TableModule,
-    SharedModule,
-    NgIf,
-    ActionDropdownComponent,
-    AdvancedInputFilterComponent,
-    ButtonComponent,
-    NgbTooltip,
-    TableExpanderIconComponent,
-    NgClass,
-    ActorAvatarComponent,
-    AutoColspanDirective,
-    DatePipe
+    VideoCommentListAdminOwnerComponent
   ]
 })
-export class VideoCommentListComponent extends RestTable <VideoCommentAdmin> implements OnInit {
-  comments: VideoCommentAdmin[]
-  totalRecords = 0
-  sort: SortMeta = { field: 'createdAt', order: -1 }
-  pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
-
-  videoCommentActions: DropdownAction<VideoCommentAdmin>[][] = []
-
+export class VideoCommentListComponent {
   syndicationItems = [
     {
       format: FeedFormat.RSS,
@@ -66,154 +34,4 @@ export class VideoCommentListComponent extends RestTable <VideoCommentAdmin> imp
       url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.JSON.toLowerCase()
     }
   ]
-
-  bulkActions: DropdownAction<VideoCommentAdmin[]>[] = []
-
-  inputFilters: AdvancedInputFilter[] = [
-    {
-      title: $localize`Advanced filters`,
-      children: [
-        {
-          value: 'local:true',
-          label: $localize`Local comments`
-        },
-        {
-          value: 'local:false',
-          label: $localize`Remote comments`
-        },
-        {
-          value: 'localVideo:true',
-          label: $localize`Comments on local videos`
-        }
-      ]
-    }
-  ]
-
-  get authUser () {
-    return this.auth.getUser()
-  }
-
-  constructor (
-    protected router: Router,
-    protected route: ActivatedRoute,
-    private auth: AuthService,
-    private notifier: Notifier,
-    private confirmService: ConfirmService,
-    private videoCommentService: VideoCommentService,
-    private markdownRenderer: MarkdownService,
-    private bulkService: BulkService
-  ) {
-    super()
-
-    this.videoCommentActions = [
-      [
-        {
-          label: $localize`Delete this comment`,
-          handler: comment => this.deleteComment(comment),
-          isDisplayed: () => this.authUser.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)
-        },
-
-        {
-          label: $localize`Delete all comments of this account`,
-          description: $localize`Comments are deleted after a few minutes`,
-          handler: comment => this.deleteUserComments(comment),
-          isDisplayed: () => this.authUser.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)
-        }
-      ]
-    ]
-  }
-
-  ngOnInit () {
-    this.initialize()
-
-    this.bulkActions = [
-      {
-        label: $localize`Delete`,
-        handler: comments => this.removeComments(comments),
-        isDisplayed: () => this.authUser.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT),
-        iconName: 'delete'
-      }
-    ]
-  }
-
-  getIdentifier () {
-    return 'VideoCommentListComponent'
-  }
-
-  toHtml (text: string) {
-    return this.markdownRenderer.textMarkdownToHTML({ markdown: text, withHtml: true, withEmoji: true })
-  }
-
-  protected reloadDataInternal () {
-    this.videoCommentService.getAdminVideoComments({
-      pagination: this.pagination,
-      sort: this.sort,
-      search: this.search
-    }).subscribe({
-      next: async resultList => {
-        this.totalRecords = resultList.total
-
-        this.comments = []
-
-        for (const c of resultList.data) {
-          this.comments.push(
-            new VideoCommentAdmin(c, await this.toHtml(c.text))
-          )
-        }
-      },
-
-      error: err => this.notifier.error(err.message)
-    })
-  }
-
-  private removeComments (comments: VideoCommentAdmin[]) {
-    const commentArgs = comments.map(c => ({ videoId: c.video.id, commentId: c.id }))
-
-    this.videoCommentService.deleteVideoComments(commentArgs)
-      .subscribe({
-        next: () => {
-          this.notifier.success(
-            formatICU(
-              $localize`{count, plural, =1 {1 comment deleted.} other {{count} comments deleted.}}`,
-              { count: commentArgs.length }
-            )
-          )
-
-          this.reloadData()
-        },
-
-        error: err => this.notifier.error(err.message),
-
-        complete: () => this.selectedRows = []
-      })
-  }
-
-  private deleteComment (comment: VideoCommentAdmin) {
-    this.videoCommentService.deleteVideoComment(comment.video.id, comment.id)
-      .subscribe({
-        next: () => this.reloadData(),
-
-        error: err => this.notifier.error(err.message)
-      })
-  }
-
-  private async deleteUserComments (comment: VideoCommentAdmin) {
-    const message = $localize`Do you really want to delete all comments of ${comment.by}?`
-    const res = await this.confirmService.confirm(message, $localize`Delete`)
-    if (res === false) return
-
-    const options = {
-      accountName: comment.by,
-      scope: 'instance' as 'instance'
-    }
-
-    this.bulkService.removeCommentsOf(options)
-      .subscribe({
-        next: () => {
-          this.notifier.success($localize`Comments of ${options.accountName} will be deleted in a few minutes`)
-        },
-
-        error: err => this.notifier.error(err.message)
-      })
-  }
 }
diff --git a/client/src/app/+admin/overview/videos/video-admin.service.ts b/client/src/app/+admin/overview/videos/video-admin.service.ts
index 6a45eb201..8ab91be96 100644
--- a/client/src/app/+admin/overview/videos/video-admin.service.ts
+++ b/client/src/app/+admin/overview/videos/video-admin.service.ts
@@ -113,7 +113,8 @@ export class VideoAdminService {
       VideoInclude.BLOCKED_OWNER |
       VideoInclude.NOT_PUBLISHED_STATE |
       VideoInclude.FILES |
-      VideoInclude.SOURCE
+      VideoInclude.SOURCE |
+      VideoInclude.AUTOMATIC_TAGS
 
     let privacyOneOf = getAllPrivacies()
 
@@ -143,6 +144,10 @@ export class VideoAdminService {
       excludePublic: {
         prefix: 'excludePublic',
         handler: () => true
+      },
+      autoTagOneOf: {
+        prefix: 'autoTag:',
+        multiple: true
       }
     })
 
diff --git a/client/src/app/+admin/overview/videos/video-list.component.html b/client/src/app/+admin/overview/videos/video-list.component.html
index 0bdccbab8..1dd0953f8 100644
--- a/client/src/app/+admin/overview/videos/video-list.component.html
+++ b/client/src/app/+admin/overview/videos/video-list.component.html
@@ -70,22 +70,34 @@
       </td>
 
       <td>
-        @if (video.isLocal) {
-          <span class="pt-badge badge-blue" i18n>Local</span>
-        } @else {
-          <span class="pt-badge badge-purple" i18n>Remote</span>
-        }
+        <div>
+          @if (video.isLocal) {
+            <span class="pt-badge badge-blue" i18n>Local</span>
+          } @else {
+            <span class="pt-badge badge-purple" i18n>Remote</span>
+          }
 
-        <span [ngClass]="getPrivacyBadgeClass(video)" class="pt-badge">{{ video.privacy.label }}</span>
+          <span [ngClass]="getPrivacyBadgeClass(video)" class="pt-badge">{{ video.privacy.label }}</span>
 
-        <span *ngIf="video.nsfw" class="pt-badge badge-red" i18n>NSFW</span>
+          <span *ngIf="video.nsfw" class="pt-badge badge-red" i18n>NSFW</span>
 
-        <span *ngIf="isUnpublished(video)" class="pt-badge badge-yellow">{{ video.state.label }}</span>
+          <span *ngIf="isUnpublished(video)" class="pt-badge badge-yellow">{{ video.state.label }}</span>
 
-        <span *ngIf="isAccountBlocked(video)" class="pt-badge badge-red" i18n>Account muted</span>
-        <span *ngIf="isServerBlocked(video)" class="pt-badge badge-red" i18n>Server muted</span>
+          <span *ngIf="isAccountBlocked(video)" class="pt-badge badge-red" i18n>Account muted</span>
+          <span *ngIf="isServerBlocked(video)" class="pt-badge badge-red" i18n>Server muted</span>
 
-        <span *ngIf="isVideoBlocked(video)" class="pt-badge badge-red" i18n>Blocked</span>
+          <span *ngIf="isVideoBlocked(video)" class="pt-badge badge-red" i18n>Blocked</span>
+        </div>
+
+        <div>
+          @for (tag of video.automaticTags; track tag) {
+            <a
+              i18n-title title="Only display videos with this tag"
+              class="pt-badge badge-secondary me-1"
+              [routerLink]="[ '.' ]" [queryParams]="{ 'search': buildSearchAutoTag(tag) }"
+            >{{ tag }}</a>
+          }
+        </div>
       </td>
 
       <td>
diff --git a/client/src/app/+admin/overview/videos/video-list.component.ts b/client/src/app/+admin/overview/videos/video-list.component.ts
index 30868499d..3e6325e32 100644
--- a/client/src/app/+admin/overview/videos/video-list.component.ts
+++ b/client/src/app/+admin/overview/videos/video-list.component.ts
@@ -1,6 +1,6 @@
 import { DatePipe, NgClass, NgFor, NgIf } from '@angular/common'
 import { Component, OnInit, ViewChild } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
+import { ActivatedRoute, Router, RouterLink } from '@angular/router'
 import { AuthService, ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
 import { formatICU, getAbsoluteAPIUrl } from '@app/helpers'
 import { Video } from '@app/shared/shared-main/video/video.model'
@@ -51,6 +51,7 @@ import { VideoAdminService } from './video-admin.service'
     EmbedComponent,
     VideoBlockComponent,
     DatePipe,
+    RouterLink,
     BytesPipe
   ]
 })
@@ -256,6 +257,14 @@ export class VideoListComponent extends RestTable <Video> implements OnInit {
       })
   }
 
+  buildSearchAutoTag (tag: string) {
+    const str = `autoTag:"${tag}"`
+
+    if (this.search) return this.search + ' ' + str
+
+    return str
+  }
+
   protected reloadDataInternal () {
     this.loading = true
 
diff --git a/client/src/app/+admin/routes.ts b/client/src/app/+admin/routes.ts
index a0af800c1..81e047fca 100644
--- a/client/src/app/+admin/routes.ts
+++ b/client/src/app/+admin/routes.ts
@@ -23,6 +23,7 @@ import { TwoFactorService } from '@app/shared/shared-users/two-factor.service'
 import { UserAdminService } from '@app/shared/shared-users/user-admin.service'
 import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
 import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
+import { WatchedWordsListService } from '@app/shared/standalone-watched-words/watched-words-list.service'
 
 export default [
   {
@@ -52,7 +53,8 @@ export default [
       DynamicElementService,
       FindInBulkService,
       SearchService,
-      VideoPlaylistService
+      VideoPlaylistService,
+      WatchedWordsListService
     ],
     children: [
       {
diff --git a/client/src/app/+my-account/my-account-auto-tag-policies/automatic-tag.service.ts b/client/src/app/+my-account/my-account-auto-tag-policies/automatic-tag.service.ts
new file mode 100644
index 000000000..7e435e6a9
--- /dev/null
+++ b/client/src/app/+my-account/my-account-auto-tag-policies/automatic-tag.service.ts
@@ -0,0 +1,45 @@
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor } from '@app/core'
+import { AutomaticTagAvailable, CommentAutomaticTagPolicies } from '@peertube/peertube-models'
+import { catchError } from 'rxjs/operators'
+import { environment } from 'src/environments/environment'
+
+@Injectable({ providedIn: 'root' })
+export class AutomaticTagService {
+  private static BASE_AUTOMATIC_TAGS_URL = environment.apiUrl + '/api/v1/automatic-tags/'
+
+  constructor (
+    private authHttp: HttpClient,
+    private restExtractor: RestExtractor
+  ) {}
+
+  listAvailable (options: {
+    accountName: string
+  }) {
+    const url = AutomaticTagService.BASE_AUTOMATIC_TAGS_URL + 'accounts/' + options.accountName + '/available'
+
+    return this.authHttp.get<AutomaticTagAvailable>(url)
+      .pipe(catchError(res => this.restExtractor.handleError(res)))
+  }
+
+  getCommentPolicies (options: {
+    accountName: string
+  }) {
+    const url = AutomaticTagService.BASE_AUTOMATIC_TAGS_URL + 'policies/accounts/' + options.accountName + '/comments'
+
+    return this.authHttp.get<CommentAutomaticTagPolicies>(url)
+      .pipe(catchError(res => this.restExtractor.handleError(res)))
+  }
+
+  updateCommentPolicies (options: {
+    accountName: string
+    review: string[]
+  }) {
+    const url = AutomaticTagService.BASE_AUTOMATIC_TAGS_URL + 'policies/accounts/' + options.accountName + '/comments'
+
+    return this.authHttp.put(url, { review: options.review })
+      .pipe(catchError(res => this.restExtractor.handleError(res)))
+  }
+
+}
diff --git a/client/src/app/+my-account/my-account-auto-tag-policies/my-account-auto-tag-policies.component.html b/client/src/app/+my-account/my-account-auto-tag-policies/my-account-auto-tag-policies.component.html
new file mode 100644
index 000000000..8aa15f872
--- /dev/null
+++ b/client/src/app/+my-account/my-account-auto-tag-policies/my-account-auto-tag-policies.component.html
@@ -0,0 +1,15 @@
+<h1>
+  <my-global-icon iconName="no" aria-hidden="true"></my-global-icon>
+  <ng-container i18n>Your automatic tag policies</ng-container>
+</h1>
+
+<strong class="d-block mb-3" i18n>Automatically block comments:</strong>
+
+@for (tag of tags; track tag; let i = $index) {
+  <div class="form-group ms-3">
+    <my-peertube-checkbox
+      [inputName]="'tag-' + i" [(ngModel)]="tag.review" [labelText]="getLabelText(tag)"
+      (ngModelChange)="updatePolicies()"
+    ></my-peertube-checkbox>
+  </div>
+}
diff --git a/client/src/app/+my-account/my-account-auto-tag-policies/my-account-auto-tag-policies.component.ts b/client/src/app/+my-account/my-account-auto-tag-policies/my-account-auto-tag-policies.component.ts
new file mode 100644
index 000000000..3c8978058
--- /dev/null
+++ b/client/src/app/+my-account/my-account-auto-tag-policies/my-account-auto-tag-policies.component.ts
@@ -0,0 +1,71 @@
+import { Component, OnInit } from '@angular/core'
+import { FormsModule } from '@angular/forms'
+import { AuthService, Notifier } from '@app/core'
+import { PeertubeCheckboxComponent } from '@app/shared/shared-forms/peertube-checkbox.component'
+import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
+import { AutomaticTagAvailableType } from '@peertube/peertube-models'
+import { forkJoin } from 'rxjs'
+import { first } from 'rxjs/operators'
+import { AutomaticTagService } from './automatic-tag.service'
+
+@Component({
+  templateUrl: './my-account-auto-tag-policies.component.html',
+  standalone: true,
+  imports: [
+    GlobalIconComponent,
+    FormsModule,
+    PeertubeCheckboxComponent
+  ]
+})
+export class MyAccountAutoTagPoliciesComponent implements OnInit {
+  tags: { name: string, review: boolean, type: AutomaticTagAvailableType }[] = []
+
+  constructor (
+    private authService: AuthService,
+    private autoTagsService: AutomaticTagService,
+    private notifier: Notifier
+  ) {
+
+  }
+
+  ngOnInit () {
+    this.authService.userInformationLoaded
+      .pipe(first())
+      .subscribe(() => this.loadAvailableTags())
+  }
+
+  getLabelText (tag: { name: string, type: AutomaticTagAvailableType }) {
+    if (tag.name === 'external-link') {
+      return $localize`That contain an external link`
+    }
+
+    return $localize`That contain any word from your "${tag.name}" watched word list`
+  }
+
+  updatePolicies () {
+    const accountName = this.authService.getUser().account.name
+
+    this.autoTagsService.updateCommentPolicies({
+      accountName,
+      review: this.tags.filter(t => t.review).map(t => t.name)
+    }).subscribe({
+      next: () => {
+        this.notifier.success($localize`Comment policies updated`)
+      },
+
+      error: err => this.notifier.error(err.message)
+    })
+  }
+
+  private loadAvailableTags () {
+    const accountName = this.authService.getUser().account.name
+
+    forkJoin([
+      this.autoTagsService.listAvailable({ accountName }),
+      this.autoTagsService.getCommentPolicies({ accountName })
+    ]).subscribe(([ resAvailable, policies ]) => {
+      this.tags = resAvailable.available
+        .map(a => ({ name: a.name, type: a.type, review: policies.review.includes(a.name) }))
+    })
+  }
+}
diff --git a/client/src/app/+my-account/my-account-comments-on-my-videos/comments-on-my-videos.component.html b/client/src/app/+my-account/my-account-comments-on-my-videos/comments-on-my-videos.component.html
new file mode 100644
index 000000000..265767cb8
--- /dev/null
+++ b/client/src/app/+my-account/my-account-comments-on-my-videos/comments-on-my-videos.component.html
@@ -0,0 +1,7 @@
+<h1>
+  <my-global-icon iconName="message-circle" aria-hidden="true"></my-global-icon>
+  <ng-container i18n>Comments on your videos</ng-container>
+</h1>
+
+<my-video-comment-list-admin-owner mode="user"></my-video-comment-list-admin-owner>
+
diff --git a/client/src/app/+my-account/my-account-comments-on-my-videos/comments-on-my-videos.component.ts b/client/src/app/+my-account/my-account-comments-on-my-videos/comments-on-my-videos.component.ts
new file mode 100644
index 000000000..1c3110b5e
--- /dev/null
+++ b/client/src/app/+my-account/my-account-comments-on-my-videos/comments-on-my-videos.component.ts
@@ -0,0 +1,14 @@
+import { Component } from '@angular/core'
+import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
+import { VideoCommentListAdminOwnerComponent } from '../../shared/shared-video-comment/video-comment-list-admin-owner.component'
+
+@Component({
+  templateUrl: './comments-on-my-videos.component.html',
+  standalone: true,
+  imports: [
+    GlobalIconComponent,
+    VideoCommentListAdminOwnerComponent
+  ]
+})
+export class CommentsOnMyVideosComponent {
+}
diff --git a/client/src/app/+my-account/my-account-watched-words-list/my-account-watched-words-list.component.html b/client/src/app/+my-account/my-account-watched-words-list/my-account-watched-words-list.component.html
new file mode 100644
index 000000000..132bf5a69
--- /dev/null
+++ b/client/src/app/+my-account/my-account-watched-words-list/my-account-watched-words-list.component.html
@@ -0,0 +1,10 @@
+<h1>
+  <my-global-icon iconName="no" aria-hidden="true"></my-global-icon>
+  <ng-container i18n>Your watched words lists</ng-container>
+</h1>
+
+<em class="d-block" i18n>Comments that contain any of the watched words are automatically tagged with the name of the list.</em>
+<em class="d-block mb-3" i18n>These automatic tags can be used to filter comments or <a routerLink="/my-account/auto-tag-policies">automatically block</a> them.</em>
+
+<my-watched-words-list-admin-owner mode="user"></my-watched-words-list-admin-owner>
+
diff --git a/client/src/app/+my-account/my-account-watched-words-list/my-account-watched-words-list.component.ts b/client/src/app/+my-account/my-account-watched-words-list/my-account-watched-words-list.component.ts
new file mode 100644
index 000000000..3f4de4f0b
--- /dev/null
+++ b/client/src/app/+my-account/my-account-watched-words-list/my-account-watched-words-list.component.ts
@@ -0,0 +1,19 @@
+import { NgIf } from '@angular/common'
+import { Component } from '@angular/core'
+import { RouterLink } from '@angular/router'
+import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
+import { WatchedWordsListAdminOwnerComponent } from '@app/shared/standalone-watched-words/watched-words-list-admin-owner.component'
+
+@Component({
+  templateUrl: './my-account-watched-words-list.component.html',
+  standalone: true,
+  imports: [
+    GlobalIconComponent,
+    WatchedWordsListAdminOwnerComponent,
+    NgIf,
+    RouterLink
+  ]
+})
+export class MyAccountWatchedWordsListComponent {
+
+}
diff --git a/client/src/app/+my-account/my-account.component.ts b/client/src/app/+my-account/my-account.component.ts
index 197956cdc..696896627 100644
--- a/client/src/app/+my-account/my-account.component.ts
+++ b/client/src/app/+my-account/my-account.component.ts
@@ -32,27 +32,6 @@ export class MyAccountComponent implements OnInit {
   private buildMenu () {
     const clientRoutes = this.pluginService.getAllRegisteredClientRoutesForParent('/my-account') || {}
 
-    const moderationEntries: TopMenuDropdownParam = {
-      label: $localize`Moderation`,
-      children: [
-        {
-          label: $localize`Muted accounts`,
-          routerLink: '/my-account/blocklist/accounts',
-          iconName: 'user-x'
-        },
-        {
-          label: $localize`Muted servers`,
-          routerLink: '/my-account/blocklist/servers',
-          iconName: 'peertube-x'
-        },
-        {
-          label: $localize`Abuse reports`,
-          routerLink: '/my-account/abuses',
-          iconName: 'flag'
-        }
-      ]
-    }
-
     this.menuEntries = [
       {
         label: $localize`Settings`,
@@ -74,7 +53,41 @@ export class MyAccountComponent implements OnInit {
         routerLink: '/my-account/applications'
       },
 
-      moderationEntries,
+      {
+        label: $localize`Moderation`,
+        children: [
+          {
+            label: $localize`Muted accounts`,
+            routerLink: '/my-account/blocklist/accounts',
+            iconName: 'user-x'
+          },
+          {
+            label: $localize`Muted servers`,
+            routerLink: '/my-account/blocklist/servers',
+            iconName: 'peertube-x'
+          },
+          {
+            label: $localize`Abuse reports`,
+            routerLink: '/my-account/abuses',
+            iconName: 'flag'
+          },
+          {
+            label: $localize`Comments on your videos`,
+            routerLink: '/my-account/videos/comments',
+            iconName: 'message-circle'
+          },
+          {
+            label: $localize`Watched words`,
+            routerLink: '/my-account/watched-words/list',
+            iconName: 'eye-open'
+          },
+          {
+            label: $localize`Auto tag policies`,
+            routerLink: '/my-account/auto-tag-policies',
+            iconName: 'no'
+          }
+        ]
+      },
 
       ...Object.values(clientRoutes)
         .map(clientRoute => ({
diff --git a/client/src/app/+my-account/routes.ts b/client/src/app/+my-account/routes.ts
index d46c42c92..6c2a1e1e8 100644
--- a/client/src/app/+my-account/routes.ts
+++ b/client/src/app/+my-account/routes.ts
@@ -1,20 +1,25 @@
 import { Routes } from '@angular/router'
+import { AbuseService } from '@app/shared/shared-moderation/abuse.service'
+import { BlocklistService } from '@app/shared/shared-moderation/blocklist.service'
+import { BulkService } from '@app/shared/shared-moderation/bulk.service'
+import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
+import { PluginPagesComponent } from '@app/shared/shared-plugin-pages/plugin-pages.component'
+import { TwoFactorService } from '@app/shared/shared-users/two-factor.service'
+import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
+import { WatchedWordsListService } from '@app/shared/standalone-watched-words/watched-words-list.service'
 import { CanDeactivateGuard, LoginGuard } from '../core'
 import { MyAccountAbusesListComponent } from './my-account-abuses/my-account-abuses-list.component'
 import { MyAccountApplicationsComponent } from './my-account-applications/my-account-applications.component'
+import { MyAccountAutoTagPoliciesComponent } from './my-account-auto-tag-policies/my-account-auto-tag-policies.component'
 import { MyAccountBlocklistComponent } from './my-account-blocklist/my-account-blocklist.component'
 import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component'
+import { CommentsOnMyVideosComponent } from './my-account-comments-on-my-videos/comments-on-my-videos.component'
+import { MyAccountImportExportComponent, UserImportExportService } from './my-account-import-export'
 import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component'
 import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
-import { MyAccountImportExportComponent, UserImportExportService } from './my-account-import-export'
-import { MyAccountComponent } from './my-account.component'
 import { MyAccountTwoFactorComponent } from './my-account-settings/my-account-two-factor/my-account-two-factor.component'
-import { AbuseService } from '@app/shared/shared-moderation/abuse.service'
-import { BlocklistService } from '@app/shared/shared-moderation/blocklist.service'
-import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
-import { TwoFactorService } from '@app/shared/shared-users/two-factor.service'
-import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
-import { PluginPagesComponent } from '@app/shared/shared-plugin-pages/plugin-pages.component'
+import { MyAccountWatchedWordsListComponent } from './my-account-watched-words-list/my-account-watched-words-list.component'
+import { MyAccountComponent } from './my-account.component'
 
 export default [
   {
@@ -26,7 +31,9 @@ export default [
       BlocklistService,
       AbuseService,
       VideoCommentService,
-      VideoBlockService
+      VideoBlockService,
+      BulkService,
+      WatchedWordsListService
     ],
     canActivateChild: [ LoginGuard ],
     children: [
@@ -152,6 +159,15 @@ export default [
           }
         }
       },
+      {
+        path: 'videos/comments',
+        component: CommentsOnMyVideosComponent,
+        data: {
+          meta: {
+            title: $localize`Comments on your videos`
+          }
+        }
+      },
       {
         path: 'import-export',
         component: MyAccountImportExportComponent,
@@ -162,6 +178,24 @@ export default [
           }
         }
       },
+      {
+        path: 'watched-words/list',
+        component: MyAccountWatchedWordsListComponent,
+        data: {
+          meta: {
+            title: $localize`Your watched words`
+          }
+        }
+      },
+      {
+        path: 'auto-tag-policies',
+        component: MyAccountAutoTagPoliciesComponent,
+        data: {
+          meta: {
+            title: $localize`Your automatic tag policies`
+          }
+        }
+      },
       {
         path: 'p',
         children: [
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
index 84c165236..a1a49ff7d 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
@@ -438,10 +438,14 @@
               </div>
             </div>
 
-            <my-peertube-checkbox
-              inputName="commentsEnabled" formControlName="commentsEnabled"
-              i18n-labelText labelText="Enable video comments"
-            ></my-peertube-checkbox>
+            <div class="form-group mb-4">
+              <label i18n for="commentsPolicy">Comments policy</label>
+              <my-select-options labelForId="commentsPolicy" [items]="commentPolicies" formControlName="commentsPolicy" [clearable]="false"></my-select-options>
+
+              <div *ngIf="formErrors.commentsPolicy" class="form-error" role="alert">
+                {{ formErrors.commentsPolicy }}
+              </div>
+            </div>
 
             <my-peertube-checkbox
               inputName="downloadEnabled" formControlName="downloadEnabled"
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
index 48b52e372..09d3c2b0f 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
@@ -1,10 +1,10 @@
-import { forkJoin } from 'rxjs'
-import { map } from 'rxjs/operators'
-import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model'
+import { DatePipe, NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common'
 import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
-import { AbstractControl, FormArray, FormGroup, Validators, FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { AbstractControl, FormArray, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'
 import { HooksService, PluginService, ServerService } from '@app/core'
 import { removeElementFromArray } from '@app/helpers'
+import { BuildFormArgument, BuildFormValidator } from '@app/shared/form-validators/form-validator.model'
+import { VIDEO_CHAPTERS_ARRAY_VALIDATOR, VIDEO_CHAPTER_TITLE_VALIDATOR } from '@app/shared/form-validators/video-chapter-validators'
 import {
   VIDEO_CATEGORY_VALIDATOR,
   VIDEO_CHANNEL_VALIDATOR,
@@ -19,8 +19,14 @@ import {
   VIDEO_SUPPORT_VALIDATOR,
   VIDEO_TAGS_ARRAY_VALIDATOR
 } from '@app/shared/form-validators/video-validators'
-import { VIDEO_CHAPTERS_ARRAY_VALIDATOR, VIDEO_CHAPTER_TITLE_VALIDATOR } from '@app/shared/form-validators/video-chapter-validators'
-import { NgbModal, NgbNav, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavContent, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'
+import { FormReactiveErrors, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service'
+import { FormValidatorService } from '@app/shared/shared-forms/form-validator.service'
+import { InstanceService } from '@app/shared/shared-main/instance/instance.service'
+import { VideoCaptionEdit, VideoCaptionWithPathEdit } from '@app/shared/shared-main/video-caption/video-caption-edit.model'
+import { VideoChaptersEdit } from '@app/shared/shared-main/video/video-chapters-edit.model'
+import { VideoEdit } from '@app/shared/shared-main/video/video-edit.model'
+import { VideoService } from '@app/shared/shared-main/video/video.service'
+import { NgbModal, NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'
 import {
   HTMLServerConfig,
   LiveVideo,
@@ -28,6 +34,7 @@ import {
   RegisterClientFormFieldOptions,
   RegisterClientVideoFieldOptions,
   VideoChapter,
+  VideoCommentPolicyType,
   VideoConstant,
   VideoDetails,
   VideoPrivacy,
@@ -36,35 +43,29 @@ import {
 } from '@peertube/peertube-models'
 import { logger } from '@root-helpers/logger'
 import { PluginInfo } from '@root-helpers/plugins-manager'
+import { CalendarModule } from 'primeng/calendar'
+import { forkJoin } from 'rxjs'
+import { map } from 'rxjs/operators'
+import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model'
+import { DynamicFormFieldComponent } from '../../../shared/shared-forms/dynamic-form-field.component'
+import { InputTextComponent } from '../../../shared/shared-forms/input-text.component'
+import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
+import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
+import { PreviewUploadComponent } from '../../../shared/shared-forms/preview-upload.component'
+import { SelectChannelComponent } from '../../../shared/shared-forms/select/select-channel.component'
+import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
+import { SelectTagsComponent } from '../../../shared/shared-forms/select/select-tags.component'
+import { TimestampInputComponent } from '../../../shared/shared-forms/timestamp-input.component'
+import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
+import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
+import { DeleteButtonComponent } from '../../../shared/shared-main/buttons/delete-button.component'
+import { HelpComponent } from '../../../shared/shared-main/misc/help.component'
+import { EmbedComponent } from '../../../shared/shared-main/video/embed.component'
+import { LiveDocumentationLinkComponent } from '../../../shared/shared-video-live/live-documentation-link.component'
 import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
 import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
 import { VideoCaptionEditModalContentComponent } from './video-caption-edit-modal-content/video-caption-edit-modal-content.component'
 import { VideoEditType } from './video-edit.type'
-import { PreviewUploadComponent } from '../../../shared/shared-forms/preview-upload.component'
-import { LiveDocumentationLinkComponent } from '../../../shared/shared-video-live/live-documentation-link.component'
-import { EmbedComponent } from '../../../shared/shared-main/video/embed.component'
-import { DeleteButtonComponent } from '../../../shared/shared-main/buttons/delete-button.component'
-import { TimestampInputComponent } from '../../../shared/shared-forms/timestamp-input.component'
-import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
-import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
-import { CalendarModule } from 'primeng/calendar'
-import { InputTextComponent } from '../../../shared/shared-forms/input-text.component'
-import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
-import { SelectChannelComponent } from '../../../shared/shared-forms/select/select-channel.component'
-import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
-import { SelectTagsComponent } from '../../../shared/shared-forms/select/select-tags.component'
-import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
-import { HelpComponent } from '../../../shared/shared-main/misc/help.component'
-import { NgIf, NgFor, NgTemplateOutlet, NgClass, DatePipe } from '@angular/common'
-import { DynamicFormFieldComponent } from '../../../shared/shared-forms/dynamic-form-field.component'
-import { InstanceService } from '@app/shared/shared-main/instance/instance.service'
-import { VideoCaptionWithPathEdit, VideoCaptionEdit } from '@app/shared/shared-main/video-caption/video-caption-edit.model'
-import { VideoChaptersEdit } from '@app/shared/shared-main/video/video-chapters-edit.model'
-import { VideoEdit } from '@app/shared/shared-main/video/video-edit.model'
-import { VideoService } from '@app/shared/shared-main/video/video.service'
-import { BuildFormArgument, BuildFormValidator } from '@app/shared/form-validators/form-validator.model'
-import { FormReactiveErrors, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service'
-import { FormValidatorService } from '@app/shared/shared-forms/form-validator.service'
 
 type VideoLanguages = VideoConstant<string> & { group?: string }
 type PluginField = {
@@ -144,6 +145,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
   replayPrivacies: VideoConstant<VideoPrivacyType> [] = []
   videoCategories: VideoConstant<number>[] = []
   videoLicences: VideoConstant<number>[] = []
+  commentPolicies: VideoConstant<VideoCommentPolicyType>[] = []
   videoLanguages: VideoLanguages[] = []
   latencyModes: SelectOptionsItem[] = [
     {
@@ -202,7 +204,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
   updateForm () {
     const defaultValues: any = {
       nsfw: 'false',
-      commentsEnabled: this.serverConfig.defaults.publish.commentsEnabled,
+      commentsPolicy: this.serverConfig.defaults.publish.commentsPolicy,
       downloadEnabled: this.serverConfig.defaults.publish.downloadEnabled,
       waitTranscoding: true,
       licence: this.serverConfig.defaults.publish.licence,
@@ -214,7 +216,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
       videoPassword: VIDEO_PASSWORD_VALIDATOR,
       channelId: VIDEO_CHANNEL_VALIDATOR,
       nsfw: null,
-      commentsEnabled: null,
+      commentsPolicy: null,
       downloadEnabled: null,
       waitTranscoding: null,
       category: VIDEO_CATEGORY_VALIDATOR,
@@ -272,6 +274,9 @@ export class VideoEditComponent implements OnInit, OnDestroy {
     this.serverService.getVideoLicences()
         .subscribe(res => this.videoLicences = res)
 
+    this.serverService.getCommentPolicies()
+      .subscribe(res => this.commentPolicies = res)
+
     forkJoin([
       this.instanceService.getAbout(),
       this.serverService.getVideoLanguages()
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html
index 80ea22a20..ec0d5184e 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html
@@ -27,6 +27,8 @@
           <a [routerLink]="['/w', video.shortUUID, { 'threadId': comment.threadId }]" class="comment-date" [title]="comment.createdAt">
             {{ comment.createdAt | myFromNow }}
           </a>
+
+          <span *ngIf="comment.heldForReview" class="pt-badge badge-red ms-2" i18n>Pending review</span>
         </div>
 
         <div
@@ -83,6 +85,7 @@
             (wantedToReply)="onWantToReply($event)"
             (wantedToDelete)="onWantToDelete($event)"
             (wantedToRedraft)="onWantToRedraft($event)"
+            (wantedToApprove)="onWantToApprove($event)"
             (resetReply)="onResetReply()"
             (timestampClicked)="handleTimestampClicked($event)"
             [redraftValue]="redraftValue"
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts
index 7e1ceffe7..59c48f4fe 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts
@@ -1,20 +1,20 @@
+import { NgClass, NgFor, NgIf } from '@angular/common'
 import { Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core'
+import { RouterLink } from '@angular/router'
 import { MarkdownService, Notifier, UserService } from '@app/core'
 import { AuthService } from '@app/core/auth'
-import { CommentReportComponent } from '@app/shared/shared-moderation/report-modals/comment-report.component'
-import { User, UserRight } from '@peertube/peertube-models'
-import { FromNowPipe } from '../../../../shared/shared-main/angular/from-now.pipe'
-import { VideoCommentAddComponent } from './video-comment-add.component'
-import { UserModerationDropdownComponent } from '../../../../shared/shared-moderation/user-moderation-dropdown.component'
-import { TimestampRouteTransformerDirective } from '../timestamp-route-transformer.directive'
-import { RouterLink } from '@angular/router'
-import { ActorAvatarComponent } from '../../../../shared/shared-actor-image/actor-avatar.component'
-import { NgIf, NgClass, NgFor } from '@angular/common'
-import { Video } from '@app/shared/shared-main/video/video.model'
 import { Account } from '@app/shared/shared-main/account/account.model'
 import { DropdownAction } from '@app/shared/shared-main/buttons/action-dropdown.component'
-import { VideoComment } from '@app/shared/shared-video-comment/video-comment.model'
+import { Video } from '@app/shared/shared-main/video/video.model'
+import { CommentReportComponent } from '@app/shared/shared-moderation/report-modals/comment-report.component'
 import { VideoCommentThreadTree } from '@app/shared/shared-video-comment/video-comment-thread-tree.model'
+import { VideoComment } from '@app/shared/shared-video-comment/video-comment.model'
+import { User, UserRight } from '@peertube/peertube-models'
+import { ActorAvatarComponent } from '../../../../shared/shared-actor-image/actor-avatar.component'
+import { FromNowPipe } from '../../../../shared/shared-main/angular/from-now.pipe'
+import { UserModerationDropdownComponent } from '../../../../shared/shared-moderation/user-moderation-dropdown.component'
+import { TimestampRouteTransformerDirective } from '../timestamp-route-transformer.directive'
+import { VideoCommentAddComponent } from './video-comment-add.component'
 
 @Component({
   selector: 'my-video-comment',
@@ -49,6 +49,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
 
   @Output() wantedToReply = new EventEmitter<VideoComment>()
   @Output() wantedToDelete = new EventEmitter<VideoComment>()
+  @Output() wantedToApprove = new EventEmitter<VideoComment>()
   @Output() wantedToRedraft = new EventEmitter<VideoComment>()
   @Output() threadCreated = new EventEmitter<VideoCommentThreadTree>()
   @Output() resetReply = new EventEmitter()
@@ -115,6 +116,10 @@ export class VideoCommentComponent implements OnInit, OnChanges {
     this.wantedToRedraft.emit(comment || this.comment)
   }
 
+  onWantToApprove (comment?: VideoComment) {
+    this.wantedToApprove.emit(comment || this.comment)
+  }
+
   isUserLoggedIn () {
     return this.authService.isLoggedIn()
   }
@@ -127,12 +132,12 @@ export class VideoCommentComponent implements OnInit, OnChanges {
     this.timestampClicked.emit(timestamp)
   }
 
-  isRemovableByUser () {
+  canBeRemovedOrApprovedByUser () {
     return this.comment.account && this.isUserLoggedIn() &&
       (
         this.user.account.id === this.comment.account.id ||
         this.user.account.id === this.video.account.id ||
-        this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)
+        this.user.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT)
       )
   }
 
@@ -196,6 +201,14 @@ export class VideoCommentComponent implements OnInit, OnChanges {
 
     this.prependModerationActions = []
 
+    if (this.canBeRemovedOrApprovedByUser() && this.comment.heldForReview) {
+      this.prependModerationActions.push({
+        label: $localize`Approve`,
+        iconName: 'tick',
+        handler: () => this.onWantToApprove()
+      })
+    }
+
     if (this.isReportableByUser()) {
       this.prependModerationActions.push({
         label: $localize`Report this comment`,
@@ -204,7 +217,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
       })
     }
 
-    if (this.isRemovableByUser()) {
+    if (this.canBeRemovedOrApprovedByUser()) {
       this.prependModerationActions.push({
         label: $localize`Remove`,
         iconName: 'delete',
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html
index 0932d2b7f..b2cbd01f6 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html
@@ -17,7 +17,7 @@
     </div>
   </div>
 
-  <ng-template [ngIf]="video.commentsEnabled === true">
+  @if (commentsEnabled) {
     <my-video-comment-add
       [video]="video"
       [videoPassword]="videoPassword"
@@ -43,6 +43,7 @@
           (wantedToReply)="onWantedToReply($event)"
           (wantedToDelete)="onWantedToDelete($event)"
           (wantedToRedraft)="onWantedToRedraft($event)"
+          (wantedToApprove)="onWantToApprove($event)"
           (threadCreated)="onThreadCreated($event)"
           (resetReply)="onResetReply()"
           (timestampClicked)="handleTimestampClicked($event)"
@@ -62,6 +63,7 @@
           (wantedToReply)="onWantedToReply($event)"
           (wantedToDelete)="onWantedToDelete($event)"
           (wantedToRedraft)="onWantedToRedraft($event)"
+          (wantedToApprove)="onWantToApprove($event)"
           (threadCreated)="onThreadCreated($event)"
           (resetReply)="onResetReply()"
           (timestampClicked)="handleTimestampClicked($event)"
@@ -89,9 +91,7 @@
 
       </div>
     </div>
-  </ng-template>
-
-  <div *ngIf="video.commentsEnabled === false" i18n>
-    Comments are disabled.
-  </div>
+  } @else {
+    <div i18n>Comments are disabled.</div>
+  }
 </div>
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts
index 32a90f98d..e88b1699c 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts
@@ -1,21 +1,21 @@
-import { Subject, Subscription } from 'rxjs'
+import { NgFor, NgIf } from '@angular/common'
 import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core'
 import { ActivatedRoute } from '@angular/router'
-import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, User } from '@app/core'
+import { AuthService, ComponentPagination, ConfirmService, Notifier, User, hasMoreItems } from '@app/core'
 import { HooksService } from '@app/core/plugins/hooks.service'
-import { PeerTubeProblemDocument, ServerErrorCode } from '@peertube/peertube-models'
-import { LoaderComponent } from '../../../../shared/shared-main/loaders/loader.component'
-import { VideoCommentComponent } from './video-comment.component'
-import { InfiniteScrollerDirective } from '../../../../shared/shared-main/angular/infinite-scroller.directive'
-import { VideoCommentAddComponent } from './video-comment-add.component'
-import { NgIf, NgFor } from '@angular/common'
-import { NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownButtonItem, NgbDropdownItem } from '@ng-bootstrap/ng-bootstrap'
-import { FeedComponent } from '../../../../shared/shared-main/feeds/feed.component'
-import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
 import { Syndication } from '@app/shared/shared-main/feeds/syndication.model'
+import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
+import { VideoCommentThreadTree } from '@app/shared/shared-video-comment/video-comment-thread-tree.model'
 import { VideoComment } from '@app/shared/shared-video-comment/video-comment.model'
 import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
-import { VideoCommentThreadTree } from '@app/shared/shared-video-comment/video-comment-thread-tree.model'
+import { NgbDropdown, NgbDropdownButtonItem, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle } from '@ng-bootstrap/ng-bootstrap'
+import { PeerTubeProblemDocument, ServerErrorCode, VideoCommentPolicy } from '@peertube/peertube-models'
+import { Subject, Subscription } from 'rxjs'
+import { InfiniteScrollerDirective } from '../../../../shared/shared-main/angular/infinite-scroller.directive'
+import { FeedComponent } from '../../../../shared/shared-main/feeds/feed.component'
+import { LoaderComponent } from '../../../../shared/shared-main/loaders/loader.component'
+import { VideoCommentAddComponent } from './video-comment-add.component'
+import { VideoCommentComponent } from './video-comment.component'
 
 @Component({
   selector: 'my-video-comments',
@@ -61,6 +61,8 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
   commentReplyRedraftValue: string
   commentThreadRedraftValue: string
 
+  commentsEnabled: boolean
+
   threadComments: { [ id: number ]: VideoCommentThreadTree } = {}
   threadLoading: { [ id: number ]: boolean } = {}
 
@@ -258,6 +260,19 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
     }
   }
 
+  onWantToApprove (comment: VideoComment) {
+    this.videoCommentService.approveComments([ { commentId: comment.id, videoId: comment.videoId } ])
+      .subscribe({
+        next: () => {
+          comment.heldForReview = false
+
+          this.notifier.success($localize`Comment approved`)
+        },
+
+        error: err => this.notifier.error(err.message)
+      })
+  }
+
   isUserLoggedIn () {
     return this.authService.isLoggedIn()
   }
@@ -277,23 +292,25 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
   }
 
   private resetVideo () {
-    if (this.video.commentsEnabled === true) {
-      // Reset all our fields
-      this.highlightedThread = null
-      this.comments = []
-      this.threadComments = {}
-      this.threadLoading = {}
-      this.inReplyToCommentId = undefined
-      this.componentPagination.currentPage = 1
-      this.componentPagination.totalItems = null
-      this.totalNotDeletedComments = null
+    if (this.video.commentsPolicy.id === VideoCommentPolicy.DISABLED) return
 
-      this.syndicationItems = this.videoCommentService.getVideoCommentsFeeds(this.video)
-      this.loadMoreThreads()
+    // Reset all our fields
+    this.highlightedThread = null
+    this.comments = []
+    this.threadComments = {}
+    this.threadLoading = {}
+    this.inReplyToCommentId = undefined
+    this.componentPagination.currentPage = 1
+    this.componentPagination.totalItems = null
+    this.totalNotDeletedComments = null
 
-      if (this.activatedRoute.snapshot.params['threadId']) {
-        this.processHighlightedThread(+this.activatedRoute.snapshot.params['threadId'])
-      }
+    this.commentsEnabled = true
+
+    this.syndicationItems = this.videoCommentService.getVideoCommentsFeeds(this.video)
+    this.loadMoreThreads()
+
+    if (this.activatedRoute.snapshot.params['threadId']) {
+      this.processHighlightedThread(+this.activatedRoute.snapshot.params['threadId'])
     }
   }
 
diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts
index 75ac8ddc1..f60b50d3b 100644
--- a/client/src/app/core/server/server.service.ts
+++ b/client/src/app/core/server/server.service.ts
@@ -8,6 +8,7 @@ import {
   HTMLServerConfig,
   ServerConfig,
   ServerStats,
+  VideoCommentPolicy,
   VideoConstant,
   VideoPlaylistPrivacyType,
   VideoPrivacyType
@@ -104,6 +105,24 @@ export class ServerService {
     return this.htmlConfig
   }
 
+  getCommentPolicies () {
+    return of([
+      {
+        id: VideoCommentPolicy.DISABLED,
+        label: $localize`Comments are disabled`
+      },
+      {
+        id: VideoCommentPolicy.ENABLED,
+        label: $localize`Comments are enabled`,
+        description: $localize`Comments may require approval depending on your auto tag policies`
+      },
+      {
+        id: VideoCommentPolicy.REQUIRES_APPROVAL,
+        label: $localize`Any new comment requires approval`
+      }
+    ])
+  }
+
   getVideoCategories () {
     if (!this.videoCategoriesObservable) {
       this.videoCategoriesObservable = this.loadAttributeEnum<number>(ServerService.BASE_VIDEO_URL, 'categories', true)
diff --git a/client/src/app/shared/form-validators/host-validators.ts b/client/src/app/shared/form-validators/host-validators.ts
index 3d9c476b5..602ea80ec 100644
--- a/client/src/app/shared/form-validators/host-validators.ts
+++ b/client/src/app/shared/form-validators/host-validators.ts
@@ -1,5 +1,7 @@
 import { AbstractControl, ValidatorFn, Validators } from '@angular/forms'
+import { splitAndGetNotEmpty } from '@root-helpers/string'
 import { BuildFormValidator } from './form-validator.model'
+import { unique } from './shared/validator-utils'
 
 export function validateHost (value: string) {
   // Thanks to http://stackoverflow.com/a/106223
@@ -64,28 +66,6 @@ const validHostsOrHandles: ValidatorFn = (control: AbstractControl) => {
 
 // ---------------------------------------------------------------------------
 
-export function splitAndGetNotEmpty (value: string) {
-  return value
-    .split('\n')
-    .filter(line => line && line.length !== 0) // Eject empty hosts
-}
-
-export const unique: ValidatorFn = (control: AbstractControl) => {
-  if (!control.value) return null
-
-  const hosts = splitAndGetNotEmpty(control.value)
-
-  if (hosts.every((host: string) => hosts.indexOf(host) === hosts.lastIndexOf(host))) {
-    return null
-  }
-
-  return {
-    unique: {
-      reason: 'invalid'
-    }
-  }
-}
-
 export const UNIQUE_HOSTS_VALIDATOR: BuildFormValidator = {
   VALIDATORS: [ Validators.required, validHosts, unique ],
   MESSAGES: {
diff --git a/client/src/app/shared/form-validators/shared/validator-utils.ts b/client/src/app/shared/form-validators/shared/validator-utils.ts
new file mode 100644
index 000000000..6e0e411a2
--- /dev/null
+++ b/client/src/app/shared/form-validators/shared/validator-utils.ts
@@ -0,0 +1,18 @@
+import { AbstractControl, ValidatorFn } from '@angular/forms'
+import { splitAndGetNotEmpty } from '@root-helpers/string'
+
+export const unique: ValidatorFn = (control: AbstractControl) => {
+  if (!control.value) return null
+
+  const hosts = splitAndGetNotEmpty(control.value)
+
+  if (hosts.every((host: string) => hosts.indexOf(host) === hosts.lastIndexOf(host))) {
+    return null
+  }
+
+  return {
+    unique: {
+      reason: 'invalid'
+    }
+  }
+}
diff --git a/client/src/app/shared/form-validators/watched-words-list-validators.ts b/client/src/app/shared/form-validators/watched-words-list-validators.ts
new file mode 100644
index 000000000..9a932aa6a
--- /dev/null
+++ b/client/src/app/shared/form-validators/watched-words-list-validators.ts
@@ -0,0 +1,51 @@
+import { AbstractControl, ValidatorFn, Validators } from '@angular/forms'
+import { splitAndGetNotEmpty } from '@root-helpers/string'
+import { BuildFormValidator } from './form-validator.model'
+import { unique } from './shared/validator-utils'
+
+const validWords: ValidatorFn = (control: AbstractControl) => {
+  if (!control.value) return null
+
+  const errors = []
+  const words = splitAndGetNotEmpty(control.value)
+
+  for (const word of words) {
+    if (word.length < 1 || word.length > 100) {
+      errors.push($localize`${word} is not valid (min 1 character/max 100 characters)`)
+    }
+  }
+
+  if (words.length > 500) {
+    errors.push($localize`There are too much words in the list (max 500 words)`)
+  }
+
+  // valid
+  if (errors.length === 0) return null
+
+  return {
+    validWords: {
+      reason: 'invalid',
+      value: errors.join('. ') + '.'
+    }
+  }
+}
+
+// ---------------------------------------------------------------------------
+
+export const WATCHED_WORDS_LIST_NAME_VALIDATOR: BuildFormValidator = {
+  VALIDATORS: [ Validators.required, Validators.minLength(1), Validators.maxLength(100) ],
+  MESSAGES: {
+    required: $localize`List name is required.`,
+    minlength: $localize`List name must be at least 1 character long.`,
+    maxlength: $localize`List name cannot be more than 100 characters long.`
+  }
+}
+
+export const UNIQUE_WATCHED_WORDS_VALIDATOR: BuildFormValidator = {
+  VALIDATORS: [ Validators.required, unique, validWords ],
+  MESSAGES: {
+    required: $localize`Words are required.`,
+    unique: $localize`Words entered contain duplicates.`,
+    validWords: $localize`A word must be between 1 and 100 characters and the total number of words must not exceed 500 items`
+  }
+}
diff --git a/client/src/app/shared/shared-main/misc/top-menu-dropdown.component.html b/client/src/app/shared/shared-main/misc/top-menu-dropdown.component.html
index 9d8a5ba68..00c95fec2 100644
--- a/client/src/app/shared/shared-main/misc/top-menu-dropdown.component.html
+++ b/client/src/app/shared/shared-main/misc/top-menu-dropdown.component.html
@@ -10,35 +10,37 @@
 
     <ng-container *ngIf="!menuEntry.routerLink && isDisplayed(menuEntry)">
       <!-- On mobile, use a modal to display sub menu items -->
-      <li *ngIf="isInSmallView">
-        <button class="sub-menu-entry" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" (click)="openModal(id)">
-          {{ menuEntry.label }}
-
-          <span class="chevron-down"></span>
-        </button>
-      </li>
-
-      <!-- On desktop, use a classic dropdown -->
-      <div *ngIf="!isInSmallView" ngbDropdown #dropdown="ngbDropdown" autoClose="true" container="body">
+      @if (isInSmallView) {
         <li>
-          <button ngbDropdownToggle class="sub-menu-entry" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }">{{ menuEntry.label }}</button>
+          <button class="sub-menu-entry" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" (click)="openModal(id)">
+            {{ menuEntry.label }}
+
+            <span class="chevron-down"></span>
+          </button>
         </li>
-
-        <ul ngbDropdownMenu>
-          <li *ngFor="let menuChild of menuEntry.children">
-            <a
-              *ngIf="isDisplayed(menuChild)" ngbDropdownItem
-              routerLinkActive="active" ariaCurrentWhenActive="page"
-              [routerLink]="menuChild.routerLink" #routerLink (click)="onActiveLinkScrollToTop(routerLink)"
-              [queryParams]="menuChild.queryParams"
-            >
-              <my-global-icon *ngIf="menuChild.iconName" [iconName]="menuChild.iconName" aria-hidden="true"></my-global-icon>
-
-              {{ menuChild.label }}
-            </a>
+      } @else {
+        <!-- On desktop, use a classic dropdown -->
+        <div ngbDropdown #dropdown="ngbDropdown" autoClose="true" container="body">
+          <li>
+            <button ngbDropdownToggle class="sub-menu-entry" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }">{{ menuEntry.label }}</button>
           </li>
-        </ul>
-      </div>
+
+          <ul ngbDropdownMenu>
+            <li *ngFor="let menuChild of menuEntry.children">
+              <a
+                *ngIf="isDisplayed(menuChild)" ngbDropdownItem
+                routerLinkActive="active" ariaCurrentWhenActive="page"
+                [routerLink]="menuChild.routerLink" #routerLink (click)="onActiveLinkScrollToTop(routerLink)"
+                [queryParams]="menuChild.queryParams"
+              >
+                <my-global-icon *ngIf="menuChild.iconName" [iconName]="menuChild.iconName" aria-hidden="true"></my-global-icon>
+
+                {{ menuChild.label }}
+              </a>
+            </li>
+          </ul>
+        </div>
+      }
     </ng-container>
   </ul>
 </div>
diff --git a/client/src/app/shared/shared-main/misc/top-menu-dropdown.component.ts b/client/src/app/shared/shared-main/misc/top-menu-dropdown.component.ts
index eab69c58d..6370daa7c 100644
--- a/client/src/app/shared/shared-main/misc/top-menu-dropdown.component.ts
+++ b/client/src/app/shared/shared-main/misc/top-menu-dropdown.component.ts
@@ -1,6 +1,6 @@
 import { Subscription } from 'rxjs'
 import { filter } from 'rxjs/operators'
-import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'
+import { Component, Input, OnChanges, OnDestroy, OnInit, ViewChild } from '@angular/core'
 import { NavigationEnd, Router, RouterLinkActive, RouterLink } from '@angular/router'
 import { MenuService, ScreenService } from '@app/core'
 import { scrollToTop } from '@app/helpers'
@@ -42,7 +42,7 @@ export type TopMenuDropdownParam = {
     GlobalIconComponent
   ]
 })
-export class TopMenuDropdownComponent implements OnInit, OnDestroy {
+export class TopMenuDropdownComponent implements OnInit, OnChanges, OnDestroy {
   @Input() menuEntries: TopMenuDropdownParam[] = []
 
   @ViewChild('modal', { static: true }) modal: NgbModal
@@ -82,6 +82,10 @@ export class TopMenuDropdownComponent implements OnInit, OnDestroy {
       .subscribe(() => this.updateChildLabels(window.location.pathname))
   }
 
+  ngOnChanges () {
+    this.updateChildLabels(window.location.pathname)
+  }
+
   ngOnDestroy () {
     if (this.routeSub) this.routeSub.unsubscribe()
   }
diff --git a/client/src/app/shared/shared-main/users/user-notification.model.ts b/client/src/app/shared/shared-main/users/user-notification.model.ts
index 1a58a6218..8e5907255 100644
--- a/client/src/app/shared/shared-main/users/user-notification.model.ts
+++ b/client/src/app/shared/shared-main/users/user-notification.model.ts
@@ -36,6 +36,7 @@ export class UserNotification implements UserNotificationServer {
   comment?: {
     id: number
     threadId: number
+    heldForReview: boolean
     account: ActorInfo & { avatarUrl?: string }
     video: VideoInfo
   }
@@ -96,6 +97,9 @@ export class UserNotification implements UserNotificationServer {
   videoUrl?: string
   commentUrl?: any[]
 
+  commentReviewUrl?: string
+  commentReviewQueryParams?: { [id: string]: string } = {}
+
   abuseUrl?: string
   abuseQueryParams?: { [id: string]: string } = {}
 
@@ -163,6 +167,9 @@ export class UserNotification implements UserNotificationServer {
           if (!this.comment) break
           this.accountUrl = this.buildAccountUrl(this.comment.account)
           this.commentUrl = this.buildCommentUrl(this.comment)
+
+          this.commentReviewUrl = '/my-account/videos/comments'
+          this.commentReviewQueryParams.search = 'heldForReview:true'
           break
 
         case UserNotificationType.NEW_ABUSE_FOR_MODERATORS:
diff --git a/client/src/app/shared/shared-main/video/video-details.model.ts b/client/src/app/shared/shared-main/video/video-details.model.ts
index 5ad0bb857..f595414c0 100644
--- a/client/src/app/shared/shared-main/video/video-details.model.ts
+++ b/client/src/app/shared/shared-main/video/video-details.model.ts
@@ -1,6 +1,7 @@
 import { Account } from '@app/shared/shared-main/account/account.model'
 import { VideoChannel } from '@app/shared/shared-main/video-channel/video-channel.model'
 import {
+  VideoCommentPolicyType,
   VideoConstant,
   VideoDetails as VideoDetailsServerModel,
   VideoFile,
@@ -16,12 +17,14 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
   channel: VideoChannel
   tags: string[]
   account: Account
-  commentsEnabled: boolean
   downloadEnabled: boolean
 
   waitTranscoding: boolean
   state: VideoConstant<VideoStateType>
 
+  commentsEnabled: never
+  commentsPolicy: VideoConstant<VideoCommentPolicyType>
+
   likesPercent: number
   dislikesPercent: number
 
@@ -40,7 +43,7 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
     this.account = new Account(hash.account)
     this.tags = hash.tags
     this.support = hash.support
-    this.commentsEnabled = hash.commentsEnabled
+    this.commentsPolicy = hash.commentsPolicy
     this.downloadEnabled = hash.downloadEnabled
 
     this.inputFileUpdatedAt = hash.inputFileUpdatedAt
diff --git a/client/src/app/shared/shared-main/video/video-edit.model.ts b/client/src/app/shared/shared-main/video/video-edit.model.ts
index a3e736c0f..e8e1f90ca 100644
--- a/client/src/app/shared/shared-main/video/video-edit.model.ts
+++ b/client/src/app/shared/shared-main/video/video-edit.model.ts
@@ -1,6 +1,13 @@
 import { getAbsoluteAPIUrl } from '@app/helpers'
 import { objectKeysTyped } from '@peertube/peertube-core-utils'
-import { VideoPassword, VideoPrivacy, VideoPrivacyType, VideoScheduleUpdate, VideoUpdate } from '@peertube/peertube-models'
+import {
+  VideoCommentPolicyType,
+  VideoPassword,
+  VideoPrivacy,
+  VideoPrivacyType,
+  VideoScheduleUpdate,
+  VideoUpdate
+} from '@peertube/peertube-models'
 import { VideoDetails } from './video-details.model'
 
 export class VideoEdit implements VideoUpdate {
@@ -13,7 +20,7 @@ export class VideoEdit implements VideoUpdate {
   name: string
   tags: string[]
   nsfw: boolean
-  commentsEnabled: boolean
+  commentsPolicy: VideoCommentPolicyType
   downloadEnabled: boolean
   waitTranscoding: boolean
   channelId: number
@@ -52,7 +59,7 @@ export class VideoEdit implements VideoUpdate {
 
     this.support = video.support
 
-    this.commentsEnabled = video.commentsEnabled
+    this.commentsPolicy = video.commentsPolicy.id
     this.downloadEnabled = video.downloadEnabled
 
     if (video.thumbnailPath) this.thumbnailUrl = getAbsoluteAPIUrl() + video.thumbnailPath
@@ -109,7 +116,7 @@ export class VideoEdit implements VideoUpdate {
       name: this.name,
       tags: this.tags,
       nsfw: this.nsfw,
-      commentsEnabled: this.commentsEnabled,
+      commentsPolicy: this.commentsPolicy,
       downloadEnabled: this.downloadEnabled,
       waitTranscoding: this.waitTranscoding,
       channelId: this.channelId,
diff --git a/client/src/app/shared/shared-main/video/video-import.service.ts b/client/src/app/shared/shared-main/video/video-import.service.ts
index 7aa330bc4..05101d72d 100644
--- a/client/src/app/shared/shared-main/video/video-import.service.ts
+++ b/client/src/app/shared/shared-main/video/video-import.service.ts
@@ -99,7 +99,7 @@ export class VideoImportService {
       tags: video.tags,
       nsfw: video.nsfw,
       waitTranscoding: video.waitTranscoding,
-      commentsEnabled: video.commentsEnabled,
+      commentsPolicy: video.commentsPolicy,
       downloadEnabled: video.downloadEnabled,
       thumbnailfile: video.thumbnailfile,
       previewfile: video.previewfile,
diff --git a/client/src/app/shared/shared-main/video/video.model.ts b/client/src/app/shared/shared-main/video/video.model.ts
index 7c5b2e9ca..5bbe5431f 100644
--- a/client/src/app/shared/shared-main/video/video.model.ts
+++ b/client/src/app/shared/shared-main/video/video.model.ts
@@ -114,6 +114,8 @@ export class Video implements VideoServerModel {
 
   videoSource?: VideoSource
 
+  automaticTags?: string[]
+
   static buildWatchUrl (video: Partial<Pick<Video, 'uuid' | 'shortUUID'>>) {
     return buildVideoWatchPath({ shortUUID: video.shortUUID || video.uuid })
   }
@@ -205,6 +207,8 @@ export class Video implements VideoServerModel {
     this.pluginData = hash.pluginData
 
     this.aspectRatio = hash.aspectRatio
+
+    this.automaticTags = hash.automaticTags
   }
 
   isVideoNSFWForUser (user: User, serverConfig: HTMLServerConfig) {
diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts
index 33acad2cb..c287b921b 100644
--- a/client/src/app/shared/shared-main/video/video.service.ts
+++ b/client/src/app/shared/shared-main/video/video.service.ts
@@ -109,7 +109,7 @@ export class VideoService {
       tags: video.tags,
       nsfw: video.nsfw,
       waitTranscoding: video.waitTranscoding,
-      commentsEnabled: video.commentsEnabled,
+      commentsPolicy: video.commentsPolicy,
       downloadEnabled: video.downloadEnabled,
       thumbnailfile: video.thumbnailfile,
       previewfile: video.previewfile,
diff --git a/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts b/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts
index 9d33a12c1..f524193fa 100644
--- a/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts
+++ b/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts
@@ -1,11 +1,12 @@
+import { NgClass, NgIf } from '@angular/common'
 import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
 import { FormReactive } from '@app/shared/shared-forms/form-reactive'
 import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
-import { splitAndGetNotEmpty, UNIQUE_HOSTS_VALIDATOR } from '../form-validators/host-validators'
-import { NgClass, NgIf } from '@angular/common'
-import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { splitAndGetNotEmpty } from '@root-helpers/string'
+import { UNIQUE_HOSTS_VALIDATOR } from '../form-validators/host-validators'
 import { GlobalIconComponent } from '../shared-icons/global-icon.component'
 
 @Component({
diff --git a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
index e982db51f..e6fd5cf06 100644
--- a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
+++ b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
@@ -407,7 +407,7 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
       ])
     }
 
-    if (this.account && this.displayOptions.instanceAccount && authUser.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)) {
+    if (this.account && this.displayOptions.instanceAccount && authUser.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT)) {
       instanceActions = instanceActions.concat([
         {
           label: $localize`Remove comments from your instance`,
diff --git a/client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.html b/client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.html
new file mode 100644
index 000000000..a9fd8367a
--- /dev/null
+++ b/client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.html
@@ -0,0 +1,122 @@
+<p-table
+  [value]="comments" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
+  [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id"
+  [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
+  [showCurrentPageReport]="true" [currentPageReportTemplate]="getPaginationTemplate()"
+  [expandedRowKeys]="expandedRows" [(selection)]="selectedRows"
+>
+  <ng-template pTemplate="caption">
+    <div class="caption">
+      <div>
+        <my-action-dropdown
+          *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
+          [actions]="bulkActions" [entry]="selectedRows"
+        >
+        </my-action-dropdown>
+      </div>
+
+      <div class="ms-auto right-form">
+        <my-advanced-input-filter [filters]="inputFilters" (search)="onSearch($event)"></my-advanced-input-filter>
+
+        <my-button i18n-label label="Refresh" icon="refresh" (click)="reloadData()"></my-button>
+      </div>
+    </div>
+  </ng-template>
+
+  <ng-template pTemplate="header">
+    <tr>
+      <th scope="col" style="width: 40px;">
+        <p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
+      </th>
+      <th scope="col" style="width: 40px;">
+        <span i18n class="visually-hidden">More information</span>
+      </th>
+      <th scope="col" style="width: 150px;">
+        <span i18n class="visually-hidden">Actions</span>
+      </th>
+      <th scope="col" i18n>Account</th>
+      <th scope="col" i18n>Video</th>
+      <th scope="col" i18n>Comment</th>
+      <th scope="col" i18n>Auto tags</th>
+      <th scope="col" style="width: 150px;" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="createdAt">Date <p-sortIcon field="createdAt"></p-sortIcon></th>
+    </tr>
+  </ng-template>
+
+  <ng-template pTemplate="body" let-videoComment let-expanded="expanded">
+    <tr [pSelectableRow]="videoComment">
+
+      <td class="checkbox-cell">
+        <p-tableCheckbox [value]="videoComment" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox>
+      </td>
+
+      <td class="expand-cell">
+        <my-table-expander-icon [pRowToggler]="videoComment" i18n-tooltip tooltip="See full comment" [expanded]="expanded"></my-table-expander-icon>
+      </td>
+
+      <td class="action-cell">
+        <my-action-dropdown
+          [ngClass]="{ 'show': expanded }" placement="bottom-right" container="body"
+          i18n-label label="Actions" [actions]="videoCommentActions" [entry]="videoComment"
+        ></my-action-dropdown>
+      </td>
+
+      <td>
+        <a [href]="videoComment.account.localUrl" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
+          <div class="chip two-lines">
+            <my-actor-avatar [actor]="videoComment.account" actorType="account" size="32"></my-actor-avatar>
+          <div>
+              {{ videoComment.account.displayName }}
+              <span>{{ videoComment.by }}</span>
+            </div>
+          </div>
+        </a>
+      </td>
+
+      <td class="video">
+        <em i18n>Commented video</em>
+
+        <a [href]="videoComment.localUrl" target="_blank" rel="noopener noreferrer">{{ videoComment.video.name }}</a>
+      </td>
+
+      <td class="c-hand comment-content-cell" [pRowToggler]="videoComment">
+        <span *ngIf="videoComment.heldForReview" class="pt-badge badge-red float-start me-2" i18n>Pending review</span>
+
+        <div class="comment-html">
+          <div [innerHTML]="videoComment.textHtml"></div>
+        </div>
+      </td>
+
+      <td>
+        @for (tag of videoComment.automaticTags; track tag) {
+          <a
+            i18n-title title="Only display comments with this tag"
+            class="pt-badge badge-secondary me-1"
+            [routerLink]="[ '.' ]" [queryParams]="{ 'search': buildSearchAutoTag(tag) }"
+          >{{ tag }}</a>
+        }
+      </td>
+
+      <td class="c-hand" [pRowToggler]="videoComment">{{ videoComment.createdAt | date: 'short' }}</td>
+    </tr>
+  </ng-template>
+
+  <ng-template pTemplate="rowexpansion" let-videoComment>
+    <tr>
+      <td class="expand-cell" myAutoColspan>
+        <div [innerHTML]="videoComment.textHtml"></div>
+      </td>
+    </tr>
+  </ng-template>
+
+  <ng-template pTemplate="emptymessage">
+    <tr>
+      <td myAutoColspan>
+        <div class="no-results">
+          <ng-container *ngIf="search" i18n>No comments found matching current filters.</ng-container>
+          <ng-container *ngIf="!search" i18n>No comments found.</ng-container>
+        </div>
+      </td>
+    </tr>
+  </ng-template>
+</p-table>
+
diff --git a/client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.scss b/client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.scss
new file mode 100644
index 000000000..875ed2602
--- /dev/null
+++ b/client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.scss
@@ -0,0 +1,60 @@
+@use '_variables' as *;
+@use '_mixins' as *;
+
+my-global-icon {
+  width: 24px;
+  height: 24px;
+}
+
+.video {
+  display: flex;
+  flex-direction: column;
+
+  em {
+    font-size: 11px;
+  }
+
+  a {
+    @include ellipsis;
+
+    color: pvar(--mainForegroundColor);
+  }
+}
+
+.comment-content-cell {
+  > .pt-badge {
+    position: relative;
+    top: 2px;
+  }
+}
+
+.comment-html {
+  ::ng-deep {
+    > div {
+      max-height: 22px;
+    }
+
+    div,
+    p {
+      @include ellipsis;
+    }
+
+    p {
+      margin: 0;
+    }
+  }
+}
+
+.right-form {
+  display: flex;
+
+  > *:not(:last-child) {
+    @include margin-right(10px);
+  }
+}
+
+@media screen and (max-width: $primeng-breakpoint) {
+  .video {
+    align-items: flex-start !important;
+  }
+}
diff --git a/client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.ts b/client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.ts
new file mode 100644
index 000000000..0d3c24e1a
--- /dev/null
+++ b/client/src/app/shared/shared-video-comment/video-comment-list-admin-owner.component.ts
@@ -0,0 +1,260 @@
+import { DatePipe, NgClass, NgIf } from '@angular/common'
+import { Component, Input, OnInit } from '@angular/core'
+import { ActivatedRoute, Router, RouterLink } from '@angular/router'
+import { AuthService, ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
+import { formatICU } from '@app/helpers'
+import { BulkService } from '@app/shared/shared-moderation/bulk.service'
+import { VideoCommentForAdminOrUser } from '@app/shared/shared-video-comment/video-comment.model'
+import { VideoCommentService } from '@app/shared/shared-video-comment/video-comment.service'
+import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
+import { UserRight } from '@peertube/peertube-models'
+import { SharedModule, SortMeta } from 'primeng/api'
+import { TableModule } from 'primeng/table'
+import { ActorAvatarComponent } from '../shared-actor-image/actor-avatar.component'
+import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../shared-forms/advanced-input-filter.component'
+import { GlobalIconComponent } from '../shared-icons/global-icon.component'
+import { AutoColspanDirective } from '../shared-main/angular/auto-colspan.directive'
+import { ActionDropdownComponent, DropdownAction } from '../shared-main/buttons/action-dropdown.component'
+import { ButtonComponent } from '../shared-main/buttons/button.component'
+import { FeedComponent } from '../shared-main/feeds/feed.component'
+import { TableExpanderIconComponent } from '../shared-tables/table-expander-icon.component'
+
+@Component({
+  selector: 'my-video-comment-list-admin-owner',
+  templateUrl: './video-comment-list-admin-owner.component.html',
+  styleUrls: [ '../shared-moderation/moderation.scss', './video-comment-list-admin-owner.component.scss' ],
+  standalone: true,
+  imports: [
+    GlobalIconComponent,
+    FeedComponent,
+    TableModule,
+    SharedModule,
+    NgIf,
+    ActionDropdownComponent,
+    AdvancedInputFilterComponent,
+    ButtonComponent,
+    NgbTooltip,
+    TableExpanderIconComponent,
+    NgClass,
+    ActorAvatarComponent,
+    AutoColspanDirective,
+    DatePipe,
+    RouterLink
+  ]
+})
+export class VideoCommentListAdminOwnerComponent extends RestTable <VideoCommentForAdminOrUser> implements OnInit {
+  @Input({ required: true }) mode: 'user' | 'admin'
+
+  comments: VideoCommentForAdminOrUser[]
+  totalRecords = 0
+  sort: SortMeta = { field: 'createdAt', order: -1 }
+  pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
+
+  videoCommentActions: DropdownAction<VideoCommentForAdminOrUser>[][] = []
+
+  bulkActions: DropdownAction<VideoCommentForAdminOrUser[]>[] = []
+
+  inputFilters: AdvancedInputFilter[] = []
+
+  get authUser () {
+    return this.auth.getUser()
+  }
+
+  constructor (
+    protected router: Router,
+    protected route: ActivatedRoute,
+    private auth: AuthService,
+    private notifier: Notifier,
+    private confirmService: ConfirmService,
+    private videoCommentService: VideoCommentService,
+    private markdownRenderer: MarkdownService,
+    private bulkService: BulkService
+  ) {
+    super()
+
+    this.videoCommentActions = [
+      [
+        {
+          label: $localize`Delete this comment`,
+          handler: comment => this.removeComment(comment),
+          isDisplayed: () => this.mode === 'user' || this.authUser.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT)
+        },
+        {
+          label: $localize`Delete all comments of this account`,
+          description: $localize`Comments are deleted after a few minutes`,
+          handler: comment => this.removeCommentsOfAccount(comment),
+          isDisplayed: () => this.mode === 'admin' && this.authUser.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT)
+        }
+      ],
+      [
+        {
+          label: $localize`Approve this comment`,
+          handler: comment => this.approveComments([ comment ]),
+          isDisplayed: comment => this.mode === 'user' && comment.heldForReview
+        }
+      ]
+    ]
+  }
+
+  ngOnInit () {
+    this.initialize()
+
+    this.bulkActions = [
+      {
+        label: $localize`Delete`,
+        handler: comments => this.removeComments(comments),
+        isDisplayed: () => this.mode === 'user' || this.authUser.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT),
+        iconName: 'delete'
+      },
+      {
+        label: $localize`Approve`,
+        handler: comments => this.approveComments(comments),
+        isDisplayed: comments => this.mode === 'user' && comments.every(c => c.heldForReview),
+        iconName: 'tick'
+      }
+    ]
+
+    if (this.mode === 'admin') {
+      this.inputFilters = [
+        {
+          title: $localize`Advanced filters`,
+          children: [
+            {
+              value: 'local:true',
+              label: $localize`Local comments`
+            },
+            {
+              value: 'local:false',
+              label: $localize`Remote comments`
+            },
+            {
+              value: 'localVideo:true',
+              label: $localize`Comments on local videos`
+            }
+          ]
+        }
+      ]
+    } else {
+      this.inputFilters = [
+        {
+          title: $localize`Advanced filters`,
+          children: [
+            {
+              value: 'heldForReview:true',
+              label: $localize`Display comments awaiting your approval`
+            }
+          ]
+        }
+      ]
+    }
+  }
+
+  getIdentifier () {
+    return 'VideoCommentListAdminOwnerComponent'
+  }
+
+  toHtml (text: string) {
+    return this.markdownRenderer.textMarkdownToHTML({ markdown: text, withHtml: true, withEmoji: true })
+  }
+
+  buildSearchAutoTag (tag: string) {
+    const str = `autoTag:"${tag}"`
+
+    if (this.search) return this.search + ' ' + str
+
+    return str
+  }
+
+  protected reloadDataInternal () {
+    const method = this.mode === 'admin'
+      ? this.videoCommentService.listAdminVideoComments.bind(this.videoCommentService)
+      : this.videoCommentService.listVideoCommentsOfMyVideos.bind(this.videoCommentService)
+
+    method({ pagination: this.pagination, sort: this.sort, search: this.search }).subscribe({
+      next: async resultList => {
+        this.totalRecords = resultList.total
+
+        this.comments = []
+
+        for (const c of resultList.data) {
+          this.comments.push(new VideoCommentForAdminOrUser(c, await this.toHtml(c.text)))
+        }
+      },
+
+      error: err => this.notifier.error(err.message)
+    })
+  }
+
+  private approveComments (comments: VideoCommentForAdminOrUser[]) {
+    const commentArgs = comments.map(c => ({ videoId: c.video.id, commentId: c.id }))
+
+    this.videoCommentService.approveComments(commentArgs)
+      .subscribe({
+        next: () => {
+          this.notifier.success(
+            formatICU(
+              $localize`{count, plural, =1 {Comment approved.} other {{count} comments approved.}}`,
+              { count: commentArgs.length }
+            )
+          )
+
+          this.reloadData()
+        },
+
+        error: err => this.notifier.error(err.message),
+
+        complete: () => this.selectedRows = []
+      })
+  }
+
+  private removeComments (comments: VideoCommentForAdminOrUser[]) {
+    const commentArgs = comments.map(c => ({ videoId: c.video.id, commentId: c.id }))
+
+    this.videoCommentService.deleteVideoComments(commentArgs)
+      .subscribe({
+        next: () => {
+          this.notifier.success(
+            formatICU(
+              $localize`{count, plural, =1 {1 comment deleted.} other {{count} comments deleted.}}`,
+              { count: commentArgs.length }
+            )
+          )
+
+          this.reloadData()
+        },
+
+        error: err => this.notifier.error(err.message),
+
+        complete: () => this.selectedRows = []
+      })
+  }
+
+  private removeComment (comment: VideoCommentForAdminOrUser) {
+    this.videoCommentService.deleteVideoComment(comment.video.id, comment.id)
+      .subscribe({
+        next: () => this.reloadData(),
+
+        error: err => this.notifier.error(err.message)
+      })
+  }
+
+  private async removeCommentsOfAccount (comment: VideoCommentForAdminOrUser) {
+    const message = $localize`Do you really want to delete all comments of ${comment.by}?`
+    const res = await this.confirmService.confirm(message, $localize`Delete`)
+    if (res === false) return
+
+    const options = {
+      accountName: comment.by,
+      scope: 'instance' as 'instance'
+    }
+
+    this.bulkService.removeCommentsOf(options)
+      .subscribe({
+        next: () => {
+          this.notifier.success($localize`Comments of ${options.accountName} will be deleted in a few minutes`)
+        },
+
+        error: err => this.notifier.error(err.message)
+      })
+  }
+}
diff --git a/client/src/app/shared/shared-video-comment/video-comment.model.ts b/client/src/app/shared/shared-video-comment/video-comment.model.ts
index 7b02f29cc..efa9228b3 100644
--- a/client/src/app/shared/shared-video-comment/video-comment.model.ts
+++ b/client/src/app/shared/shared-video-comment/video-comment.model.ts
@@ -2,7 +2,7 @@ import { getAbsoluteAPIUrl } from '@app/helpers'
 import {
   Account as AccountInterface,
   VideoComment as VideoCommentServerModel,
-  VideoCommentAdmin as VideoCommentAdminServerModel
+  VideoCommentForAdminOrUser as VideoCommentForAdminOrUserServerModel
 } from '@peertube/peertube-models'
 import { Actor } from '../shared-main/account/actor.model'
 import { Video } from '../shared-main/video/video.model'
@@ -18,6 +18,7 @@ export class VideoComment implements VideoCommentServerModel {
   updatedAt: Date | string
   deletedAt: Date | string
   isDeleted: boolean
+  heldForReview: boolean
   account: AccountInterface
   totalRepliesFromVideoAuthor: number
   totalReplies: number
@@ -36,6 +37,7 @@ export class VideoComment implements VideoCommentServerModel {
     this.updatedAt = new Date(hash.updatedAt.toString())
     this.deletedAt = hash.deletedAt ? new Date(hash.deletedAt.toString()) : null
     this.isDeleted = hash.isDeleted
+    this.heldForReview = hash.heldForReview
     this.account = hash.account
     this.totalRepliesFromVideoAuthor = hash.totalRepliesFromVideoAuthor
     this.totalReplies = hash.totalReplies
@@ -50,7 +52,7 @@ export class VideoComment implements VideoCommentServerModel {
   }
 }
 
-export class VideoCommentAdmin implements VideoCommentAdminServerModel {
+export class VideoCommentForAdminOrUser implements VideoCommentForAdminOrUserServerModel {
   id: number
   url: string
   text: string
@@ -72,20 +74,28 @@ export class VideoCommentAdmin implements VideoCommentAdminServerModel {
     localUrl: string
   }
 
+  heldForReview: boolean
+
+  automaticTags: string[]
+
   by: string
 
-  constructor (hash: VideoCommentAdminServerModel, textHtml: string) {
+  constructor (hash: VideoCommentForAdminOrUserServerModel, textHtml: string) {
     this.id = hash.id
     this.url = hash.url
     this.text = hash.text
     this.textHtml = textHtml
 
+    this.heldForReview = hash.heldForReview
+
     this.threadId = hash.threadId
     this.inReplyToCommentId = hash.inReplyToCommentId
 
     this.createdAt = new Date(hash.createdAt.toString())
     this.updatedAt = new Date(hash.updatedAt.toString())
 
+    this.automaticTags = hash.automaticTags
+
     this.video = {
       id: hash.video.id,
       uuid: hash.video.uuid,
diff --git a/client/src/app/shared/shared-video-comment/video-comment.service.ts b/client/src/app/shared/shared-video-comment/video-comment.service.ts
index 97c735d71..73faa0b97 100644
--- a/client/src/app/shared/shared-video-comment/video-comment.service.ts
+++ b/client/src/app/shared/shared-video-comment/video-comment.service.ts
@@ -1,6 +1,3 @@
-import { SortMeta } from 'primeng/api'
-import { from, Observable } from 'rxjs'
-import { catchError, concatMap, map, toArray } from 'rxjs/operators'
 import { HttpClient, HttpParams } from '@angular/common/http'
 import { Injectable } from '@angular/core'
 import { ComponentPaginationLight, RestExtractor, RestPagination, RestService } from '@app/core'
@@ -10,21 +7,25 @@ import {
   ResultList,
   ThreadsResultList,
   Video,
-  VideoComment as VideoCommentServerModel,
-  VideoCommentAdmin,
   VideoCommentCreate,
+  VideoCommentForAdminOrUser,
+  VideoComment as VideoCommentServerModel,
   VideoCommentThreadTree as VideoCommentThreadTreeServerModel
 } from '@peertube/peertube-models'
+import { SortMeta } from 'primeng/api'
+import { Observable, from } from 'rxjs'
+import { catchError, concatMap, map, toArray } from 'rxjs/operators'
 import { environment } from '../../../environments/environment'
+import { VideoPasswordService } from '../shared-main/video/video-password.service'
 import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
 import { VideoComment } from './video-comment.model'
-import { VideoPasswordService } from '../shared-main/video/video-password.service'
 
 @Injectable()
 export class VideoCommentService {
   static BASE_FEEDS_URL = environment.apiUrl + '/feeds/video-comments.'
 
   private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
+  private static BASE_ME_URL = environment.apiUrl + '/api/v1/users/me/'
 
   constructor (
     private authHttp: HttpClient,
@@ -57,11 +58,52 @@ export class VideoCommentService {
                )
   }
 
-  getAdminVideoComments (options: {
+  // ---------------------------------------------------------------------------
+
+  approveComments (comments: {
+    videoId: number
+    commentId: number
+  }[]) {
+    return from(comments)
+      .pipe(
+        concatMap(({ videoId, commentId }) => {
+          const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + commentId + '/approve'
+
+          return this.authHttp.post(url, {})
+            .pipe(catchError(err => this.restExtractor.handleError(err)))
+        }),
+        toArray()
+      )
+  }
+
+  // ---------------------------------------------------------------------------
+
+  listVideoCommentsOfMyVideos (options: {
     pagination: RestPagination
     sort: SortMeta
     search?: string
-  }): Observable<ResultList<VideoCommentAdmin>> {
+  }): Observable<ResultList<VideoCommentForAdminOrUser>> {
+    const { pagination, sort, search } = options
+    const url = VideoCommentService.BASE_ME_URL + 'videos/comments'
+
+    let params = new HttpParams()
+    params = this.restService.addRestGetParams(params, pagination, sort)
+
+    if (search) {
+      params = this.buildParamsFromSearch(search, params)
+    }
+
+    return this.authHttp.get<ResultList<VideoCommentForAdminOrUser>>(url, { params })
+      .pipe(
+        catchError(res => this.restExtractor.handleError(res))
+      )
+  }
+
+  listAdminVideoComments (options: {
+    pagination: RestPagination
+    sort: SortMeta
+    search?: string
+  }): Observable<ResultList<VideoCommentForAdminOrUser>> {
     const { pagination, sort, search } = options
     const url = VideoCommentService.BASE_VIDEO_URL + 'comments'
 
@@ -72,12 +114,14 @@ export class VideoCommentService {
       params = this.buildParamsFromSearch(search, params)
     }
 
-    return this.authHttp.get<ResultList<VideoCommentAdmin>>(url, { params })
+    return this.authHttp.get<ResultList<VideoCommentForAdminOrUser>>(url, { params })
       .pipe(
         catchError(res => this.restExtractor.handleError(res))
       )
   }
 
+  // ---------------------------------------------------------------------------
+
   getVideoCommentThreads (parameters: {
     videoId: string
     videoPassword: string
@@ -118,6 +162,8 @@ export class VideoCommentService {
                )
   }
 
+  // ---------------------------------------------------------------------------
+
   deleteVideoComment (videoId: number | string, commentId: number) {
     const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comments/${commentId}`
 
@@ -134,6 +180,8 @@ export class VideoCommentService {
       )
   }
 
+  // ---------------------------------------------------------------------------
+
   getVideoCommentsFeeds (video: Pick<Video, 'uuid'>) {
     const feeds = [
       {
@@ -204,6 +252,16 @@ export class VideoCommentService {
         isBoolean: true
       },
 
+      isHeldForReview: {
+        prefix: 'heldForReview:',
+        isBoolean: true
+      },
+
+      autoTagOneOf: {
+        prefix: 'autoTag:',
+        multiple: true
+      },
+
       searchAccount: { prefix: 'account:' },
       searchVideo: { prefix: 'video:' }
     })
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist.service.ts b/client/src/app/shared/shared-video-playlist/video-playlist.service.ts
index 5d6362aac..c593ceeeb 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist.service.ts
+++ b/client/src/app/shared/shared-video-playlist/video-playlist.service.ts
@@ -58,7 +58,7 @@ export class VideoPlaylistService {
   ) {
     this.videoExistsInPlaylistObservable = merge(
       buildBulkObservable({
-        time: 500,
+        time: 5000,
         bulkGet: (videoIds: number[]) => {
           // We added a delay to the request, so ensure the user is still logged in
           if (this.auth.isLoggedIn()) {
diff --git a/client/src/app/shared/standalone-notifications/user-notifications.component.html b/client/src/app/shared/standalone-notifications/user-notifications.component.html
index 86165bb77..d3367ede9 100644
--- a/client/src/app/shared/standalone-notifications/user-notifications.component.html
+++ b/client/src/app/shared/standalone-notifications/user-notifications.component.html
@@ -94,6 +94,7 @@
 
           <div class="message" i18n>
             <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> commented your video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.comment.video.name }}</a>
+            <ng-container *ngIf="notification.comment.heldForReview">. This comment requires <a (click)="markAsRead(notification)" [routerLink]="notification.commentReviewUrl" [queryParams]="notification.commentReviewQueryParams">your approval</a></ng-container>
           </div>
         </ng-container>
 
diff --git a/client/src/app/shared/standalone-watched-words/watched-words-list-admin-owner.component.html b/client/src/app/shared/standalone-watched-words/watched-words-list-admin-owner.component.html
new file mode 100644
index 000000000..f47cc1cfd
--- /dev/null
+++ b/client/src/app/shared/standalone-watched-words/watched-words-list-admin-owner.component.html
@@ -0,0 +1,86 @@
+<p-table
+  [value]="lists" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
+  [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id"
+  [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="false"
+  [showCurrentPageReport]="true" [currentPageReportTemplate]="getPaginationTemplate()"
+  [expandedRowKeys]="expandedRows"
+>
+  <ng-template pTemplate="caption">
+    <div class="caption">
+      <div class="left-buttons">
+        <button type="button" *ngIf="!isInSelectionMode()" class="peertube-create-button" (click)="openCreateOrUpdateList()">
+          <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
+          <ng-container i18n>Create a new list</ng-container>
+        </button>
+      </div>
+
+      <div class="ms-auto right-form">
+        <my-button i18n-label label="Refresh" icon="refresh" (click)="reloadData()"></my-button>
+      </div>
+    </div>
+  </ng-template>
+
+  <ng-template pTemplate="header">
+    <tr>
+      <th scope="col" style="width: 40px;">
+        <span i18n class="visually-hidden">More information</span>
+      </th>
+      <th scope="col" style="width: 150px;">
+        <span i18n class="visually-hidden">Actions</span>
+      </th>
+      <th scope="col" style="width: 300px;" i18n>List name</th>
+      <th scope="col" style="width: 300px;" i18n>Words</th>
+      <th scope="col" style="width: 150px;" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="updatedAt">Date <p-sortIcon field="updatedAt"></p-sortIcon></th>
+    </tr>
+  </ng-template>
+
+  <ng-template pTemplate="body" let-list let-expanded="expanded">
+    <tr>
+
+      <td class="expand-cell">
+        <my-table-expander-icon [pRowToggler]="list" i18n-tooltip tooltip="See all words" [expanded]="expanded"></my-table-expander-icon>
+      </td>
+
+      <td class="action-cell">
+        <my-action-dropdown
+          [ngClass]="{ 'show': expanded }" placement="bottom-right" container="body"
+          i18n-label label="Actions" [actions]="actions" [entry]="list"
+        ></my-action-dropdown>
+      </td>
+
+      <td>
+        {{ list.listName }}
+      </td>
+
+      <td i18n>
+        {{ list.words.length }} words
+      </td>
+
+      <td>{{ list.updatedAt | date: 'short' }}</td>
+    </tr>
+  </ng-template>
+
+  <ng-template pTemplate="rowexpansion" let-list>
+    <tr>
+      <td class="expand-cell" myAutoColspan>
+        <ul>
+          @for (word of list.words; track word) {
+            <li>{{ word }}</li>
+          }
+        </ul>
+      </td>
+    </tr>
+  </ng-template>
+
+  <ng-template pTemplate="emptymessage">
+    <tr>
+      <td myAutoColspan>
+        <div class="no-results">
+          <ng-container i18n>No watched word lists found.</ng-container>
+        </div>
+      </td>
+    </tr>
+  </ng-template>
+</p-table>
+
+<my-watched-words-list-save-modal #saveModal [accountName]="accountNameParam" (listAddedOrUpdated)="reloadData()"></my-watched-words-list-save-modal>
diff --git a/client/src/app/shared/standalone-watched-words/watched-words-list-admin-owner.component.ts b/client/src/app/shared/standalone-watched-words/watched-words-list-admin-owner.component.ts
new file mode 100644
index 000000000..6b441cb19
--- /dev/null
+++ b/client/src/app/shared/standalone-watched-words/watched-words-list-admin-owner.component.ts
@@ -0,0 +1,138 @@
+import { DatePipe, NgClass, NgIf } from '@angular/common'
+import { Component, Input, OnInit, ViewChild } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { AuthService, ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
+import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
+import { UserRight, WatchedWordsList } from '@peertube/peertube-models'
+import { SharedModule, SortMeta } from 'primeng/api'
+import { TableModule } from 'primeng/table'
+import { first } from 'rxjs'
+import { GlobalIconComponent } from '../shared-icons/global-icon.component'
+import { AutoColspanDirective } from '../shared-main/angular/auto-colspan.directive'
+import { ActionDropdownComponent, DropdownAction } from '../shared-main/buttons/action-dropdown.component'
+import { ButtonComponent } from '../shared-main/buttons/button.component'
+import { TableExpanderIconComponent } from '../shared-tables/table-expander-icon.component'
+import { WatchedWordsListSaveModalComponent } from './watched-words-list-save-modal.component'
+import { WatchedWordsListService } from './watched-words-list.service'
+
+@Component({
+  selector: 'my-watched-words-list-admin-owner',
+  templateUrl: './watched-words-list-admin-owner.component.html',
+  standalone: true,
+  imports: [
+    GlobalIconComponent,
+    TableModule,
+    SharedModule,
+    NgIf,
+    ActionDropdownComponent,
+    ButtonComponent,
+    TableExpanderIconComponent,
+    NgClass,
+    AutoColspanDirective,
+    DatePipe,
+    NgbTooltip,
+    WatchedWordsListSaveModalComponent
+  ]
+})
+export class WatchedWordsListAdminOwnerComponent extends RestTable<WatchedWordsList> implements OnInit {
+  @Input({ required: true }) mode: 'user' | 'admin'
+
+  @ViewChild('saveModal', { static: true }) saveModal: WatchedWordsListSaveModalComponent
+
+  lists: WatchedWordsList[]
+  totalRecords = 0
+  sort: SortMeta = { field: 'createdAt', order: -1 }
+  pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
+
+  actions: DropdownAction<WatchedWordsList>[][] = []
+
+  get authUser () {
+    return this.auth.getUser()
+  }
+
+  get accountNameParam () {
+    if (this.mode === 'admin') return undefined
+
+    return this.authUser.account.name
+  }
+
+  constructor (
+    protected router: Router,
+    protected route: ActivatedRoute,
+    private auth: AuthService,
+    private notifier: Notifier,
+    private confirmService: ConfirmService,
+    private watchedWordsListService: WatchedWordsListService
+  ) {
+    super()
+
+    const isDisplayed = () => this.mode === 'user' || this.authUser.hasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS)
+
+    this.actions = [
+      [
+        {
+          iconName: 'edit',
+          label: $localize`Update`,
+          handler: list => this.openCreateOrUpdateList(list),
+          isDisplayed
+        }
+      ],
+      [
+        {
+          iconName: 'delete',
+          label: $localize`Delete`,
+          handler: list => this.removeList(list),
+          isDisplayed
+        }
+      ]
+    ]
+  }
+
+  ngOnInit () {
+    this.initialize()
+
+    this.auth.userInformationLoaded
+      .pipe(first())
+      .subscribe(() => this.reloadData())
+  }
+
+  getIdentifier () {
+    return 'WatchedWordsListAdminOwnerComponent'
+  }
+
+  openCreateOrUpdateList (list?: WatchedWordsList) {
+    this.saveModal.show(list)
+  }
+
+  protected reloadDataInternal () {
+    this.watchedWordsListService.list({ pagination: this.pagination, sort: this.sort, accountName: this.accountNameParam })
+      .subscribe({
+        next: resultList => {
+          this.totalRecords = resultList.total
+          this.lists = resultList.data
+        },
+
+        error: err => this.notifier.error(err.message)
+      })
+  }
+
+  private async removeList (list: WatchedWordsList) {
+    const message = $localize`Are you sure you want to delete this ${list.listName} list?`
+    const res = await this.confirmService.confirm(message, $localize`Delete list`)
+    if (res === false) return
+
+    this.watchedWordsListService.deleteList({
+      listId: list.id,
+      accountName: this.accountNameParam
+    }).subscribe({
+      next: () => {
+        this.notifier.success($localize`${list.listName} removed`)
+
+        this.reloadData()
+      },
+
+      error: err => this.notifier.error(err.message)
+    })
+  }
+
+}
diff --git a/client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.html b/client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.html
new file mode 100644
index 000000000..0c3b02a77
--- /dev/null
+++ b/client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.html
@@ -0,0 +1,49 @@
+<ng-template #modal>
+  <ng-container [formGroup]="form">
+
+    <div class="modal-header">
+      <h4 i18n class="modal-title">Save watched words list</h4>
+
+      <button class="border-0 p-0" title="Close this modal" i18n-title (click)="hide()">
+        <my-global-icon iconName="cross"></my-global-icon>
+      </button>
+    </div>
+
+    <div class="modal-body">
+
+      <div class="form-group">
+        <label i18n for="listName">List name</label>
+
+        <input
+          type="text" id="listName" class="form-control"
+          formControlName="listName" [ngClass]="{ 'input-error': formErrors['listName'] }"
+        >
+
+        <div *ngIf="formErrors.listName" class="form-error" role="alert">{{ formErrors.listName }}</div>
+      </div>
+
+      <div class="form-group">
+        <label i18n for="words">Words</label>
+
+        <div i18n class="form-group-description">One word or group of words per line.</div>
+
+        <textarea id="words" formControlName="words" class="form-control"[ngClass]="{ 'input-error': formErrors['words'] }"></textarea>
+
+        <div *ngIf="formErrors.words" class="form-error" role="alert">{{ formErrors.words }}</div>
+      </div>
+
+    </div>
+
+    <div class="modal-footer inputs">
+      <input
+        type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
+        (click)="hide()" (key.enter)="hide()"
+      >
+
+      <input
+        type="submit" i18n-value value="Save" class="peertube-button orange-button"
+        [disabled]="!form.valid" (click)="addOrUpdate()"
+      >
+    </div>
+  </ng-container>
+</ng-template>
diff --git a/client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.scss b/client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.scss
new file mode 100644
index 000000000..59e731b79
--- /dev/null
+++ b/client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.scss
@@ -0,0 +1,6 @@
+@use '_variables' as *;
+@use '_mixins' as *;
+
+textarea {
+  min-height: 300px;
+}
diff --git a/client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.ts b/client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.ts
new file mode 100644
index 000000000..ed2d90c61
--- /dev/null
+++ b/client/src/app/shared/standalone-watched-words/watched-words-list-save-modal.component.ts
@@ -0,0 +1,95 @@
+import { NgClass, NgIf } from '@angular/common'
+import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { Notifier } from '@app/core'
+import { FormReactive } from '@app/shared/shared-forms/form-reactive'
+import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
+import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
+import { WatchedWordsList } from '@peertube/peertube-models'
+import { splitAndGetNotEmpty } from '@root-helpers/string'
+import { UNIQUE_WATCHED_WORDS_VALIDATOR, WATCHED_WORDS_LIST_NAME_VALIDATOR } from '../form-validators/watched-words-list-validators'
+import { GlobalIconComponent } from '../shared-icons/global-icon.component'
+import { WatchedWordsListService } from './watched-words-list.service'
+
+@Component({
+  selector: 'my-watched-words-list-save-modal',
+  styleUrls: [ './watched-words-list-save-modal.component.scss' ],
+  templateUrl: './watched-words-list-save-modal.component.html',
+  standalone: true,
+  imports: [ FormsModule, ReactiveFormsModule, GlobalIconComponent, NgIf, NgClass ]
+})
+
+export class WatchedWordsListSaveModalComponent extends FormReactive implements OnInit {
+  @Input({ required: true }) accountName: string
+
+  @Output() listAddedOrUpdated = new EventEmitter<void>()
+
+  @ViewChild('modal', { static: true }) modal: ElementRef
+
+  private openedModal: NgbModalRef
+  private listToUpdate: WatchedWordsList
+
+  constructor (
+    protected formReactiveService: FormReactiveService,
+    private modalService: NgbModal,
+    private notifier: Notifier,
+    private watchedWordsService: WatchedWordsListService
+  ) {
+    super()
+  }
+
+  ngOnInit () {
+    this.buildForm({
+      listName: WATCHED_WORDS_LIST_NAME_VALIDATOR,
+      words: UNIQUE_WATCHED_WORDS_VALIDATOR
+    })
+  }
+
+  show (list?: WatchedWordsList) {
+    this.listToUpdate = list
+
+    this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
+
+    if (list) {
+      this.form.patchValue({
+        listName: list.listName,
+        words: list.words.join('\n')
+      })
+    }
+  }
+
+  hide () {
+    this.openedModal.close()
+    this.form.reset()
+
+    this.listToUpdate = undefined
+  }
+
+  addOrUpdate () {
+    const commonParams = {
+      accountName: this.accountName,
+      listName: this.form.value['listName'],
+      words: splitAndGetNotEmpty(this.form.value['words'])
+    }
+
+    const obs = this.listToUpdate
+      ? this.watchedWordsService.updateList({ ...commonParams, listId: this.listToUpdate.id })
+      : this.watchedWordsService.addList(commonParams)
+
+    obs.subscribe({
+      next: () => {
+        if (this.listToUpdate) {
+          this.notifier.success($localize`${commonParams.listName} updated`)
+        } else {
+          this.notifier.success($localize`${commonParams.listName} created`)
+        }
+
+        this.listAddedOrUpdated.emit()
+      },
+
+      error: err => this.notifier.error(err.message)
+    })
+
+    this.hide()
+  }
+}
diff --git a/client/src/app/shared/standalone-watched-words/watched-words-list.service.ts b/client/src/app/shared/standalone-watched-words/watched-words-list.service.ts
new file mode 100644
index 000000000..baf263e46
--- /dev/null
+++ b/client/src/app/shared/standalone-watched-words/watched-words-list.service.ts
@@ -0,0 +1,86 @@
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor, RestPagination, RestService } from '@app/core'
+import { ResultList, WatchedWordsList } from '@peertube/peertube-models'
+import { SortMeta } from 'primeng/api'
+import { Observable } from 'rxjs'
+import { catchError } from 'rxjs/operators'
+import { environment } from '../../../environments/environment'
+
+@Injectable()
+export class WatchedWordsListService {
+  private static BASE_WATCHED_WORDS_URL = environment.apiUrl + '/api/v1/watched-words/'
+
+  constructor (
+    private authHttp: HttpClient,
+    private restExtractor: RestExtractor,
+    private restService: RestService
+  ) {}
+
+  list (options: {
+    accountName?: string
+    pagination: RestPagination
+    sort: SortMeta
+  }): Observable<ResultList<WatchedWordsList>> {
+    const { pagination, sort } = options
+    const url = this.buildServerOrAccountListPath(options)
+
+    let params = new HttpParams()
+    params = this.restService.addRestGetParams(params, pagination, sort)
+
+    return this.authHttp.get<ResultList<WatchedWordsList>>(url, { params })
+      .pipe(catchError(res => this.restExtractor.handleError(res)))
+  }
+
+  addList (options: {
+    accountName?: string
+    listName: string
+    words: string[]
+  }) {
+    const { listName, words } = options
+
+    const url = this.buildServerOrAccountListPath(options)
+    const body = { listName, words }
+
+    return this.authHttp.post(url, body)
+      .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+
+  updateList (options: {
+    accountName?: string
+
+    listId: number
+    listName: string
+    words: string[]
+  }) {
+    const { listName, words } = options
+
+    const url = this.buildServerOrAccountListPath(options)
+    const body = { listName, words }
+
+    return this.authHttp.put(url, body)
+      .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+
+  deleteList (options: {
+    accountName?: string
+    listId: number
+  }) {
+    const url = this.buildServerOrAccountListPath(options)
+
+    return this.authHttp.delete(url)
+      .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+
+  private buildServerOrAccountListPath (options: { accountName?: string, listId?: number }) {
+    let suffixPath = options.accountName
+      ? '/accounts/' + options.accountName + '/lists'
+      : '/server/lists'
+
+    if (options.listId) {
+      suffixPath += '/' + options.listId
+    }
+
+    return WatchedWordsListService.BASE_WATCHED_WORDS_URL + suffixPath
+  }
+}
diff --git a/client/src/root-helpers/string.ts b/client/src/root-helpers/string.ts
index 46439b535..a3055da56 100644
--- a/client/src/root-helpers/string.ts
+++ b/client/src/root-helpers/string.ts
@@ -15,3 +15,9 @@ export function randomString (length: number) {
 
   return result
 }
+
+export function splitAndGetNotEmpty (value: string) {
+  return value
+    .split('\n')
+    .filter(line => line && line.length !== 0) // Eject empty lines
+}
diff --git a/config/default.yaml b/config/default.yaml
index e543d0378..cb6d49fd2 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -123,7 +123,8 @@ defaults:
   publish:
     download_enabled: true
 
-    comments_enabled: true
+    # enabled = 1, disabled = 2, requires_approval = 3
+    comments_policy: 1
 
     # public = 1, unlisted = 2, private = 3, internal = 4
     privacy: 1
diff --git a/config/production.yaml.example b/config/production.yaml.example
index 254dd113f..9d2fe08be 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -121,7 +121,8 @@ defaults:
   publish:
     download_enabled: true
 
-    comments_enabled: true
+    # enabled = 1, disabled = 2, requires_approval = 3
+    comments_policy: 1
 
     # public = 1, unlisted = 2, private = 3, internal = 4
     privacy: 1
diff --git a/package.json b/package.json
index fa009c8e4..48323ea3c 100644
--- a/package.json
+++ b/package.json
@@ -143,6 +143,7 @@
     "js-yaml": "^4.0.0",
     "jsonld": "~8.3.1",
     "jsonwebtoken": "^9.0.2",
+    "linkify-it": "^5.0.0",
     "lodash-es": "^4.17.21",
     "lru-cache": "^10.0.1",
     "magnet-uri": "^7.0.5",
@@ -201,6 +202,7 @@
     "@types/fs-extra": "^11.0.1",
     "@types/jsonld": "^1.5.9",
     "@types/jsonwebtoken": "^9.0.5",
+    "@types/linkify-it": "^3.0.5",
     "@types/lodash-es": "^4.17.8",
     "@types/magnet-uri": "^5.1.1",
     "@types/maildev": "^0.0.7",
diff --git a/packages/core-utils/src/users/user-role.ts b/packages/core-utils/src/users/user-role.ts
index 0add3a0a8..64c120b37 100644
--- a/packages/core-utils/src/users/user-role.ts
+++ b/packages/core-utils/src/users/user-role.ts
@@ -17,14 +17,16 @@ const userRoleRights: { [ id in UserRoleType ]: UserRightType[] } = {
     UserRight.MANAGE_ANY_VIDEO_CHANNEL,
     UserRight.REMOVE_ANY_VIDEO,
     UserRight.REMOVE_ANY_VIDEO_PLAYLIST,
-    UserRight.REMOVE_ANY_VIDEO_COMMENT,
+    UserRight.MANAGE_ANY_VIDEO_COMMENT,
     UserRight.UPDATE_ANY_VIDEO,
     UserRight.SEE_ALL_VIDEOS,
     UserRight.MANAGE_ACCOUNTS_BLOCKLIST,
     UserRight.MANAGE_SERVERS_BLOCKLIST,
     UserRight.MANAGE_USERS,
     UserRight.SEE_ALL_COMMENTS,
-    UserRight.MANAGE_REGISTRATIONS
+    UserRight.MANAGE_REGISTRATIONS,
+    UserRight.MANAGE_INSTANCE_WATCHED_WORDS,
+    UserRight.MANAGE_INSTANCE_AUTO_TAGS
   ],
 
   [UserRole.USER]: []
diff --git a/packages/models/src/activitypub/activity.ts b/packages/models/src/activitypub/activity.ts
index 22f9edd78..9e6402c08 100644
--- a/packages/models/src/activitypub/activity.ts
+++ b/packages/models/src/activitypub/activity.ts
@@ -33,7 +33,9 @@ export type Activity =
   ActivityReject |
   ActivityView |
   ActivityDislike |
-  ActivityFlag
+  ActivityFlag |
+  ActivityApproveReply |
+  ActivityRejectReply
 
 export type ActivityType =
   'Create' |
@@ -47,7 +49,9 @@ export type ActivityType =
   'Reject' |
   'View' |
   'Dislike' |
-  'Flag'
+  'Flag' |
+  'ApproveReply' |
+  'RejectReply'
 
 export interface ActivityAudience {
   to: string[]
@@ -89,6 +93,18 @@ export interface ActivityAccept extends BaseActivity {
   object: ActivityFollow
 }
 
+export interface ActivityApproveReply extends BaseActivity {
+  type: 'ApproveReply'
+  object: string
+  inReplyTo: string
+}
+
+export interface ActivityRejectReply extends BaseActivity {
+  type: 'RejectReply'
+  object: string
+  inReplyTo: string
+}
+
 export interface ActivityReject extends BaseActivity {
   type: 'Reject'
   object: ActivityFollow
diff --git a/packages/models/src/activitypub/context.ts b/packages/models/src/activitypub/context.ts
index e52463c6c..f0989698d 100644
--- a/packages/models/src/activitypub/context.ts
+++ b/packages/models/src/activitypub/context.ts
@@ -14,4 +14,6 @@ export type ContextType =
   'Actor' |
   'Collection' |
   'WatchAction' |
-  'Chapters'
+  'Chapters' |
+  'ApproveReply' |
+  'RejectReply'
diff --git a/packages/models/src/activitypub/objects/video-comment-object.ts b/packages/models/src/activitypub/objects/video-comment-object.ts
index 880dd2ee2..63260d225 100644
--- a/packages/models/src/activitypub/objects/video-comment-object.ts
+++ b/packages/models/src/activitypub/objects/video-comment-object.ts
@@ -13,4 +13,9 @@ export interface VideoCommentObject {
   url: string
   attributedTo: ActivityPubAttributedTo
   tag: ActivityTagObject[]
+
+  replyApproval: string | null
+
+  to?: string[]
+  cc?: string[]
 }
diff --git a/packages/models/src/activitypub/objects/video-object.ts b/packages/models/src/activitypub/objects/video-object.ts
index 16dbe1aab..63177c239 100644
--- a/packages/models/src/activitypub/objects/video-object.ts
+++ b/packages/models/src/activitypub/objects/video-object.ts
@@ -1,4 +1,4 @@
-import { LiveVideoLatencyModeType, VideoStateType } from '../../videos/index.js'
+import { LiveVideoLatencyModeType, VideoCommentPolicyType, VideoStateType } from '../../videos/index.js'
 import {
   ActivityIconObject,
   ActivityIdentifierObject,
@@ -29,7 +29,10 @@ export interface VideoObject {
   permanentLive: boolean
   latencyMode: LiveVideoLatencyModeType
 
-  commentsEnabled: boolean
+  commentsEnabled?: boolean
+  commentsPolicy: VideoCommentPolicyType
+  canReply: 'as:Public' | 'https://www.w3.org/ns/activitystreams#Public'
+
   downloadEnabled: boolean
   waitTranscoding: boolean
   state: VideoStateType
diff --git a/packages/models/src/import-export/peertube-export-format/auto-tag-policies-export.ts b/packages/models/src/import-export/peertube-export-format/auto-tag-policies-export.ts
new file mode 100644
index 000000000..48796c615
--- /dev/null
+++ b/packages/models/src/import-export/peertube-export-format/auto-tag-policies-export.ts
@@ -0,0 +1,5 @@
+export interface AutoTagPoliciesJSON {
+  reviewComments: {
+    name: string
+  }[]
+}
diff --git a/packages/models/src/import-export/peertube-export-format/index.ts b/packages/models/src/import-export/peertube-export-format/index.ts
index c0cbbf4b8..3ecec47eb 100644
--- a/packages/models/src/import-export/peertube-export-format/index.ts
+++ b/packages/models/src/import-export/peertube-export-format/index.ts
@@ -1,5 +1,6 @@
 export * from './account-export.model.js'
 export * from './actor-export.model.js'
+export * from './auto-tag-policies-export.js'
 export * from './blocklist-export.model.js'
 export * from './channel-export.model.js'
 export * from './comments-export.model.js'
@@ -11,3 +12,4 @@ export * from './user-settings-export.model.js'
 export * from './user-video-history-export.js'
 export * from './video-export.model.js'
 export * from './video-playlists-export.model.js'
+export * from './watched-words-lists-export.js'
diff --git a/packages/models/src/import-export/peertube-export-format/user-video-history-export.ts b/packages/models/src/import-export/peertube-export-format/user-video-history-export.ts
index 11e92f356..8e40b2e16 100644
--- a/packages/models/src/import-export/peertube-export-format/user-video-history-export.ts
+++ b/packages/models/src/import-export/peertube-export-format/user-video-history-export.ts
@@ -4,7 +4,7 @@ export interface UserVideoHistoryExportJSON {
     lastTimecode: number
     createdAt: string
     updatedAt: string
-  }[]
 
-  archiveFiles?: never
+    archiveFiles?: never
+  }[]
 }
diff --git a/packages/models/src/import-export/peertube-export-format/video-export.model.ts b/packages/models/src/import-export/peertube-export-format/video-export.model.ts
index a23a015ac..6e49c6193 100644
--- a/packages/models/src/import-export/peertube-export-format/video-export.model.ts
+++ b/packages/models/src/import-export/peertube-export-format/video-export.model.ts
@@ -1,5 +1,6 @@
 import {
   LiveVideoLatencyModeType,
+  VideoCommentPolicyType,
   VideoFileMetadata,
   VideoPrivacyType,
   VideoStateType,
@@ -53,7 +54,10 @@ export interface VideoExportJSON {
 
     nsfw: boolean
 
-    commentsEnabled: boolean
+    // TODO: remove, deprecated in 6.2
+    commentsEnabled?: boolean
+    commentsPolicy: VideoCommentPolicyType
+
     downloadEnabled: boolean
 
     channel: {
diff --git a/packages/models/src/import-export/peertube-export-format/watched-words-lists-export.ts b/packages/models/src/import-export/peertube-export-format/watched-words-lists-export.ts
new file mode 100644
index 000000000..3c0dadc4d
--- /dev/null
+++ b/packages/models/src/import-export/peertube-export-format/watched-words-lists-export.ts
@@ -0,0 +1,10 @@
+export interface WatchedWordsListsJSON {
+  watchedWordLists: {
+    createdAt: string
+    updatedAt: string
+    listName: string
+    words: string[]
+
+    archiveFiles?: never
+  }[]
+}
diff --git a/packages/models/src/import-export/user-import-result.model.ts b/packages/models/src/import-export/user-import-result.model.ts
index 156725c42..73c0c4930 100644
--- a/packages/models/src/import-export/user-import-result.model.ts
+++ b/packages/models/src/import-export/user-import-result.model.ts
@@ -18,5 +18,8 @@ export interface UserImportResultSummary {
     userSettings: Summary
 
     userVideoHistory: Summary
+
+    watchedWordsLists: Summary
+    commentAutoTagPolicies: Summary
   }
 }
diff --git a/packages/models/src/moderation/automatic-tag-available.model.ts b/packages/models/src/moderation/automatic-tag-available.model.ts
new file mode 100644
index 000000000..407866316
--- /dev/null
+++ b/packages/models/src/moderation/automatic-tag-available.model.ts
@@ -0,0 +1,8 @@
+export type AutomaticTagAvailableType = 'core' | 'watched-words-list'
+
+export interface AutomaticTagAvailable {
+  available: {
+    name: string
+    type: AutomaticTagAvailableType
+  }[]
+}
diff --git a/packages/models/src/moderation/automatic-tag-policy.enum.ts b/packages/models/src/moderation/automatic-tag-policy.enum.ts
new file mode 100644
index 000000000..38d8f41fc
--- /dev/null
+++ b/packages/models/src/moderation/automatic-tag-policy.enum.ts
@@ -0,0 +1,6 @@
+export const AutomaticTagPolicy = {
+  NONE: 1,
+  REVIEW_COMMENT: 2
+} as const
+
+export type AutomaticTagPolicyType = typeof AutomaticTagPolicy[keyof typeof AutomaticTagPolicy]
diff --git a/packages/models/src/moderation/comment-automatic-tag-policies-update.model.ts b/packages/models/src/moderation/comment-automatic-tag-policies-update.model.ts
new file mode 100644
index 000000000..2c0f0efbf
--- /dev/null
+++ b/packages/models/src/moderation/comment-automatic-tag-policies-update.model.ts
@@ -0,0 +1,3 @@
+export interface CommentAutomaticTagPoliciesUpdate {
+  review: string[]
+}
diff --git a/packages/models/src/moderation/comment-automatic-tag-policies.model.ts b/packages/models/src/moderation/comment-automatic-tag-policies.model.ts
new file mode 100644
index 000000000..61def2b02
--- /dev/null
+++ b/packages/models/src/moderation/comment-automatic-tag-policies.model.ts
@@ -0,0 +1,3 @@
+export interface CommentAutomaticTagPolicies {
+  review: string[]
+}
diff --git a/packages/models/src/moderation/index.ts b/packages/models/src/moderation/index.ts
index 52e21e7b3..fb27da7e0 100644
--- a/packages/models/src/moderation/index.ts
+++ b/packages/models/src/moderation/index.ts
@@ -1,4 +1,9 @@
 export * from './abuse/index.js'
-export * from './block-status.model.js'
+export * from './automatic-tag-available.model.js'
 export * from './account-block.model.js'
+export * from './comment-automatic-tag-policies-update.model.js'
+export * from './comment-automatic-tag-policies.model.js'
+export * from './automatic-tag-policy.enum.js'
+export * from './block-status.model.js'
 export * from './server-block.model.js'
+export * from './watched-words-list.model.js'
diff --git a/packages/models/src/moderation/watched-words-list.model.ts b/packages/models/src/moderation/watched-words-list.model.ts
new file mode 100644
index 000000000..c0312e86d
--- /dev/null
+++ b/packages/models/src/moderation/watched-words-list.model.ts
@@ -0,0 +1,9 @@
+export interface WatchedWordsList {
+  id: number
+
+  listName: string
+  words: string[]
+
+  updatedAt: Date | string
+  createdAt: Date | string
+}
diff --git a/packages/models/src/search/videos-common-query.model.ts b/packages/models/src/search/videos-common-query.model.ts
index 45181a739..e8f327c94 100644
--- a/packages/models/src/search/videos-common-query.model.ts
+++ b/packages/models/src/search/videos-common-query.model.ts
@@ -21,8 +21,6 @@ export interface VideosCommonQuery {
 
   languageOneOf?: string[]
 
-  privacyOneOf?: VideoPrivacyType[]
-
   tagsOneOf?: string[]
   tagsAllOf?: string[]
 
@@ -36,6 +34,10 @@ export interface VideosCommonQuery {
   search?: string
 
   excludeAlreadyWatched?: boolean
+
+  // Only available with special user right
+  autoTagOneOf?: string[]
+  privacyOneOf?: VideoPrivacyType[]
 }
 
 export interface VideosCommonQueryAfterSanitize extends VideosCommonQuery {
diff --git a/packages/models/src/server/server-config.model.ts b/packages/models/src/server/server-config.model.ts
index 9106c5b95..2e3626854 100644
--- a/packages/models/src/server/server-config.model.ts
+++ b/packages/models/src/server/server-config.model.ts
@@ -1,4 +1,4 @@
-import { ActorImage } from '../index.js'
+import { ActorImage, VideoCommentPolicyType } from '../index.js'
 import { ClientScriptJSON } from '../plugins/plugin-package-json.model.js'
 import { NSFWPolicyType } from '../videos/nsfw-policy.type.js'
 import { VideoPrivacyType } from '../videos/video-privacy.enum.js'
@@ -57,7 +57,11 @@ export interface ServerConfig {
   defaults: {
     publish: {
       downloadEnabled: boolean
+
+      // TODO: remove, deprecated in 6.2
       commentsEnabled: boolean
+      commentsPolicy: VideoCommentPolicyType
+
       privacy: VideoPrivacyType
       licence: number
     }
diff --git a/packages/models/src/users/user-notification.model.ts b/packages/models/src/users/user-notification.model.ts
index 74cc16adb..e8435f031 100644
--- a/packages/models/src/users/user-notification.model.ts
+++ b/packages/models/src/users/user-notification.model.ts
@@ -85,6 +85,7 @@ export interface UserNotification {
     threadId: number
     account: ActorInfo
     video: VideoInfo
+    heldForReview: boolean
   }
 
   abuse?: {
diff --git a/packages/models/src/users/user-right.enum.ts b/packages/models/src/users/user-right.enum.ts
index 1da8b64f9..fe8e32308 100644
--- a/packages/models/src/users/user-right.enum.ts
+++ b/packages/models/src/users/user-right.enum.ts
@@ -26,7 +26,7 @@ export const UserRight = {
 
   REMOVE_ANY_VIDEO: 14,
   REMOVE_ANY_VIDEO_PLAYLIST: 15,
-  REMOVE_ANY_VIDEO_COMMENT: 16,
+  MANAGE_ANY_VIDEO_COMMENT: 16,
 
   UPDATE_ANY_VIDEO: 17,
   UPDATE_ANY_VIDEO_PLAYLIST: 18,
@@ -50,7 +50,10 @@ export const UserRight = {
   MANAGE_RUNNERS: 29,
 
   MANAGE_USER_EXPORTS: 30,
-  MANAGE_USER_IMPORTS: 31
+  MANAGE_USER_IMPORTS: 31,
+
+  MANAGE_INSTANCE_WATCHED_WORDS: 32,
+  MANAGE_INSTANCE_AUTO_TAGS: 33
 } as const
 
 export type UserRightType = typeof UserRight[keyof typeof UserRight]
diff --git a/packages/models/src/videos/comment/index.ts b/packages/models/src/videos/comment/index.ts
index bd26c652d..0c251fce4 100644
--- a/packages/models/src/videos/comment/index.ts
+++ b/packages/models/src/videos/comment/index.ts
@@ -1,2 +1,3 @@
 export * from './video-comment-create.model.js'
 export * from './video-comment.model.js'
+export * from './video-comment-policy.enum.js'
diff --git a/packages/models/src/videos/comment/video-comment-policy.enum.ts b/packages/models/src/videos/comment/video-comment-policy.enum.ts
new file mode 100644
index 000000000..d4664305c
--- /dev/null
+++ b/packages/models/src/videos/comment/video-comment-policy.enum.ts
@@ -0,0 +1,7 @@
+export const VideoCommentPolicy = {
+  ENABLED: 1,
+  DISABLED: 2,
+  REQUIRES_APPROVAL: 3
+} as const
+
+export type VideoCommentPolicyType = typeof VideoCommentPolicy[keyof typeof VideoCommentPolicy]
diff --git a/packages/models/src/videos/comment/video-comment.model.ts b/packages/models/src/videos/comment/video-comment.model.ts
index e2266545a..c818cb736 100644
--- a/packages/models/src/videos/comment/video-comment.model.ts
+++ b/packages/models/src/videos/comment/video-comment.model.ts
@@ -5,19 +5,25 @@ export interface VideoComment {
   id: number
   url: string
   text: string
+
   threadId: number
   inReplyToCommentId: number
   videoId: number
+
   createdAt: Date | string
   updatedAt: Date | string
   deletedAt: Date | string
+
   isDeleted: boolean
   totalRepliesFromVideoAuthor: number
   totalReplies: number
+
   account: Account
+
+  heldForReview: boolean
 }
 
-export interface VideoCommentAdmin {
+export interface VideoCommentForAdminOrUser {
   id: number
   url: string
   text: string
@@ -35,6 +41,10 @@ export interface VideoCommentAdmin {
     uuid: string
     name: string
   }
+
+  heldForReview: boolean
+
+  automaticTags: string[]
 }
 
 export type VideoCommentThreads = ResultList<VideoComment> & { totalNotDeletedComments: number }
diff --git a/packages/models/src/videos/video-create.model.ts b/packages/models/src/videos/video-create.model.ts
index 472201211..0eca38372 100644
--- a/packages/models/src/videos/video-create.model.ts
+++ b/packages/models/src/videos/video-create.model.ts
@@ -1,3 +1,4 @@
+import { VideoCommentPolicyType } from './comment/video-comment-policy.enum.js'
 import { VideoPrivacyType } from './video-privacy.enum.js'
 import { VideoScheduleUpdate } from './video-schedule-update.model.js'
 
@@ -13,7 +14,11 @@ export interface VideoCreate {
   nsfw?: boolean
   waitTranscoding?: boolean
   tags?: string[]
+
+  // TODO: remove, deprecated in 6.2
   commentsEnabled?: boolean
+  commentsPolicy?: VideoCommentPolicyType
+
   downloadEnabled?: boolean
   privacy: VideoPrivacyType
   scheduleUpdate?: VideoScheduleUpdate
diff --git a/packages/models/src/videos/video-include.enum.ts b/packages/models/src/videos/video-include.enum.ts
index de8763a85..72f3d0b14 100644
--- a/packages/models/src/videos/video-include.enum.ts
+++ b/packages/models/src/videos/video-include.enum.ts
@@ -5,7 +5,8 @@ export const VideoInclude = {
   BLOCKED_OWNER: 1 << 2,
   FILES: 1 << 3,
   CAPTIONS: 1 << 4,
-  SOURCE: 1 << 5
+  SOURCE: 1 << 5,
+  AUTOMATIC_TAGS: 1 << 6
 } as const
 
 export type VideoIncludeType = typeof VideoInclude[keyof typeof VideoInclude]
diff --git a/packages/models/src/videos/video-update.model.ts b/packages/models/src/videos/video-update.model.ts
index 8af298160..5e7580a44 100644
--- a/packages/models/src/videos/video-update.model.ts
+++ b/packages/models/src/videos/video-update.model.ts
@@ -1,3 +1,4 @@
+import { VideoCommentPolicyType } from './index.js'
 import { VideoPrivacyType } from './video-privacy.enum.js'
 import { VideoScheduleUpdate } from './video-schedule-update.model.js'
 
@@ -10,7 +11,11 @@ export interface VideoUpdate {
   support?: string
   privacy?: VideoPrivacyType
   tags?: string[]
+
+  // TODO: remove, deprecated in 6.2
   commentsEnabled?: boolean
+  commentsPolicy?: VideoCommentPolicyType
+
   downloadEnabled?: boolean
   nsfw?: boolean
   waitTranscoding?: boolean
diff --git a/packages/models/src/videos/video.model.ts b/packages/models/src/videos/video.model.ts
index f29efecd1..5b37e0a43 100644
--- a/packages/models/src/videos/video.model.ts
+++ b/packages/models/src/videos/video.model.ts
@@ -1,6 +1,7 @@
 import { Account, AccountSummary } from '../actors/index.js'
 import { VideoChannel, VideoChannelSummary } from './channel/video-channel.model.js'
 import { VideoFile } from './file/index.js'
+import { VideoCommentPolicyType } from './index.js'
 import { VideoConstant } from './video-constant.model.js'
 import { VideoPrivacyType } from './video-privacy.enum.js'
 import { VideoScheduleUpdate } from './video-schedule-update.model.js'
@@ -78,17 +79,26 @@ export interface VideoAdditionalAttributes {
   streamingPlaylists: VideoStreamingPlaylist[]
 
   videoSource: VideoSource
+
+  automaticTags: string[]
 }
 
 export interface VideoDetails extends Video {
-  // Deprecated in 5.0
+  // TODO: remove, deprecated in 5.0
   descriptionPath: string
 
   support: string
   channel: VideoChannel
   account: Account
   tags: string[]
+
+  // TODO: remove, deprecated in 6.2
   commentsEnabled: boolean
+  commentsPolicy: {
+    id: VideoCommentPolicyType
+    label: string
+  }
+
   downloadEnabled: boolean
 
   // Not optional in details (unlike in parent Video)
diff --git a/packages/server-commands/src/moderation/automatic-tags-command.ts b/packages/server-commands/src/moderation/automatic-tags-command.ts
new file mode 100644
index 000000000..fb6dfaa88
--- /dev/null
+++ b/packages/server-commands/src/moderation/automatic-tags-command.ts
@@ -0,0 +1,68 @@
+import { pick } from '@peertube/peertube-core-utils'
+import {
+  AutomaticTagAvailable,
+  CommentAutomaticTagPolicies,
+  CommentAutomaticTagPoliciesUpdate,
+  HttpStatusCode
+} from '@peertube/peertube-models'
+import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
+
+export class AutomaticTagsCommand extends AbstractCommand {
+
+  getCommentPolicies (options: OverrideCommandOptions & {
+    accountName: string
+  }) {
+    const path = '/api/v1/automatic-tags/policies/accounts/' + options.accountName + '/comments'
+
+    return this.getRequestBody<CommentAutomaticTagPolicies>({
+      ...options,
+
+      path,
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.OK_200
+    })
+  }
+
+  updateCommentPolicies (options: OverrideCommandOptions & CommentAutomaticTagPoliciesUpdate & {
+    accountName: string
+  }) {
+    const path = '/api/v1/automatic-tags/policies/accounts/' + options.accountName + '/comments'
+
+    return this.putBodyRequest({
+      ...options,
+
+      path,
+      fields: pick(options, [ 'review' ]),
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
+  getAccountAvailable (options: OverrideCommandOptions & {
+    accountName: string
+  }) {
+    const path = '/api/v1/automatic-tags/accounts/' + options.accountName + '/available'
+
+    return this.getRequestBody<AutomaticTagAvailable>({
+      ...options,
+
+      path,
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.OK_200
+    })
+  }
+
+  getServerAvailable (options: OverrideCommandOptions = {}) {
+    const path = '/api/v1/automatic-tags/server/available'
+
+    return this.getRequestBody<AutomaticTagAvailable>({
+      ...options,
+
+      path,
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.OK_200
+    })
+  }
+}
diff --git a/packages/server-commands/src/moderation/index.ts b/packages/server-commands/src/moderation/index.ts
index 8164afd7c..c65ba638c 100644
--- a/packages/server-commands/src/moderation/index.ts
+++ b/packages/server-commands/src/moderation/index.ts
@@ -1 +1,3 @@
 export * from './abuses-command.js'
+export * from './automatic-tags-command.js'
+export * from './watched-words-command.js'
diff --git a/packages/server-commands/src/moderation/watched-words-command.ts b/packages/server-commands/src/moderation/watched-words-command.ts
new file mode 100644
index 000000000..92566f8e5
--- /dev/null
+++ b/packages/server-commands/src/moderation/watched-words-command.ts
@@ -0,0 +1,87 @@
+import { pick } from '@peertube/peertube-core-utils'
+import {
+  HttpStatusCode,
+  ResultList, WatchedWordsList
+} from '@peertube/peertube-models'
+import { unwrapBody } from '../index.js'
+import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
+
+export class WatchedWordsCommand extends AbstractCommand {
+
+  listWordsLists (options: OverrideCommandOptions & {
+    start?: number
+    count?: number
+    sort?: string
+
+    accountName?: string
+  }) {
+    const query = {
+      sort: '-createdAt',
+
+      ...pick(options, [ 'start', 'count', 'sort' ])
+    }
+
+    return this.getRequestBody<ResultList<WatchedWordsList>>({
+      ...options,
+
+      path: this.buildAPIBasePath(options.accountName),
+      query,
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.OK_200
+    })
+  }
+
+  createList (options: OverrideCommandOptions & {
+    listName: string
+    words: string[]
+    accountName?: string
+  }) {
+    const body = pick(options, [ 'listName', 'words' ])
+
+    return unwrapBody<{ watchedWordsList: { id: number } }>(this.postBodyRequest({
+      ...options,
+
+      path: this.buildAPIBasePath(options.accountName),
+      fields: body,
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.OK_200
+    }))
+  }
+
+  updateList (options: OverrideCommandOptions & {
+    listId: number
+    accountName?: string
+    listName?: string
+    words?: string[]
+  }) {
+    const body = pick(options, [ 'listName', 'words' ])
+
+    return this.putBodyRequest({
+      ...options,
+
+      path: this.buildAPIBasePath(options.accountName) + '/' + options.listId,
+      fields: body,
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+    })
+  }
+
+  deleteList (options: OverrideCommandOptions & {
+    listId: number
+    accountName?: string
+  }) {
+    return this.deleteRequest({
+      ...options,
+
+      path: this.buildAPIBasePath(options.accountName) + '/' + options.listId,
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+    })
+  }
+
+  private buildAPIBasePath (accountName?: string) {
+    return accountName
+      ? '/api/v1/watched-words/accounts/' + accountName + '/lists'
+      : '/api/v1/watched-words/server/lists'
+  }
+}
diff --git a/packages/server-commands/src/server/server.ts b/packages/server-commands/src/server/server.ts
index 8e5e1eaf0..8e27897e2 100644
--- a/packages/server-commands/src/server/server.ts
+++ b/packages/server-commands/src/server/server.ts
@@ -1,15 +1,15 @@
-import { ChildProcess, fork } from 'child_process'
-import { copy } from 'fs-extra/esm'
-import { join } from 'path'
 import { randomInt } from '@peertube/peertube-core-utils'
 import { Video, VideoChannel, VideoChannelSync, VideoCreateResult, VideoDetails } from '@peertube/peertube-models'
 import { parallelTests, root } from '@peertube/peertube-node-utils'
+import { ChildProcess, fork } from 'child_process'
+import { copy } from 'fs-extra/esm'
+import { join } from 'path'
 import { BulkCommand } from '../bulk/index.js'
 import { CLICommand } from '../cli/index.js'
 import { CustomPagesCommand } from '../custom-pages/index.js'
 import { FeedCommand } from '../feeds/index.js'
 import { LogsCommand } from '../logs/index.js'
-import { AbusesCommand } from '../moderation/index.js'
+import { AbusesCommand, AutomaticTagsCommand, WatchedWordsCommand } from '../moderation/index.js'
 import { OverviewsCommand } from '../overviews/index.js'
 import { RunnerJobsCommand, RunnerRegistrationTokensCommand, RunnersCommand } from '../runners/index.js'
 import { SearchCommand } from '../search/index.js'
@@ -17,35 +17,35 @@ import { SocketIOCommand } from '../socket/index.js'
 import {
   AccountsCommand,
   BlocklistCommand,
-  UserExportsCommand,
   LoginCommand,
   NotificationsCommand,
   RegistrationsCommand,
   SubscriptionsCommand,
   TwoFactorCommand,
-  UsersCommand,
-  UserImportsCommand
+  UserExportsCommand,
+  UserImportsCommand,
+  UsersCommand
 } from '../users/index.js'
 import {
   BlacklistCommand,
   CaptionsCommand,
   ChangeOwnershipCommand,
-  ChannelsCommand,
   ChannelSyncsCommand,
+  ChannelsCommand,
   ChaptersCommand,
   CommentsCommand,
   HistoryCommand,
-  VideoImportsCommand,
   LiveCommand,
   PlaylistsCommand,
   ServicesCommand,
   StoryboardCommand,
   StreamingPlaylistsCommand,
+  VideoImportsCommand,
   VideoPasswordsCommand,
-  VideosCommand,
   VideoStatsCommand,
   VideoStudioCommand,
   VideoTokenCommand,
+  VideosCommand,
   ViewsCommand
 } from '../videos/index.js'
 import { ConfigCommand } from './config-command.js'
@@ -163,6 +163,9 @@ export class PeerTubeServer {
   runnerRegistrationTokens?: RunnerRegistrationTokensCommand
   runnerJobs?: RunnerJobsCommand
 
+  watchedWordsLists?: WatchedWordsCommand
+  autoTags?: AutomaticTagsCommand
+
   constructor (options: { serverNumber: number } | { url: string }) {
     if ((options as any).url) {
       this.setUrl((options as any).url)
@@ -458,5 +461,8 @@ export class PeerTubeServer {
     this.runnerRegistrationTokens = new RunnerRegistrationTokensCommand(this)
     this.runnerJobs = new RunnerJobsCommand(this)
     this.videoPasswords = new VideoPasswordsCommand(this)
+
+    this.watchedWordsLists = new WatchedWordsCommand(this)
+    this.autoTags = new AutomaticTagsCommand(this)
   }
 }
diff --git a/packages/server-commands/src/videos/comments-command.ts b/packages/server-commands/src/videos/comments-command.ts
index 4835ae1fb..86ea6332b 100644
--- a/packages/server-commands/src/videos/comments-command.ts
+++ b/packages/server-commands/src/videos/comments-command.ts
@@ -1,30 +1,45 @@
 import { pick } from '@peertube/peertube-core-utils'
-import { HttpStatusCode, ResultList, VideoComment, VideoCommentThreads, VideoCommentThreadTree } from '@peertube/peertube-models'
+import {
+  HttpStatusCode,
+  ResultList,
+  VideoComment,
+  VideoCommentForAdminOrUser,
+  VideoCommentThreads,
+  VideoCommentThreadTree
+} from '@peertube/peertube-models'
 import { unwrapBody } from '../requests/index.js'
 import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
 
+type ListForAdminOrAccountCommonOptions = {
+  start?: number
+  count?: number
+  sort?: string
+  search?: string
+  searchAccount?: string
+  searchVideo?: string
+  videoId?: string | number
+  videoChannelId?: string | number
+  autoTagOneOf?: string[]
+}
+
 export class CommentsCommand extends AbstractCommand {
 
   private lastVideoId: number | string
   private lastThreadId: number
   private lastReplyId: number
 
-  listForAdmin (options: OverrideCommandOptions & {
-    start?: number
-    count?: number
-    sort?: string
+  listForAdmin (options: OverrideCommandOptions & ListForAdminOrAccountCommonOptions & {
     isLocal?: boolean
     onLocalVideo?: boolean
-    search?: string
-    searchAccount?: string
-    searchVideo?: string
   } = {}) {
-    const { sort = '-createdAt' } = options
     const path = '/api/v1/videos/comments'
 
-    const query = { sort, ...pick(options, [ 'start', 'count', 'isLocal', 'onLocalVideo', 'search', 'searchAccount', 'searchVideo' ]) }
+    const query = {
+      ...this.buildListForAdminOrAccountQuery(options),
+      ...pick(options, [ 'isLocal', 'onLocalVideo' ])
+    }
 
-    return this.getRequestBody<ResultList<VideoComment>>({
+    return this.getRequestBody<ResultList<VideoCommentForAdminOrUser>>({
       ...options,
 
       path,
@@ -34,6 +49,35 @@ export class CommentsCommand extends AbstractCommand {
     })
   }
 
+  listCommentsOnMyVideos (options: OverrideCommandOptions & ListForAdminOrAccountCommonOptions & {
+    isHeldForReview?: boolean
+  } = {}) {
+    const path = '/api/v1/users/me/videos/comments'
+
+    return this.getRequestBody<ResultList<VideoCommentForAdminOrUser>>({
+      ...options,
+
+      path,
+      query: {
+        ...this.buildListForAdminOrAccountQuery(options),
+
+        isHeldForReview: options.isHeldForReview
+      },
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.OK_200
+    })
+  }
+
+  private buildListForAdminOrAccountQuery (options: ListForAdminOrAccountCommonOptions) {
+    return {
+      sort: '-createdAt',
+
+      ...pick(options, [ 'start', 'count', 'search', 'searchAccount', 'searchVideo', 'sort', 'videoId', 'videoChannelId', 'autoTagOneOf' ])
+    }
+  }
+
+  // ---------------------------------------------------------------------------
+
   listThreads (options: OverrideCommandOptions & {
     videoId: number | string
     videoPassword?: string
@@ -71,6 +115,16 @@ export class CommentsCommand extends AbstractCommand {
     })
   }
 
+  async getThreadOf (options: OverrideCommandOptions & {
+    videoId: number | string
+    text: string
+  }) {
+    const { videoId, text } = options
+    const threadId = await this.findCommentId({ videoId, text })
+
+    return this.getThread({ ...options, videoId, threadId })
+  }
+
   async createThread (options: OverrideCommandOptions & {
     videoId: number | string
     text: string
@@ -136,11 +190,13 @@ export class CommentsCommand extends AbstractCommand {
     text: string
   }) {
     const { videoId, text } = options
-    const { data } = await this.listThreads({ videoId, count: 25, sort: '-createdAt' })
+    const { data } = await this.listForAdmin({ videoId, count: 25, sort: '-createdAt' })
 
     return data.find(c => c.text === text).id
   }
 
+  // ---------------------------------------------------------------------------
+
   delete (options: OverrideCommandOptions & {
     videoId: number | string
     commentId: number
@@ -156,4 +212,34 @@ export class CommentsCommand extends AbstractCommand {
       defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
     })
   }
+
+  async deleteAllComments (options: OverrideCommandOptions & {
+    videoUUID: string
+  }) {
+    const { data } = await this.listForAdmin({ ...options, start: 0, count: 20 })
+
+    for (const comment of data) {
+      if (comment?.video.uuid !== options.videoUUID) continue
+
+      await this.delete({ videoId: options.videoUUID, commentId: comment.id, ...options })
+    }
+  }
+
+  // ---------------------------------------------------------------------------
+
+  approve (options: OverrideCommandOptions & {
+    videoId: number | string
+    commentId: number
+  }) {
+    const { videoId, commentId } = options
+    const path = '/api/v1/videos/' + videoId + '/comments/' + commentId + '/approve'
+
+    return this.postBodyRequest({
+      ...options,
+
+      path,
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+    })
+  }
 }
diff --git a/packages/server-commands/src/videos/videos-command.ts b/packages/server-commands/src/videos/videos-command.ts
index 090345847..8397b8f4d 100644
--- a/packages/server-commands/src/videos/videos-command.ts
+++ b/packages/server-commands/src/videos/videos-command.ts
@@ -7,6 +7,7 @@ import {
   HttpStatusCodeType, ResultList,
   UserVideoRateType,
   Video,
+  VideoCommentPolicy,
   VideoCreate,
   VideoCreateResult,
   VideoDetails,
@@ -229,6 +230,7 @@ export class VideosCommand extends AbstractCommand {
     search?: string
     isLive?: boolean
     channelId?: number
+    autoTagOneOf?: string[]
   } = {}) {
     const path = '/api/v1/users/me/videos'
 
@@ -236,7 +238,7 @@ export class VideosCommand extends AbstractCommand {
       ...options,
 
       path,
-      query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive', 'channelId' ]),
+      query: pick(options, [ 'start', 'count', 'sort', 'search', 'isLive', 'channelId', 'autoTagOneOf' ]),
       implicitToken: true,
       defaultExpectedStatus: HttpStatusCode.OK_200
     })
@@ -282,7 +284,7 @@ export class VideosCommand extends AbstractCommand {
   }
 
   listAllForAdmin (options: OverrideCommandOptions & VideosCommonQuery = {}) {
-    const include = VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.BLACKLISTED | VideoInclude.BLOCKED_OWNER
+    const include = VideoInclude.NOT_PUBLISHED_STATE | VideoInclude.BLACKLISTED | VideoInclude.BLOCKED_OWNER | VideoInclude.AUTOMATIC_TAGS
     const nsfw = 'both'
     const privacyOneOf = getAllPrivacies()
 
@@ -429,7 +431,7 @@ export class VideosCommand extends AbstractCommand {
       support: 'my super support text',
       tags: [ 'tag' ],
       privacy: VideoPrivacy.PUBLIC,
-      commentsEnabled: true,
+      commentsPolicy: VideoCommentPolicy.ENABLED,
       downloadEnabled: true,
       fixture: 'video_short.webm',
 
@@ -619,7 +621,8 @@ export class VideosCommand extends AbstractCommand {
       'tagsAllOf',
       'isLocal',
       'include',
-      'skipCount'
+      'skipCount',
+      'autoTagOneOf'
     ])
   }
 
diff --git a/packages/tests/src/api/check-params/auto-tags.ts b/packages/tests/src/api/check-params/auto-tags.ts
new file mode 100644
index 000000000..6ec7ec126
--- /dev/null
+++ b/packages/tests/src/api/check-params/auto-tags.ts
@@ -0,0 +1,137 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { HttpStatusCode } from '@peertube/peertube-models'
+import {
+  PeerTubeServer,
+  cleanupTests,
+  createSingleServer, setAccessTokensToServers,
+  setDefaultVideoChannel
+} from '@peertube/peertube-server-commands'
+
+describe('Test auto tag policies API validator', function () {
+  let server: PeerTubeServer
+
+  let userToken: string
+  let userToken2: string
+
+  // ---------------------------------------------------------------
+
+  before(async function () {
+    this.timeout(120000)
+
+    server = await createSingleServer(1)
+
+    await setAccessTokensToServers([ server ])
+    await setDefaultVideoChannel([ server ])
+
+    userToken = await server.users.generateUserAndToken('user1')
+    userToken2 = await server.users.generateUserAndToken('user2')
+  })
+
+  describe('When getting available account auto tags', function () {
+    const baseParams = () => ({ accountName: 'user1', token: userToken })
+
+    it('Should fail without token', async function () {
+      await server.autoTags.getAccountAvailable({ ...baseParams(), token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should fail with a user that cannot manage account', async function () {
+      await server.autoTags.getAccountAvailable({ ...baseParams(), token: userToken2, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+    })
+
+    it('Should fail with an unknown account', async function () {
+      await server.autoTags.getAccountAvailable({ ...baseParams(), accountName: 'user42', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+    })
+
+    it('Should succeed with the correct params', async function () {
+      await server.autoTags.getAccountAvailable(baseParams())
+    })
+  })
+
+  describe('When getting available server auto tags', function () {
+
+    it('Should fail without token', async function () {
+      await server.autoTags.getServerAvailable({ token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should fail with a user that that does not have enought rights', async function () {
+      await server.autoTags.getServerAvailable({ token: userToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+    })
+
+    it('Should succeed with the correct params', async function () {
+      await server.autoTags.getServerAvailable()
+    })
+  })
+
+  describe('When getting auto tag policies', function () {
+    const baseParams = () => ({ accountName: 'user1', token: userToken })
+
+    it('Should fail without token', async function () {
+      await server.autoTags.getCommentPolicies({ ...baseParams(), token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should fail with a user that cannot manage account', async function () {
+      await server.autoTags.getCommentPolicies({ ...baseParams(), token: userToken2, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+    })
+
+    it('Should fail with an unknown account', async function () {
+      await server.autoTags.getCommentPolicies({ ...baseParams(), accountName: 'user42', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+    })
+
+    it('Should succeed with the correct params', async function () {
+      await server.autoTags.getCommentPolicies(baseParams())
+    })
+  })
+
+  describe('When updating auto tag policies', function () {
+    const baseParams = () => ({ accountName: 'user1', review: [ 'external-link' ], token: userToken })
+
+    it('Should fail without token', async function () {
+      await server.autoTags.updateCommentPolicies({
+        ...baseParams(),
+        token: null,
+        expectedStatus: HttpStatusCode.UNAUTHORIZED_401
+      })
+    })
+
+    it('Should fail with a user that cannot manage account', async function () {
+      await server.autoTags.updateCommentPolicies({
+        ...baseParams(),
+        token: userToken2,
+        expectedStatus: HttpStatusCode.FORBIDDEN_403
+      })
+    })
+
+    it('Should fail with an unknown account', async function () {
+      await server.autoTags.updateCommentPolicies({
+        ...baseParams(),
+        accountName: 'user42',
+        expectedStatus: HttpStatusCode.NOT_FOUND_404
+      })
+    })
+
+    it('Should fail with invalid review array', async function () {
+      await server.autoTags.updateCommentPolicies({
+        ...baseParams(),
+        review: 'toto' as any,
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should fail with review array that does not contain available tags', async function () {
+      await server.autoTags.updateCommentPolicies({
+        ...baseParams(),
+        review: [ 'toto' ],
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should succeed with the correct params', async function () {
+      await server.autoTags.updateCommentPolicies(baseParams())
+    })
+  })
+
+  after(async function () {
+    await cleanupTests([ server ])
+  })
+})
diff --git a/packages/tests/src/api/check-params/index.ts b/packages/tests/src/api/check-params/index.ts
index bf34b5c57..d92210d34 100644
--- a/packages/tests/src/api/check-params/index.ts
+++ b/packages/tests/src/api/check-params/index.ts
@@ -1,5 +1,6 @@
 import './abuses.js'
 import './accounts.js'
+import './auto-tags.js'
 import './blocklist.js'
 import './bulk.js'
 import './channel-import-videos.js'
@@ -8,8 +9,6 @@ import './contact-form.js'
 import './custom-pages.js'
 import './debug.js'
 import './follows.js'
-import './user-export.js'
-import './user-import.js'
 import './jobs.js'
 import './live.js'
 import './logs.js'
@@ -24,6 +23,8 @@ import './services.js'
 import './transcoding.js'
 import './two-factor.js'
 import './upload-quota.js'
+import './user-export.js'
+import './user-import.js'
 import './user-notifications.js'
 import './user-subscriptions.js'
 import './users-admin.js'
@@ -37,8 +38,8 @@ import './video-comments.js'
 import './video-files.js'
 import './video-imports.js'
 import './video-playlists.js'
-import './video-storyboards.js'
 import './video-source.js'
+import './video-storyboards.js'
 import './video-studio.js'
 import './video-token.js'
 import './videos-common-filters.js'
@@ -46,3 +47,4 @@ import './videos-history.js'
 import './videos-overviews.js'
 import './videos.js'
 import './views.js'
+import './watched-words.js'
diff --git a/packages/tests/src/api/check-params/live.ts b/packages/tests/src/api/check-params/live.ts
index f879ba5d4..6793fb6e8 100644
--- a/packages/tests/src/api/check-params/live.ts
+++ b/packages/tests/src/api/check-params/live.ts
@@ -1,7 +1,14 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
 import { omit } from '@peertube/peertube-core-utils'
-import { HttpStatusCode, LiveVideoLatencyMode, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
+import {
+  HttpStatusCode,
+  LiveVideoCreate,
+  LiveVideoLatencyMode,
+  VideoCommentPolicy,
+  VideoCreateResult,
+  VideoPrivacy
+} from '@peertube/peertube-models'
 import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils'
 import {
   LiveCommand,
@@ -67,7 +74,7 @@ describe('Test video lives API validator', function () {
   })
 
   describe('When creating a live', function () {
-    let baseCorrectParams
+    let baseCorrectParams: LiveVideoCreate
 
     before(function () {
       baseCorrectParams = {
@@ -76,7 +83,7 @@ describe('Test video lives API validator', function () {
         licence: 1,
         language: 'pt',
         nsfw: false,
-        commentsEnabled: true,
+        commentsPolicy: VideoCommentPolicy.ENABLED,
         downloadEnabled: true,
         waitTranscoding: true,
         description: 'my super description',
@@ -120,6 +127,12 @@ describe('Test video lives API validator', function () {
       await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
     })
 
+    it('Should fail with bad comments policy', async function () {
+      const fields = { ...baseCorrectParams, commentsPolicy: 42 }
+
+      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
+    })
+
     it('Should fail with a long description', async function () {
       const fields = { ...baseCorrectParams, description: 'super'.repeat(2500) }
 
diff --git a/packages/tests/src/api/check-params/video-channel-syncs.ts b/packages/tests/src/api/check-params/video-channel-syncs.ts
index e6231298c..aff3dde34 100644
--- a/packages/tests/src/api/check-params/video-channel-syncs.ts
+++ b/packages/tests/src/api/check-params/video-channel-syncs.ts
@@ -285,7 +285,7 @@ describe('Test video channel sync API validator', () => {
       })
     })
 
-    it('should succeed when user delete a sync they own', async function () {
+    it('Should succeed when user delete a sync they own', async function () {
       const { videoChannelSync } = await command.create({
         attributes: {
           externalChannelUrl: FIXTURE_URLS.youtubeChannel,
diff --git a/packages/tests/src/api/check-params/video-comments.ts b/packages/tests/src/api/check-params/video-comments.ts
index 177361606..19eec417c 100644
--- a/packages/tests/src/api/check-params/video-comments.ts
+++ b/packages/tests/src/api/check-params/video-comments.ts
@@ -1,17 +1,18 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
-import { expect } from 'chai'
-import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
-import { HttpStatusCode, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
+import { HttpStatusCode, VideoCommentPolicy, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
 import {
+  PeerTubeServer,
   cleanupTests,
   createSingleServer,
   makeDeleteRequest,
   makeGetRequest,
   makePostBodyRequest,
-  PeerTubeServer,
-  setAccessTokensToServers
+  setAccessTokensToServers,
+  setDefaultVideoChannel
 } from '@peertube/peertube-server-commands'
+import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
+import { expect } from 'chai'
 
 describe('Test video comments API validator', function () {
   let pathThread: string
@@ -36,6 +37,7 @@ describe('Test video comments API validator', function () {
     server = await createSingleServer(1)
 
     await setAccessTokensToServers([ server ])
+    await setDefaultVideoChannel([ server ])
 
     {
       video = await server.videos.upload({ attributes: {} })
@@ -397,9 +399,10 @@ describe('Test video comments API validator', function () {
   })
 
   describe('When a video has comments disabled', function () {
+
     before(async function () {
-      video = await server.videos.upload({ attributes: { commentsEnabled: false } })
-      pathThread = '/api/v1/videos/' + video.uuid + '/comment-threads'
+      video = await server.videos.upload({ attributes: { commentsPolicy: VideoCommentPolicy.DISABLED } })
+      pathThread = `/api/v1/videos/${video.uuid}/comment-threads`
     })
 
     it('Should return an empty thread list', async function () {
@@ -430,52 +433,133 @@ describe('Test video comments API validator', function () {
     it('Should return conflict on comment thread add')
   })
 
-  describe('When listing admin comments threads', function () {
-    const path = '/api/v1/videos/comments'
+  describe('When listing admin/user comments', function () {
+    const paths = [ '/api/v1/videos/comments', '/api/v1/users/me/videos/comments' ]
 
-    it('Should fail with a bad start pagination', async function () {
-      await checkBadStartPagination(server.url, path, server.accessToken)
-    })
-
-    it('Should fail with a bad count pagination', async function () {
-      await checkBadCountPagination(server.url, path, server.accessToken)
-    })
-
-    it('Should fail with an incorrect sort', async function () {
-      await checkBadSortPagination(server.url, path, server.accessToken)
+    it('Should fail with a bad start/count pagination of invalid sort', async function () {
+      for (const path of paths) {
+        await checkBadStartPagination(server.url, path, server.accessToken)
+        await checkBadCountPagination(server.url, path, server.accessToken)
+        await checkBadSortPagination(server.url, path, server.accessToken)
+      }
     })
 
     it('Should fail with a non authenticated user', async function () {
-      await makeGetRequest({
-        url: server.url,
-        path,
-        expectedStatus: HttpStatusCode.UNAUTHORIZED_401
-      })
+      await server.comments.listForAdmin({ token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+      await server.comments.listCommentsOnMyVideos({ token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
     })
 
-    it('Should fail with a non admin user', async function () {
-      await makeGetRequest({
-        url: server.url,
-        path,
+    it('Should fail to list admin comments with a non admin user', async function () {
+      await server.comments.listForAdmin({ token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+    })
+
+    it('Should fail with an invalid video', async function () {
+      await server.comments.listForAdmin({ videoId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+      await server.comments.listCommentsOnMyVideos({ videoId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+      await server.comments.listForAdmin({ videoId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+      await server.comments.listCommentsOnMyVideos({ videoId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+    })
+
+    it('Should fail with an invalid channel', async function () {
+      await server.comments.listForAdmin({ videoChannelId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+      await server.comments.listCommentsOnMyVideos({ videoChannelId: 'toto', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+      await server.comments.listForAdmin({ videoChannelId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+      await server.comments.listCommentsOnMyVideos({ videoChannelId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+    })
+
+    it('Should fail to list comments on my videos with non owned video or channel', async function () {
+      await server.comments.listCommentsOnMyVideos({
+        videoId: video.uuid,
+        token: userAccessToken,
+        expectedStatus: HttpStatusCode.FORBIDDEN_403
+      })
+
+      await server.comments.listCommentsOnMyVideos({
+        videoChannelId: server.store.channel.id,
         token: userAccessToken,
         expectedStatus: HttpStatusCode.FORBIDDEN_403
       })
     })
 
     it('Should succeed with the correct params', async function () {
-      await makeGetRequest({
-        url: server.url,
-        path,
-        token: server.accessToken,
-        query: {
-          isLocal: false,
-          search: 'toto',
-          searchAccount: 'toto',
-          searchVideo: 'toto'
-        },
-        expectedStatus: HttpStatusCode.OK_200
+      const base = {
+        search: 'toto',
+        searchAccount: 'toto',
+        searchVideo: 'toto',
+        videoId: video.uuid,
+        videoChannelId: server.store.channel.id,
+        autoTagOneOf: [ 'external-link' ]
+      }
+
+      await server.comments.listForAdmin({ ...base, isLocal: false })
+      await server.comments.listCommentsOnMyVideos(base)
+    })
+  })
+
+  describe('When approving a comment', function () {
+    let videoId: string
+    let commentId: number
+    let deletedCommentId: number
+
+    before(async function () {
+      {
+        const res = await server.videos.upload({
+          attributes: {
+            name: 'review policy',
+            commentsPolicy: VideoCommentPolicy.REQUIRES_APPROVAL
+          }
+        })
+
+        videoId = res.uuid
+      }
+
+      {
+        const res = await server.comments.createThread({ text: 'thread', videoId, token: userAccessToken })
+        commentId = res.id
+      }
+
+      {
+        const res = await server.comments.createThread({ text: 'deleted', videoId, token: userAccessToken })
+        deletedCommentId = res.id
+
+        await server.comments.delete({ commentId: deletedCommentId, videoId })
+      }
+    })
+
+    it('Should fail with a non authenticated user', async function () {
+      await server.comments.approve({ token: 'none', commentId, videoId, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+    })
+
+    it('Should fail with another user', async function () {
+      await server.comments.approve({ token: userAccessToken2, commentId, videoId, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+    })
+
+    it('Should fail with an incorrect video', async function () {
+      await server.comments.approve({ token: userAccessToken2, commentId, videoId: 42, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+    })
+
+    it('Should fail with an incorrect comment', async function () {
+      await server.comments.approve({ token: userAccessToken2, commentId: 42, videoId, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+    })
+
+    it('Should fail with a deleted comment', async function () {
+      await server.comments.approve({
+        token: userAccessToken,
+        commentId: deletedCommentId,
+        videoId,
+        expectedStatus: HttpStatusCode.CONFLICT_409
       })
     })
+
+    it('Should succeed with the correct params', async function () {
+      await server.comments.approve({ token: userAccessToken, commentId, videoId })
+    })
+
+    it('Should fail with an already held for review comment', async function () {
+      await server.comments.approve({ token: userAccessToken, commentId, videoId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+    })
   })
 
   after(async function () {
diff --git a/packages/tests/src/api/check-params/video-imports.ts b/packages/tests/src/api/check-params/video-imports.ts
index effae487e..354d2c196 100644
--- a/packages/tests/src/api/check-params/video-imports.ts
+++ b/packages/tests/src/api/check-params/video-imports.ts
@@ -1,21 +1,21 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
 import { omit } from '@peertube/peertube-core-utils'
-import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models'
-import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
-import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js'
+import { HttpStatusCode, VideoCommentPolicy, VideoImportCreate, VideoPrivacy } from '@peertube/peertube-models'
 import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils'
 import {
+  PeerTubeServer,
   cleanupTests,
   createSingleServer,
   makeGetRequest,
   makePostBodyRequest,
   makeUploadRequest,
-  PeerTubeServer,
   setAccessTokensToServers,
   setDefaultVideoChannel,
   waitJobs
 } from '@peertube/peertube-server-commands'
+import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
+import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js'
 
 describe('Test video imports API validator', function () {
   const path = '/api/v1/videos/imports'
@@ -74,7 +74,7 @@ describe('Test video imports API validator', function () {
   })
 
   describe('When adding a video import', function () {
-    let baseCorrectParams
+    let baseCorrectParams: VideoImportCreate
 
     before(function () {
       baseCorrectParams = {
@@ -84,7 +84,7 @@ describe('Test video imports API validator', function () {
         licence: 1,
         language: 'pt',
         nsfw: false,
-        commentsEnabled: true,
+        commentsPolicy: VideoCommentPolicy.ENABLED,
         downloadEnabled: true,
         waitTranscoding: true,
         description: 'my super description',
@@ -176,6 +176,12 @@ describe('Test video imports API validator', function () {
       await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
     })
 
+    it('Should fail with a bad commentsPolicy', async function () {
+      const fields = { ...baseCorrectParams, commentsPolicy: 42 }
+
+      await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
+    })
+
     it('Should fail with a long description', async function () {
       const fields = { ...baseCorrectParams, description: 'super'.repeat(2500) }
 
diff --git a/packages/tests/src/api/check-params/video-passwords.ts b/packages/tests/src/api/check-params/video-passwords.ts
index c6e893172..45a080620 100644
--- a/packages/tests/src/api/check-params/video-passwords.ts
+++ b/packages/tests/src/api/check-params/video-passwords.ts
@@ -1,23 +1,24 @@
-import { expect } from 'chai'
 import {
   HttpStatusCode,
   HttpStatusCodeType,
   PeerTubeProblemDocument,
   ServerErrorCode,
+  VideoCommentPolicy,
   VideoCreateResult,
   VideoPrivacy
 } from '@peertube/peertube-models'
 import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils'
 import {
+  PeerTubeServer,
   cleanupTests,
   createSingleServer,
   makePostBodyRequest,
-  PeerTubeServer,
   setAccessTokensToServers
 } from '@peertube/peertube-server-commands'
 import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
 import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js'
 import { checkUploadVideoParam } from '@tests/shared/videos.js'
+import { expect } from 'chai'
 
 describe('Test video passwords validator', function () {
   let path: string
@@ -89,7 +90,7 @@ describe('Test video passwords validator', function () {
       licence: 1,
       language: 'pt',
       nsfw: false,
-      commentsEnabled: true,
+      commentsPolicy: VideoCommentPolicy.ENABLED,
       downloadEnabled: true,
       waitTranscoding: true,
       description: 'my super description',
diff --git a/packages/tests/src/api/check-params/videos-common-filters.ts b/packages/tests/src/api/check-params/videos-common-filters.ts
index 8286957b8..95860ba39 100644
--- a/packages/tests/src/api/check-params/videos-common-filters.ts
+++ b/packages/tests/src/api/check-params/videos-common-filters.ts
@@ -57,6 +57,7 @@ describe('Test video filters validators', function () {
       isLocal?: boolean
       include?: VideoIncludeType
       privacyOneOf?: VideoPrivacyType[]
+      autoTagOneOf?: string[]
       expectedStatus: HttpStatusCodeType
       excludeAlreadyWatched?: boolean
       unauthenticatedUser?: boolean
@@ -81,6 +82,7 @@ describe('Test video filters validators', function () {
           query: {
             isLocal: options.isLocal,
             privacyOneOf: options.privacyOneOf,
+            autoTagOneOf: options.autoTagOneOf,
             include: options.include,
             excludeAlreadyWatched: options.excludeAlreadyWatched,
             filter: options.filter
@@ -110,6 +112,22 @@ describe('Test video filters validators', function () {
       })
     })
 
+    it('Should fail to use autoTagOneOf with a simple user', async function () {
+      await testEndpoints({
+        autoTagOneOf: [ 'test' ],
+        token: userAccessToken,
+        expectedStatus: HttpStatusCode.UNAUTHORIZED_401
+      })
+    })
+
+    it('Should succeed to use autoTagOneOf with a moderator', async function () {
+      await testEndpoints({
+        autoTagOneOf: [ 'test' ],
+        token: moderatorAccessToken,
+        expectedStatus: HttpStatusCode.OK_200
+      })
+    })
+
     it('Should fail with a bad include', async function () {
       await testEndpoints({ include: 'toto' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
     })
diff --git a/packages/tests/src/api/check-params/videos.ts b/packages/tests/src/api/check-params/videos.ts
index c349ed9fe..a3fd1c0d6 100644
--- a/packages/tests/src/api/check-params/videos.ts
+++ b/packages/tests/src/api/check-params/videos.ts
@@ -1,22 +1,28 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
-import { expect } from 'chai'
-import { join } from 'path'
 import { omit, randomInt } from '@peertube/peertube-core-utils'
-import { HttpStatusCode, PeerTubeProblemDocument, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
+import {
+  HttpStatusCode,
+  PeerTubeProblemDocument,
+  VideoCommentPolicy,
+  VideoCreateResult,
+  VideoPrivacy
+} from '@peertube/peertube-models'
 import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils'
 import {
+  PeerTubeServer,
   cleanupTests,
   createSingleServer,
   makeDeleteRequest,
   makeGetRequest,
   makePutBodyRequest,
   makeUploadRequest,
-  PeerTubeServer,
   setAccessTokensToServers
 } from '@peertube/peertube-server-commands'
-import { checkBadStartPagination, checkBadCountPagination, checkBadSortPagination } from '@tests/shared/checks.js'
+import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
 import { checkUploadVideoParam } from '@tests/shared/videos.js'
+import { expect } from 'chai'
+import { join } from 'path'
 
 describe('Test videos API validator', function () {
   const path = '/api/v1/videos/'
@@ -183,29 +189,30 @@ describe('Test videos API validator', function () {
   })
 
   describe('When adding a video', function () {
-    let baseCorrectParams
+    const baseCorrectParams = {
+      name: 'my super name',
+      category: 5,
+      licence: 1,
+      language: 'pt',
+      nsfw: false,
+      commentsPolicy: VideoCommentPolicy.ENABLED,
+      downloadEnabled: true,
+      waitTranscoding: true,
+      description: 'my super description',
+      support: 'my super support text',
+      tags: [ 'tag1', 'tag2' ],
+      privacy: VideoPrivacy.PUBLIC,
+      channelId: -1,
+      originallyPublishedAt: new Date().toISOString()
+    }
+
     const baseCorrectAttaches = {
       fixture: buildAbsoluteFixturePath('video_short.webm')
     }
 
     before(function () {
       // Put in before to have channelId
-      baseCorrectParams = {
-        name: 'my super name',
-        category: 5,
-        licence: 1,
-        language: 'pt',
-        nsfw: false,
-        commentsEnabled: true,
-        downloadEnabled: true,
-        waitTranscoding: true,
-        description: 'my super description',
-        support: 'my super support text',
-        tags: [ 'tag1', 'tag2' ],
-        privacy: VideoPrivacy.PUBLIC,
-        channelId,
-        originallyPublishedAt: new Date().toISOString()
-      }
+      baseCorrectParams.channelId = channelId
     })
 
     function runSuite (mode: 'legacy' | 'resumable') {
@@ -260,6 +267,13 @@ describe('Test videos API validator', function () {
         await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } })
       })
 
+      it('Should fail with bad commentsPolicy', async function () {
+        const fields = { ...baseCorrectParams, commentsPolicy: 42 as any }
+        const attaches = baseCorrectAttaches
+
+        await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } })
+      })
+
       it('Should fail with a long description', async function () {
         const fields = { ...baseCorrectParams, description: 'super'.repeat(2500) }
         const attaches = baseCorrectAttaches
@@ -331,7 +345,7 @@ describe('Test videos API validator', function () {
       })
 
       it('Should fail with a bad schedule update (miss updateAt)', async function () {
-        const fields = { ...baseCorrectParams, scheduleUpdate: { privacy: VideoPrivacy.PUBLIC } }
+        const fields = { ...baseCorrectParams, scheduleUpdate: { privacy: VideoPrivacy.PUBLIC } as any }
         const attaches = baseCorrectAttaches
 
         await checkUploadVideoParam({ ...baseOptions(), attributes: { ...fields, ...attaches } })
@@ -509,7 +523,7 @@ describe('Test videos API validator', function () {
       licence: 2,
       language: 'pt',
       nsfw: false,
-      commentsEnabled: false,
+      commentsPolicy: VideoCommentPolicy.DISABLED,
       downloadEnabled: false,
       description: 'my super description',
       privacy: VideoPrivacy.PUBLIC,
diff --git a/packages/tests/src/api/check-params/views.ts b/packages/tests/src/api/check-params/views.ts
index 69f8ffc6e..c965a5479 100644
--- a/packages/tests/src/api/check-params/views.ts
+++ b/packages/tests/src/api/check-params/views.ts
@@ -10,7 +10,7 @@ import {
   setDefaultVideoChannel
 } from '@peertube/peertube-server-commands'
 
-describe('Test videos views', function () {
+describe('Test videos views API validators', function () {
   let servers: PeerTubeServer[]
   let liveVideoId: string
   let videoId: string
diff --git a/packages/tests/src/api/check-params/watched-words.ts b/packages/tests/src/api/check-params/watched-words.ts
new file mode 100644
index 000000000..68fbd2752
--- /dev/null
+++ b/packages/tests/src/api/check-params/watched-words.ts
@@ -0,0 +1,254 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { HttpStatusCode, UserRole } from '@peertube/peertube-models'
+import {
+  PeerTubeServer,
+  WatchedWordsCommand,
+  cleanupTests,
+  createSingleServer,
+  makeGetRequest,
+  setAccessTokensToServers,
+  setDefaultAccountAvatar
+} from '@peertube/peertube-server-commands'
+import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js'
+
+describe('Test watched words API validators', function () {
+  let server: PeerTubeServer
+
+  let userToken: string
+  let userToken2: string
+  let moderatorToken: string
+
+  let command: WatchedWordsCommand
+
+  let accountListId: number
+  let serverListId: number
+
+  before(async function () {
+    this.timeout(120000)
+
+    server = await createSingleServer(1)
+    await setAccessTokensToServers([ server ])
+    await setDefaultAccountAvatar([ server ])
+
+    userToken = await server.users.generateUserAndToken('user1')
+    userToken2 = await server.users.generateUserAndToken('user2')
+    moderatorToken = await server.users.generateUserAndToken('moderator', UserRole.MODERATOR)
+
+    command = server.watchedWordsLists
+
+    {
+      const { watchedWordsList } = await command.createList({
+        accountName: 'user1',
+        token: userToken,
+        listName: 'default',
+        words: [ 'word1' ]
+      })
+      accountListId = watchedWordsList.id
+    }
+
+    {
+      const { watchedWordsList } = await command.createList({
+        listName: 'default',
+        words: [ 'word1' ]
+      })
+      serverListId = watchedWordsList.id
+    }
+  })
+
+  describe('Account & server watched words', function () {
+
+    describe('When listing watched words', function () {
+      const paths = [
+        '/api/v1/watched-words/accounts/user1/lists',
+        '/api/v1/watched-words/server/lists'
+      ]
+
+      it('Should fail with an unauthenticated user', async function () {
+        for (const path of paths) {
+          await makeGetRequest({
+            url: server.url,
+            path,
+            expectedStatus: HttpStatusCode.UNAUTHORIZED_401
+          })
+        }
+      })
+
+      it('Should fail with the wrong token', async function () {
+        for (const path of paths) {
+          await makeGetRequest({
+            url: server.url,
+            token: userToken2,
+            path,
+            expectedStatus: HttpStatusCode.FORBIDDEN_403
+          })
+        }
+      })
+
+      it('Should fail with a bad start/count pagination or incorrect sort', async function () {
+        for (const path of paths) {
+          await checkBadStartPagination(server.url, path, userToken)
+          await checkBadCountPagination(server.url, path, userToken)
+          await checkBadSortPagination(server.url, path, userToken)
+        }
+      })
+    })
+
+    describe('When adding/updating watched words', function () {
+      const baseParams = () => ([
+        {
+          token: userToken,
+          accountName: 'user1',
+          listName: 'list',
+          words: [ 'word1' ],
+          listId: accountListId
+        },
+        {
+          token: moderatorToken,
+          listName: 'list',
+          words: [ 'word1' ],
+          listId: serverListId
+        }
+      ])
+
+      it('Should fail with an unauthenticated user', async function () {
+        for (const baseParam of baseParams()) {
+          await command.createList({ ...baseParam, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+          await command.updateList({ ...baseParam, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+        }
+      })
+
+      it('Should fail with the wrong token', async function () {
+        for (const baseParam of baseParams()) {
+          await command.createList({ ...baseParam, token: userToken2, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+          await command.updateList({ ...baseParam, token: userToken2, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+        }
+      })
+
+      it('Should fail with an invalid listName', async function () {
+        for (const baseParam of baseParams()) {
+          await command.createList({ ...baseParam, listName: null, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+          for (const listName of [ '', 'a'.repeat(500) ]) {
+            await command.createList({ ...baseParam, listName, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+            await command.updateList({ ...baseParam, listName, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+          }
+        }
+      })
+
+      it('Should fail with invalid words', async function () {
+        const bigArray: string[] = []
+        for (let i = 0; i < 550; i++) {
+          bigArray.push(`word${i}`)
+        }
+
+        const toTest = [
+          [],
+          bigArray,
+          [ 'a'.repeat(102) ],
+          [ '' ],
+          [ '', 'word' ]
+        ]
+
+        for (const baseParam of baseParams()) {
+          await command.createList({ ...baseParam, words: null, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+          for (const words of toTest) {
+            await command.createList({ ...baseParam, words, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+            await command.updateList({ ...baseParam, words, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+          }
+        }
+      })
+
+      it('Should succeed with the correct params', async function () {
+        for (const baseParam of baseParams()) {
+          await command.createList(baseParam)
+          await command.updateList({ ...baseParam, listName: 'updated-list' })
+        }
+      })
+
+      it('Should succeed to update a list with the same name', async function () {
+        for (const baseParam of baseParams()) {
+          await command.updateList({ ...baseParam, listName: 'updated-list' })
+        }
+      })
+
+      it('Should fail to add a list with an already existing name', async function () {
+        for (const baseParam of baseParams()) {
+          await command.createList({ ...baseParam, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+          await command.updateList({ ...baseParam, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+        }
+      })
+    })
+
+    describe('When deleting watched words', function () {
+      const baseParams = () => ([
+        {
+          token: userToken,
+          accountName: 'user1',
+          listId: accountListId
+        },
+        {
+          token: moderatorToken,
+          listId: serverListId
+        }
+      ])
+
+      it('Should fail with an unauthenticated user', async function () {
+        for (const baseParam of baseParams()) {
+          await command.deleteList({ ...baseParam, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
+        }
+      })
+
+      it('Should fail with the wrong token', async function () {
+        for (const baseParam of baseParams()) {
+          await command.deleteList({ ...baseParam, token: userToken2, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+        }
+      })
+
+      it('Should succeed with the correct params', async function () {
+        for (const baseParam of baseParams()) {
+          await command.deleteList(baseParam)
+        }
+      })
+    })
+  })
+
+  describe('Account specific watched words', function () {
+
+    describe('When listing watched words', function () {
+      it('Should fail with an unknown account', async function () {
+        await command.listWordsLists({ accountName: 'unknown', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+      })
+    })
+
+    describe('When adding/updating watched words', function () {
+      const baseParams = () => ({
+        token: userToken,
+        accountName: 'user1',
+        listName: 'list',
+        words: [ 'word1' ]
+      })
+
+      it('Should fail with an unknown account', async function () {
+        await command.createList({ ...baseParams(), accountName: 'unknown', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+      })
+    })
+
+    describe('When deleting watched words', function () {
+      const baseParams = () => ({
+        listId: accountListId,
+        token: userToken,
+        accountName: 'user1'
+      })
+
+      it('Should fail with an unknown account', async function () {
+        await command.deleteList({ ...baseParams(), accountName: 'unknown', expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+      })
+    })
+  })
+
+  after(async function () {
+    await cleanupTests([ server ])
+  })
+})
diff --git a/packages/tests/src/api/live/live.ts b/packages/tests/src/api/live/live.ts
index 1bb850a28..fa5862088 100644
--- a/packages/tests/src/api/live/live.ts
+++ b/packages/tests/src/api/live/live.ts
@@ -9,6 +9,7 @@ import {
   LiveVideo,
   LiveVideoCreate,
   LiveVideoLatencyMode,
+  VideoCommentPolicy,
   VideoDetails,
   VideoPrivacy,
   VideoState,
@@ -88,7 +89,7 @@ describe('Test live', function () {
         waitTranscoding: false,
         name: 'my super live',
         tags: [ 'tag1', 'tag2' ],
-        commentsEnabled: false,
+        commentsPolicy: VideoCommentPolicy.DISABLED,
         downloadEnabled: false,
         saveReplay: true,
         replaySettings: { privacy: VideoPrivacy.PUBLIC },
diff --git a/packages/tests/src/api/moderation/automatic-tags.ts b/packages/tests/src/api/moderation/automatic-tags.ts
new file mode 100644
index 000000000..228a947c3
--- /dev/null
+++ b/packages/tests/src/api/moderation/automatic-tags.ts
@@ -0,0 +1,489 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { VideoPrivacy } from '@peertube/peertube-models'
+import {
+  cleanupTests, createMultipleServers,
+  doubleFollow,
+  PeerTubeServer,
+  setAccessTokensToServers,
+  setDefaultAccountAvatar,
+  setDefaultVideoChannel,
+  waitJobs
+} from '@peertube/peertube-server-commands'
+import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js'
+import { expect } from 'chai'
+
+describe('Test automatic tags', function () {
+  let servers: PeerTubeServer[]
+  let videoUUID: string
+
+  before(async function () {
+    this.timeout(120000)
+
+    servers = await createMultipleServers(2)
+    await setAccessTokensToServers(servers)
+    await setDefaultVideoChannel(servers)
+    await setDefaultAccountAvatar(servers)
+
+    await servers[1].config.enableLive({ allowReplay: false })
+
+    await doubleFollow(servers[0], servers[1]);
+
+    ({ uuid: videoUUID } = await servers[0].videos.quickUpload({ name: 'video' }))
+
+    await waitJobs(servers)
+  })
+
+  describe('Automatic tags on comments', function () {
+
+    describe('Built in external link auto tag', function () {
+
+      it('Should not assign external-link automatic tag with no URL inside the comment', async function () {
+        const tests = [
+          'my super comment',
+          'toto.azfazfe',
+          'Hello. Hi friends'
+        ]
+
+        for (const toTest of tests) {
+          await servers[0].comments.createThread({ videoId: videoUUID, text: toTest })
+          await waitJobs(servers)
+        }
+
+        for (const server of servers) {
+          const { data } = await server.comments.listForAdmin()
+
+          for (const comment of data) {
+            expect(comment.automaticTags, `"${comment.text}" has an automatic tag`).to.have.lengthOf(0)
+          }
+        }
+
+        await servers[0].comments.deleteAllComments({ videoUUID })
+        await waitJobs(servers)
+      })
+
+      it('Should not assign external-link automatic tag if the URL is an internal link', async function () {
+        const tests = [
+          `Hi ${servers[0].url}`
+        ]
+
+        for (const toTest of tests) {
+          await servers[0].comments.createThread({ videoId: videoUUID, text: toTest })
+          await waitJobs(servers)
+        }
+
+        // Server 1
+        {
+          const { data } = await servers[0].comments.listForAdmin()
+
+          for (const comment of data) {
+            expect(comment.automaticTags, `"${comment.text}" has an automatic tag`).to.have.lengthOf(0)
+          }
+        }
+
+        // Server 2
+        {
+          const { data } = await servers[1].comments.listForAdmin()
+
+          for (const comment of data) {
+            expect(comment.automaticTags, `"${comment.text}" hasn't an automatic tag`).to.have.lengthOf(1)
+            expect(comment.automaticTags[0]).to.equal('external-link')
+          }
+        }
+
+        await servers[0].comments.deleteAllComments({ videoUUID })
+        await waitJobs(servers)
+      })
+
+      it('Should assign external-link automatic tag', async function () {
+        const tests = [
+          'example.com',
+          'Hi example.com'
+        ]
+
+        for (const toTest of tests) {
+          await servers[0].comments.createThread({ videoId: videoUUID, text: toTest })
+          await waitJobs(servers)
+        }
+
+        for (const server of servers) {
+          const { data } = await server.comments.listForAdmin()
+
+          for (const comment of data) {
+            expect(comment.automaticTags).to.have.lengthOf(1)
+            expect(comment.automaticTags[0]).to.equal('external-link')
+          }
+        }
+
+        await servers[0].comments.deleteAllComments({ videoUUID })
+        await waitJobs(servers)
+      })
+    })
+
+    describe('With watched words', function () {
+      let accountListId: number
+
+      it('Should create watched words list and automatically assign an automatic tag', async function () {
+        // Account list
+        {
+          await servers[0].watchedWordsLists.createList({ listName: 'list 1', words: [ 'word 1', 'word 2' ], accountName: 'root' })
+
+          const { watchedWordsList } = await servers[0].watchedWordsLists.createList({
+            listName: 'list 2',
+            words: [ 'nemo' ],
+            accountName: 'root'
+          })
+          accountListId = watchedWordsList.id
+        }
+
+        // Server list
+        {
+          await servers[0].watchedWordsLists.createList({ listName: 'server 2', words: [ 'word 2' ] })
+        }
+
+        await servers[0].comments.createThread({ videoId: videoUUID, text: 'hi captain' })
+        await servers[0].comments.addReplyToLastThread({ text: 'hi captain nemo' })
+        await servers[1].comments.createThread({ videoId: videoUUID, text: 'hi captain nemo word 2 example.com' })
+
+        await waitJobs(servers)
+
+        // Server comments list must not include account personal watched words
+        {
+          const { data } = await servers[0].comments.listForAdmin()
+          const c = (text: string) => data.find(c => c.text === text)
+
+          expect(c('hi captain').automaticTags).to.have.lengthOf(0)
+          expect(c('hi captain nemo').automaticTags).to.have.lengthOf(0)
+          expect(c('hi captain nemo word 2 example.com').automaticTags).to.have.members([ 'server 2', 'external-link' ])
+        }
+
+        {
+          const { data } = await servers[0].comments.listCommentsOnMyVideos()
+          const c = (text: string) => data.find(c => c.text === text)
+
+          expect(c('hi captain').automaticTags).to.have.lengthOf(0)
+          expect(c('hi captain nemo').automaticTags).to.have.members([ 'list 2' ])
+          expect(c('hi captain nemo word 2 example.com').automaticTags).to.have.members([ 'list 1', 'list 2', 'external-link' ])
+        }
+      })
+
+      it('Should update watched words list and assign auto tag with new words', async function () {
+        // No tags
+        {
+          await servers[0].comments.createThread({ videoId: videoUUID, text: 'my nautilus' })
+
+          const { data } = await servers[0].comments.listCommentsOnMyVideos()
+          expect(data.find(c => c.text === 'my nautilus').automaticTags).to.have.lengthOf(0)
+        }
+
+        {
+          await servers[0].watchedWordsLists.updateList({
+            accountName: 'root',
+            listId: accountListId,
+            words: [ 'nautilus' ],
+            listName: 'list 3'
+          })
+
+          await servers[0].comments.createThread({ videoId: videoUUID, text: 'captain nemo' })
+          await servers[0].comments.createThread({ videoId: videoUUID, text: 'my nautilus 2' })
+          await servers[0].comments.createThread({ videoId: videoUUID, text: 'word 1' })
+
+          const { data } = await servers[0].comments.listCommentsOnMyVideos()
+          // Previous comment still have the same automatic tags
+          expect(data.find(c => c.text === 'my nautilus').automaticTags).to.have.lengthOf(0)
+
+          expect(data.find(c => c.text === 'captain nemo').automaticTags).to.have.lengthOf(0)
+          expect(data.find(c => c.text === 'my nautilus 2').automaticTags).to.have.members([ 'list 3' ])
+          expect(data.find(c => c.text === 'word 1').automaticTags).to.have.members([ 'list 1' ])
+        }
+      })
+
+      it('Should delete watched words list and so not assign auto tags anymore', async function () {
+        await servers[0].watchedWordsLists.deleteList({ accountName: 'root', listId: accountListId })
+
+        await servers[0].comments.createThread({ videoId: videoUUID, text: 'my nautilus 3' })
+        await servers[0].comments.createThread({ videoId: videoUUID, text: 'word 2' })
+
+        const { data } = await servers[0].comments.listCommentsOnMyVideos()
+        expect(data.find(c => c.text === 'my nautilus 3').automaticTags).to.have.lengthOf(0)
+        expect(data.find(c => c.text === 'word 2').automaticTags).to.have.members([ 'list 1' ])
+      })
+    })
+
+    describe('Searching comments with specific tags', function () {
+
+      it('Should search in "comments on my videos" comments with specific automatic tags', async function () {
+        {
+          const { total, data } = await servers[0].comments.listCommentsOnMyVideos({ autoTagOneOf: [ 'unknown' ] })
+          expect(total).to.equal(0)
+          expect(data).to.have.lengthOf(0)
+        }
+
+        {
+          for (const autoTagOneOf of [ [ 'list 1' ], [ 'list 1', 'unknown' ] ]) {
+            const { total, data } = await servers[0].comments.listCommentsOnMyVideos({ autoTagOneOf })
+
+            expect(total).to.equal(3)
+
+            expect(data.map(c => c.text)).to.have.members([
+              'hi captain nemo word 2 example.com',
+              'word 1',
+              'word 2'
+            ])
+          }
+        }
+      })
+
+      it('Should search in admin comments with specific automatic tags', async function () {
+        {
+          const { total, data } = await servers[0].comments.listForAdmin({ autoTagOneOf: [ 'list 1' ] })
+
+          expect(total).to.equal(0)
+          expect(data).to.have.lengthOf(0)
+        }
+
+        {
+          const { total, data } = await servers[0].comments.listForAdmin({ autoTagOneOf: [ 'external-link' ] })
+
+          expect(total).to.equal(1)
+          expect(data).to.have.lengthOf(1)
+          expect(data[0].text).to.equal('hi captain nemo word 2 example.com')
+        }
+      })
+    })
+
+  })
+
+  describe('Automatic tags on videos', function () {
+
+    before(async function () {
+      await servers[0].videos.removeAll()
+
+      await waitJobs(servers)
+    })
+
+    describe('Built in external link auto tag', function () {
+
+      it('Should not assign external-link automatic tag with no URL inside the video', async function () {
+        const tests = [
+          'my super video',
+          'toto.azfazfe',
+          'Hello. Hi friends'
+        ]
+
+        for (const toTest of tests) {
+          await servers[0].videos.upload({ attributes: { name: toTest, description: toTest } })
+          await waitJobs(servers)
+        }
+
+        for (const server of servers) {
+          const { data } = await server.videos.listAllForAdmin()
+
+          for (const video of data) {
+            expect(video.automaticTags, `"${video.name}" has an automatic tag`).to.have.lengthOf(0)
+          }
+        }
+
+        await servers[0].videos.removeAll()
+        await waitJobs(servers)
+      })
+
+      it('Should not assign external-link automatic tag if the URL is an internal link', async function () {
+        const tests = [
+          `Hi ${servers[0].url}`
+        ]
+
+        for (const toTest of tests) {
+          await servers[0].videos.upload({ attributes: { name: toTest, description: toTest } })
+          await waitJobs(servers)
+        }
+
+        // Server 1
+        {
+          const { data } = await servers[0].videos.listAllForAdmin()
+
+          for (const video of data) {
+            expect(video.automaticTags, `"${video.name}" has an automatic tag`).to.have.lengthOf(0)
+          }
+        }
+
+        // Server 2
+        {
+          const { data } = await servers[1].videos.listAllForAdmin()
+
+          for (const video of data) {
+            expect(video.automaticTags, `"${video.name}" hasn't an automatic tag`).to.have.lengthOf(1)
+            expect(video.automaticTags[0]).to.equal('external-link')
+          }
+        }
+
+        await servers[0].videos.removeAll()
+        await waitJobs(servers)
+      })
+
+      it('Should assign external-link automatic tag', async function () {
+        const tests = [
+          'example.com',
+          'Hi example.com'
+        ]
+
+        for (const toTest of tests) {
+          await servers[0].videos.upload({ attributes: { name: toTest, description: toTest } })
+          await waitJobs(servers)
+        }
+
+        for (const server of servers) {
+          const { data } = await server.videos.listAllForAdmin()
+
+          for (const video of data) {
+            expect(video.automaticTags).to.have.lengthOf(1)
+            expect(video.automaticTags[0]).to.equal('external-link')
+          }
+        }
+
+        await servers[0].videos.removeAll()
+        await waitJobs(servers)
+      })
+    })
+
+    describe('With watched words', function () {
+      let serverListId: number
+      let liveUUID: string
+
+      it('Should create watched words list and automatically assign an automatic tag', async function () {
+        // Server list
+        {
+          await servers[0].watchedWordsLists.createList({
+            listName: 'donald list',
+            words: [ 'riri', 'fifi', 'loulou' ]
+          })
+
+          const { watchedWordsList } = await servers[0].watchedWordsLists.createList({
+            listName: 'mickey list',
+            words: [ 'dingo', 'pluto' ]
+          })
+          serverListId = watchedWordsList.id
+        }
+
+        // Account list
+        {
+          await servers[0].watchedWordsLists.createList({ listName: 'picsou list', words: [ 'goldie' ], accountName: 'root' })
+        }
+
+        await servers[0].videos.upload({ attributes: { name: 'my dear goldie', description: 'hi riri and fifi' } })
+        await servers[0].videoImports.importVideo({
+          attributes: {
+            targetUrl: FIXTURE_URLS.goodVideo,
+            channelId: servers[0].store.channel.id,
+            name: 'import video',
+            description: 'pluto dog'
+          }
+        })
+        const { uuid } = await servers[1].live.create({
+          fields: {
+            channelId: servers[0].store.channel.id,
+            privacy: VideoPrivacy.PUBLIC,
+            name: 'live loulou',
+            description: 'dingo and minnie'
+          }
+        })
+        liveUUID = uuid
+
+        await waitJobs(servers)
+
+        // Server videos list must not include account personal watched words
+        {
+          const { data } = await servers[0].videos.listAllForAdmin()
+          const v = (name: string) => data.find(c => c.name === name)
+
+          expect(v('my dear goldie').automaticTags).to.have.members([ 'donald list' ])
+          expect(v('import video').automaticTags).to.have.members([ 'mickey list' ])
+          expect(v('live loulou').automaticTags).to.have.members([ 'donald list', 'mickey list' ])
+        }
+
+        {
+          const { data } = await servers[0].videos.listMyVideos()
+
+          for (const video of data) {
+            expect(video.automaticTags).to.not.exist
+          }
+        }
+      })
+
+      it('Should update watched words list and assign auto tag on update', async function () {
+        const { uuid } = await servers[0].videos.quickUpload({ name: 'hi minnie' })
+
+        {
+          const { data } = await servers[0].videos.listAllForAdmin()
+          expect(data.find(v => v.name === 'hi minnie').automaticTags).to.have.lengthOf(0)
+        }
+
+        {
+          await servers[0].watchedWordsLists.updateList({
+            listId: serverListId,
+            words: [ 'Minnie' ],
+            listName: 'mickey list v2'
+          })
+
+          await servers[0].videos.update({ id: uuid, attributes: { name: 'hi minnie v2' } })
+
+          const { data } = await servers[0].videos.listAllForAdmin()
+          expect(data.find(v => v.name === 'hi minnie v2').automaticTags).to.have.members([ 'mickey list v2' ])
+        }
+      })
+
+      it('Should not update remote video if name/description has not changed', async function () {
+        await servers[1].videos.update({
+          id: liveUUID,
+          attributes: {
+            channelId: servers[0].store.channel.id,
+            tags: [ 'super tag' ]
+          }
+        })
+
+        await waitJobs(servers)
+
+        const { data } = await servers[0].videos.listAllForAdmin()
+        expect(data.find(v => v.name === 'live loulou').automaticTags).to.have.members([ 'donald list', 'mickey list' ])
+      })
+
+      it('Should update remote video if name/description has changed', async function () {
+        await servers[1].videos.update({
+          id: liveUUID,
+          attributes: { name: 'live loulou v2' }
+        })
+
+        await waitJobs(servers)
+
+        const { data } = await servers[0].videos.listAllForAdmin()
+        expect(data.find(v => v.name === 'live loulou v2').automaticTags).to.have.members([ 'donald list', 'mickey list v2' ])
+      })
+    })
+
+    describe('Searching videos with specific tags', function () {
+
+      it('Should search in admin videos with specific automatic tags', async function () {
+        {
+          const { total, data } = await servers[0].videos.listAllForAdmin({ autoTagOneOf: [ 'picsou list' ] })
+
+          expect(total).to.equal(0)
+          expect(data).to.have.lengthOf(0)
+        }
+
+        {
+          const { total, data } = await servers[0].videos.listAllForAdmin({ autoTagOneOf: [ 'mickey list v2' ] })
+
+          expect(total).to.equal(2)
+          expect(data).to.have.lengthOf(2)
+
+          expect(data.map(d => d.name)).to.have.members([ 'hi minnie v2', 'live loulou v2' ])
+        }
+      })
+    })
+
+  })
+
+  after(async function () {
+    await cleanupTests(servers)
+  })
+})
diff --git a/packages/tests/src/api/moderation/comment-approval.ts b/packages/tests/src/api/moderation/comment-approval.ts
new file mode 100644
index 000000000..c9c02130e
--- /dev/null
+++ b/packages/tests/src/api/moderation/comment-approval.ts
@@ -0,0 +1,552 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import {
+  ActivityApproveReply,
+  ActivityPubOrderedCollection,
+  HttpStatusCode,
+  UserRole,
+  VideoCommentObject,
+  VideoCommentPolicy,
+  VideoCommentPolicyType,
+  VideoPrivacy
+} from '@peertube/peertube-models'
+import {
+  PeerTubeServer,
+  cleanupTests, createMultipleServers,
+  doubleFollow,
+  makeActivityPubGetRequest, makeActivityPubRawRequest, setAccessTokensToServers,
+  setDefaultAccountAvatar,
+  waitJobs
+} from '@peertube/peertube-server-commands'
+import { expectStartWith } from '@tests/shared/checks.js'
+import { expect } from 'chai'
+
+describe('Test comments approval', function () {
+  let servers: PeerTubeServer[]
+  let userToken: string
+  let anotherUserToken: string
+  let moderatorToken: string
+
+  async function createVideo (commentsPolicy: VideoCommentPolicyType) {
+    const { uuid } = await servers[0].videos.upload({
+      token: userToken,
+      attributes: {
+        name: 'review policy: ' + commentsPolicy,
+        privacy: VideoPrivacy.PUBLIC,
+        commentsPolicy
+      }
+    })
+
+    await waitJobs(servers)
+
+    return uuid
+  }
+
+  before(async function () {
+    this.timeout(120000)
+
+    servers = await createMultipleServers(3)
+    await setAccessTokensToServers(servers)
+    await setDefaultAccountAvatar(servers)
+
+    await doubleFollow(servers[0], servers[1])
+    await doubleFollow(servers[0], servers[2])
+    await doubleFollow(servers[1], servers[2])
+
+    userToken = await servers[0].users.generateUserAndToken('user1')
+    anotherUserToken = await servers[0].users.generateUserAndToken('user2')
+    moderatorToken = await servers[0].users.generateUserAndToken('moderator', UserRole.MODERATOR)
+  })
+
+  describe('On video with comments requiring approval', function () {
+    let videoId: string
+
+    before(async function () {
+      this.timeout(30000)
+
+      videoId = await createVideo(VideoCommentPolicy.REQUIRES_APPROVAL)
+    })
+
+    it('Should create a local and remote comment that require approval', async function () {
+      this.timeout(30000)
+
+      await servers[0].comments.createThread({ text: 'local', videoId, token: anotherUserToken })
+      await servers[1].comments.createThread({ text: 'remote', videoId })
+      await waitJobs(servers)
+
+      const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
+      expect(data).to.have.lengthOf(2)
+
+      for (const c of data) {
+        expect(c.heldForReview).to.be.true
+      }
+    })
+
+    it('Should display comments depending on the user', async function () {
+      // Owner see the comments
+      {
+        const { data } = await servers[0].comments.listThreads({ videoId, token: userToken })
+        expect(data).to.have.lengthOf(2)
+
+        for (const c of data) {
+          expect(c.heldForReview).to.be.true
+        }
+      }
+
+      // Anonymous doesn't see the comments
+      for (const server of servers) {
+        const { data } = await server.comments.listThreads({ videoId })
+        expect(data).to.have.lengthOf(0)
+      }
+
+      // Owner of the comment can see it
+      {
+        const { data } = await servers[1].comments.listThreads({ videoId, token: servers[1].accessToken })
+        expect(data).to.have.lengthOf(1)
+        expect(data[0].heldForReview).to.be.true
+        expect(data[0].text).to.equal('remote')
+      }
+    })
+
+    it('Should create a local and remote reply and require approval', async function () {
+      await servers[0].comments.addReplyToLastThread({ text: 'local reply', token: anotherUserToken })
+      await servers[1].comments.addReplyToLastThread({ text: 'remote reply' })
+      await waitJobs(servers)
+
+      const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
+      expect(data).to.have.lengthOf(4)
+
+      for (const c of data) {
+        expect(c.heldForReview).to.be.true
+      }
+    })
+
+    it('Should approve a thread comment', async function () {
+      {
+        const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
+        const commentId = data.find(c => c.text === 'remote').id
+        await servers[0].comments.approve({ commentId, videoId, token: userToken })
+        await waitJobs(servers)
+      }
+
+      // Owner and moderators
+      for (const token of [ userToken, moderatorToken ]) {
+        const { data: threads } = await servers[0].comments.listThreads({ videoId, token })
+        expect(threads).to.have.lengthOf(2)
+
+        for (const c of threads) {
+          if (c.text === 'remote') expect(c.heldForReview).to.be.false
+          else expect(c.heldForReview).to.be.true
+
+          const thread = await servers[0].comments.getThread({ videoId, threadId: c.id, token })
+          expect(thread.children).to.have.lengthOf(1)
+          expect(thread.children[0].comment.heldForReview).to.equal(true)
+        }
+      }
+
+      // Anonymous
+      for (const server of servers) {
+        const { data } = await server.comments.listThreads({ videoId })
+        expect(data).to.have.lengthOf(1)
+        expect(data[0].heldForReview).to.be.false
+        expect(data[0].text).to.equal('remote')
+
+        const thread = await server.comments.getThread({ videoId, threadId: data[0].id })
+        expect(thread.children).to.have.lengthOf(0)
+      }
+
+      // Owner of the comment can see it
+      {
+        const { data } = await servers[1].comments.listThreads({ videoId, token: servers[1].accessToken })
+        expect(data).to.have.lengthOf(1)
+        expect(data[0].heldForReview).to.be.false
+        expect(data[0].text).to.equal('remote')
+
+        const thread = await servers[1].comments.getThread({ videoId, threadId: data[0].id, token: servers[1].accessToken })
+        expect(thread.children).to.have.lengthOf(1)
+        expect(thread.children[0].comment.heldForReview).to.equal(true)
+      }
+    })
+
+    it('Should approve a reply comment', async function () {
+      {
+        const commentId = await servers[0].comments.findCommentId({ videoId, text: 'remote reply' })
+        await servers[0].comments.approve({ commentId, videoId, token: userToken })
+        await waitJobs(servers)
+
+        // Owner
+        {
+          const { data } = await servers[0].comments.listThreads({ videoId, token: userToken })
+          expect(data.filter(c => c.heldForReview)).to.have.lengthOf(1)
+
+          const thread = await servers[0].comments.getThreadOf({ videoId, text: 'remote', token: userToken })
+          expect(thread.children).to.have.lengthOf(1)
+          expect(thread.children[0].comment.text).to.equal('remote reply')
+          expect(thread.children[0].comment.heldForReview).to.be.false
+        }
+
+        // Other users
+        for (const server of servers) {
+          const thread = await server.comments.getThreadOf({ videoId, text: 'remote' })
+          expect(thread.children).to.have.lengthOf(1)
+          expect(thread.children[0].comment.text).to.equal('remote reply')
+          expect(thread.children[0].comment.heldForReview).to.be.false
+        }
+      }
+    })
+
+    it('Should list and filter on comments awaiting approval', async function () {
+      {
+        const { total, data } = await servers[0].comments.listCommentsOnMyVideos({ videoId, token: userToken })
+        expect(total).to.equal(4)
+        expect(data).to.have.lengthOf(4)
+      }
+
+      {
+        const { total, data } = await servers[0].comments.listCommentsOnMyVideos({ videoId, token: userToken, isHeldForReview: true })
+        expect(total).to.equal(2)
+        expect(data).to.have.lengthOf(2)
+        expect(data.filter(c => c.heldForReview)).to.have.lengthOf(2)
+      }
+
+      {
+        const { total, data } = await servers[0].comments.listCommentsOnMyVideos({ videoId, token: userToken, isHeldForReview: false })
+        expect(total).to.equal(2)
+        expect(data).to.have.lengthOf(2)
+        expect(data.filter(c => !c.heldForReview)).to.have.lengthOf(2)
+      }
+    })
+
+    it('Should approve a reply of a non approved reply', async function () {
+      const threadId = await servers[0].comments.findCommentId({ videoId, text: 'local' })
+
+      const { id: replyId } = await servers[0].comments.addReply({
+        videoId,
+        toCommentId: await servers[0].comments.findCommentId({ videoId, text: 'local reply' }),
+        text: 'local reply 2',
+        token: anotherUserToken
+      })
+
+      await servers[0].comments.approve({ commentId: replyId, videoId, token: userToken })
+      await servers[0].comments.approve({ commentId: threadId, videoId, token: userToken })
+      await waitJobs(servers)
+
+      // Owner
+      {
+        const { data } = await servers[0].comments.listThreads({ videoId, token: userToken })
+        expect(data.filter(c => c.heldForReview)).to.have.lengthOf(0)
+
+        const thread = await servers[0].comments.getThreadOf({ videoId, text: 'local', token: userToken })
+        expect(thread.children).to.have.lengthOf(1)
+        expect(thread.children[0].comment.text).to.equal('local reply')
+        expect(thread.children[0].comment.heldForReview).to.be.true
+
+        expect(thread.children[0].children).to.have.lengthOf(1)
+        expect(thread.children[0].children[0].comment.text).to.equal('local reply 2')
+        expect(thread.children[0].children[0].comment.heldForReview).to.be.false
+      }
+
+      // Other users
+      for (const server of servers) {
+        const thread = await server.comments.getThreadOf({ videoId, text: 'local' })
+        expect(thread.children).to.have.lengthOf(0)
+      }
+    })
+
+    it('Should have appropriate ActivityPub representation', async function () {
+      const localNonApprovedId = await servers[0].comments.findCommentId({ text: 'local reply', videoId })
+      const localApprovedId = await servers[0].comments.findCommentId({ text: 'local', videoId })
+      const remoteApprovedId = await servers[0].comments.findCommentId({ text: 'remote', videoId })
+
+      {
+        for (const page of [ 1, 2 ]) {
+          const res = await makeActivityPubGetRequest(servers[0].url, `/videos/watch/${videoId}/comments?page=${page}`)
+          const { totalItems, orderedItems } = res.body as ActivityPubOrderedCollection<string>
+
+          expect(totalItems).to.equal(4)
+          expect(orderedItems.some(url => url === `${servers[0].url}/videos/watch/${videoId}/comments/${localNonApprovedId}`)).to.be.false
+        }
+      }
+
+      {
+        await makeActivityPubGetRequest(
+          servers[0].url,
+          `/videos/watch/${videoId}/comments/${localNonApprovedId}`,
+          HttpStatusCode.NOT_FOUND_404
+        )
+
+        await makeActivityPubGetRequest(
+          servers[0].url,
+          `/videos/watch/${videoId}/comments/${localNonApprovedId}/approve-reply`,
+          HttpStatusCode.NOT_FOUND_404
+        )
+      }
+
+      const toTest = [ { server: servers[0], commentId: localApprovedId }, { server: servers[1], commentId: remoteApprovedId } ]
+      for (const { server, commentId } of toTest) {
+        const res = await makeActivityPubGetRequest(server.url, `/videos/watch/${videoId}/comments/${commentId}`)
+        const { replyApproval } = res.body as VideoCommentObject
+
+        expectStartWith(replyApproval, `${servers[0].url}/videos/watch/${videoId}/comments/`)
+        const res2 = await makeActivityPubRawRequest(replyApproval, HttpStatusCode.OK_200)
+
+        const object = res2.body as ActivityApproveReply
+        expect(object.type).to.equal('ApproveReply')
+      }
+    })
+
+    it('Should remove an approved/non-approved comments', async function () {
+      this.timeout(60000)
+
+      {
+        const commentId = await servers[1].comments.findCommentId({ videoId, text: 'local' })
+        await servers[1].comments.addReply({ videoId, toCommentId: commentId, text: 'remote reply on local' })
+        await waitJobs(servers)
+      }
+
+      for (const text of [ 'remote', 'local reply', 'remote reply on local' ]) {
+        const commentId = await servers[0].comments.findCommentId({ videoId, text })
+        await servers[0].comments.delete({ videoId, commentId })
+      }
+
+      await waitJobs(servers)
+
+      // Owner
+      {
+        const { data } = await servers[0].comments.listThreads({ videoId, token: userToken, sort: '-createdAt' })
+        expect(data).to.have.lengthOf(2)
+
+        {
+          const remote = data[0]
+          expect(remote.isDeleted).to.be.true
+
+          const thread = await servers[0].comments.getThread({ videoId, token: userToken, threadId: remote.id })
+          expect(thread.children).to.have.lengthOf(1)
+          expect(thread.children[0].comment.text).to.equal('remote reply')
+        }
+
+        {
+          const local = data[1]
+          expect(local.isDeleted).to.be.false
+
+          const thread = await servers[0].comments.getThread({ videoId, token: userToken, threadId: local.id })
+          expect(thread.children).to.have.lengthOf(2)
+
+          {
+            const localReply = thread.children[0]
+            expect(localReply.comment.deletedAt).to.exist
+            expect(localReply.comment.heldForReview).to.be.true
+            expect(localReply.children).to.have.lengthOf(1)
+
+            expect(localReply.children).to.have.lengthOf(1)
+            expect(localReply.children[0].comment.text).to.equal('local reply 2')
+            expect(localReply.children[0].comment.heldForReview).to.be.false
+            expect(localReply.children[0].children).to.have.lengthOf(0)
+          }
+
+          {
+            expect(thread.children[1].comment.deletedAt).to.exist
+          }
+        }
+      }
+
+      // Other users
+      for (const server of servers) {
+        const { data } = await server.comments.listThreads({ videoId, sort: '-createdAt' })
+        expect(data).to.have.lengthOf(2)
+
+        {
+          const remote = data[0]
+          expect(remote.isDeleted).to.be.true
+
+          const thread = await server.comments.getThread({ videoId, threadId: remote.id })
+          expect(thread.children).to.have.lengthOf(1)
+          expect(thread.children[0].comment.text).to.equal('remote reply')
+        }
+
+        {
+          const local = data[1]
+          expect(local.isDeleted).to.be.false
+
+          const thread = await server.comments.getThread({ videoId, threadId: local.id })
+          // Anonymous users cannot see the thread because the delete comment was held for review
+          expect(thread.children).to.have.lengthOf(0)
+        }
+      }
+    })
+
+    it('Should not require review for video uploader, admins and moderators', async function () {
+      for (const token of [ userToken, moderatorToken, servers[0].accessToken ]) {
+        await servers[0].comments.createThread({ videoId, text: 'right', token })
+      }
+
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const { data } = await server.comments.listThreads({ videoId, sort: '-createdAt' })
+        expect(data.filter(c => c.text === 'right')).to.have.lengthOf(3)
+      }
+    })
+  })
+
+  describe('On video with comments with some tags requiring approval', function () {
+    let videoId: string
+
+    before(async function () {
+      this.timeout(30000)
+
+      videoId = await createVideo(VideoCommentPolicy.ENABLED)
+    })
+
+    it('Should only have built-in auto tag policies and no policies set', async function () {
+      const { review } = await servers[0].autoTags.getCommentPolicies({ accountName: 'user1', token: userToken })
+      expect(review).to.have.lengthOf(0)
+
+      const { available } = await servers[0].autoTags.getAccountAvailable({ accountName: 'user1', token: userToken })
+      expect(available.map(a => a.name)).to.deep.equal([ 'external-link' ])
+    })
+
+    it('Should add watched words and so available tag policies', async function () {
+      await servers[0].watchedWordsLists.createList({
+        token: userToken,
+        listName: 'forbidden-list',
+        words: [ 'forbidden' ],
+        accountName: 'user1'
+      })
+
+      await servers[0].watchedWordsLists.createList({
+        token: userToken,
+        listName: 'allowed-list',
+        words: [ 'allowed' ],
+        accountName: 'user1'
+      })
+
+      const { review } = await servers[0].autoTags.getCommentPolicies({ accountName: 'user1', token: userToken })
+      expect(review).to.have.lengthOf(0)
+
+      const { available } = await servers[0].autoTags.getAccountAvailable({ accountName: 'user1', token: userToken })
+      expect(available.map(a => a.name)).to.have.deep.members([ 'external-link', 'forbidden-list', 'allowed-list' ])
+    })
+
+    it('Should update policies', async function () {
+      await servers[0].autoTags.updateCommentPolicies({
+        accountName: 'user1',
+        review: [ 'external-link', 'forbidden-list' ],
+        token: userToken
+      })
+
+      const { review } = await servers[0].autoTags.getCommentPolicies({ accountName: 'user1', token: userToken })
+      expect(review).to.have.deep.members([ 'external-link', 'forbidden-list' ])
+    })
+
+    it('Should publish a comment without approval', async function () {
+      const threadText = '1 - framasoft and allowed'
+      const replyText = '1 - frama and allowed'
+
+      await servers[0].comments.createThread({ token: anotherUserToken, videoId, text: threadText })
+      await waitJobs(servers)
+
+      const commentId = await servers[1].comments.findCommentId({ videoId, text: threadText })
+      await servers[1].comments.addReply({ text: replyText, videoId, toCommentId: commentId })
+
+      await waitJobs(servers)
+
+      const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
+      const t = data.find(c => c.text === threadText)
+      const r = data.find(c => c.text === replyText)
+
+      expect(t.automaticTags).to.have.members([ 'allowed-list' ])
+      expect(t.heldForReview).to.be.false
+
+      expect(r.automaticTags).to.have.members([ 'allowed-list' ])
+      expect(r.heldForReview).to.be.false
+    })
+
+    it('Should publish a comment with approval', async function () {
+      const threadText = '2 - framasoft.org and allowed'
+      const replyText = '2 - https://framasoft.org and forbidden'
+
+      await servers[1].comments.createThread({ videoId, text: threadText })
+      await waitJobs(servers)
+
+      const commentId = await servers[0].comments.findCommentId({ videoId, text: threadText })
+      await servers[0].comments.addReply({ token: anotherUserToken, text: replyText, videoId, toCommentId: commentId })
+
+      await waitJobs(servers)
+
+      const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
+      const t = data.find(c => c.text === threadText)
+      const r = data.find(c => c.text === replyText)
+
+      expect(t.automaticTags).to.have.members([ 'external-link', 'allowed-list' ])
+      expect(t.heldForReview).to.be.true
+
+      expect(r.automaticTags).to.have.members([ 'external-link', 'forbidden-list' ])
+      expect(r.heldForReview).to.be.true
+    })
+
+    it('Should update policies and not update previously tags set', async function () {
+      await servers[0].autoTags.updateCommentPolicies({ accountName: 'user1', review: [ 'forbidden-list' ], token: userToken })
+
+      const { review } = await servers[0].autoTags.getCommentPolicies({ accountName: 'user1', token: userToken })
+      expect(review).to.have.deep.members([ 'forbidden-list' ])
+
+      const { available } = await servers[0].autoTags.getAccountAvailable({ accountName: 'user1', token: userToken })
+      expect(available.map(a => a.name)).to.have.deep.members([ 'external-link', 'forbidden-list', 'allowed-list' ])
+
+      const { data } = await servers[0].comments.listCommentsOnMyVideos({ videoId, token: userToken })
+      expect(data.filter(c => c.heldForReview)).to.have.lengthOf(2)
+    })
+
+    it('Should publish a comment with and without approval base on the new policies', async function () {
+      const threadText = '3 - framasoft.org and allowed'
+      const replyText = '3 - forbidden'
+
+      await servers[0].comments.createThread({ token: anotherUserToken, videoId, text: threadText })
+      await servers[0].comments.addReplyToLastThread({ token: anotherUserToken, text: replyText })
+      await waitJobs(servers)
+
+      const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
+      const t = data.find(c => c.text === threadText)
+      const r = data.find(c => c.text === replyText)
+
+      expect(t.automaticTags).to.have.members([ 'external-link', 'allowed-list' ])
+      expect(t.heldForReview).to.be.false
+
+      expect(r.automaticTags).to.have.members([ 'forbidden-list' ])
+      expect(r.heldForReview).to.be.true
+    })
+
+    it('Should not require approval for a moderator but it should have the tag set', async function () {
+      await servers[0].comments.createThread({ token: moderatorToken, videoId, text: 'forbidden' })
+      await waitJobs(servers)
+
+      const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
+      const t = data.find(c => c.text === 'forbidden')
+
+      expect(t.automaticTags).to.have.members([ 'forbidden-list' ])
+      expect(t.heldForReview).to.be.false
+    })
+
+    it('Should not have threads waiting for approval before approbation for anonymous users on server 1 and 3', async function () {
+      for (const server of [ servers[0], servers[2] ]) {
+        const { data } = await server.comments.listThreads({ videoId })
+        expect(data).to.have.lengthOf(3)
+
+        expect(data.some(c => c.text === '2 - framasoft.org and allowed')).to.be.false
+      }
+    })
+
+    it('Should see threads waiting for approval before approbation for anonymous users on server 2', async function () {
+      const { data } = await servers[1].comments.listThreads({ videoId })
+      expect(data).to.have.lengthOf(4)
+
+      expect(data.some(c => c.text === '2 - framasoft.org and allowed')).to.be.true
+      expect(data.some(c => c.text === '2 - https://framasoft.org and forbidden')).to.be.false
+    })
+  })
+
+  after(async function () {
+    await cleanupTests(servers)
+  })
+})
diff --git a/packages/tests/src/api/moderation/index.ts b/packages/tests/src/api/moderation/index.ts
index e3794d01e..7ce9c4a3e 100644
--- a/packages/tests/src/api/moderation/index.ts
+++ b/packages/tests/src/api/moderation/index.ts
@@ -1,4 +1,6 @@
 export * from './abuses.js'
+export * from './automatic-tags.js'
 export * from './blocklist-notification.js'
 export * from './blocklist.js'
 export * from './video-blacklist.js'
+export * from './watched-words.js'
diff --git a/packages/tests/src/api/moderation/watched-words.ts b/packages/tests/src/api/moderation/watched-words.ts
new file mode 100644
index 000000000..3665529ff
--- /dev/null
+++ b/packages/tests/src/api/moderation/watched-words.ts
@@ -0,0 +1,189 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import {
+  cleanupTests,
+  createSingleServer,
+  PeerTubeServer,
+  setAccessTokensToServers,
+  setDefaultAccountAvatar
+} from '@peertube/peertube-server-commands'
+import { expect } from 'chai'
+
+describe('Test watched words', function () {
+  let server: PeerTubeServer
+  let userToken: string
+
+  before(async function () {
+    this.timeout(120000)
+
+    server = await createSingleServer(1)
+    await setAccessTokensToServers([ server ])
+    await setDefaultAccountAvatar([ server ])
+
+    userToken = await server.users.generateUserAndToken('user1')
+  })
+
+  function runTests (mode: 'server' | 'account') {
+    let listId: number
+    let accountName: string
+    let token: string
+
+    before(() => {
+      accountName = mode === 'server'
+        ? undefined
+        : 'user1'
+
+      token = mode === 'server'
+        ? server.accessToken
+        : userToken
+    })
+
+    it('Should list empty watched words', async function () {
+      const { data, total } = await server.watchedWordsLists.listWordsLists({ token, accountName })
+
+      expect(total).to.equal(0)
+      expect(data).to.have.lengthOf(0)
+    })
+
+    it('Should add watched words lists', async function () {
+      {
+        const { watchedWordsList } = await server.watchedWordsLists.createList({
+          token,
+          listName: 'list user one',
+          words: [ 'word1' ],
+          accountName
+        })
+
+        listId = watchedWordsList.id
+      }
+
+      {
+        await server.watchedWordsLists.createList({
+          token,
+          listName: 'list user two',
+          words: [ 'word2', 'word3' ],
+          accountName
+        })
+      }
+
+      if (mode === 'account') {
+        await server.watchedWordsLists.createList({
+          listName: 'list one',
+          words: [ 'word4', 'word5' ],
+          accountName: 'root'
+        })
+      }
+    })
+
+    it('Should list watched words', async function () {
+      if (mode === 'account') {
+        const { data, total } = await server.watchedWordsLists.listWordsLists({ accountName: 'root' })
+
+        expect(total).to.equal(1)
+
+        expect(data).to.have.lengthOf(1)
+        expect(data[0].id).to.exist
+        expect(data[0].createdAt).to.exist
+        expect(data[0].updatedAt).to.exist
+        expect(data[0].listName).to.equal('list one')
+        expect(data[0].words).to.deep.equal([ 'word4', 'word5' ])
+      }
+
+      // With sort, start, count
+      {
+        const { data, total } = await server.watchedWordsLists.listWordsLists({
+          token,
+          accountName,
+          sort: 'createdAt'
+        })
+
+        expect(total).to.equal(2)
+        expect(data).to.have.lengthOf(2)
+
+        expect(data[0].listName).to.equal('list user one')
+        expect(data[0].words).to.deep.equal([ 'word1' ])
+
+        expect(data[1].listName).to.equal('list user two')
+        expect(data[1].words).to.deep.equal([ 'word2', 'word3' ])
+      }
+
+      {
+        const { data, total } = await server.watchedWordsLists.listWordsLists({
+          token,
+          accountName,
+          sort: '-listName'
+        })
+
+        expect(total).to.equal(2)
+        expect(data).to.have.lengthOf(2)
+
+        expect(data[0].listName).to.equal('list user two')
+        expect(data[1].listName).to.equal('list user one')
+      }
+
+      {
+        const { data, total } = await server.watchedWordsLists.listWordsLists({
+          accountName,
+          token,
+          sort: '-listName',
+          start: 1,
+          count: 1
+        })
+
+        expect(total).to.equal(2)
+        expect(data).to.have.lengthOf(1)
+
+        expect(data[0].listName).to.equal('list user one')
+      }
+    })
+
+    it('Should update watched words lists', async function () {
+      await server.watchedWordsLists.updateList({
+        listId,
+        token,
+        accountName,
+        words: [ 'updated-word1', 'updated-word2' ]
+      })
+
+      await server.watchedWordsLists.updateList({
+        listId,
+        token,
+        accountName,
+        listName: 'updated-list'
+      })
+
+      const { data } = await server.watchedWordsLists.listWordsLists({ token, accountName })
+      const list = data.find(l => l.id === listId)
+
+      expect(list.listName).to.equal('updated-list')
+      expect(list.words).to.deep.equal([ 'updated-word1', 'updated-word2' ])
+    })
+
+    it('Should delete watched words lists', async function () {
+      await server.watchedWordsLists.deleteList({
+        listId,
+        token,
+        accountName
+      })
+
+      const { total, data } = await server.watchedWordsLists.listWordsLists({ token, accountName })
+      expect(total).to.equal(1)
+      expect(data).to.have.lengthOf(1)
+
+      const list = data.find(l => l.id === listId)
+      expect(list).to.not.exist
+    })
+  }
+
+  describe('Managing account watched words', function () {
+    runTests('account')
+  })
+
+  describe('Managing instance watched words', function () {
+    runTests('server')
+  })
+
+  after(async function () {
+    await cleanupTests([ server ])
+  })
+})
diff --git a/packages/tests/src/api/notifications/comments-notifications.ts b/packages/tests/src/api/notifications/comments-notifications.ts
index 6c29436a6..5defc3234 100644
--- a/packages/tests/src/api/notifications/comments-notifications.ts
+++ b/packages/tests/src/api/notifications/comments-notifications.ts
@@ -1,14 +1,15 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
-import { expect } from 'chai'
-import { UserNotification } from '@peertube/peertube-models'
-import { cleanupTests, PeerTubeServer, waitJobs } from '@peertube/peertube-server-commands'
+import { UserNotification, UserNotificationType, VideoCommentPolicy } from '@peertube/peertube-models'
+import { PeerTubeServer, cleanupTests, setDefaultAccountAvatar, waitJobs } from '@peertube/peertube-server-commands'
 import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js'
-import { prepareNotificationsTest, CheckerBaseParams, checkNewCommentOnMyVideo, checkCommentMention } from '@tests/shared/notifications.js'
+import { CheckerBaseParams, checkCommentMention, checkNewCommentOnMyVideo, prepareNotificationsTest } from '@tests/shared/notifications.js'
+import { expect } from 'chai'
 
 describe('Test comments notifications', function () {
   let servers: PeerTubeServer[] = []
   let userToken: string
+  let userToken2: string
   let userNotifications: UserNotification[] = []
   let emails: object[] = []
 
@@ -24,6 +25,9 @@ describe('Test comments notifications', function () {
     userToken = res.userAccessToken
     servers = res.servers
     userNotifications = res.userNotifications
+
+    userToken2 = await servers[0].users.generateUserAndToken('user2')
+    await setDefaultAccountAvatar(servers[0], userToken2)
   })
 
   describe('Comment on my video notifications', function () {
@@ -94,11 +98,9 @@ describe('Test comments notifications', function () {
       this.timeout(60000)
 
       const { uuid, shortUUID } = await servers[0].videos.upload({ token: userToken, attributes: { name: 'super video' } })
-
       await waitJobs(servers)
 
       await servers[1].comments.createThread({ videoId: uuid, text: 'comment' })
-
       await waitJobs(servers)
 
       const { data } = await servers[0].comments.listThreads({ videoId: uuid })
@@ -147,6 +149,45 @@ describe('Test comments notifications', function () {
       await checkNewCommentOnMyVideo({ ...baseParams, shortUUID, threadId, commentId, checkType: 'presence' })
     })
 
+    it('Should send a new comment notification of a comment that requires approval', async function () {
+      this.timeout(60000)
+
+      const { id: videoId, uuid, shortUUID } = await servers[0].videos.upload({
+        token: userToken,
+        attributes: { name: 'super video', commentsPolicy: VideoCommentPolicy.REQUIRES_APPROVAL }
+      })
+      await waitJobs(servers)
+
+      let localCommentId: number
+      {
+        const created = await servers[0].comments.createThread({ videoId: uuid, text: 'local approval', token: userToken2 })
+        const commentId = localCommentId = created.id
+
+        await waitJobs(servers)
+        await checkNewCommentOnMyVideo({ ...baseParams, shortUUID, threadId: commentId, commentId, checkType: 'presence', approval: true })
+      }
+
+      {
+        await servers[1].comments.createThread({ videoId: uuid, text: 'remote approval' })
+        await waitJobs(servers)
+
+        const commentId = await servers[0].comments.findCommentId({ token: userToken, videoId, text: 'remote approval' })
+
+        await checkNewCommentOnMyVideo({ ...baseParams, shortUUID, threadId: commentId, commentId, checkType: 'presence', approval: true })
+      }
+
+      // It should not re-notify on approval
+      {
+        await servers[0].comments.approve({ token: userToken, commentId: localCommentId, videoId: shortUUID })
+        await waitJobs(servers)
+
+        const notifications = baseParams.socketNotifications
+          .filter(n => n.type === UserNotificationType.NEW_COMMENT_ON_MY_VIDEO && n.comment?.video?.shortUUID === shortUUID)
+
+        expect(notifications).to.have.lengthOf(2)
+      }
+    })
+
     it('Should convert markdown in comment to html', async function () {
       this.timeout(60000)
 
@@ -276,6 +317,64 @@ describe('Test comments notifications', function () {
       await checkCommentMention({ ...baseParams, shortUUID, commentId, threadId, byAccountDisplayName, checkType: 'presence' })
     })
 
+    it('Should not send a new mention notification before approval', async function () {
+      this.timeout(60000)
+
+      const { id: videoId, uuid, shortUUID } = await servers[0].videos.upload({
+        attributes: { name: 'super video', commentsPolicy: VideoCommentPolicy.REQUIRES_APPROVAL }
+      })
+      await waitJobs(servers)
+
+      const localText = '@user_1 local approval'
+      {
+        const { id: threadId } = await servers[0].comments.createThread({ videoId: uuid, text: localText, token: userToken2 })
+        await waitJobs(servers)
+
+        await checkCommentMention({
+          ...baseParams,
+          shortUUID,
+          threadId,
+          commentId: threadId,
+          byAccountDisplayName: 'user2',
+          checkType: 'absence'
+        })
+      }
+
+      const remoteText = `@user_1@${servers[0].host} remote approval`
+      {
+        await servers[1].comments.createThread({ videoId: uuid, text: remoteText })
+        await waitJobs(servers)
+
+        const threadId = await servers[0].comments.findCommentId({ token: userToken, videoId, text: remoteText })
+        const byAccountDisplayName = 'super root 2 name'
+        await checkCommentMention({ ...baseParams, shortUUID, threadId, commentId: threadId, byAccountDisplayName, checkType: 'absence' })
+      }
+
+      // It should notify on approval
+      {
+        const toTest = [
+          { text: localText, byAccountDisplayName: 'user2' },
+          { text: remoteText, byAccountDisplayName: 'super root 2 name' }
+        ]
+
+        for (const { text, byAccountDisplayName } of toTest) {
+          const localCommentId = await servers[0].comments.findCommentId({ token: userToken, videoId, text })
+
+          await servers[0].comments.approve({ commentId: localCommentId, videoId: shortUUID })
+          await waitJobs(servers)
+
+          await checkCommentMention({
+            ...baseParams,
+            shortUUID,
+            threadId: localCommentId,
+            commentId: localCommentId,
+            byAccountDisplayName,
+            checkType: 'presence'
+          })
+        }
+      }
+    })
+
     it('Should convert markdown in comment to html', async function () {
       this.timeout(60000)
 
diff --git a/packages/tests/src/api/server/config-defaults.ts b/packages/tests/src/api/server/config-defaults.ts
index 35ac89fdf..42b517ba2 100644
--- a/packages/tests/src/api/server/config-defaults.ts
+++ b/packages/tests/src/api/server/config-defaults.ts
@@ -1,7 +1,7 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
 import { expect } from 'chai'
-import { VideoDetails, VideoPrivacy } from '@peertube/peertube-models'
+import { VideoCommentPolicy, VideoDetails, VideoPrivacy } from '@peertube/peertube-models'
 import {
   cleanupTests,
   createSingleServer,
@@ -31,7 +31,7 @@ describe('Test config defaults', function () {
       const overrideConfig = {
         defaults: {
           publish: {
-            comments_enabled: false,
+            comments_policy: 2,
             download_enabled: false,
             privacy: VideoPrivacy.INTERNAL,
             licence: 4
@@ -46,13 +46,14 @@ describe('Test config defaults', function () {
     const attributes = {
       name: 'video',
       downloadEnabled: undefined,
-      commentsEnabled: undefined,
+      commentsPolicy: undefined,
       licence: undefined,
       privacy: VideoPrivacy.PUBLIC // Privacy is mandatory for server
     }
 
     function checkVideo (video: VideoDetails) {
       expect(video.downloadEnabled).to.be.false
+      expect(video.commentsPolicy.id).to.equal(VideoCommentPolicy.DISABLED)
       expect(video.commentsEnabled).to.be.false
       expect(video.licence.id).to.equal(4)
     }
@@ -67,6 +68,7 @@ describe('Test config defaults', function () {
       const config = await server.config.getConfig()
 
       expect(config.defaults.publish.commentsEnabled).to.be.false
+      expect(config.defaults.publish.commentsPolicy).to.equal(VideoCommentPolicy.DISABLED)
       expect(config.defaults.publish.downloadEnabled).to.be.false
       expect(config.defaults.publish.licence).to.equal(4)
       expect(config.defaults.publish.privacy).to.equal(VideoPrivacy.INTERNAL)
diff --git a/packages/tests/src/api/server/follows.ts b/packages/tests/src/api/server/follows.ts
index 448f28d62..264f408c2 100644
--- a/packages/tests/src/api/server/follows.ts
+++ b/packages/tests/src/api/server/follows.ts
@@ -1,12 +1,12 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
-import { expect } from 'chai'
-import { Video, VideoPrivacy } from '@peertube/peertube-models'
-import { cleanupTests, createMultipleServers, PeerTubeServer, setAccessTokensToServers, waitJobs } from '@peertube/peertube-server-commands'
+import { Video, VideoCommentPolicy, VideoPrivacy } from '@peertube/peertube-models'
+import { PeerTubeServer, cleanupTests, createMultipleServers, setAccessTokensToServers, waitJobs } from '@peertube/peertube-server-commands'
 import { expectAccountFollows, expectChannelsFollows } from '@tests/shared/actors.js'
 import { testCaptionFile } from '@tests/shared/captions.js'
 import { dateIsValid } from '@tests/shared/checks.js'
 import { completeVideoCheck } from '@tests/shared/videos.js'
+import { expect } from 'chai'
 
 describe('Test follows', function () {
 
@@ -463,7 +463,7 @@ describe('Test follows', function () {
             name: 'root',
             host: servers[2].host
           },
-          commentsEnabled: true,
+          commentsPolicy: VideoCommentPolicy.ENABLED,
           downloadEnabled: true,
           duration: 5,
           tags: [ 'tag1', 'tag2', 'tag3' ],
diff --git a/packages/tests/src/api/server/handle-down.ts b/packages/tests/src/api/server/handle-down.ts
index 474048037..f371a474d 100644
--- a/packages/tests/src/api/server/handle-down.ts
+++ b/packages/tests/src/api/server/handle-down.ts
@@ -1,19 +1,19 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
-import { expect } from 'chai'
 import { wait } from '@peertube/peertube-core-utils'
-import { HttpStatusCode, JobState, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
+import { HttpStatusCode, JobState, VideoCommentPolicy, VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
 import {
-  cleanupTests,
   CommentsCommand,
+  PeerTubeServer,
+  cleanupTests,
   createMultipleServers,
   killallServers,
-  PeerTubeServer,
   setAccessTokensToServers,
   waitJobs
 } from '@peertube/peertube-server-commands'
 import { SQLCommand } from '@tests/shared/sql-command.js'
 import { completeVideoCheck } from '@tests/shared/videos.js'
+import { expect } from 'chai'
 
 describe('Test handle downs', function () {
   let servers: PeerTubeServer[] = []
@@ -59,7 +59,7 @@ describe('Test handle downs', function () {
     duration: 10,
     tags: [ 'tag1p1', 'tag2p1' ],
     privacy: VideoPrivacy.PUBLIC,
-    commentsEnabled: true,
+    commentsPolicy: VideoCommentPolicy.ENABLED,
     downloadEnabled: true,
     channel: {
       name: 'root_channel',
diff --git a/packages/tests/src/api/users/user-export.ts b/packages/tests/src/api/users/user-export.ts
index 06013d863..513ef6c80 100644
--- a/packages/tests/src/api/users/user-export.ts
+++ b/packages/tests/src/api/users/user-export.ts
@@ -4,6 +4,7 @@ import { wait } from '@peertube/peertube-core-utils'
 import {
   AccountExportJSON, ActivityPubActor,
   ActivityPubOrderedCollection,
+  AutoTagPoliciesJSON,
   BlocklistExportJSON,
   ChannelExportJSON,
   CommentsExportJSON,
@@ -24,7 +25,8 @@ import {
   VideoPlaylistPrivacy,
   VideoPlaylistsExportJSON,
   VideoPlaylistType,
-  VideoPrivacy
+  VideoPrivacy,
+  WatchedWordsListsJSON
 } from '@peertube/peertube-models'
 import { areMockObjectStorageTestsDisabled } from '@peertube/peertube-node-utils'
 import {
@@ -172,7 +174,9 @@ function runTest (withObjectStorage: boolean) {
       'peertube/likes.json',
       'peertube/user-settings.json',
       'peertube/video-playlists.json',
-      'peertube/videos.json'
+      'peertube/videos.json',
+      'peertube/watched-words-lists.json',
+      'peertube/automatic-tag-policies.json'
     ]
 
     for (const file of files) {
@@ -574,6 +578,28 @@ function runTest (withObjectStorage: boolean) {
         expect(secondaryChannelVideo.channel.name).to.equal('noah_second_channel')
       }
     }
+
+    {
+      const json = await parseZIPJSONFile<WatchedWordsListsJSON>(zip, 'peertube/watched-words-lists.json')
+
+      expect(json.watchedWordLists).to.have.lengthOf(2)
+
+      expect(json.watchedWordLists[0].createdAt).to.exist
+      expect(json.watchedWordLists[0].updatedAt).to.exist
+      expect(json.watchedWordLists[0].listName).to.equal('forbidden-list')
+      expect(json.watchedWordLists[0].words).to.have.members([ 'forbidden' ])
+
+      expect(json.watchedWordLists[1].createdAt).to.exist
+      expect(json.watchedWordLists[1].updatedAt).to.exist
+      expect(json.watchedWordLists[1].listName).to.equal('allowed-list')
+      expect(json.watchedWordLists[1].words).to.have.members([ 'allowed', 'allowed2' ])
+    }
+
+    {
+      const json = await parseZIPJSONFile<AutoTagPoliciesJSON>(zip, 'peertube/automatic-tag-policies.json')
+      expect(json.reviewComments).to.have.lengthOf(2)
+      expect(json.reviewComments.map(r => r.name)).to.have.members([ 'external-link', 'forbidden-list' ])
+    }
   })
 
   it('Should have a valid export of static files', async function () {
diff --git a/packages/tests/src/api/users/user-import.ts b/packages/tests/src/api/users/user-import.ts
index 7eb93d6d7..b08323b99 100644
--- a/packages/tests/src/api/users/user-import.ts
+++ b/packages/tests/src/api/users/user-import.ts
@@ -5,6 +5,7 @@ import {
   LiveVideoLatencyMode,
   UserImportState,
   UserNotificationSettingValue,
+  VideoCommentPolicy,
   VideoCreateResult,
   VideoPlaylistPrivacy,
   VideoPlaylistType,
@@ -336,6 +337,25 @@ function runTest (withObjectStorage: boolean) {
     expect(data[1].url).to.equal(server.url + '/videos/watch/' + noahVideo.uuid)
   })
 
+  it('Should have correctly imported watched words lists', async function () {
+    const { data } = await remoteServer.watchedWordsLists.listWordsLists({ token: remoteNoahToken, accountName: 'noah_remote' })
+
+    expect(data).to.have.lengthOf(2)
+
+    expect(data[0].listName).to.equal('allowed-list')
+    expect(data[0].words).to.have.members([ 'allowed', 'allowed2' ])
+
+    expect(data[1].listName).to.equal('forbidden-list')
+    expect(data[1].words).to.have.members([ 'forbidden' ])
+  })
+
+  it('Should have correctly imported auto tag policies', async function () {
+    const { review } = await remoteServer.autoTags.getCommentPolicies({ token: remoteNoahToken, accountName: 'noah_remote' })
+
+    expect(review).to.have.lengthOf(2)
+    expect(review).to.have.members([ 'external-link', 'forbidden-list' ])
+  })
+
   it('Should have correctly imported user videos', async function () {
     const { data } = await remoteServer.videos.listMyVideos({ token: remoteNoahToken })
     expect(data).to.have.lengthOf(5)
@@ -386,7 +406,7 @@ function runTest (withObjectStorage: boolean) {
             privacy: (VideoPrivacy.PUBLIC),
             category: (12),
             tags: [ 'tag1', 'tag2' ],
-            commentsEnabled: false,
+            commentsPolicy: VideoCommentPolicy.DISABLED,
             downloadEnabled: false,
             nsfw: false,
             description: ('video description'),
@@ -539,6 +559,18 @@ function runTest (withObjectStorage: boolean) {
       const { data } = await remoteServer.videos.listMyVideos({ token: remoteNoahToken })
       expect(data).to.have.lengthOf(5)
     }
+
+    // Watched words
+    {
+      const { data } = await remoteServer.watchedWordsLists.listWordsLists({ token: remoteNoahToken, accountName: 'noah_remote' })
+      expect(data).to.have.lengthOf(2)
+    }
+
+    // Auto tag policies
+    {
+      const { review } = await remoteServer.autoTags.getCommentPolicies({ token: remoteNoahToken, accountName: 'noah_remote' })
+      expect(review).to.have.lengthOf(2)
+    }
   })
 
   it('Should have received an email on finished import', async function () {
diff --git a/packages/tests/src/api/videos/multiple-servers.ts b/packages/tests/src/api/videos/multiple-servers.ts
index 6a38017f5..6d2f05155 100644
--- a/packages/tests/src/api/videos/multiple-servers.ts
+++ b/packages/tests/src/api/videos/multiple-servers.ts
@@ -1,7 +1,7 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
 import { wait } from '@peertube/peertube-core-utils'
-import { HttpStatusCode, VideoCommentThreadTree, VideoPrivacy } from '@peertube/peertube-models'
+import { HttpStatusCode, VideoCommentPolicy, VideoCommentThreadTree, VideoPrivacy } from '@peertube/peertube-models'
 import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils'
 import {
   PeerTubeServer,
@@ -107,7 +107,7 @@ describe('Test multiple servers', function () {
           duration: 10,
           tags: [ 'tag1p1', 'tag2p1' ],
           privacy: VideoPrivacy.PUBLIC,
-          commentsEnabled: true,
+          commentsPolicy: VideoCommentPolicy.ENABLED,
           downloadEnabled: true,
           channel: {
             displayName: 'my channel',
@@ -193,7 +193,7 @@ describe('Test multiple servers', function () {
             name: 'user1',
             host: servers[1].host
           },
-          commentsEnabled: true,
+          commentsPolicy: VideoCommentPolicy.ENABLED,
           downloadEnabled: true,
           duration: 5,
           tags: [ 'tag1p2', 'tag2p2', 'tag3p2' ],
@@ -309,7 +309,7 @@ describe('Test multiple servers', function () {
             host: servers[2].host
           },
           duration: 5,
-          commentsEnabled: true,
+          commentsPolicy: VideoCommentPolicy.ENABLED,
           downloadEnabled: true,
           tags: [ 'tag1p3' ],
           privacy: VideoPrivacy.PUBLIC,
@@ -342,7 +342,7 @@ describe('Test multiple servers', function () {
             name: 'root',
             host: servers[2].host
           },
-          commentsEnabled: true,
+          commentsPolicy: VideoCommentPolicy.ENABLED,
           downloadEnabled: true,
           duration: 5,
           tags: [ 'tag2p3', 'tag3p3', 'tag4p3' ],
@@ -671,7 +671,7 @@ describe('Test multiple servers', function () {
             host: servers[2].host
           },
           duration: 5,
-          commentsEnabled: true,
+          commentsPolicy: VideoCommentPolicy.ENABLED,
           downloadEnabled: true,
           tags: [ 'tag_up_1', 'tag_up_2' ],
           privacy: VideoPrivacy.PUBLIC,
@@ -1022,7 +1022,7 @@ describe('Test multiple servers', function () {
       this.timeout(20000)
 
       const attributes = {
-        commentsEnabled: false,
+        commentsPolicy: VideoCommentPolicy.DISABLED,
         downloadEnabled: false
       }
 
@@ -1033,6 +1033,8 @@ describe('Test multiple servers', function () {
       for (const server of servers) {
         const video = await server.videos.get({ id: videoUUID })
         expect(video.commentsEnabled).to.be.false
+        expect(video.commentsPolicy.id).to.equal(VideoCommentPolicy.DISABLED)
+        expect(video.commentsPolicy.label).to.equal('Disabled')
         expect(video.downloadEnabled).to.be.false
 
         const text = 'my super forbidden comment'
@@ -1079,7 +1081,7 @@ describe('Test multiple servers', function () {
           },
           isLocal,
           duration: 5,
-          commentsEnabled: true,
+          commentsPolicy: VideoCommentPolicy.ENABLED,
           downloadEnabled: true,
           tags: [],
           privacy: VideoPrivacy.PUBLIC,
diff --git a/packages/tests/src/api/videos/single-server.ts b/packages/tests/src/api/videos/single-server.ts
index 82b5fe6ce..1370a2369 100644
--- a/packages/tests/src/api/videos/single-server.ts
+++ b/packages/tests/src/api/videos/single-server.ts
@@ -2,7 +2,7 @@
 
 import { expect } from 'chai'
 import { wait } from '@peertube/peertube-core-utils'
-import { Video, VideoPrivacy } from '@peertube/peertube-models'
+import { Video, VideoCommentPolicy, VideoPrivacy } from '@peertube/peertube-models'
 import { checkVideoFilesWereRemoved, completeVideoCheck } from '@tests/shared/videos.js'
 import { testImageGeneratedByFFmpeg } from '@tests/shared/checks.js'
 import {
@@ -39,7 +39,7 @@ describe('Test a single server', function () {
       duration: 5,
       tags: [ 'tag1', 'tag2', 'tag3' ],
       privacy: VideoPrivacy.PUBLIC,
-      commentsEnabled: true,
+      commentsPolicy: VideoCommentPolicy.ENABLED,
       downloadEnabled: true,
       channel: {
         displayName: 'Main root channel',
@@ -72,7 +72,7 @@ describe('Test a single server', function () {
       tags: [ 'tagup1', 'tagup2' ],
       privacy: VideoPrivacy.PUBLIC,
       duration: 5,
-      commentsEnabled: false,
+      commentsPolicy: VideoCommentPolicy.DISABLED,
       downloadEnabled: false,
       channel: {
         name: 'root_channel',
@@ -345,7 +345,7 @@ describe('Test a single server', function () {
         language: 'ar',
         nsfw: false,
         description: 'my super description updated',
-        commentsEnabled: false,
+        commentsPolicy: VideoCommentPolicy.DISABLED,
         downloadEnabled: false,
         tags: [ 'tagup1', 'tagup2' ]
       }
diff --git a/packages/tests/src/api/videos/video-comments.ts b/packages/tests/src/api/videos/video-comments.ts
index 7b21287e4..9bbeb0fce 100644
--- a/packages/tests/src/api/videos/video-comments.ts
+++ b/packages/tests/src/api/videos/video-comments.ts
@@ -1,16 +1,16 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
-import { expect } from 'chai'
-import { dateIsValid, testImage } from '@tests/shared/checks.js'
 import {
-  cleanupTests,
   CommentsCommand,
-  createSingleServer,
   PeerTubeServer,
+  cleanupTests,
+  createSingleServer,
   setAccessTokensToServers,
   setDefaultAccountAvatar,
   setDefaultChannelAvatar
 } from '@peertube/peertube-server-commands'
+import { dateIsValid, testImage } from '@tests/shared/checks.js'
+import { expect } from 'chai'
 
 describe('Test video comments', function () {
   let server: PeerTubeServer
@@ -246,11 +246,16 @@ describe('Test video comments', function () {
     })
   })
 
-  describe('All instance comments', function () {
+  describe('Listing comments on my videos and in admin', function () {
 
-    it('Should list instance comments as admin', async function () {
-      {
-        const { data, total } = await command.listForAdmin({ start: 0, count: 1 })
+    const listFunctions = () => ([
+      command.listForAdmin.bind(command),
+      command.listCommentsOnMyVideos.bind(command)
+    ])
+
+    it('Should list comments', async function () {
+      for (const fn of listFunctions()) {
+        const { data, total } = await fn({ start: 0, count: 1 })
 
         expect(total).to.equal(7)
         expect(data).to.have.lengthOf(1)
@@ -260,8 +265,8 @@ describe('Test video comments', function () {
         expect(data[0].account.avatars).to.have.lengthOf(4)
       }
 
-      {
-        const { data, total } = await command.listForAdmin({ start: 1, count: 2 })
+      for (const fn of listFunctions()) {
+        const { data, total } = await fn({ start: 1, count: 2 })
 
         expect(total).to.equal(7)
         expect(data).to.have.lengthOf(2)
@@ -269,6 +274,10 @@ describe('Test video comments', function () {
         expect(data[0].account.avatars).to.have.lengthOf(4)
         expect(data[1].account.avatars).to.have.lengthOf(4)
       }
+
+      const { data, total } = await command.listCommentsOnMyVideos({ token: userAccessTokenServer1 })
+      expect(data).to.have.lengthOf(0)
+      expect(total).to.equal(0)
     })
 
     it('Should filter instance comments by isLocal', async function () {
@@ -294,39 +303,92 @@ describe('Test video comments', function () {
       }
     })
 
-    it('Should search instance comments by account', async function () {
-      const { total, data } = await command.listForAdmin({ searchAccount: 'user' })
+    it('Should search comments by account', async function () {
+      for (const fn of listFunctions()) {
+        const { total, data } = await fn({ searchAccount: 'user' })
 
-      expect(data).to.have.lengthOf(1)
-      expect(total).to.equal(1)
+        expect(data).to.have.lengthOf(1)
+        expect(total).to.equal(1)
 
-      expect(data[0].text).to.equal('a first answer to thread 4 by a third party')
+        expect(data[0].text).to.equal('a first answer to thread 4 by a third party')
+      }
+
+      const { data, total } = await command.listCommentsOnMyVideos({ token: userAccessTokenServer1, searchAccount: 'user' })
+      expect(data).to.have.lengthOf(0)
+      expect(total).to.equal(0)
     })
 
-    it('Should search instance comments by video', async function () {
-      {
-        const { total, data } = await command.listForAdmin({ searchVideo: 'video' })
+    it('Should search comments by video', async function () {
+      for (const fn of listFunctions()) {
+        const { total, data } = await fn({ searchVideo: 'video' })
 
         expect(data).to.have.lengthOf(7)
         expect(total).to.equal(7)
       }
 
-      {
-        const { total, data } = await command.listForAdmin({ searchVideo: 'hello' })
+      for (const fn of listFunctions()) {
+        const { total, data } = await fn({ searchVideo: 'hello' })
 
         expect(data).to.have.lengthOf(0)
         expect(total).to.equal(0)
       }
+
+      const { data, total } = await command.listCommentsOnMyVideos({ token: userAccessTokenServer1, searchVideo: 'video' })
+      expect(data).to.have.lengthOf(0)
+      expect(total).to.equal(0)
+    })
+
+    it('Should search comments', async function () {
+      for (const fn of listFunctions()) {
+        const { total, data } = await fn({ search: 'super thread 3' })
+
+        expect(total).to.equal(1)
+
+        expect(data).to.have.lengthOf(1)
+        expect(data[0].text).to.equal('super thread 3')
+      }
+
+      const { data, total } = await command.listCommentsOnMyVideos({ token: userAccessTokenServer1, search: 'super thread 3' })
+      expect(data).to.have.lengthOf(0)
+      expect(total).to.equal(0)
+    })
+
+    it('Should filter by videoId', async function () {
+      const { uuid: otherVideo } = await server.videos.upload()
+
+      {
+        const { total, data } = await command.listForAdmin({ videoId: videoUUID })
+        expect(data).to.have.lengthOf(7)
+        expect(total).to.equal(7)
+      }
+
+      {
+        const { total, data } = await command.listForAdmin({ videoId: otherVideo })
+        expect(data).to.have.lengthOf(0)
+        expect(total).to.equal(0)
+      }
     })
 
-    it('Should search instance comments', async function () {
-      const { total, data } = await command.listForAdmin({ search: 'super thread 3' })
+    it('Should filter by channelId', async function () {
+      const { id: videoChannelId } = await server.channels.create({ attributes: { name: 'other_channel' } })
+      const { videoChannels: rootChannels } = await server.users.getMyInfo()
 
-      expect(total).to.equal(1)
+      await server.videos.upload({ attributes: { channelId: videoChannelId } })
 
-      expect(data).to.have.lengthOf(1)
-      expect(data[0].text).to.equal('super thread 3')
+      {
+        const { total, data } = await command.listForAdmin({ videoChannelId: rootChannels[0].id })
+        expect(data).to.have.lengthOf(7)
+        expect(total).to.equal(7)
+      }
+
+      {
+        const { total, data } = await command.listForAdmin({ videoChannelId })
+        expect(data).to.have.lengthOf(0)
+        expect(total).to.equal(0)
+      }
     })
+
+    // Auto tags filter is checked auto tags test file
   })
 
   after(async function () {
diff --git a/packages/tests/src/feeds/feeds.ts b/packages/tests/src/feeds/feeds.ts
index ed833ffd1..ed36a87bc 100644
--- a/packages/tests/src/feeds/feeds.ts
+++ b/packages/tests/src/feeds/feeds.ts
@@ -1,25 +1,25 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
-import * as chai from 'chai'
-import chaiJSONSChema from 'chai-json-schema'
-import chaiXML from 'chai-xml'
-import { XMLParser, XMLValidator } from 'fast-xml-parser'
-import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models'
+import { HttpStatusCode, VideoCommentPolicy, VideoPrivacy } from '@peertube/peertube-models'
 import {
+  PeerTubeServer,
+  PluginsCommand,
   cleanupTests,
   createMultipleServers,
   createSingleServer,
   doubleFollow,
   makeGetRequest,
   makeRawRequest,
-  PeerTubeServer,
-  PluginsCommand,
   setAccessTokensToServers,
   setDefaultChannelAvatar,
   setDefaultVideoChannel,
   stopFfmpeg,
   waitJobs
 } from '@peertube/peertube-server-commands'
+import * as chai from 'chai'
+import chaiJSONSChema from 'chai-json-schema'
+import chaiXML from 'chai-xml'
+import { XMLParser, XMLValidator } from 'fast-xml-parser'
 
 chai.use(chaiXML)
 chai.use(chaiJSONSChema)
@@ -39,6 +39,8 @@ describe('Test syndication feeds', () => {
   let userChannelId: number
   let userFeedToken: string
 
+  let videoIdWithComments: string
+  let videoIdWithoutComments: string
   let liveId: string
 
   before(async function () {
@@ -74,7 +76,8 @@ describe('Test syndication feeds', () => {
     }
 
     {
-      await servers[0].videos.upload({ token: userAccessToken, attributes: { name: 'user video' } })
+      const { uuid } = await servers[0].videos.upload({ token: userAccessToken, attributes: { name: 'user video' } })
+      videoIdWithoutComments = uuid
     }
 
     {
@@ -83,10 +86,11 @@ describe('Test syndication feeds', () => {
         description: 'my super description for server 1',
         fixture: 'video_short.webm'
       }
-      const { id } = await servers[0].videos.upload({ attributes })
+      const { uuid } = await servers[0].videos.upload({ attributes })
+      videoIdWithComments = uuid
 
-      await servers[0].comments.createThread({ videoId: id, text: 'super comment 1' })
-      await servers[0].comments.createThread({ videoId: id, text: 'super comment 2' })
+      await servers[0].comments.createThread({ videoId: uuid, text: 'super comment 1' })
+      await servers[0].comments.createThread({ videoId: uuid, text: 'super comment 2' })
     }
 
     {
@@ -479,6 +483,89 @@ describe('Test syndication feeds', () => {
       }
     })
 
+    it('Should filter by videoId', async function () {
+      {
+        const json = await servers[0].feed.getJSON({ feed: 'video-comments', query: { videoId: videoIdWithComments }, ignoreCache: true })
+        expect(JSON.parse(json).items.length).to.be.equal(2)
+      }
+
+      {
+        const json = await servers[0].feed.getJSON({
+          feed: 'video-comments',
+          query: { videoId: videoIdWithoutComments },
+          ignoreCache: true
+        })
+        expect(JSON.parse(json).items.length).to.be.equal(0)
+      }
+    })
+
+    it('Should filter by videoChannelId/videoChannelName', async function () {
+      {
+        const json = await servers[0].feed.getJSON({ feed: 'video-comments', query: { videoChannelId: rootChannelId }, ignoreCache: true })
+        expect(JSON.parse(json).items.length).to.be.equal(2)
+      }
+
+      {
+        const json = await servers[0].feed.getJSON({
+          feed: 'video-comments',
+          query: { videoChannelName: 'root_channel' },
+          ignoreCache: true
+        })
+        expect(JSON.parse(json).items.length).to.be.equal(2)
+      }
+
+      {
+        const json = await servers[0].feed.getJSON({ feed: 'video-comments', query: { videoChannelId: userChannelId }, ignoreCache: true })
+        expect(JSON.parse(json).items.length).to.be.equal(0)
+      }
+
+      {
+        const json = await servers[0].feed.getJSON({
+          feed: 'video-comments',
+          query: { videoChannelName: 'john_channel' },
+          ignoreCache: true
+        })
+        expect(JSON.parse(json).items.length).to.be.equal(0)
+      }
+    })
+
+    it('Should filter by accountId/accountName', async function () {
+      {
+        const json = await servers[0].feed.getJSON({ feed: 'video-comments', query: { accountId: rootAccountId }, ignoreCache: true })
+        expect(JSON.parse(json).items.length).to.be.equal(2)
+      }
+
+      {
+        const json = await servers[0].feed.getJSON({ feed: 'video-comments', query: { accountName: 'root' }, ignoreCache: true })
+        expect(JSON.parse(json).items.length).to.be.equal(2)
+      }
+
+      {
+        const json = await servers[0].feed.getJSON({ feed: 'video-comments', query: { accountId: userAccountId }, ignoreCache: true })
+        expect(JSON.parse(json).items.length).to.be.equal(0)
+      }
+
+      {
+        const json = await servers[0].feed.getJSON({ feed: 'video-comments', query: { accountName: 'john' }, ignoreCache: true })
+        expect(JSON.parse(json).items.length).to.be.equal(0)
+      }
+    })
+
+    it('Should not list non approved comments', async function () {
+      await servers[0].videos.update({ id: videoIdWithComments, attributes: { commentsPolicy: VideoCommentPolicy.REQUIRES_APPROVAL } })
+      await servers[0].comments.createThread({ videoId: videoIdWithComments, text: 'approval comment', token: userAccessToken })
+
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const json = await server.feed.getJSON({ feed: 'video-comments', ignoreCache: true })
+
+        const jsonObj = JSON.parse(json)
+        expect(jsonObj.items.length).to.be.equal(2)
+        expect(jsonObj.items.some(i => i.content_html.includes('approval'))).to.be.false
+      }
+    })
+
     it('Should not list comments from muted accounts or instances', async function () {
       this.timeout(30000)
 
diff --git a/packages/tests/src/server-helpers/index.ts b/packages/tests/src/server-helpers/index.ts
index 04a26560c..28fd5d99c 100644
--- a/packages/tests/src/server-helpers/index.ts
+++ b/packages/tests/src/server-helpers/index.ts
@@ -8,3 +8,4 @@ import './mentions.js'
 import './request.js'
 import './validator.js'
 import './version.js'
+import './regexp.js'
diff --git a/packages/tests/src/server-helpers/regexp.ts b/packages/tests/src/server-helpers/regexp.ts
new file mode 100644
index 000000000..ae0a0555b
--- /dev/null
+++ b/packages/tests/src/server-helpers/regexp.ts
@@ -0,0 +1,60 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { wordsToRegExp } from '@peertube/peertube-server/core/helpers/regexp.js'
+import { expect } from 'chai'
+
+describe('Regexp', function () {
+
+  it('Should correctly create a regex from multiple latin words', function () {
+    const regexp = wordsToRegExp([ 'hi', 'toto ', 'hello picsou' ])
+
+    expect('hi').to.match(regexp)
+    expect('coucou toto').to.match(regexp)
+    expect('coucou toto').to.match(regexp)
+
+    expect('coucoutoto').to.not.match(regexp)
+
+    expect('coucoutoto hello coucou').to.not.match(regexp)
+
+    expect('coucoutoto hello picsou hello').to.match(regexp)
+    expect('coucoutoto hello picsou').to.match(regexp)
+  })
+
+  it('Should correctly create a regex from non latin words', function () {
+    const regexp = wordsToRegExp([ '🇫🇷', '🇨🇦 hi' ])
+
+    expect('🇫🇷').to.match(regexp)
+    expect(' 🇫🇷 ').to.match(regexp)
+    expect('coucou 🇫🇷 toto').to.match(regexp)
+    expect('hello 🇨🇦 hi toto').to.match(regexp)
+    expect('hello 🇨🇦 hi').to.match(regexp)
+    expect('🇨🇦 hi').to.match(regexp)
+    expect('🇫🇷🇨🇦 hi').to.match(regexp)
+
+    expect('coucou 🇫🇷toto').to.not.match(regexp)
+    expect('e🇨🇦 hi').to.not.match(regexp)
+    expect('hello 🇨🇦 toto').to.not.match(regexp)
+  })
+
+  it('Should correctly create a regex from single word', function () {
+    const regexp = wordsToRegExp([ 'hi my friend' ])
+
+    expect('hi').to.not.match(regexp)
+    expect('hi my friend').to.match(regexp)
+    expect(' hi my friend ').to.match(regexp)
+
+    expect(' hi my friendy ').to.not.match(regexp)
+  })
+
+  it('Should correctly escape words to regex', function () {
+    const regexp = wordsToRegExp([ 'hi[hello]', 'toto. ', 'coucou+' ])
+
+    expect('1 2 toto. 3').to.match(regexp)
+    expect('hi[hello]').to.match(regexp)
+    expect(' coucou coucou+').to.match(regexp)
+
+    expect('hihello').to.not.match(regexp)
+    expect('totoa').to.not.match(regexp)
+    expect('1 2 toto 3').to.not.match(regexp)
+  })
+})
diff --git a/packages/tests/src/shared/import-export.ts b/packages/tests/src/shared/import-export.ts
index c9377f7b9..616ceb1f7 100644
--- a/packages/tests/src/shared/import-export.ts
+++ b/packages/tests/src/shared/import-export.ts
@@ -7,6 +7,7 @@ import {
   UserExport,
   UserNotificationSettingValue,
   VideoCommentObject,
+  VideoCommentPolicy,
   VideoObject,
   VideoPlaylistPrivacy,
   VideoPrivacy
@@ -202,7 +203,7 @@ export async function prepareImportExportTests (options: {
       name: 'noah public video second channel',
       category: 12,
       tags: [ 'tag1', 'tag2' ],
-      commentsEnabled: false,
+      commentsPolicy: VideoCommentPolicy.DISABLED,
       description: 'video description',
       downloadEnabled: false,
       language: 'fr',
@@ -315,6 +316,27 @@ export async function prepareImportExportTests (options: {
   await server.views.view({ id: noahVideo.uuid, token: noahToken, currentTime: 4 })
   await server.views.view({ id: externalVideo.uuid, token: noahToken, currentTime: 2 })
 
+  // Watched words and auto tag policies
+  await servers[0].watchedWordsLists.createList({
+    token: noahToken,
+    listName: 'forbidden-list',
+    words: [ 'forbidden' ],
+    accountName: 'noah'
+  })
+
+  await servers[0].watchedWordsLists.createList({
+    token: noahToken,
+    listName: 'allowed-list',
+    words: [ 'allowed', 'allowed2' ],
+    accountName: 'noah'
+  })
+
+  await servers[0].autoTags.updateCommentPolicies({
+    accountName: 'noah',
+    review: [ 'external-link', 'forbidden-list' ],
+    token: noahToken
+  })
+
   return {
     rootId,
 
diff --git a/packages/tests/src/shared/notifications.ts b/packages/tests/src/shared/notifications.ts
index c3612f790..5f4b240f9 100644
--- a/packages/tests/src/shared/notifications.ts
+++ b/packages/tests/src/shared/notifications.ts
@@ -437,8 +437,9 @@ async function checkNewCommentOnMyVideo (options: CheckerBaseParams & {
   commentId: number
   threadId: number
   checkType: CheckerType
+  approval?: boolean // default false
 }) {
-  const { server, shortUUID, commentId, threadId, checkType, emails } = options
+  const { server, shortUUID, commentId, threadId, checkType, emails, approval = false } = options
   const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO
 
   function notificationChecker (notification: UserNotification, checkType: CheckerType) {
@@ -449,6 +450,8 @@ async function checkNewCommentOnMyVideo (options: CheckerBaseParams & {
       checkComment(notification.comment, commentId, threadId)
       checkActor(notification.comment.account)
       checkVideo(notification.comment.video, undefined, shortUUID)
+
+      expect(notification.comment.heldForReview).to.equal(approval)
     } else {
       expect(notification).to.satisfy((n: UserNotification) => {
         return n?.comment === undefined || n.comment.id !== commentId
@@ -456,10 +459,16 @@ async function checkNewCommentOnMyVideo (options: CheckerBaseParams & {
     }
   }
 
-  const commentUrl = `${server.url}/w/${shortUUID};threadId=${threadId}`
+  const commentUrl = approval
+    ? `${server.url}/my-account/videos/comments?search=heldForReview:true`
+    : `${server.url}/w/${shortUUID};threadId=${threadId}`
 
   function emailNotificationFinder (email: object) {
-    return email['text'].indexOf(commentUrl) !== -1
+    const text = email['text']
+
+    return text.includes(commentUrl) &&
+      (approval && text.includes('requires approval')) ||
+      (!approval && !text.includes('requires approval'))
   }
 
   await checkNotification({ ...options, notificationChecker, emailNotificationFinder })
diff --git a/packages/tests/src/shared/videos.ts b/packages/tests/src/shared/videos.ts
index acf647ac1..1fe43d0c9 100644
--- a/packages/tests/src/shared/videos.ts
+++ b/packages/tests/src/shared/videos.ts
@@ -1,7 +1,16 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */
 
 import { uuidRegex } from '@peertube/peertube-core-utils'
-import { HttpStatusCode, HttpStatusCodeType, VideoCaption, VideoDetails, VideoPrivacy, VideoResolution } from '@peertube/peertube-models'
+import {
+  HttpStatusCode,
+  HttpStatusCodeType,
+  VideoCaption,
+  VideoCommentPolicy,
+  VideoCommentPolicyType,
+  VideoDetails,
+  VideoPrivacy,
+  VideoResolution
+} from '@peertube/peertube-models'
 import { buildAbsoluteFixturePath, getFileSize, getFilenameFromUrl, getLowercaseExtension } from '@peertube/peertube-node-utils'
 import { PeerTubeServer, VideoEdit, getRedirectionUrl, makeRawRequest, waitJobs } from '@peertube/peertube-server-commands'
 import {
@@ -139,7 +148,7 @@ export async function completeVideoCheck (options: {
     licence: number
     language: string
     nsfw: boolean
-    commentsEnabled: boolean
+    commentsPolicy: VideoCommentPolicyType
     downloadEnabled: boolean
     description: string
     support: string
@@ -214,7 +223,8 @@ export async function completeVideoCheck (options: {
   expect(video.url).to.contain(originServer.host)
   expect(video.tags).to.deep.equal(attributes.tags)
 
-  expect(video.commentsEnabled).to.equal(attributes.commentsEnabled)
+  expect(video.commentsEnabled).to.equal(attributes.commentsPolicy !== VideoCommentPolicy.DISABLED)
+  expect(video.commentsPolicy.id).to.equal(attributes.commentsPolicy)
   expect(video.downloadEnabled).to.equal(attributes.downloadEnabled)
 
   expect(dateIsValid(video.createdAt)).to.be.true
diff --git a/server/core/assets/email-templates/user-import-completed/html.pug b/server/core/assets/email-templates/user-import-completed/html.pug
index 5805cd5d4..ddd6f023d 100644
--- a/server/core/assets/email-templates/user-import-completed/html.pug
+++ b/server/core/assets/email-templates/user-import-completed/html.pug
@@ -47,3 +47,9 @@ block content
     li
       strong Video history:
       +displaySummary(resultStats.userVideoHistory)
+    li
+      strong Watched Words Lists:
+      +displaySummary(resultStats.watchedWordsLists)
+    li
+      strong Comment auto tag policies:
+      +displaySummary(resultStats.commentAutoTagPolicies)
diff --git a/server/core/assets/email-templates/video-comment-new/html.pug b/server/core/assets/email-templates/video-comment-new/html.pug
index cbb683fee..a11787026 100644
--- a/server/core/assets/email-templates/video-comment-new/html.pug
+++ b/server/core/assets/email-templates/video-comment-new/html.pug
@@ -7,5 +7,10 @@ block content
   p.
     #[a(href=accountUrl title=handle) #{accountName}] added a comment on your video
     "#[a(href=videoUrl) #{video.name}]":
+
   blockquote !{commentHtml}
+
+  if requiresApproval
+    | This comment requires approval.
+
   br(style="display: none;")
diff --git a/server/core/controllers/activitypub/client.ts b/server/core/controllers/activitypub/client.ts
index c725f05e3..eb8770074 100644
--- a/server/core/controllers/activitypub/client.ts
+++ b/server/core/controllers/activitypub/client.ts
@@ -1,6 +1,5 @@
-import cors from 'cors'
-import express from 'express'
 import {
+  HttpStatusCode,
   VideoChaptersObject,
   VideoCommentObject,
   VideoPlaylistPrivacy,
@@ -9,12 +8,17 @@ import {
 } from '@peertube/peertube-models'
 import { activityPubCollectionPagination } from '@server/lib/activitypub/collection.js'
 import { getContextFilter } from '@server/lib/activitypub/context.js'
+import { buildChaptersAPHasPart } from '@server/lib/activitypub/video-chapters.js'
+import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
 import { getServerActor } from '@server/models/application/application.js'
+import { VideoChapterModel } from '@server/models/video/video-chapter.js'
 import { MAccountId, MActorId, MChannelId, MVideoId } from '@server/types/models/index.js'
+import cors from 'cors'
+import express from 'express'
 import { activityPubContextify } from '../../helpers/activity-pub-utils.js'
 import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants.js'
 import { audiencify, getAudience } from '../../lib/activitypub/audience.js'
-import { buildAnnounceWithVideoAudience, buildLikeActivity } from '../../lib/activitypub/send/index.js'
+import { buildAnnounceWithVideoAudience, buildApprovalActivity, buildLikeActivity } from '../../lib/activitypub/send/index.js'
 import { buildCreateActivity } from '../../lib/activitypub/send/send-create.js'
 import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike.js'
 import {
@@ -54,9 +58,6 @@ import { VideoCommentModel } from '../../models/video/video-comment.js'
 import { VideoPlaylistModel } from '../../models/video/video-playlist.js'
 import { VideoShareModel } from '../../models/video/video-share.js'
 import { activityPubResponse } from './utils.js'
-import { VideoChapterModel } from '@server/models/video/video-chapter.js'
-import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
-import { buildChaptersAPHasPart } from '@server/lib/activitypub/video-chapters.js'
 
 const activityPubClientRouter = express.Router()
 activityPubClientRouter.use(cors())
@@ -141,19 +142,28 @@ activityPubClientRouter.get('/videos/watch/:id/dislikes',
   asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
   asyncMiddleware(videoDislikesController)
 )
+
+// ---------------------------------------------------------------------------
+
 activityPubClientRouter.get('/videos/watch/:id/comments',
   executeIfActivityPub,
   activityPubRateLimiter,
   asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
   asyncMiddleware(videoCommentsController)
 )
-activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId',
+activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId/approve-reply',
+  executeIfActivityPub,
+  activityPubRateLimiter,
+  asyncMiddleware(videoCommentGetValidator),
+  asyncMiddleware(videoCommentApprovedController)
+)
+activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId/activity',
   executeIfActivityPub,
   activityPubRateLimiter,
   asyncMiddleware(videoCommentGetValidator),
   asyncMiddleware(videoCommentController)
 )
-activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId/activity',
+activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId',
   executeIfActivityPub,
   activityPubRateLimiter,
   asyncMiddleware(videoCommentGetValidator),
@@ -408,8 +418,10 @@ async function videoCommentController (req: express.Request, res: express.Respon
   const videoComment = res.locals.videoCommentFull
 
   if (redirectIfNotOwned(videoComment.url, res)) return
+  if (videoComment.Video.isOwned() && videoComment.heldForReview === true) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
+
+  const threadParentComments = await VideoCommentModel.listThreadParentComments({ comment: videoComment })
 
-  const threadParentComments = await VideoCommentModel.listThreadParentComments(videoComment, undefined)
   const isPublic = true // Comments are always public
   let videoCommentObject = videoComment.toActivityPubObject(threadParentComments)
 
@@ -426,6 +438,16 @@ async function videoCommentController (req: express.Request, res: express.Respon
   return activityPubResponse(activityPubContextify(videoCommentObject, 'Comment', getContextFilter()), res)
 }
 
+async function videoCommentApprovedController (req: express.Request, res: express.Response) {
+  const comment = res.locals.videoCommentFull
+
+  if (!comment.Video.isOwned() || comment.heldForReview === true) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
+
+  const activity = buildApprovalActivity({ comment, type: 'ApproveReply' })
+
+  return activityPubResponse(activityPubContextify(activity, 'ApproveReply', getContextFilter()), res)
+}
+
 async function videoChaptersController (req: express.Request, res: express.Response) {
   const video = res.locals.onlyVideo
 
diff --git a/server/core/controllers/api/automatic-tags.ts b/server/core/controllers/api/automatic-tags.ts
new file mode 100644
index 000000000..e107e3101
--- /dev/null
+++ b/server/core/controllers/api/automatic-tags.ts
@@ -0,0 +1,82 @@
+import { AutomaticTagPolicy, CommentAutomaticTagPoliciesUpdate, HttpStatusCode, UserRight } from '@peertube/peertube-models'
+import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js'
+import { setAccountAutomaticTagsPolicy } from '@server/lib/automatic-tags/automatic-tags.js'
+import {
+  manageAccountAutomaticTagsValidator,
+  updateAutomaticTagPoliciesValidator
+} from '@server/middlewares/validators/automatic-tags.js'
+import { getServerActor } from '@server/models/application/application.js'
+import express from 'express'
+import {
+  apiRateLimiter,
+  asyncMiddleware,
+  authenticate,
+  ensureUserHasRight
+} from '../../middlewares/index.js'
+
+const automaticTagRouter = express.Router()
+
+automaticTagRouter.use(apiRateLimiter)
+
+automaticTagRouter.get('/policies/accounts/:accountName/comments',
+  authenticate,
+  asyncMiddleware(manageAccountAutomaticTagsValidator),
+  asyncMiddleware(getAutomaticTagPolicies)
+)
+
+automaticTagRouter.put('/policies/accounts/:accountName/comments',
+  authenticate,
+  asyncMiddleware(manageAccountAutomaticTagsValidator),
+  asyncMiddleware(updateAutomaticTagPoliciesValidator),
+  asyncMiddleware(updateAutomaticTagPolicies)
+)
+
+// ---------------------------------------------------------------------------
+
+automaticTagRouter.get('/accounts/:accountName/available',
+  authenticate,
+  asyncMiddleware(manageAccountAutomaticTagsValidator),
+  asyncMiddleware(getAccountAutomaticTagAvailable)
+)
+
+automaticTagRouter.get('/server/available',
+  authenticate,
+  ensureUserHasRight(UserRight.MANAGE_INSTANCE_AUTO_TAGS),
+  asyncMiddleware(getServerAutomaticTagAvailable)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+  automaticTagRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function getAutomaticTagPolicies (req: express.Request, res: express.Response) {
+  const result = await AutomaticTagger.getAutomaticTagPolicies(res.locals.account)
+
+  return res.json(result)
+}
+
+async function updateAutomaticTagPolicies (req: express.Request, res: express.Response) {
+  await setAccountAutomaticTagsPolicy({
+    account: res.locals.account,
+    policy: AutomaticTagPolicy.REVIEW_COMMENT,
+    tags: (req.body as CommentAutomaticTagPoliciesUpdate).review
+  })
+
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
+
+async function getAccountAutomaticTagAvailable (req: express.Request, res: express.Response) {
+  const result = await AutomaticTagger.getAutomaticTagAvailable(res.locals.account)
+
+  return res.json(result)
+}
+
+async function getServerAutomaticTagAvailable (req: express.Request, res: express.Response) {
+  const result = await AutomaticTagger.getAutomaticTagAvailable((await getServerActor()).Account)
+
+  return res.json(result)
+}
diff --git a/server/core/controllers/api/index.ts b/server/core/controllers/api/index.ts
index b48a6645e..d5034c1de 100644
--- a/server/core/controllers/api/index.ts
+++ b/server/core/controllers/api/index.ts
@@ -1,9 +1,10 @@
+import { HttpStatusCode } from '@peertube/peertube-models'
+import { logger } from '@server/helpers/logger.js'
 import cors from 'cors'
 import express from 'express'
-import { logger } from '@server/helpers/logger.js'
-import { HttpStatusCode } from '@peertube/peertube-models'
 import { abuseRouter } from './abuse.js'
 import { accountsRouter } from './accounts.js'
+import { automaticTagRouter } from './automatic-tags.js'
 import { blocklistRouter } from './blocklist.js'
 import { bulkRouter } from './bulk.js'
 import { configRouter } from './config.js'
@@ -21,6 +22,7 @@ import { videoChannelSyncRouter } from './video-channel-sync.js'
 import { videoChannelRouter } from './video-channel.js'
 import { videoPlaylistRouter } from './video-playlist.js'
 import { videosRouter } from './videos/index.js'
+import { watchedWordsRouter } from './watched-words.js'
 
 const apiRouter = express.Router()
 
@@ -49,6 +51,8 @@ apiRouter.use('/plugins', pluginRouter)
 apiRouter.use('/custom-pages', customPageRouter)
 apiRouter.use('/blocklist', blocklistRouter)
 apiRouter.use('/runners', runnersRouter)
+apiRouter.use('/watched-words', watchedWordsRouter)
+apiRouter.use('/automatic-tags', automaticTagRouter)
 
 apiRouter.use('/ping', pong)
 apiRouter.use('/*', badRequest)
diff --git a/server/core/controllers/api/users/me.ts b/server/core/controllers/api/users/me.ts
index 8f85788c1..1dadb6e43 100644
--- a/server/core/controllers/api/users/me.ts
+++ b/server/core/controllers/api/users/me.ts
@@ -1,16 +1,17 @@
-import 'multer'
-import express from 'express'
 import { pick } from '@peertube/peertube-core-utils'
 import {
   ActorImageType,
+  UserVideoRate as FormattedUserVideoRate,
   HttpStatusCode,
   UserUpdateMe,
-  UserVideoQuota,
-  UserVideoRate as FormattedUserVideoRate
+  UserVideoQuota
 } from '@peertube/peertube-models'
-import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '@server/helpers/audit-logger.js'
-import { Hooks } from '@server/lib/plugins/hooks.js'
 import { AttributesOnly } from '@peertube/peertube-typescript-utils'
+import { UserAuditView, auditLoggerFactory, getAuditIdFromRes } from '@server/helpers/audit-logger.js'
+import { Hooks } from '@server/lib/plugins/hooks.js'
+import { VideoCommentModel } from '@server/models/video/video-comment.js'
+import express from 'express'
+import 'multer'
 import { createReqFiles } from '../../../helpers/express-utils.js'
 import { getFormattedObjects } from '../../../helpers/utils.js'
 import { CONFIG } from '../../../initializers/config.js'
@@ -34,6 +35,7 @@ import { updateAvatarValidator } from '../../../middlewares/validators/actor-ima
 import {
   deleteMeValidator,
   getMyVideoImportsValidator,
+  listCommentsOnUserVideosValidator,
   usersVideosValidator,
   videoImportsSortValidator,
   videosSortValidator
@@ -75,6 +77,16 @@ meRouter.get('/me/videos/imports',
   asyncMiddleware(getUserVideoImports)
 )
 
+meRouter.get('/me/videos/comments',
+  authenticate,
+  paginationValidator,
+  videosSortValidator,
+  setDefaultVideosSort,
+  setDefaultPagination,
+  asyncMiddleware(listCommentsOnUserVideosValidator),
+  asyncMiddleware(listCommentsOnUserVideos)
+)
+
 meRouter.get('/me/videos',
   authenticate,
   paginationValidator,
@@ -82,7 +94,7 @@ meRouter.get('/me/videos',
   setDefaultVideosSort,
   setDefaultPagination,
   asyncMiddleware(usersVideosValidator),
-  asyncMiddleware(getUserVideos)
+  asyncMiddleware(listUserVideos)
 )
 
 meRouter.get('/me/videos/:videoId/rating',
@@ -117,7 +129,7 @@ export {
 
 // ---------------------------------------------------------------------------
 
-async function getUserVideos (req: express.Request, res: express.Response) {
+async function listUserVideos (req: express.Request, res: express.Response) {
   const user = res.locals.oauth.token.User
 
   const apiOptions = await Hooks.wrapObject({
@@ -145,6 +157,36 @@ async function getUserVideos (req: express.Request, res: express.Response) {
   return res.json(getFormattedObjects(resultList.data, resultList.total, { additionalAttributes }))
 }
 
+async function listCommentsOnUserVideos (req: express.Request, res: express.Response) {
+  const userAccount = res.locals.oauth.token.User.Account
+
+  const options = {
+    ...pick(req.query, [
+      'start',
+      'count',
+      'sort',
+      'search',
+      'searchAccount',
+      'searchVideo',
+      'autoTagOneOf'
+    ]),
+
+    autoTagOfAccountId: userAccount.id,
+    videoAccountOwnerId: userAccount.id,
+    heldForReview: req.query.isHeldForReview,
+
+    videoChannelOwnerId: res.locals.videoChannel?.id,
+    videoId: res.locals.videoAll?.id
+  }
+
+  const resultList = await VideoCommentModel.listCommentsForApi(options)
+
+  return res.json({
+    total: resultList.total,
+    data: resultList.data.map(c => c.toFormattedForAdminOrUserJSON())
+  })
+}
+
 async function getUserVideoImports (req: express.Request, res: express.Response) {
   const user = res.locals.oauth.token.User
   const resultList = await VideoImportModel.listUserVideoImportsForApi({
diff --git a/server/core/controllers/api/videos/comment.ts b/server/core/controllers/api/videos/comment.ts
index e7fe6ca43..8fa731db9 100644
--- a/server/core/controllers/api/videos/comment.ts
+++ b/server/core/controllers/api/videos/comment.ts
@@ -1,19 +1,21 @@
-import express from 'express'
+import { pick } from '@peertube/peertube-core-utils'
 import {
   HttpStatusCode,
   ResultList,
   ThreadsResultList,
   UserRight,
   VideoCommentCreate,
+  VideoCommentPolicy,
   VideoCommentThreads
 } from '@peertube/peertube-models'
+import { getServerActor } from '@server/models/application/application.js'
 import { MCommentFormattable } from '@server/types/models/index.js'
-import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger.js'
+import express from 'express'
+import { CommentAuditView, auditLoggerFactory, getAuditIdFromRes } from '../../../helpers/audit-logger.js'
 import { getFormattedObjects } from '../../../helpers/utils.js'
-import { sequelizeTypescript } from '../../../initializers/database.js'
 import { Notifier } from '../../../lib/notifier/index.js'
 import { Hooks } from '../../../lib/plugins/hooks.js'
-import { buildFormattedCommentTree, createVideoComment, removeComment } from '../../../lib/video-comment.js'
+import { approveComment, buildFormattedCommentTree, createLocalVideoComment, removeComment } from '../../../lib/video-comment.js'
 import {
   asyncMiddleware,
   asyncRetryTransactionMiddleware,
@@ -27,14 +29,14 @@ import {
 import {
   addVideoCommentReplyValidator,
   addVideoCommentThreadValidator,
-  listVideoCommentsValidator,
+  approveVideoCommentValidator,
+  listAllVideoCommentsForAdminValidator,
   listVideoCommentThreadsValidator,
   listVideoThreadCommentsValidator,
   removeVideoCommentValidator,
-  videoCommentsValidator,
-  videoCommentThreadsSortValidator
+  videoCommentThreadsSortValidator,
+  videoCommentsValidator
 } from '../../../middlewares/validators/index.js'
-import { AccountModel } from '../../../models/account/account.js'
 import { VideoCommentModel } from '../../../models/video/video-comment.js'
 
 const auditLogger = auditLoggerFactory('comments')
@@ -71,6 +73,12 @@ videoCommentRouter.delete('/:videoId/comments/:commentId',
   asyncRetryTransactionMiddleware(removeVideoComment)
 )
 
+videoCommentRouter.post('/:videoId/comments/:commentId/approve',
+  authenticate,
+  asyncMiddleware(approveVideoCommentValidator),
+  asyncMiddleware(approveVideoComment)
+)
+
 videoCommentRouter.get('/comments',
   authenticate,
   ensureUserHasRight(UserRight.SEE_ALL_COMMENTS),
@@ -78,7 +86,7 @@ videoCommentRouter.get('/comments',
   videoCommentsValidator,
   setDefaultSort,
   setDefaultPagination,
-  listVideoCommentsValidator,
+  asyncMiddleware(listAllVideoCommentsForAdminValidator),
   asyncMiddleware(listComments)
 )
 
@@ -92,22 +100,29 @@ export {
 
 async function listComments (req: express.Request, res: express.Response) {
   const options = {
-    start: req.query.start,
-    count: req.query.count,
-    sort: req.query.sort,
+    ...pick(req.query, [
+      'start',
+      'count',
+      'sort',
+      'isLocal',
+      'onLocalVideo',
+      'search',
+      'searchAccount',
+      'searchVideo',
+      'autoTagOneOf'
+    ]),
 
-    isLocal: req.query.isLocal,
-    onLocalVideo: req.query.onLocalVideo,
-    search: req.query.search,
-    searchAccount: req.query.searchAccount,
-    searchVideo: req.query.searchVideo
+    videoId: res.locals.onlyImmutableVideo?.id,
+    videoChannelOwnerId: res.locals.videoChannel?.id,
+    autoTagOfAccountId: (await getServerActor()).Account.id,
+    heldForReview: undefined
   }
 
   const resultList = await VideoCommentModel.listCommentsForApi(options)
 
   return res.json({
     total: resultList.total,
-    data: resultList.data.map(c => c.toFormattedAdminJSON())
+    data: resultList.data.map(c => c.toFormattedForAdminOrUserJSON())
   })
 }
 
@@ -117,10 +132,9 @@ async function listVideoThreads (req: express.Request, res: express.Response) {
 
   let resultList: ThreadsResultList<MCommentFormattable>
 
-  if (video.commentsEnabled === true) {
+  if (video.commentsPolicy !== VideoCommentPolicy.DISABLED) {
     const apiOptions = await Hooks.wrapObject({
-      videoId: video.id,
-      isVideoOwned: video.isOwned(),
+      video,
       start: req.query.start,
       count: req.query.count,
       sort: req.query.sort,
@@ -152,9 +166,9 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo
 
   let resultList: ResultList<MCommentFormattable>
 
-  if (video.commentsEnabled === true) {
+  if (video.commentsPolicy !== VideoCommentPolicy.DISABLED) {
     const apiOptions = await Hooks.wrapObject({
-      videoId: video.id,
+      video,
       threadId: res.locals.videoCommentThread.id,
       user
     }, 'filter:api.video-thread-comments.list.params')
@@ -184,15 +198,11 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo
 async function addVideoCommentThread (req: express.Request, res: express.Response) {
   const videoCommentInfo: VideoCommentCreate = req.body
 
-  const comment = await sequelizeTypescript.transaction(async t => {
-    const account = await AccountModel.load(res.locals.oauth.token.User.Account.id, t)
-
-    return createVideoComment({
-      text: videoCommentInfo.text,
-      inReplyToComment: null,
-      video: res.locals.videoAll,
-      account
-    }, t)
+  const comment = await createLocalVideoComment({
+    text: videoCommentInfo.text,
+    inReplyToComment: null,
+    video: res.locals.videoAll,
+    user: res.locals.oauth.token.User
   })
 
   Notifier.Instance.notifyOnNewComment(comment)
@@ -206,15 +216,11 @@ async function addVideoCommentThread (req: express.Request, res: express.Respons
 async function addVideoCommentReply (req: express.Request, res: express.Response) {
   const videoCommentInfo: VideoCommentCreate = req.body
 
-  const comment = await sequelizeTypescript.transaction(async t => {
-    const account = await AccountModel.load(res.locals.oauth.token.User.Account.id, t)
-
-    return createVideoComment({
-      text: videoCommentInfo.text,
-      inReplyToComment: res.locals.videoCommentFull,
-      video: res.locals.videoAll,
-      account
-    }, t)
+  const comment = await createLocalVideoComment({
+    text: videoCommentInfo.text,
+    inReplyToComment: res.locals.videoCommentFull,
+    video: res.locals.videoAll,
+    user: res.locals.oauth.token.User
   })
 
   Notifier.Instance.notifyOnNewComment(comment)
@@ -226,13 +232,17 @@ async function addVideoCommentReply (req: express.Request, res: express.Response
 }
 
 async function removeVideoComment (req: express.Request, res: express.Response) {
-  const videoCommentInstance = res.locals.videoCommentFull
+  const comment = res.locals.videoCommentFull
 
-  await removeComment(videoCommentInstance, req, res)
+  await removeComment(comment, req, res)
 
-  auditLogger.delete(getAuditIdFromRes(res), new CommentAuditView(videoCommentInstance.toFormattedJSON()))
+  auditLogger.delete(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
 
-  return res.type('json')
-            .status(HttpStatusCode.NO_CONTENT_204)
-            .end()
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
+
+async function approveVideoComment (req: express.Request, res: express.Response) {
+  await approveComment(res.locals.videoCommentFull)
+
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
 }
diff --git a/server/core/controllers/api/videos/update.ts b/server/core/controllers/api/videos/update.ts
index 965dd829a..23c53f7ad 100644
--- a/server/core/controllers/api/videos/update.ts
+++ b/server/core/controllers/api/videos/update.ts
@@ -1,8 +1,10 @@
 import { forceNumber } from '@peertube/peertube-core-utils'
-import { HttpStatusCode, ThumbnailType, VideoPrivacy, VideoPrivacyType, VideoUpdate } from '@peertube/peertube-models'
+import { HttpStatusCode, ThumbnailType, VideoCommentPolicy, VideoPrivacy, VideoPrivacyType, VideoUpdate } from '@peertube/peertube-models'
 import { exists } from '@server/helpers/custom-validators/misc.js'
 import { changeVideoChannelShare } from '@server/lib/activitypub/share.js'
 import { isNewVideoPrivacyForFederation, isPrivacyForFederation } from '@server/lib/activitypub/videos/federate.js'
+import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js'
+import { setAndSaveVideoAutomaticTags } from '@server/lib/automatic-tags/automatic-tags.js'
 import { updateLocalVideoMiniatureFromExisting } from '@server/lib/thumbnail.js'
 import { replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js'
 import { addVideoJobsAfterUpdate } from '@server/lib/video-jobs.js'
@@ -65,6 +67,7 @@ async function updateVideo (req: express.Request, res: express.Response) {
       // Refresh video since thumbnails to prevent concurrent updates
       const video = await VideoModel.loadFull(videoFromReq.id, t)
 
+      const oldName = video.name
       const oldDescription = video.description
       const oldVideoChannel = video.VideoChannel
 
@@ -77,7 +80,6 @@ async function updateVideo (req: express.Request, res: express.Response) {
         'waitTranscoding',
         'support',
         'description',
-        'commentsEnabled',
         'downloadEnabled'
       ]
 
@@ -85,6 +87,15 @@ async function updateVideo (req: express.Request, res: express.Response) {
         if (videoInfoToUpdate[key] !== undefined) video.set(key, videoInfoToUpdate[key])
       }
 
+      // Special treatment for comments policy to support deprecated commentsEnabled attribute
+      if (videoInfoToUpdate.commentsPolicy !== undefined) {
+        video.commentsPolicy = videoInfoToUpdate.commentsPolicy
+      } else if (videoInfoToUpdate.commentsEnabled === true) {
+        video.commentsPolicy = VideoCommentPolicy.ENABLED
+      } else if (videoInfoToUpdate.commentsEnabled === false) {
+        video.commentsPolicy = VideoCommentPolicy.DISABLED
+      }
+
       if (videoInfoToUpdate.originallyPublishedAt !== undefined) {
         video.originallyPublishedAt = videoInfoToUpdate.originallyPublishedAt
           ? new Date(videoInfoToUpdate.originallyPublishedAt)
@@ -142,6 +153,11 @@ async function updateVideo (req: express.Request, res: express.Response) {
         })
       }
 
+      if (oldName !== video.name || oldDescription !== video.description) {
+        const automaticTags = await new AutomaticTagger().buildVideoAutomaticTags({ video, transaction: t })
+        await setAndSaveVideoAutomaticTags({ video, automaticTags, transaction: t })
+      }
+
       await autoBlacklistVideoIfNeeded({
         video: videoInstanceUpdated,
         user: res.locals.oauth.token.User,
diff --git a/server/core/controllers/api/watched-words.ts b/server/core/controllers/api/watched-words.ts
new file mode 100644
index 000000000..800257dad
--- /dev/null
+++ b/server/core/controllers/api/watched-words.ts
@@ -0,0 +1,162 @@
+import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
+import { Awaitable } from '@peertube/peertube-typescript-utils'
+import {
+  addWatchedWordsListValidatorFactory,
+  getWatchedWordsListValidatorFactory,
+  manageAccountWatchedWordsListValidator,
+  updateWatchedWordsListValidatorFactory
+} from '@server/middlewares/validators/watched-words.js'
+import { getServerActor } from '@server/models/application/application.js'
+import { WatchedWordsListModel } from '@server/models/watched-words/watched-words-list.js'
+import { MAccountId } from '@server/types/models/index.js'
+import express from 'express'
+import { getFormattedObjects } from '../../helpers/utils.js'
+import {
+  apiRateLimiter,
+  asyncMiddleware,
+  authenticate, ensureUserHasRight, paginationValidator,
+  setDefaultPagination,
+  setDefaultSort,
+  watchedWordsListsSortValidator
+} from '../../middlewares/index.js'
+
+const watchedWordsRouter = express.Router()
+
+watchedWordsRouter.use(apiRateLimiter)
+
+{
+  const common = [
+    authenticate,
+    paginationValidator,
+    watchedWordsListsSortValidator,
+    setDefaultSort,
+    setDefaultPagination
+  ]
+
+  watchedWordsRouter.get('/accounts/:accountName/lists',
+    ...common,
+
+    asyncMiddleware(manageAccountWatchedWordsListValidator),
+    asyncMiddleware(listWatchedWordsListsFactory(res => res.locals.account))
+  )
+
+  watchedWordsRouter.get('/server/lists',
+    ...common,
+
+    ensureUserHasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS),
+    asyncMiddleware(listWatchedWordsListsFactory(() => getServerActor().then(a => a.Account)))
+  )
+}
+
+// ---------------------------------------------------------------------------
+
+{
+  watchedWordsRouter.post('/accounts/:accountName/lists',
+    authenticate,
+    asyncMiddleware(manageAccountWatchedWordsListValidator),
+    asyncMiddleware(addWatchedWordsListValidatorFactory(res => res.locals.account)),
+    asyncMiddleware(addWatchedWordsListFactory(res => res.locals.account))
+  )
+
+  watchedWordsRouter.post('/server/lists',
+    authenticate,
+    ensureUserHasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS),
+    asyncMiddleware(addWatchedWordsListValidatorFactory(() => getServerActor().then(a => a.Account))),
+    asyncMiddleware(addWatchedWordsListFactory(() => getServerActor().then(a => a.Account)))
+  )
+}
+
+// ---------------------------------------------------------------------------
+
+{
+  watchedWordsRouter.put('/accounts/:accountName/lists/:listId',
+    authenticate,
+    asyncMiddleware(manageAccountWatchedWordsListValidator),
+    asyncMiddleware(getWatchedWordsListValidatorFactory(res => res.locals.account)),
+    asyncMiddleware(updateWatchedWordsListValidatorFactory(res => res.locals.account)),
+    asyncMiddleware(updateWatchedWordsList)
+  )
+
+  watchedWordsRouter.put('/server/lists/:listId',
+    authenticate,
+    ensureUserHasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS),
+    asyncMiddleware(getWatchedWordsListValidatorFactory(() => getServerActor().then(a => a.Account))),
+    asyncMiddleware(updateWatchedWordsListValidatorFactory(() => getServerActor().then(a => a.Account))),
+    asyncMiddleware(updateWatchedWordsList)
+  )
+}
+
+// ---------------------------------------------------------------------------
+
+{
+  watchedWordsRouter.delete('/accounts/:accountName/lists/:listId',
+    authenticate,
+    asyncMiddleware(manageAccountWatchedWordsListValidator),
+    asyncMiddleware(getWatchedWordsListValidatorFactory(res => res.locals.account)),
+    asyncMiddleware(deleteWatchedWordsList)
+  )
+
+  watchedWordsRouter.delete('/server/lists/:listId',
+    authenticate,
+    ensureUserHasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS),
+    asyncMiddleware(getWatchedWordsListValidatorFactory(() => getServerActor().then(a => a.Account))),
+    asyncMiddleware(deleteWatchedWordsList)
+  )
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  watchedWordsRouter
+}
+
+// ---------------------------------------------------------------------------
+
+function listWatchedWordsListsFactory (accountGetter: (res: express.Response) => Awaitable<MAccountId>) {
+  return async (req: express.Request, res: express.Response) => {
+    const resultList = await WatchedWordsListModel.listForAPI({
+      accountId: (await accountGetter(res)).id,
+      start: req.query.start,
+      count: req.query.count,
+      sort: req.query.sort
+    })
+
+    return res.json(getFormattedObjects(resultList.data, resultList.total))
+  }
+}
+
+function addWatchedWordsListFactory (accountGetter: (res: express.Response) => Awaitable<MAccountId>) {
+  return async (req: express.Request, res: express.Response) => {
+    const list = await WatchedWordsListModel.createList({
+      accountId: (await accountGetter(res)).id,
+
+      listName: req.body.listName,
+      words: req.body.words
+    })
+
+    return res.json({
+      watchedWordsList: {
+        id: list.id
+      }
+    })
+  }
+}
+
+async function updateWatchedWordsList (req: express.Request, res: express.Response) {
+  const list = res.locals.watchedWordsList
+
+  await list.updateList({
+    listName: req.body.listName,
+    words: req.body.words
+  })
+
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
+
+async function deleteWatchedWordsList (req: express.Request, res: express.Response) {
+  const list = res.locals.watchedWordsList
+
+  await list.destroy()
+
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
diff --git a/server/core/controllers/feeds/comment-feeds.ts b/server/core/controllers/feeds/comment-feeds.ts
index 4178f198e..105ae27ac 100644
--- a/server/core/controllers/feeds/comment-feeds.ts
+++ b/server/core/controllers/feeds/comment-feeds.ts
@@ -1,14 +1,14 @@
-import express from 'express'
 import { toSafeHtml } from '@server/helpers/markdown.js'
 import { cacheRouteFactory } from '@server/middlewares/index.js'
+import express from 'express'
 import { CONFIG } from '../../initializers/config.js'
 import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants.js'
 import {
   asyncMiddleware,
+  feedsAccountOrChannelFiltersValidator,
   feedsFormatValidator,
   setFeedFormatContentType,
-  videoCommentsFeedsValidator,
-  feedsAccountOrChannelFiltersValidator
+  videoCommentsFeedsValidator
 } from '../../middlewares/index.js'
 import { VideoCommentModel } from '../../models/video/video-comment.js'
 import { buildFeedMetadata, initFeed, sendFeed } from './shared/index.js'
@@ -49,9 +49,9 @@ async function generateVideoCommentsFeed (req: express.Request, res: express.Res
   const comments = await VideoCommentModel.listForFeed({
     start,
     count: CONFIG.FEEDS.COMMENTS.COUNT,
-    videoId: video ? video.id : undefined,
-    accountId: account ? account.id : undefined,
-    videoChannelId: videoChannel ? videoChannel.id : undefined
+    videoId: video?.id,
+    videoAccountOwnerId: account?.id,
+    videoChannelOwnerId: videoChannel?.id
   })
 
   const { name, description, imageUrl, link } = await buildFeedMetadata({ video, account, videoChannel })
diff --git a/server/core/helpers/activity-pub-utils.ts b/server/core/helpers/activity-pub-utils.ts
index 9ff88370d..525793795 100644
--- a/server/core/helpers/activity-pub-utils.ts
+++ b/server/core/helpers/activity-pub-utils.ts
@@ -49,7 +49,7 @@ export async function getApplicationActorOfHost (host: string) {
   return found?.href || undefined
 }
 
-export function getAPPublicValue () {
+export function getAPPublicValue (): 'https://www.w3.org/ns/activitystreams#Public' {
   return 'https://www.w3.org/ns/activitystreams#Public'
 }
 
@@ -137,10 +137,19 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
       '@type': 'sc:Number',
       '@id': 'pt:fps'
     },
+
+    // Keep for federation compatibility
     commentsEnabled: {
       '@type': 'sc:Boolean',
       '@id': 'pt:commentsEnabled'
     },
+
+    canReply: 'pt:canReply',
+    commentsPolicy: {
+      '@type': 'sc:Number',
+      '@id': 'pt:commentsPolicy'
+    },
+
     downloadEnabled: {
       '@type': 'sc:Boolean',
       '@id': 'pt:downloadEnabled'
@@ -261,10 +270,21 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
   Reject: buildContext(),
   Accept: buildContext(),
   Announce: buildContext(),
-  Comment: buildContext(),
+
+  Comment: buildContext({
+    replyApproval: 'pt:replyApproval'
+  }),
+
   Delete: buildContext(),
   Rate: buildContext(),
 
+  ApproveReply: buildContext({
+    ApproveReply: 'pt:ApproveReply'
+  }),
+  RejectReply: buildContext({
+    RejectReply: 'pt:RejectReply'
+  }),
+
   Chapters: buildContext({
     hasPart: 'sc:hasPart',
     endOffset: 'sc:endOffset',
diff --git a/server/core/helpers/audit-logger.ts b/server/core/helpers/audit-logger.ts
index 5958acbf5..c4b95faae 100644
--- a/server/core/helpers/audit-logger.ts
+++ b/server/core/helpers/audit-logger.ts
@@ -127,7 +127,7 @@ const videoKeysToKeep = new Set([
   'channel-uuid',
   'channel-name',
   'support',
-  'commentsEnabled',
+  'commentsPolicy',
   'downloadEnabled'
 ])
 class VideoAuditView extends EntityAuditView {
diff --git a/server/core/helpers/custom-validators/activitypub/activity.ts b/server/core/helpers/custom-validators/activitypub/activity.ts
index 05399ebdf..83cb06062 100644
--- a/server/core/helpers/custom-validators/activitypub/activity.ts
+++ b/server/core/helpers/custom-validators/activitypub/activity.ts
@@ -10,7 +10,7 @@ import { sanitizeAndCheckVideoCommentObject } from './video-comments.js'
 import { sanitizeAndCheckVideoTorrentObject } from './videos.js'
 import { isWatchActionObjectValid } from './watch-action.js'
 
-function isRootActivityValid (activity: any) {
+export function isRootActivityValid (activity: any) {
   return isCollection(activity) || isActivity(activity)
 }
 
@@ -26,6 +26,8 @@ function isActivity (activity: any) {
     (isActivityPubUrlValid(activity.actor) || isActivityPubUrlValid(activity.actor.id))
 }
 
+// ---------------------------------------------------------------------------
+
 const activityCheckers: { [ P in ActivityType ]: (activity: Activity) => boolean } = {
   Create: isCreateActivityValid,
   Update: isUpdateActivityValid,
@@ -38,10 +40,12 @@ const activityCheckers: { [ P in ActivityType ]: (activity: Activity) => boolean
   Like: isLikeActivityValid,
   View: isViewActivityValid,
   Flag: isFlagActivityValid,
-  Dislike: isDislikeActivityValid
+  Dislike: isDislikeActivityValid,
+  ApproveReply: isApproveReplyActivityValid,
+  RejectReply: isRejectReplyActivityValid
 }
 
-function isActivityValid (activity: any) {
+export function isActivityValid (activity: any) {
   const checker = activityCheckers[activity.type]
   // Unknown activity type
   if (!checker) return false
@@ -49,34 +53,34 @@ function isActivityValid (activity: any) {
   return checker(activity)
 }
 
-function isFlagActivityValid (activity: any) {
+export function isFlagActivityValid (activity: any) {
   return isBaseActivityValid(activity, 'Flag') &&
     isAbuseReasonValid(activity.content) &&
     isActivityPubUrlValid(activity.object)
 }
 
-function isLikeActivityValid (activity: any) {
+export function isLikeActivityValid (activity: any) {
   return isBaseActivityValid(activity, 'Like') &&
     isObjectValid(activity.object)
 }
 
-function isDislikeActivityValid (activity: any) {
+export function isDislikeActivityValid (activity: any) {
   return isBaseActivityValid(activity, 'Dislike') &&
     isObjectValid(activity.object)
 }
 
-function isAnnounceActivityValid (activity: any) {
+export function isAnnounceActivityValid (activity: any) {
   return isBaseActivityValid(activity, 'Announce') &&
     isObjectValid(activity.object)
 }
 
-function isViewActivityValid (activity: any) {
+export function isViewActivityValid (activity: any) {
   return isBaseActivityValid(activity, 'View') &&
     isActivityPubUrlValid(activity.actor) &&
     isActivityPubUrlValid(activity.object)
 }
 
-function isCreateActivityValid (activity: any) {
+export function isCreateActivityValid (activity: any) {
   return isBaseActivityValid(activity, 'Create') &&
     (
       isViewActivityValid(activity.object) ||
@@ -91,7 +95,7 @@ function isCreateActivityValid (activity: any) {
     )
 }
 
-function isUpdateActivityValid (activity: any) {
+export function isUpdateActivityValid (activity: any) {
   return isBaseActivityValid(activity, 'Update') &&
     (
       isCacheFileObjectValid(activity.object) ||
@@ -101,26 +105,26 @@ function isUpdateActivityValid (activity: any) {
     )
 }
 
-function isDeleteActivityValid (activity: any) {
+export function isDeleteActivityValid (activity: any) {
   // We don't really check objects
   return isBaseActivityValid(activity, 'Delete') &&
     isObjectValid(activity.object)
 }
 
-function isFollowActivityValid (activity: any) {
+export function isFollowActivityValid (activity: any) {
   return isBaseActivityValid(activity, 'Follow') &&
     isObjectValid(activity.object)
 }
 
-function isAcceptActivityValid (activity: any) {
+export function isAcceptActivityValid (activity: any) {
   return isBaseActivityValid(activity, 'Accept')
 }
 
-function isRejectActivityValid (activity: any) {
+export function isRejectActivityValid (activity: any) {
   return isBaseActivityValid(activity, 'Reject')
 }
 
-function isUndoActivityValid (activity: any) {
+export function isUndoActivityValid (activity: any) {
   return isBaseActivityValid(activity, 'Undo') &&
     (
       isFollowActivityValid(activity.object) ||
@@ -131,21 +135,14 @@ function isUndoActivityValid (activity: any) {
     )
 }
 
-// ---------------------------------------------------------------------------
-
-export {
-  isRootActivityValid,
-  isActivityValid,
-  isFlagActivityValid,
-  isLikeActivityValid,
-  isDislikeActivityValid,
-  isAnnounceActivityValid,
-  isViewActivityValid,
-  isCreateActivityValid,
-  isUpdateActivityValid,
-  isDeleteActivityValid,
-  isFollowActivityValid,
-  isAcceptActivityValid,
-  isRejectActivityValid,
-  isUndoActivityValid
+export function isApproveReplyActivityValid (activity: any) {
+  return isBaseActivityValid(activity, 'ApproveReply') &&
+    isActivityPubUrlValid(activity.object) &&
+    isActivityPubUrlValid(activity.inReplyTo)
+}
+
+export function isRejectReplyActivityValid (activity: any) {
+  return isBaseActivityValid(activity, 'RejectReply') &&
+    isActivityPubUrlValid(activity.object) &&
+    isActivityPubUrlValid(activity.inReplyTo)
 }
diff --git a/server/core/helpers/custom-validators/activitypub/video-comments.ts b/server/core/helpers/custom-validators/activitypub/video-comments.ts
index 944b5e996..d000fcf44 100644
--- a/server/core/helpers/custom-validators/activitypub/video-comments.ts
+++ b/server/core/helpers/custom-validators/activitypub/video-comments.ts
@@ -2,8 +2,9 @@ import { hasAPPublic } from '@server/helpers/activity-pub-utils.js'
 import validator from 'validator'
 import { exists, isArray, isDateValid } from '../misc.js'
 import { isActivityPubUrlValid } from './misc.js'
+import { ActivityTombstoneObject, VideoCommentObject } from '@peertube/peertube-models'
 
-function sanitizeAndCheckVideoCommentObject (comment: any) {
+function sanitizeAndCheckVideoCommentObject (comment: VideoCommentObject | ActivityTombstoneObject) {
   if (!comment) return false
 
   if (!isCommentTypeValid(comment)) return false
@@ -23,6 +24,7 @@ function sanitizeAndCheckVideoCommentObject (comment: any) {
     isDateValid(comment.published) &&
     isActivityPubUrlValid(comment.url) &&
     isArray(comment.to) &&
+    (!exists(comment.replyApproval) || isActivityPubUrlValid(comment.replyApproval)) &&
     (hasAPPublic(comment.to) || hasAPPublic(comment.cc)) // Only accept public comments
 }
 
diff --git a/server/core/helpers/custom-validators/activitypub/videos.ts b/server/core/helpers/custom-validators/activitypub/videos.ts
index 5561b94d8..255d051d7 100644
--- a/server/core/helpers/custom-validators/activitypub/videos.ts
+++ b/server/core/helpers/custom-validators/activitypub/videos.ts
@@ -1,18 +1,20 @@
-import validator from 'validator'
 import {
   ActivityPubStoryboard,
   ActivityTrackerUrlObject,
   ActivityVideoFileMetadataUrlObject,
   LiveVideoLatencyMode,
+  VideoCommentPolicy,
   VideoObject,
   VideoState
 } from '@peertube/peertube-models'
 import { logger } from '@server/helpers/logger.js'
+import validator from 'validator'
 import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../../initializers/constants.js'
 import { peertubeTruncate } from '../../core-utils.js'
 import { isArray, isBooleanValid, isDateValid, isUUIDValid } from '../misc.js'
 import { isLiveLatencyModeValid } from '../video-lives.js'
 import {
+  isVideoCommentsPolicyValid,
   isVideoDescriptionValid,
   isVideoDurationValid,
   isVideoNameValid,
@@ -66,12 +68,21 @@ function sanitizeAndCheckVideoTorrentObject (video: VideoObject) {
   if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED
   if (!isBooleanValid(video.waitTranscoding)) video.waitTranscoding = false
   if (!isBooleanValid(video.downloadEnabled)) video.downloadEnabled = true
-  if (!isBooleanValid(video.commentsEnabled)) video.commentsEnabled = false
   if (!isBooleanValid(video.isLiveBroadcast)) video.isLiveBroadcast = false
   if (!isBooleanValid(video.liveSaveReplay)) video.liveSaveReplay = false
   if (!isBooleanValid(video.permanentLive)) video.permanentLive = false
   if (!isLiveLatencyModeValid(video.latencyMode)) video.latencyMode = LiveVideoLatencyMode.DEFAULT
 
+  if (video.commentsPolicy) {
+    if (!isVideoCommentsPolicyValid(video.commentsPolicy)) {
+      video.commentsPolicy = VideoCommentPolicy.DISABLED
+    }
+  } else if (video.commentsEnabled === true) { // Fallback to deprecated attribute
+    video.commentsPolicy = VideoCommentPolicy.ENABLED
+  } else {
+    video.commentsPolicy = VideoCommentPolicy.DISABLED
+  }
+
   return isActivityPubUrlValid(video.id) &&
     isVideoNameValid(video.name) &&
     isActivityPubVideoDurationValid(video.duration) &&
@@ -138,12 +149,12 @@ function isAPVideoTrackerUrlObject (url: any): url is ActivityTrackerUrlObject {
 // ---------------------------------------------------------------------------
 
 export {
-  sanitizeAndCheckVideoTorrentUpdateActivity,
-  isRemoteStringIdentifierValid,
-  sanitizeAndCheckVideoTorrentObject,
-  isRemoteVideoUrlValid,
   isAPVideoFileUrlMetadataObject,
-  isAPVideoTrackerUrlObject
+  isAPVideoTrackerUrlObject,
+  isRemoteStringIdentifierValid,
+  isRemoteVideoUrlValid,
+  sanitizeAndCheckVideoTorrentObject,
+  sanitizeAndCheckVideoTorrentUpdateActivity
 }
 
 // ---------------------------------------------------------------------------
diff --git a/server/core/helpers/custom-validators/videos.ts b/server/core/helpers/custom-validators/videos.ts
index 0c916a0ea..b7959202f 100644
--- a/server/core/helpers/custom-validators/videos.ts
+++ b/server/core/helpers/custom-validators/videos.ts
@@ -1,12 +1,13 @@
+import { HttpStatusCode, VideoIncludeType, VideoPrivacy, VideoPrivacyType, VideoRateType } from '@peertube/peertube-models'
+import { getVideoWithAttributes } from '@server/helpers/video.js'
 import { Request, Response, UploadFilesForCheck } from 'express'
 import { decode as magnetUriDecode } from 'magnet-uri'
 import validator from 'validator'
-import { HttpStatusCode, VideoIncludeType, VideoPrivacy, VideoPrivacyType, VideoRateType } from '@peertube/peertube-models'
-import { getVideoWithAttributes } from '@server/helpers/video.js'
 import {
   CONSTRAINTS_FIELDS,
   MIMETYPES,
   VIDEO_CATEGORIES,
+  VIDEO_COMMENTS_POLICY,
   VIDEO_LICENCES,
   VIDEO_LIVE,
   VIDEO_PRIVACIES,
@@ -46,6 +47,10 @@ export function isVideoDescriptionValid (value: string) {
   return value === null || (exists(value) && validator.default.isLength(value, VIDEOS_CONSTRAINTS_FIELDS.DESCRIPTION))
 }
 
+export function isVideoCommentsPolicyValid (value: any) {
+  return value === null || VIDEO_COMMENTS_POLICY[value] !== undefined
+}
+
 export function isVideoSupportValid (value: string) {
   return value === null || (exists(value) && validator.default.isLength(value, VIDEOS_CONSTRAINTS_FIELDS.SUPPORT))
 }
diff --git a/server/core/helpers/custom-validators/watched-words.ts b/server/core/helpers/custom-validators/watched-words.ts
new file mode 100644
index 000000000..fc2b770fe
--- /dev/null
+++ b/server/core/helpers/custom-validators/watched-words.ts
@@ -0,0 +1,17 @@
+import validator from 'validator'
+import { CONSTRAINTS_FIELDS } from '../../initializers/constants.js'
+import { exists, isArray } from './misc.js'
+
+export function isWatchedWordListNameValid (listName: string) {
+  return exists(listName) && validator.default.isLength(listName, CONSTRAINTS_FIELDS.WATCHED_WORDS.LIST_NAME)
+}
+
+export function isWatchedWordValid (word: string) {
+  return exists(word) && validator.default.isLength(word, CONSTRAINTS_FIELDS.WATCHED_WORDS.WORD)
+}
+
+export function areWatchedWordsValid (words: string[]) {
+  return isArray(words) &&
+    validator.default.isInt(words.length.toString(), CONSTRAINTS_FIELDS.WATCHED_WORDS.WORDS) &&
+    words.every(word => isWatchedWordValid(word))
+}
diff --git a/server/core/helpers/query.ts b/server/core/helpers/query.ts
index 881046b5f..28cab9c21 100644
--- a/server/core/helpers/query.ts
+++ b/server/core/helpers/query.ts
@@ -26,7 +26,8 @@ function pickCommonVideoQuery (query: VideosCommonQueryAfterSanitize) {
     'hasWebtorrentFiles', // TODO: Remove in v7
     'hasWebVideoFiles',
     'search',
-    'excludeAlreadyWatched'
+    'excludeAlreadyWatched',
+    'autoTagOneOf'
   ])
 }
 
diff --git a/server/core/helpers/regexp.ts b/server/core/helpers/regexp.ts
index 257054cea..ca869e280 100644
--- a/server/core/helpers/regexp.ts
+++ b/server/core/helpers/regexp.ts
@@ -1,5 +1,5 @@
 // Thanks to https://regex101.com
-function regexpCapture (str: string, regex: RegExp, maxIterations = 100) {
+export function regexpCapture (str: string, regex: RegExp, maxIterations = 100) {
   const result: RegExpExecArray[] = []
   let m: RegExpExecArray
   let i = 0
@@ -17,6 +17,20 @@ function regexpCapture (str: string, regex: RegExp, maxIterations = 100) {
   return result
 }
 
-export {
-  regexpCapture
+export function wordsToRegExp (words: string[]) {
+  if (words.length === 0) throw new Error('Need words with at least one element')
+
+  const innerRegex = words
+    .map(word => escapeForRegex(word.trim()))
+    .join('|')
+
+  return new RegExp(`(?:\\P{L}|^)(?:${innerRegex})(?=\\P{L}|$)`, 'iu')
+}
+
+// ---------------------------------------------------------------------------
+// Private
+// ---------------------------------------------------------------------------
+
+function escapeForRegex (value: string) {
+  return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
 }
diff --git a/server/core/initializers/checker-before-init.ts b/server/core/initializers/checker-before-init.ts
index 58926ae32..b0d218b8a 100644
--- a/server/core/initializers/checker-before-init.ts
+++ b/server/core/initializers/checker-before-init.ts
@@ -48,7 +48,7 @@ function checkMissedConfig () {
     'auto_blacklist.videos.of_users.enabled', 'trending.videos.interval_days',
     'client.videos.miniature.display_author_avatar',
     'client.videos.miniature.prefer_author_display_name', 'client.menu.login.redirect_on_single_external_auth',
-    'defaults.publish.download_enabled', 'defaults.publish.comments_enabled', 'defaults.publish.privacy', 'defaults.publish.licence',
+    'defaults.publish.download_enabled', 'defaults.publish.comments_policy', 'defaults.publish.privacy', 'defaults.publish.licence',
     'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route',
     'instance.is_nsfw', 'instance.default_nsfw_policy', 'instance.robots', 'instance.securitytxt',
     'services.twitter.username',
diff --git a/server/core/initializers/config.ts b/server/core/initializers/config.ts
index c6914a7e7..be8e77910 100644
--- a/server/core/initializers/config.ts
+++ b/server/core/initializers/config.ts
@@ -5,6 +5,7 @@ import { dirname, join } from 'path'
 import {
   BroadcastMessageLevel,
   NSFWPolicyType,
+  VideoCommentPolicyType,
   VideoPrivacyType,
   VideoRedundancyConfigFilter,
   VideosRedundancyStrategy
@@ -92,7 +93,7 @@ const CONFIG = {
   DEFAULTS: {
     PUBLISH: {
       DOWNLOAD_ENABLED: config.get<boolean>('defaults.publish.download_enabled'),
-      COMMENTS_ENABLED: config.get<boolean>('defaults.publish.comments_enabled'),
+      COMMENTS_POLICY: config.get<VideoCommentPolicyType>('defaults.publish.comments_policy'),
       PRIVACY: config.get<VideoPrivacyType>('defaults.publish.privacy'),
       LICENCE: config.get<number>('defaults.publish.licence')
     },
diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts
index 4b6eecadc..006febe3a 100644
--- a/server/core/initializers/constants.ts
+++ b/server/core/initializers/constants.ts
@@ -18,6 +18,8 @@ import {
   UserRegistrationStateType,
   VideoChannelSyncState,
   VideoChannelSyncStateType,
+  VideoCommentPolicy,
+  VideoCommentPolicyType,
   VideoImportState,
   VideoImportStateType,
   VideoPlaylistPrivacy,
@@ -134,6 +136,8 @@ const SORTABLE_COLUMNS = {
   ACCOUNTS_BLOCKLIST: [ 'createdAt' ],
   SERVERS_BLOCKLIST: [ 'createdAt' ],
 
+  WATCHED_WORDS_LISTS: [ 'createdAt', 'updatedAt', 'listName' ],
+
   USER_NOTIFICATIONS: [ 'createdAt', 'read' ],
 
   VIDEO_PLAYLISTS: [ 'name', 'displayName', 'createdAt', 'updatedAt' ],
@@ -498,6 +502,11 @@ const CONSTRAINTS_FIELDS = {
   },
   VIDEO_CHAPTERS: {
     TITLE: { min: 1, max: 100 } // Length
+  },
+  WATCHED_WORDS: {
+    LIST_NAME: { min: 1, max: 100 }, // Length
+    WORDS: { min: 1, max: 500 }, // Number of total words
+    WORD: { min: 1, max: 100 } // Length
   }
 }
 
@@ -663,6 +672,12 @@ const USER_IMPORT_STATES: { [ id in UserImportStateType ]: string } = {
   [UserImportState.ERRORED]: 'Failed'
 }
 
+const VIDEO_COMMENTS_POLICY: { [ id in VideoCommentPolicyType ]: string } = {
+  [VideoCommentPolicy.DISABLED]: 'Disabled',
+  [VideoCommentPolicy.ENABLED]: 'Enabled',
+  [VideoCommentPolicy.REQUIRES_APPROVAL]: 'Requires approval'
+}
+
 const MIMETYPES = {
   AUDIO: {
     MIMETYPE_EXT: {
@@ -973,6 +988,10 @@ const LRU_CACHE = {
     MAX_SIZE: 100_000,
     TTL: parseDurationToMs('8 hours')
   },
+  WATCHED_WORDS_REGEX: {
+    MAX_SIZE: 100,
+    TTL: parseDurationToMs('24 hours')
+  },
   TRACKER_IPS: {
     MAX_SIZE: 100_000
   }
@@ -1243,6 +1262,7 @@ export {
   ACCEPT_HEADERS,
   BCRYPT_SALT_SIZE,
   TRACKER_RATE_LIMITS,
+  VIDEO_COMMENTS_POLICY,
   FILES_CACHE,
   LOG_FILENAME,
   CONSTRAINTS_FIELDS,
diff --git a/server/core/initializers/database.ts b/server/core/initializers/database.ts
index b4274be46..14ba74bdc 100644
--- a/server/core/initializers/database.ts
+++ b/server/core/initializers/database.ts
@@ -1,19 +1,22 @@
-import pg from 'pg'
-import { QueryTypes, Transaction } from 'sequelize'
-import { Sequelize as SequelizeTypescript } from 'sequelize-typescript'
 import { isTestOrDevInstance } from '@peertube/peertube-node-utils'
 import { ActorCustomPageModel } from '@server/models/account/actor-custom-page.js'
+import { AutomaticTagModel } from '@server/models/automatic-tag/automatic-tag.js'
+import { VideoAutomaticTagModel } from '@server/models/automatic-tag/video-automatic-tag.js'
+import { CommentAutomaticTagModel } from '@server/models/automatic-tag/comment-automatic-tag.js'
 import { RunnerJobModel } from '@server/models/runner/runner-job.js'
 import { RunnerRegistrationTokenModel } from '@server/models/runner/runner-registration-token.js'
 import { RunnerModel } from '@server/models/runner/runner.js'
 import { TrackerModel } from '@server/models/server/tracker.js'
 import { VideoTrackerModel } from '@server/models/server/video-tracker.js'
+import { UserExportModel } from '@server/models/user/user-export.js'
+import { UserImportModel } from '@server/models/user/user-import.js'
 import { UserNotificationModel } from '@server/models/user/user-notification.js'
 import { UserRegistrationModel } from '@server/models/user/user-registration.js'
 import { UserVideoHistoryModel } from '@server/models/user/user-video-history.js'
 import { UserModel } from '@server/models/user/user.js'
 import { StoryboardModel } from '@server/models/video/storyboard.js'
 import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js'
+import { VideoChapterModel } from '@server/models/video/video-chapter.js'
 import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
 import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting.js'
 import { VideoLiveSessionModel } from '@server/models/video/video-live-session.js'
@@ -21,6 +24,10 @@ import { VideoPasswordModel } from '@server/models/video/video-password.js'
 import { VideoSourceModel } from '@server/models/video/video-source.js'
 import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section.js'
 import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer.js'
+import { WatchedWordsListModel } from '@server/models/watched-words/watched-words-list.js'
+import pg from 'pg'
+import { QueryTypes, Transaction } from 'sequelize'
+import { Sequelize as SequelizeTypescript } from 'sequelize-typescript'
 import { logger } from '../helpers/logger.js'
 import { AbuseMessageModel } from '../models/abuse/abuse-message.js'
 import { AbuseModel } from '../models/abuse/abuse.js'
@@ -59,9 +66,7 @@ import { VideoTagModel } from '../models/video/video-tag.js'
 import { VideoModel } from '../models/video/video.js'
 import { VideoViewModel } from '../models/view/video-view.js'
 import { CONFIG } from './config.js'
-import { VideoChapterModel } from '@server/models/video/video-chapter.js'
-import { UserExportModel } from '@server/models/user/user-export.js'
-import { UserImportModel } from '@server/models/user/user-import.js'
+import { AccountAutomaticTagPolicyModel } from '@server/models/automatic-tag/account-automatic-tag-policy.js'
 
 pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string
 
@@ -176,7 +181,12 @@ async function initDatabaseModels (silent: boolean) {
     RunnerModel,
     RunnerJobModel,
     StoryboardModel,
-    UserExportModel
+    UserExportModel,
+    VideoAutomaticTagModel,
+    CommentAutomaticTagModel,
+    AutomaticTagModel,
+    WatchedWordsListModel,
+    AccountAutomaticTagPolicyModel
   ])
 
   // Check extensions exist in the database
@@ -191,9 +201,7 @@ async function initDatabaseModels (silent: boolean) {
 // ---------------------------------------------------------------------------
 
 export {
-  initDatabaseModels,
-  checkDatabaseConnectionOrDie,
-  sequelizeTypescript
+  checkDatabaseConnectionOrDie, initDatabaseModels, sequelizeTypescript
 }
 
 // ---------------------------------------------------------------------------
diff --git a/server/core/initializers/migrations/0840-auto-tags.ts b/server/core/initializers/migrations/0840-auto-tags.ts
new file mode 100644
index 000000000..d51033635
--- /dev/null
+++ b/server/core/initializers/migrations/0840-auto-tags.ts
@@ -0,0 +1,122 @@
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+  transaction: Sequelize.Transaction
+  queryInterface: Sequelize.QueryInterface
+  sequelize: Sequelize.Sequelize
+}): Promise<void> {
+  const { transaction } = utils
+
+  {
+    const query = `CREATE TABLE IF NOT EXISTS "automaticTag" ("id"   SERIAL , "name" VARCHAR(255) NOT NULL, PRIMARY KEY ("id"));`
+
+    await utils.sequelize.query(query, { transaction })
+  }
+
+  {
+    const query = `
+CREATE TABLE IF NOT EXISTS "videoAutomaticTag"(
+  "videoId" integer REFERENCES "video"("id") ON DELETE CASCADE ON UPDATE CASCADE,
+  "automaticTagId" integer NOT NULL REFERENCES "automaticTag"("id") ON DELETE CASCADE ON UPDATE CASCADE,
+  "accountId" integer REFERENCES "account"("id") ON DELETE CASCADE ON UPDATE CASCADE,
+  "createdAt" timestamp with time zone NOT NULL,
+  "updatedAt" timestamp with time zone NOT NULL,
+  PRIMARY KEY ("videoId", "automaticTagId", "accountId")
+);`
+
+    await utils.sequelize.query(query, { transaction })
+  }
+
+  {
+    const query = `
+CREATE TABLE IF NOT EXISTS "commentAutomaticTag"(
+  "commentId" integer REFERENCES "videoComment"("id") ON DELETE CASCADE ON UPDATE CASCADE,
+  "automaticTagId" integer NOT NULL REFERENCES "automaticTag"("id") ON DELETE CASCADE ON UPDATE CASCADE,
+  "accountId" integer REFERENCES "account"("id") ON DELETE CASCADE ON UPDATE CASCADE,
+  "createdAt" timestamp with time zone NOT NULL,
+  "updatedAt" timestamp with time zone NOT NULL,
+  PRIMARY KEY ("commentId", "automaticTagId", "accountId")
+);`
+
+    await utils.sequelize.query(query, { transaction })
+  }
+
+  {
+    const query = `
+CREATE TABLE IF NOT EXISTS "watchedWordsList"(
+  "id" serial,
+  "listName" varchar(255) NOT NULL,
+  "words" varchar(255)[] NOT NULL,
+  "accountId" integer NOT NULL REFERENCES "account"("id") ON DELETE CASCADE ON UPDATE CASCADE,
+  "createdAt" timestamp with time zone NOT NULL,
+  "updatedAt" timestamp with time zone NOT NULL,
+  PRIMARY KEY ("id")
+);`
+
+    await utils.sequelize.query(query, { transaction })
+  }
+
+  {
+    const query = `
+CREATE TABLE IF NOT EXISTS "accountAutomaticTagPolicy"(
+  "id" serial,
+  "policy" integer,
+  "accountId" integer NOT NULL REFERENCES "account"("id") ON DELETE CASCADE ON UPDATE CASCADE,
+  "automaticTagId" integer NOT NULL REFERENCES "automaticTag"("id") ON DELETE CASCADE ON UPDATE CASCADE,
+  "createdAt" timestamp with time zone NOT NULL,
+  "updatedAt" timestamp with time zone NOT NULL,
+  PRIMARY KEY ("id")
+);`
+
+    await utils.sequelize.query(query, { transaction })
+  }
+
+  {
+    await utils.queryInterface.addColumn('video', 'commentsPolicy', {
+      type: Sequelize.INTEGER,
+      defaultValue: 1, // ENABLED
+      allowNull: false
+    }, { transaction })
+
+    const query = `UPDATE "video" SET "commentsPolicy" = 2 WHERE "commentsEnabled" IS FALSE` // Disabled
+    await utils.sequelize.query(query, { transaction })
+
+    await utils.queryInterface.changeColumn('video', 'commentsPolicy', {
+      type: Sequelize.INTEGER,
+      defaultValue: null,
+      allowNull: false
+    }, { transaction })
+
+    await utils.queryInterface.removeColumn('video', 'commentsEnabled', { transaction })
+  }
+
+  {
+    await utils.queryInterface.addColumn('videoComment', 'heldForReview', {
+      type: Sequelize.BOOLEAN,
+      defaultValue: false,
+      allowNull: false
+    }, { transaction })
+
+    await utils.queryInterface.changeColumn('videoComment', 'heldForReview', {
+      type: Sequelize.BOOLEAN,
+      defaultValue: null,
+      allowNull: false
+    }, { transaction })
+  }
+
+  {
+    await utils.queryInterface.addColumn('videoComment', 'replyApproval', {
+      type: Sequelize.STRING,
+      defaultValue: null,
+      allowNull: true
+    }, { transaction })
+  }
+}
+
+function down (options) {
+  throw new Error('Not implemented.')
+}
+
+export {
+  down, up
+}
diff --git a/server/core/lib/activitypub/process/process-create.ts b/server/core/lib/activitypub/process/process-create.ts
index 5f031b7fa..40a3fe15e 100644
--- a/server/core/lib/activitypub/process/process-create.ts
+++ b/server/core/lib/activitypub/process/process-create.ts
@@ -1,6 +1,3 @@
-import { isBlockedByServerOrAccount } from '@server/lib/blocklist.js'
-import { isRedundancyAccepted } from '@server/lib/redundancy.js'
-import { VideoModel } from '@server/models/video/video.js'
 import {
   AbuseObject,
   ActivityCreate,
@@ -12,6 +9,10 @@ import {
   VideoObject,
   WatchActionObject
 } from '@peertube/peertube-models'
+import { isBlockedByServerOrAccount } from '@server/lib/blocklist.js'
+import { isRedundancyAccepted } from '@server/lib/redundancy.js'
+import { VideoCommentModel } from '@server/models/video/video-comment.js'
+import { VideoModel } from '@server/models/video/video.js'
 import { retryTransactionWrapper } from '../../../helpers/database-utils.js'
 import { logger } from '../../../helpers/logger.js'
 import { sequelizeTypescript } from '../../../initializers/database.js'
@@ -22,6 +23,7 @@ import { fetchAPObjectIfNeeded } from '../activity.js'
 import { createOrUpdateCacheFile } from '../cache-file.js'
 import { createOrUpdateLocalVideoViewer } from '../local-video-viewer.js'
 import { createOrUpdateVideoPlaylist } from '../playlists/index.js'
+import { sendReplyApproval } from '../send/send-reply-approval.js'
 import { forwardVideoRelatedActivity } from '../send/shared/send-utils.js'
 import { resolveThread } from '../video-comments.js'
 import { canVideoBeFederated, getOrCreateAPVideo } from '../videos/index.js'
@@ -42,7 +44,7 @@ async function processCreateActivity (options: APProcessorOptions<ActivityCreate
     // Comments will be fetched from videos
     if (options.fromFetch) return
 
-    return retryTransactionWrapper(processCreateVideoComment, activity, activityObject, byActor, notify)
+    return retryTransactionWrapper(processCreateVideoComment, activity, activityObject, byActor, options.fromFetch)
   }
 
   if (activityType === 'WatchAction') {
@@ -118,8 +120,10 @@ async function processCreateVideoComment (
   activity: ActivityCreate<VideoCommentObject | string>,
   commentObject: VideoCommentObject,
   byActor: MActorSignature,
-  notify: boolean
+  fromFetch: false
 ) {
+  if (fromFetch) throw new Error('Processing create video comment from fetch is not supported')
+
   const byAccount = byActor.Account
 
   if (!byAccount) throw new Error('Cannot create video comment with the non account actor ' + byActor.url)
@@ -146,12 +150,24 @@ async function processCreateVideoComment (
 
   // Try to not forward unwanted comments on our videos
   if (video.isOwned()) {
+    if (!canVideoBeFederated(video)) {
+      logger.info('Skip comment forward on non federated video' + video.url)
+      return
+    }
+
     if (await isBlockedByServerOrAccount(comment.Account, video.VideoChannel.Account)) {
       logger.info('Skip comment forward from blocked account or server %s.', comment.Account.Actor.url)
       return
     }
 
-    if (created === true) {
+    // New non-moderated comment -> auto approve reply
+    if (comment.heldForReview === false && created) {
+      const reply = await VideoCommentModel.loadById(comment.inReplyToCommentId)
+      sendReplyApproval(Object.assign(comment, { InReplyToVideoComment: reply }), 'ApproveReply')
+    }
+
+    // New comment or re-sent after an approval -> forward comment
+    if (comment.heldForReview === false && (created || commentObject.replyApproval)) {
       // Don't resend the activity to the sender
       const exceptions = [ byActor ]
 
@@ -159,7 +175,7 @@ async function processCreateVideoComment (
     }
   }
 
-  if (created && notify) Notifier.Instance.notifyOnNewComment(comment)
+  if (created) Notifier.Instance.notifyOnNewComment(comment)
 }
 
 async function processCreatePlaylist (
diff --git a/server/core/lib/activitypub/process/process-delete.ts b/server/core/lib/activitypub/process/process-delete.ts
index 6c4f3099a..46ca0d73e 100644
--- a/server/core/lib/activitypub/process/process-delete.ts
+++ b/server/core/lib/activitypub/process/process-delete.ts
@@ -44,7 +44,7 @@ async function processDeleteActivity (options: APProcessorOptions<ActivityDelete
   }
 
   {
-    const videoCommentInstance = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(objectUrl)
+    const videoCommentInstance = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(objectUrl)
     if (videoCommentInstance) {
       return retryTransactionWrapper(processDeleteVideoComment, byActor, videoCommentInstance, activity)
     }
diff --git a/server/core/lib/activitypub/process/process-flag.ts b/server/core/lib/activitypub/process/process-flag.ts
index 9aba1c896..45cfa7024 100644
--- a/server/core/lib/activitypub/process/process-flag.ts
+++ b/server/core/lib/activitypub/process/process-flag.ts
@@ -51,7 +51,7 @@ async function processCreateAbuse (flag: ActivityFlag, byActor: MActorSignature)
         let videoComment: MCommentOwnerVideo
         let flaggedAccount: MAccountDefault
 
-        if (!video) videoComment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(uri, t)
+        if (!video) videoComment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(uri, t)
         if (!videoComment) flaggedAccount = await AccountModel.loadByUrl(uri, t)
 
         if (!video && !videoComment && !flaggedAccount) {
diff --git a/server/core/lib/activitypub/process/process-reply-approval.ts b/server/core/lib/activitypub/process/process-reply-approval.ts
new file mode 100644
index 000000000..6755168ca
--- /dev/null
+++ b/server/core/lib/activitypub/process/process-reply-approval.ts
@@ -0,0 +1,45 @@
+import { ActivityApproveReply, ActivityRejectReply, ActivityType } from '@peertube/peertube-models'
+import { VideoCommentModel } from '@server/models/video/video-comment.js'
+import { sequelizeTypescript } from '../../../initializers/database.js'
+import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
+import { MCommentOwnerVideoReply } from '../../../types/models/index.js'
+import { sendCreateVideoCommentIfNeeded } from '../send/send-create.js'
+
+export function processReplyApprovalFactory (type: Extract<ActivityType, 'ApproveReply' | 'RejectReply'>) {
+  return async (options: APProcessorOptions<ActivityApproveReply | ActivityRejectReply>) => {
+    if (type === 'RejectReply') return // Not yet implemented
+
+    const { activity, byActor } = options
+    const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(activity.object)
+
+    if (!comment || comment.isDeleted()) {
+      throw new Error(`Cannot process reply approval on comment ${comment.url} that doesn't exist`)
+    }
+
+    if (comment.isOwned() !== true) {
+      throw new Error(`Cannot process reply approval on non-owned comment ${comment.url}`)
+    }
+
+    if (byActor.id !== comment.Video.VideoChannel.Account.Actor.id) {
+      throw new Error(`Cannot process reply approval on ${comment.url} by non video owner`)
+    }
+
+    return processApproveReply(activity.id, comment)
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Private
+// ---------------------------------------------------------------------------
+
+async function processApproveReply (replyApproval: string, comment: MCommentOwnerVideoReply) {
+  if (comment.heldForReview === false || comment.replyApproval === replyApproval) return
+
+  return sequelizeTypescript.transaction(async t => {
+    comment.heldForReview = false
+    comment.replyApproval = replyApproval
+    await comment.save({ transaction: t })
+
+    await sendCreateVideoCommentIfNeeded(comment, t)
+  })
+}
diff --git a/server/core/lib/activitypub/process/process.ts b/server/core/lib/activitypub/process/process.ts
index 5e187cecb..1db0e6f06 100644
--- a/server/core/lib/activitypub/process/process.ts
+++ b/server/core/lib/activitypub/process/process.ts
@@ -1,5 +1,5 @@
-import { StatsManager } from '@server/lib/stat-manager.js'
 import { Activity, ActivityType } from '@peertube/peertube-models'
+import { StatsManager } from '@server/lib/stat-manager.js'
 import { logger } from '../../../helpers/logger.js'
 import { APProcessorOptions } from '../../../types/activitypub-processor.model.js'
 import { MActorDefault, MActorSignature } from '../../../types/models/index.js'
@@ -15,6 +15,7 @@ import { processFlagActivity } from './process-flag.js'
 import { processFollowActivity } from './process-follow.js'
 import { processLikeActivity } from './process-like.js'
 import { processRejectActivity } from './process-reject.js'
+import { processReplyApprovalFactory } from './process-reply-approval.js'
 import { processUndoActivity } from './process-undo.js'
 import { processUpdateActivity } from './process-update.js'
 import { processViewActivity } from './process-view.js'
@@ -31,7 +32,9 @@ const processActivity: { [ P in ActivityType ]: (options: APProcessorOptions<Act
   Like: processLikeActivity,
   Dislike: processDislikeActivity,
   Flag: processFlagActivity,
-  View: processViewActivity
+  View: processViewActivity,
+  ApproveReply: processReplyApprovalFactory('ApproveReply'),
+  RejectReply: processReplyApprovalFactory('RejectReply')
 }
 
 export async function processActivities (
diff --git a/server/core/lib/activitypub/send/http.ts b/server/core/lib/activitypub/send/http.ts
index 09e1c8eef..5771db9f5 100644
--- a/server/core/lib/activitypub/send/http.ts
+++ b/server/core/lib/activitypub/send/http.ts
@@ -6,6 +6,7 @@ import { MActor } from '@server/types/models/index.js'
 import { getContextFilter } from '../context.js'
 import { buildDigestFromWorker, signJsonLDObjectFromWorker } from '@server/lib/worker/parent-process.js'
 import { signAndContextify } from '@server/helpers/activity-pub-utils.js'
+import { logger } from '@server/helpers/logger.js'
 
 type Payload <T> = { body: T, contextType: ContextType, signatureActorId?: number }
 
@@ -18,13 +19,17 @@ export async function computeBody <T> (
     const actorSignature = await ActorModel.load(payload.signatureActorId)
     if (!actorSignature) throw new Error('Unknown signature actor id.')
 
-    body = await signAndContextify({
-      byActor: { url: actorSignature.url, privateKey: actorSignature.privateKey },
-      data: payload.body,
-      contextType: payload.contextType,
-      contextFilter: getContextFilter(),
-      signerFunction: signJsonLDObjectFromWorker
-    })
+    try {
+      body = await signAndContextify({
+        byActor: { url: actorSignature.url, privateKey: actorSignature.privateKey },
+        data: payload.body,
+        contextType: payload.contextType,
+        contextFilter: getContextFilter(),
+        signerFunction: signJsonLDObjectFromWorker
+      })
+    } catch (err) {
+      logger.error('Cannot sign and contextify body', { body, err })
+    }
   }
 
   return body
diff --git a/server/core/lib/activitypub/send/index.ts b/server/core/lib/activitypub/send/index.ts
index 8711cb17b..efeb9e25a 100644
--- a/server/core/lib/activitypub/send/index.ts
+++ b/server/core/lib/activitypub/send/index.ts
@@ -6,5 +6,6 @@ export * from './send-delete.js'
 export * from './send-follow.js'
 export * from './send-like.js'
 export * from './send-reject.js'
+export * from './send-reply-approval.js'
 export * from './send-undo.js'
 export * from './send-update.js'
diff --git a/server/core/lib/activitypub/send/send-create.ts b/server/core/lib/activitypub/send/send-create.ts
index edffe7fca..f1e132634 100644
--- a/server/core/lib/activitypub/send/send-create.ts
+++ b/server/core/lib/activitypub/send/send-create.ts
@@ -15,10 +15,9 @@ import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
 import { VideoCommentModel } from '../../../models/video/video-comment.js'
 import {
   MActorLight,
-  MCommentOwnerVideo,
+  MCommentOwnerVideoReply,
   MLocalVideoViewerWithWatchSections,
-  MVideoAP,
-  MVideoAccountLight,
+  MVideoAP, MVideoAccountLight,
   MVideoPlaylistFull,
   MVideoRedundancyFileVideo,
   MVideoRedundancyStreamingPlaylistVideo
@@ -111,7 +110,7 @@ export async function sendCreateVideoPlaylist (playlist: MVideoPlaylistFull, tra
   })
 }
 
-export async function sendCreateVideoComment (comment: MCommentOwnerVideo, transaction: Transaction) {
+export async function sendCreateVideoCommentIfNeeded (comment: MCommentOwnerVideoReply, transaction: Transaction) {
   const isOrigin = comment.Video.isOwned()
 
   if (isOrigin) {
@@ -121,6 +120,11 @@ export async function sendCreateVideoComment (comment: MCommentOwnerVideo, trans
       logger.debug(`Do not send comment ${comment.url} on a video that cannot be federated`)
       return undefined
     }
+
+    if (comment.heldForReview) {
+      logger.debug(`Do not send comment ${comment.url} that requires approval`)
+      return undefined
+    }
   }
 
   logger.info('Creating job to send comment %s.', comment.url)
@@ -128,14 +132,14 @@ export async function sendCreateVideoComment (comment: MCommentOwnerVideo, trans
   const byActor = comment.Account.Actor
   const videoAccount = await AccountModel.load(comment.Video.VideoChannel.Account.id, transaction)
 
-  const threadParentComments = await VideoCommentModel.listThreadParentComments(comment, transaction)
+  const threadParentComments = await VideoCommentModel.listThreadParentComments({ comment, transaction })
   const commentObject = comment.toActivityPubObject(threadParentComments) as VideoCommentObject
 
   const actorsInvolvedInComment = await getActorsInvolvedInVideo(comment.Video, transaction)
   // Add the actor that commented too
   actorsInvolvedInComment.push(byActor)
 
-  const parentsCommentActors = threadParentComments.filter(c => !c.isDeleted())
+  const parentsCommentActors = threadParentComments.filter(c => !c.isDeleted() && !c.heldForReview)
                                                    .map(c => c.Account.Actor)
 
   let audience: ActivityAudience
diff --git a/server/core/lib/activitypub/send/send-delete.ts b/server/core/lib/activitypub/send/send-delete.ts
index 3bf5ae75a..eee777141 100644
--- a/server/core/lib/activitypub/send/send-delete.ts
+++ b/server/core/lib/activitypub/send/send-delete.ts
@@ -63,8 +63,8 @@ async function sendDeleteVideoComment (videoComment: MCommentOwnerVideo, transac
     ? videoComment.Account.Actor
     : videoAccount.Actor
 
-  const threadParentComments = await VideoCommentModel.listThreadParentComments(videoComment, transaction)
-  const threadParentCommentsFiltered = threadParentComments.filter(c => !c.isDeleted())
+  const threadParentComments = await VideoCommentModel.listThreadParentComments({ comment: videoComment, transaction })
+  const threadParentCommentsFiltered = threadParentComments.filter(c => !c.isDeleted() && !c.heldForReview)
 
   const actorsInvolvedInComment = await getActorsInvolvedInVideo(videoComment.Video, transaction)
   actorsInvolvedInComment.push(byActor) // Add the actor that commented the video
diff --git a/server/core/lib/activitypub/send/send-reply-approval.ts b/server/core/lib/activitypub/send/send-reply-approval.ts
new file mode 100644
index 000000000..a2e7b067e
--- /dev/null
+++ b/server/core/lib/activitypub/send/send-reply-approval.ts
@@ -0,0 +1,36 @@
+import { ActivityApproveReply, ActivityRejectReply } from '@peertube/peertube-models'
+import { logger } from '../../../helpers/logger.js'
+import { MCommentOwnerVideoReply } from '../../../types/models/index.js'
+import { getLocalApproveReplyActivityPubUrl } from '../url.js'
+import { unicastTo } from './shared/send-utils.js'
+
+// We can support type: 'RejectReply' in the future
+export function sendReplyApproval (comment: MCommentOwnerVideoReply, type: 'ApproveReply') {
+  logger.info('Creating job to approve reply %s.', comment.url)
+
+  const data = buildApprovalActivity({ comment, type })
+
+  return unicastTo({
+    data,
+    byActor: comment.Video.VideoChannel.Account.Actor,
+    toActorUrl: comment.Account.Actor.inboxUrl,
+    contextType: type
+  })
+}
+
+export function buildApprovalActivity (options: {
+  comment: MCommentOwnerVideoReply
+  type: 'ApproveReply'
+}): ActivityApproveReply | ActivityRejectReply {
+  const { comment, type } = options
+
+  return {
+    type,
+    id: type === 'ApproveReply'
+      ? getLocalApproveReplyActivityPubUrl(comment.Video, comment)
+      : undefined, // 'RejectReply' Not implemented yet
+    actor: comment.Video.VideoChannel.Account.Actor.url,
+    inReplyTo: comment.InReplyToVideoComment?.url ?? comment.Video.url,
+    object: comment.url
+  }
+}
diff --git a/server/core/lib/activitypub/send/shared/audience-utils.ts b/server/core/lib/activitypub/send/shared/audience-utils.ts
index be3fd2900..1e2dbd50d 100644
--- a/server/core/lib/activitypub/send/shared/audience-utils.ts
+++ b/server/core/lib/activitypub/send/shared/audience-utils.ts
@@ -45,7 +45,7 @@ export function getVideoCommentAudience (
 
 export function getAudienceFromFollowersOf (actorsInvolvedInObject: MActorFollowersUrl[]): ActivityAudience {
   return {
-    to: [ getAPPublicValue() ].concat(actorsInvolvedInObject.map(a => a.followersUrl)),
+    to: [ getAPPublicValue() as string ].concat(actorsInvolvedInObject.map(a => a.followersUrl)),
     cc: []
   }
 }
diff --git a/server/core/lib/activitypub/url.ts b/server/core/lib/activitypub/url.ts
index 4bbd1c573..9f428029b 100644
--- a/server/core/lib/activitypub/url.ts
+++ b/server/core/lib/activitypub/url.ts
@@ -6,123 +6,126 @@ import {
   MActorFollow,
   MActorId,
   MActorUrl,
-  MCommentId,
-  MLocalVideoViewer,
+  MCommentId, MLocalVideoViewer,
   MVideoId,
   MVideoPlaylistElement,
-  MVideoUrl,
   MVideoUUID,
+  MVideoUrl,
   MVideoWithHost
 } from '../../types/models/index.js'
 import { MVideoFileVideoUUID } from '../../types/models/video/video-file.js'
 import { MVideoPlaylist, MVideoPlaylistUUID } from '../../types/models/video/video-playlist.js'
 import { MStreamingPlaylist } from '../../types/models/video/video-streaming-playlist.js'
 
-function getLocalVideoActivityPubUrl (video: MVideoUUID) {
+export function getLocalVideoActivityPubUrl (video: MVideoUUID) {
   return WEBSERVER.URL + '/videos/watch/' + video.uuid
 }
 
-function getLocalVideoPlaylistActivityPubUrl (videoPlaylist: MVideoPlaylist) {
+export function getLocalVideoPlaylistActivityPubUrl (videoPlaylist: MVideoPlaylist) {
   return WEBSERVER.URL + '/video-playlists/' + videoPlaylist.uuid
 }
 
-function getLocalVideoPlaylistElementActivityPubUrl (videoPlaylist: MVideoPlaylistUUID, videoPlaylistElement: MVideoPlaylistElement) {
-  return WEBSERVER.URL + '/video-playlists/' + videoPlaylist.uuid + '/videos/' + videoPlaylistElement.id
+export function getLocalVideoPlaylistElementActivityPubUrl (playlist: MVideoPlaylistUUID, element: MVideoPlaylistElement) {
+  return WEBSERVER.URL + '/video-playlists/' + playlist.uuid + '/videos/' + element.id
 }
 
-function getLocalVideoCacheFileActivityPubUrl (videoFile: MVideoFileVideoUUID) {
+export function getLocalVideoCacheFileActivityPubUrl (videoFile: MVideoFileVideoUUID) {
   const suffixFPS = videoFile.fps && videoFile.fps !== -1 ? '-' + videoFile.fps : ''
 
   return `${WEBSERVER.URL}/redundancy/videos/${videoFile.Video.uuid}/${videoFile.resolution}${suffixFPS}`
 }
 
-function getLocalVideoCacheStreamingPlaylistActivityPubUrl (video: MVideoUUID, playlist: MStreamingPlaylist) {
+export function getLocalVideoCacheStreamingPlaylistActivityPubUrl (video: MVideoUUID, playlist: MStreamingPlaylist) {
   return `${WEBSERVER.URL}/redundancy/streaming-playlists/${playlist.getStringType()}/${video.uuid}`
 }
 
-function getLocalVideoCommentActivityPubUrl (video: MVideoUUID, videoComment: MCommentId) {
+export function getLocalVideoCommentActivityPubUrl (video: MVideoUUID, videoComment: MCommentId) {
   return WEBSERVER.URL + '/videos/watch/' + video.uuid + '/comments/' + videoComment.id
 }
 
-function getLocalVideoChannelActivityPubUrl (videoChannelName: string) {
+export function getLocalVideoChannelActivityPubUrl (videoChannelName: string) {
   return WEBSERVER.URL + '/video-channels/' + videoChannelName
 }
 
-function getLocalAccountActivityPubUrl (accountName: string) {
+export function getLocalAccountActivityPubUrl (accountName: string) {
   return WEBSERVER.URL + '/accounts/' + accountName
 }
 
-function getLocalAbuseActivityPubUrl (abuse: MAbuseId) {
+export function getLocalAbuseActivityPubUrl (abuse: MAbuseId) {
   return WEBSERVER.URL + '/admin/abuses/' + abuse.id
 }
 
-function getLocalVideoViewActivityPubUrl (byActor: MActorUrl, video: MVideoId, viewerIdentifier: string) {
+export function getLocalVideoViewActivityPubUrl (byActor: MActorUrl, video: MVideoId, viewerIdentifier: string) {
   return byActor.url + '/views/videos/' + video.id + '/' + viewerIdentifier
 }
 
-function getLocalVideoViewerActivityPubUrl (stats: MLocalVideoViewer) {
+export function getLocalVideoViewerActivityPubUrl (stats: MLocalVideoViewer) {
   return WEBSERVER.URL + '/videos/local-viewer/' + stats.uuid
 }
 
-function getVideoLikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) {
+export function getVideoLikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) {
   return byActor.url + '/likes/' + video.id
 }
 
-function getVideoDislikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) {
+export function getVideoDislikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) {
   return byActor.url + '/dislikes/' + video.id
 }
 
-function getLocalVideoSharesActivityPubUrl (video: MVideoUrl) {
+export function getLocalVideoSharesActivityPubUrl (video: MVideoUrl) {
   return video.url + '/announces'
 }
 
-function getLocalVideoCommentsActivityPubUrl (video: MVideoUrl) {
+export function getLocalVideoCommentsActivityPubUrl (video: MVideoUrl) {
   return video.url + '/comments'
 }
 
-function getLocalVideoChaptersActivityPubUrl (video: MVideoUrl) {
+export function getLocalVideoChaptersActivityPubUrl (video: MVideoUrl) {
   return video.url + '/chapters'
 }
 
-function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) {
+export function getLocalVideoLikesActivityPubUrl (video: MVideoUrl) {
   return video.url + '/likes'
 }
 
-function getLocalVideoDislikesActivityPubUrl (video: MVideoUrl) {
+export function getLocalVideoDislikesActivityPubUrl (video: MVideoUrl) {
   return video.url + '/dislikes'
 }
 
-function getLocalActorFollowActivityPubUrl (follower: MActor, following: MActorId) {
+export function getLocalActorFollowActivityPubUrl (follower: MActor, following: MActorId) {
   return follower.url + '/follows/' + following.id
 }
 
-function getLocalActorFollowAcceptActivityPubUrl (actorFollow: MActorFollow) {
+export function getLocalActorFollowAcceptActivityPubUrl (actorFollow: MActorFollow) {
   return WEBSERVER.URL + '/accepts/follows/' + actorFollow.id
 }
 
-function getLocalActorFollowRejectActivityPubUrl () {
+export function getLocalActorFollowRejectActivityPubUrl () {
   return WEBSERVER.URL + '/rejects/follows/' + new Date().toISOString()
 }
 
-function getLocalVideoAnnounceActivityPubUrl (byActor: MActorId, video: MVideoUrl) {
+export function getLocalVideoAnnounceActivityPubUrl (byActor: MActorId, video: MVideoUrl) {
   return video.url + '/announces/' + byActor.id
 }
 
-function getDeleteActivityPubUrl (originalUrl: string) {
+export function getDeleteActivityPubUrl (originalUrl: string) {
   return originalUrl + '/delete'
 }
 
-function getUpdateActivityPubUrl (originalUrl: string, updatedAt: string) {
+export function getUpdateActivityPubUrl (originalUrl: string, updatedAt: string) {
   return originalUrl + '/updates/' + updatedAt
 }
 
-function getUndoActivityPubUrl (originalUrl: string) {
+export function getUndoActivityPubUrl (originalUrl: string) {
   return originalUrl + '/undo'
 }
 
+export function getLocalApproveReplyActivityPubUrl (video: MVideoUUID, comment: MCommentId) {
+  return getLocalVideoCommentActivityPubUrl(video, comment) + '/approve-reply'
+}
+
 // ---------------------------------------------------------------------------
 
-function getAbuseTargetUrl (abuse: MAbuseFull) {
+export function getAbuseTargetUrl (abuse: MAbuseFull) {
   return abuse.VideoAbuse?.Video?.url ||
     abuse.VideoCommentAbuse?.VideoComment?.url ||
     abuse.FlaggedAccount.Actor.url
@@ -130,7 +133,7 @@ function getAbuseTargetUrl (abuse: MAbuseFull) {
 
 // ---------------------------------------------------------------------------
 
-function buildRemoteUrl (video: MVideoWithHost, path: string, scheme?: string) {
+export function buildRemoteUrl (video: MVideoWithHost, path: string, scheme?: string) {
   if (!scheme) scheme = REMOTE_SCHEME.HTTP
 
   const host = video.VideoChannel.Actor.Server.host
@@ -140,43 +143,9 @@ function buildRemoteUrl (video: MVideoWithHost, path: string, scheme?: string) {
 
 // ---------------------------------------------------------------------------
 
-function checkUrlsSameHost (url1: string, url2: string) {
+export function checkUrlsSameHost (url1: string, url2: string) {
   const idHost = new URL(url1).host
   const actorHost = new URL(url2).host
 
   return idHost && actorHost && idHost.toLowerCase() === actorHost.toLowerCase()
 }
-
-// ---------------------------------------------------------------------------
-
-export {
-  getLocalVideoActivityPubUrl,
-  getLocalVideoPlaylistActivityPubUrl,
-  getLocalVideoPlaylistElementActivityPubUrl,
-  getLocalVideoCacheFileActivityPubUrl,
-  getLocalVideoCacheStreamingPlaylistActivityPubUrl,
-  getLocalVideoCommentActivityPubUrl,
-  getLocalVideoChannelActivityPubUrl,
-  getLocalAccountActivityPubUrl,
-  getLocalAbuseActivityPubUrl,
-  getLocalActorFollowActivityPubUrl,
-  getLocalActorFollowAcceptActivityPubUrl,
-  getLocalVideoAnnounceActivityPubUrl,
-  getUpdateActivityPubUrl,
-  getUndoActivityPubUrl,
-  getVideoLikeActivityPubUrlByLocalActor,
-  getLocalVideoViewActivityPubUrl,
-  getVideoDislikeActivityPubUrlByLocalActor,
-  getLocalActorFollowRejectActivityPubUrl,
-  getDeleteActivityPubUrl,
-  getLocalVideoSharesActivityPubUrl,
-  getLocalVideoCommentsActivityPubUrl,
-  getLocalVideoChaptersActivityPubUrl,
-  getLocalVideoLikesActivityPubUrl,
-  getLocalVideoDislikesActivityPubUrl,
-  getLocalVideoViewerActivityPubUrl,
-
-  getAbuseTargetUrl,
-  checkUrlsSameHost,
-  buildRemoteUrl
-}
diff --git a/server/core/lib/activitypub/video-comments.ts b/server/core/lib/activitypub/video-comments.ts
index f710f9b97..067f8e911 100644
--- a/server/core/lib/activitypub/video-comments.ts
+++ b/server/core/lib/activitypub/video-comments.ts
@@ -1,11 +1,21 @@
+import { VideoCommentPolicy } from '@peertube/peertube-models'
 import Bluebird from 'bluebird'
 import { sanitizeAndCheckVideoCommentObject } from '../../helpers/custom-validators/activitypub/video-comments.js'
 import { logger } from '../../helpers/logger.js'
 import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants.js'
 import { VideoCommentModel } from '../../models/video/video-comment.js'
-import { MComment, MCommentOwner, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../types/models/video/index.js'
+import {
+  MComment,
+  MCommentOwner,
+  MCommentOwnerVideo,
+  MVideoAccountLight,
+  MVideoAccountLightBlacklistAllFiles
+} from '../../types/models/video/index.js'
+import { AutomaticTagger } from '../automatic-tags/automatic-tagger.js'
+import { setAndSaveCommentAutomaticTags } from '../automatic-tags/automatic-tags.js'
 import { isRemoteVideoCommentAccepted } from '../moderation.js'
 import { Hooks } from '../plugins/hooks.js'
+import { shouldCommentBeHeldForReview } from '../video-comment.js'
 import { fetchAP } from './activity.js'
 import { getOrCreateAPActor } from './actors/index.js'
 import { checkUrlsSameHost } from './url.js'
@@ -19,7 +29,7 @@ type ResolveThreadParams = {
 }
 type ResolveThreadResult = Promise<{ video: MVideoAccountLightBlacklistAllFiles, comment: MCommentOwnerVideo, commentCreated: boolean }>
 
-async function addVideoComments (commentUrls: string[]) {
+export async function addVideoComments (commentUrls: string[]) {
   return Bluebird.map(commentUrls, async commentUrl => {
     try {
       await resolveThread({ url: commentUrl, isVideo: false })
@@ -29,7 +39,7 @@ async function addVideoComments (commentUrls: string[]) {
   }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
 }
 
-async function resolveThread (params: ResolveThreadParams): ResolveThreadResult {
+export async function resolveThread (params: ResolveThreadParams): ResolveThreadResult {
   const { url, isVideo } = params
 
   if (params.commentCreated === undefined) params.commentCreated = false
@@ -54,24 +64,21 @@ async function resolveThread (params: ResolveThreadParams): ResolveThreadResult
   return resolveRemoteParentComment(params)
 }
 
-export {
-  addVideoComments,
-  resolveThread
-}
-
+// ---------------------------------------------------------------------------
+// Private
 // ---------------------------------------------------------------------------
 
 async function resolveCommentFromDB (params: ResolveThreadParams) {
   const { url, comments, commentCreated } = params
 
-  const commentFromDatabase = await VideoCommentModel.loadByUrlAndPopulateReplyAndVideoUrlAndAccount(url)
+  const commentFromDatabase = await VideoCommentModel.loadByUrlAndPopulateReplyAndVideoImmutableAndAccount(url)
   if (!commentFromDatabase) return undefined
 
   let parentComments = comments.concat([ commentFromDatabase ])
 
   // Speed up things and resolve directly the thread
   if (commentFromDatabase.InReplyToVideoComment) {
-    const data = await VideoCommentModel.listThreadParentComments(commentFromDatabase, undefined, 'DESC')
+    const data = await VideoCommentModel.listThreadParentComments({ comment: commentFromDatabase, order: 'DESC' })
 
     parentComments = parentComments.concat(data)
   }
@@ -84,6 +91,8 @@ async function resolveCommentFromDB (params: ResolveThreadParams) {
   })
 }
 
+// ---------------------------------------------------------------------------
+
 async function tryToResolveThreadFromVideo (params: ResolveThreadParams) {
   const { url, comments, commentCreated } = params
 
@@ -96,6 +105,10 @@ async function tryToResolveThreadFromVideo (params: ResolveThreadParams) {
     throw new Error('Cannot resolve thread of video that is not compatible with federation')
   }
 
+  if (video.commentsPolicy === VideoCommentPolicy.DISABLED) {
+    return undefined
+  }
+
   let resultComment: MCommentOwnerVideo
   if (comments.length !== 0) {
     const firstReply = comments[comments.length - 1] as MCommentOwnerVideo
@@ -109,8 +122,11 @@ async function tryToResolveThreadFromVideo (params: ResolveThreadParams) {
       return undefined
     }
 
+    const firstReplyAutomaticTags = await getAutomaticTagsAndAssignReview(firstReply, video)
     comments[comments.length - 1] = await firstReply.save()
 
+    await setAndSaveCommentAutomaticTags({ comment: firstReply, automaticTags: firstReplyAutomaticTags })
+
     for (let i = comments.length - 2; i >= 0; i--) {
       const comment = comments[i] as MCommentOwnerVideo
       comment.originCommentId = firstReply.id
@@ -123,7 +139,11 @@ async function tryToResolveThreadFromVideo (params: ResolveThreadParams) {
         return undefined
       }
 
+      const automaticTags = await getAutomaticTagsAndAssignReview(comment, video)
+
       comments[i] = await comment.save()
+
+      await setAndSaveCommentAutomaticTags({ comment, automaticTags })
     }
 
     resultComment = comments[0] as MCommentOwnerVideo
@@ -132,6 +152,26 @@ async function tryToResolveThreadFromVideo (params: ResolveThreadParams) {
   return { video, comment: resultComment, commentCreated }
 }
 
+async function getAutomaticTagsAndAssignReview (comment: MComment, video: MVideoAccountLight) {
+  // Remote comment already exists in database or remote video -> we don't need to rebuild automatic tags
+  if (comment.id) return []
+
+  const ownerAccount = video.VideoChannel.Account
+
+  const automaticTags = await new AutomaticTagger().buildCommentsAutomaticTags({ ownerAccount, text: comment.text })
+
+  // Third parties rely on origin, so if origin has the comment it's not held for review
+  if (video.isOwned() || comment.isOwned()) {
+    comment.heldForReview = await shouldCommentBeHeldForReview({ user: null, video, automaticTags })
+  } else {
+    comment.heldForReview = false
+  }
+
+  return automaticTags
+}
+
+// ---------------------------------------------------------------------------
+
 async function resolveRemoteParentComment (params: ResolveThreadParams) {
   const { url, comments } = params
 
@@ -169,7 +209,11 @@ async function resolveRemoteParentComment (params: ResolveThreadParams) {
     originCommentId: null,
     createdAt: new Date(body.published),
     updatedAt: new Date(body.updated),
-    deletedAt: body.deleted ? new Date(body.deleted) : null
+    replyApproval: body.replyApproval,
+
+    deletedAt: body.deleted
+      ? new Date(body.deleted)
+      : null
   }) as MCommentOwner
   comment.Account = actor ? actor.Account : null
 
diff --git a/server/core/lib/activitypub/videos/shared/abstract-builder.ts b/server/core/lib/activitypub/videos/shared/abstract-builder.ts
index 2c0ad99ac..82dba850f 100644
--- a/server/core/lib/activitypub/videos/shared/abstract-builder.ts
+++ b/server/core/lib/activitypub/videos/shared/abstract-builder.ts
@@ -1,4 +1,3 @@
-import { CreationAttributes, Transaction } from 'sequelize'
 import {
   ActivityTagObject,
   ThumbnailType,
@@ -6,9 +5,14 @@ import {
   VideoObject,
   VideoStreamingPlaylistType_Type
 } from '@peertube/peertube-models'
+import { isVideoChaptersObjectValid } from '@server/helpers/custom-validators/activitypub/video-chapters.js'
 import { deleteAllModels, filterNonExistingModels, retryTransactionWrapper } from '@server/helpers/database-utils.js'
-import { logger, LoggerTagsFn } from '@server/helpers/logger.js'
+import { LoggerTagsFn, logger } from '@server/helpers/logger.js'
+import { sequelizeTypescript } from '@server/initializers/database.js'
+import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js'
+import { setAndSaveVideoAutomaticTags } from '@server/lib/automatic-tags/automatic-tags.js'
 import { updateRemoteVideoThumbnail } from '@server/lib/thumbnail.js'
+import { replaceChapters } from '@server/lib/video-chapters.js'
 import { setVideoTags } from '@server/lib/video.js'
 import { StoryboardModel } from '@server/models/video/storyboard.js'
 import { VideoCaptionModel } from '@server/models/video/video-caption.js'
@@ -18,11 +22,14 @@ import { VideoStreamingPlaylistModel } from '@server/models/video/video-streamin
 import {
   MStreamingPlaylistFiles,
   MStreamingPlaylistFilesVideo,
+  MVideo,
   MVideoCaption,
   MVideoFile,
   MVideoFullLight,
   MVideoThumbnail
 } from '@server/types/models/index.js'
+import { CreationAttributes, Transaction } from 'sequelize'
+import { fetchAP } from '../../activity.js'
 import { findOwner, getOrCreateAPActor } from '../../actors/index.js'
 import {
   getCaptionAttributesFromObject,
@@ -35,10 +42,6 @@ import {
   getThumbnailFromIcons
 } from './object-to-model-attributes.js'
 import { getTrackerUrls, setVideoTrackers } from './trackers.js'
-import { fetchAP } from '../../activity.js'
-import { isVideoChaptersObjectValid } from '@server/helpers/custom-validators/activitypub/video-chapters.js'
-import { sequelizeTypescript } from '@server/initializers/database.js'
-import { replaceChapters } from '@server/lib/video-chapters.js'
 
 export abstract class APVideoAbstractBuilder {
   protected abstract videoObject: VideoObject
@@ -217,4 +220,17 @@ export abstract class APVideoAbstractBuilder {
     const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'streaming-playlist', t))
     playlistModel.VideoFiles = await Promise.all(upsertTasks)
   }
+
+  protected async setAutomaticTags (options: {
+    video: MVideo
+    oldVideo?: Pick<MVideo, 'name' | 'description'>
+    transaction: Transaction
+  }) {
+    const { video, transaction, oldVideo } = options
+
+    if (oldVideo && video.name === oldVideo.name && video.description === oldVideo.description) return
+
+    const automaticTags = await new AutomaticTagger().buildVideoAutomaticTags({ video, transaction })
+    await setAndSaveVideoAutomaticTags({ video, automaticTags, transaction })
+  }
 }
diff --git a/server/core/lib/activitypub/videos/shared/creator.ts b/server/core/lib/activitypub/videos/shared/creator.ts
index d387ff01e..676074343 100644
--- a/server/core/lib/activitypub/videos/shared/creator.ts
+++ b/server/core/lib/activitypub/videos/shared/creator.ts
@@ -1,10 +1,10 @@
+import { VideoObject } from '@peertube/peertube-models'
 import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger.js'
 import { sequelizeTypescript } from '@server/initializers/database.js'
 import { Hooks } from '@server/lib/plugins/hooks.js'
 import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js'
 import { VideoModel } from '@server/models/video/video.js'
 import { MVideoFullLight, MVideoThumbnail } from '@server/types/models/index.js'
-import { VideoObject } from '@peertube/peertube-models'
 import { APVideoAbstractBuilder } from './abstract-builder.js'
 import { getVideoAttributesFromObject } from './object-to-model-attributes.js'
 
@@ -41,6 +41,8 @@ export class APVideoCreator extends APVideoAbstractBuilder {
       await this.insertOrReplaceLive(videoCreated, t)
       await this.insertOrReplaceStoryboard(videoCreated, t)
 
+      await this.setAutomaticTags({ video: videoCreated, transaction: t })
+
       // We added a video in this channel, set it as updated
       await channel.setAsUpdated(t)
 
diff --git a/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts b/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts
index ae9f8a651..75685376b 100644
--- a/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts
+++ b/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts
@@ -219,7 +219,9 @@ export function getVideoAttributesFromObject (videoChannel: MChannelId, videoObj
     description,
     support,
     nsfw: videoObject.sensitive,
-    commentsEnabled: videoObject.commentsEnabled,
+
+    commentsPolicy: videoObject.commentsPolicy,
+
     downloadEnabled: videoObject.downloadEnabled,
     waitTranscoding: videoObject.waitTranscoding,
     isLive: videoObject.isLiveBroadcast,
diff --git a/server/core/lib/activitypub/videos/updater.ts b/server/core/lib/activitypub/videos/updater.ts
index e722744bd..3c7058644 100644
--- a/server/core/lib/activitypub/videos/updater.ts
+++ b/server/core/lib/activitypub/videos/updater.ts
@@ -52,6 +52,8 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
       this.checkChannelUpdateOrThrow(channelActor)
 
       const oldState = this.video.state
+      const oldVideo = { name: this.video.name, description: this.video.description }
+
       const videoUpdated = await this.updateVideo(channelActor.VideoChannel, undefined, overrideTo)
 
       await runInReadCommittedTransaction(async t => {
@@ -63,6 +65,7 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
         runInReadCommittedTransaction(t => this.setTags(videoUpdated, t)),
         runInReadCommittedTransaction(t => this.setTrackers(videoUpdated, t)),
         runInReadCommittedTransaction(t => this.setStoryboard(videoUpdated, t)),
+        runInReadCommittedTransaction(t => this.setAutomaticTags({ video: videoUpdated, transaction: t, oldVideo })),
         runInReadCommittedTransaction(t => {
           return Promise.all([
             this.setPreview(videoUpdated, t),
@@ -130,7 +133,7 @@ export class APVideoUpdater extends APVideoAbstractBuilder {
     this.video.description = videoData.description
     this.video.support = videoData.support
     this.video.nsfw = videoData.nsfw
-    this.video.commentsEnabled = videoData.commentsEnabled
+    this.video.commentsPolicy = videoData.commentsPolicy
     this.video.downloadEnabled = videoData.downloadEnabled
     this.video.waitTranscoding = videoData.waitTranscoding
     this.video.state = videoData.state
diff --git a/server/core/lib/automatic-tags/automatic-tagger.ts b/server/core/lib/automatic-tags/automatic-tagger.ts
new file mode 100644
index 000000000..634ce62ae
--- /dev/null
+++ b/server/core/lib/automatic-tags/automatic-tagger.ts
@@ -0,0 +1,142 @@
+import { AutomaticTagAvailable, AutomaticTagPolicy, CommentAutomaticTagPolicies } from '@peertube/peertube-models'
+import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
+import { WEBSERVER } from '@server/initializers/constants.js'
+import { getServerActor } from '@server/models/application/application.js'
+import { AccountAutomaticTagPolicyModel } from '@server/models/automatic-tag/account-automatic-tag-policy.js'
+import { WatchedWordsListModel } from '@server/models/watched-words/watched-words-list.js'
+import { MAccount, MAccountId, MVideo } from '@server/types/models/index.js'
+import Linkifyit from 'linkify-it'
+import { Transaction } from 'sequelize'
+
+const lTags = loggerTagsFactory('automatic-tags')
+
+const linkifyit = Linkifyit()
+
+export class AutomaticTagger {
+
+  private static readonly SPECIAL_TAGS = {
+    EXTERNAL_LINK: 'external-link'
+  }
+
+  async buildCommentsAutomaticTags (options: {
+    ownerAccount: MAccount
+    text: string
+    transaction?: Transaction
+  }) {
+    const { text, ownerAccount, transaction } = options
+
+    const serverAccount = (await getServerActor()).Account
+
+    try {
+      const [ accountTags, serverTags ] = await Promise.all([
+        this.buildAutomaticTags({ account: ownerAccount, text, transaction }),
+        this.buildAutomaticTags({ account: serverAccount, text, transaction })
+      ])
+
+      logger.debug('Built automatic tags for comment', { text, accountTags, serverTags, ...lTags() })
+
+      return [ ...accountTags, ...serverTags ]
+    } catch (err) {
+      logger.error('Cannot build comment automatic tags', { text, err, ...lTags() })
+
+      return []
+    }
+  }
+
+  async buildVideoAutomaticTags (options: {
+    video: MVideo
+    transaction?: Transaction
+  }) {
+    const { video, transaction } = options
+
+    const serverAccount = (await getServerActor()).Account
+
+    try {
+      const [ videoNameTags, videoDescriptionTags ] = await Promise.all([
+        this.buildAutomaticTags({ account: serverAccount, text: video.name, transaction }),
+        this.buildAutomaticTags({ account: serverAccount, text: video.description, transaction })
+      ])
+
+      logger.debug('Built automatic tags for video', { video, videoNameTags, videoDescriptionTags, ...lTags() })
+
+      return [ ...videoNameTags, ...videoDescriptionTags ]
+    } catch (err) {
+      logger.error('Cannot build video automatic tags', { video, err, ...lTags() })
+
+      return []
+    }
+  }
+
+  private async buildAutomaticTags (options: {
+    account: MAccount
+    text: string
+    transaction?: Transaction
+  }) {
+    const { text, account, transaction } = options
+
+    const tagsDone = new Set<string>()
+    const automaticTags: { name: string, accountId: number }[] = []
+
+    // Watched words by account that published the video
+    const watchedWords = await WatchedWordsListModel.buildWatchedWordsRegexp({ accountId: account.id, transaction })
+
+    logger.debug(`Got watched words regex for account ${account.getDisplayName()}`, { watchedWords, ...lTags() })
+
+    for (const { listName, regex } of watchedWords) {
+      try {
+        if (regex.test(text)) {
+          tagsDone.add(listName)
+          automaticTags.push({ name: listName, accountId: account.id })
+        }
+      } catch (err) {
+        logger.error('Cannot test regex against text', { regex, err, ...lTags() })
+      }
+    }
+
+    // Core PeerTube tags
+    if (!tagsDone.has(AutomaticTagger.SPECIAL_TAGS.EXTERNAL_LINK) && this.hasExternalLinks(text)) {
+      // This is a global tag, not assigned to a specific account
+      automaticTags.push({ name: AutomaticTagger.SPECIAL_TAGS.EXTERNAL_LINK, accountId: account.id })
+      tagsDone.add(AutomaticTagger.SPECIAL_TAGS.EXTERNAL_LINK)
+    }
+
+    logger.debug('Built automatic tags for text', { text, automaticTags, ...lTags() })
+
+    return automaticTags
+  }
+
+  private hasExternalLinks (text: string) {
+    if (!text) return false
+
+    const matches = linkifyit.match(text)
+    if (!matches) return false
+
+    logger.debug('Found external links in text', { matches, text, ...lTags() })
+
+    return matches.some(({ url }) => new URL(url).host !== WEBSERVER.HOST)
+  }
+
+  // ---------------------------------------------------------------------------
+
+  static async getAutomaticTagPolicies (account: MAccountId) {
+    const policies = await AccountAutomaticTagPolicyModel.listOfAccount(account)
+
+    const result: CommentAutomaticTagPolicies = {
+      review: policies.filter(p => p.policy === AutomaticTagPolicy.REVIEW_COMMENT).map(p => p.name)
+    }
+
+    return result
+  }
+
+  static async getAutomaticTagAvailable (account: MAccountId) {
+    const result: AutomaticTagAvailable = {
+      available: [
+        ...(await WatchedWordsListModel.listNamesOf(account)).map(t => ({ name: t, type: 'watched-words-list' as 'watched-words-list' })),
+
+        ...Object.values(AutomaticTagger.SPECIAL_TAGS).map(t => ({ name: t, type: 'core' as 'core' }))
+      ]
+    }
+
+    return result
+  }
+}
diff --git a/server/core/lib/automatic-tags/automatic-tags.ts b/server/core/lib/automatic-tags/automatic-tags.ts
new file mode 100644
index 000000000..3ecf79863
--- /dev/null
+++ b/server/core/lib/automatic-tags/automatic-tags.ts
@@ -0,0 +1,99 @@
+import { AutomaticTagPolicyType } from '@peertube/peertube-models'
+import { AccountAutomaticTagPolicyModel } from '@server/models/automatic-tag/account-automatic-tag-policy.js'
+import { AutomaticTagModel } from '@server/models/automatic-tag/automatic-tag.js'
+import { CommentAutomaticTagModel } from '@server/models/automatic-tag/comment-automatic-tag.js'
+import { VideoAutomaticTagModel } from '@server/models/automatic-tag/video-automatic-tag.js'
+import {
+  MAccountId,
+  MComment,
+  MCommentAdminOrUserFormattable,
+  MCommentAutomaticTagWithTag,
+  MVideo,
+  MVideoAutomaticTagWithTag
+} from '@server/types/models/index.js'
+import { Transaction } from 'sequelize'
+
+export async function setAndSaveCommentAutomaticTags (options: {
+  comment: MComment
+  automaticTags: { accountId: number, name: string }[]
+  transaction?: Transaction
+}) {
+  const { comment, automaticTags, transaction } = options
+
+  if (automaticTags.length === 0) return
+
+  const commentAutomaticTags: MCommentAutomaticTagWithTag[] = []
+
+  const accountIds = new Set(automaticTags.map(t => t.accountId))
+  for (const accountId of accountIds) {
+    await CommentAutomaticTagModel.deleteAllOfAccountAndComment({ accountId, commentId: comment.id, transaction })
+  }
+
+  for (const tag of automaticTags) {
+    const automaticTagInstance = await AutomaticTagModel.findOrCreateAutomaticTag({ tag: tag.name, transaction })
+
+    const [ commentAutomaticTag ] = await CommentAutomaticTagModel.upsert({
+      accountId: tag.accountId,
+      automaticTagId: automaticTagInstance.id,
+      commentId: comment.id
+    }, { transaction })
+
+    commentAutomaticTag.AutomaticTag = automaticTagInstance
+
+    commentAutomaticTags.push(commentAutomaticTag)
+  }
+
+  (comment as MCommentAdminOrUserFormattable).CommentAutomaticTags = commentAutomaticTags
+}
+
+export async function setAndSaveVideoAutomaticTags (options: {
+  video: MVideo
+  automaticTags: { accountId: number, name: string }[]
+  transaction?: Transaction
+}) {
+  const { video, automaticTags, transaction } = options
+
+  if (automaticTags.length === 0) return
+
+  const accountIds = new Set(automaticTags.map(t => t.accountId))
+  for (const accountId of accountIds) {
+    await VideoAutomaticTagModel.deleteAllOfAccountAndVideo({ accountId, videoId: video.id, transaction })
+  }
+
+  const videoAutomaticTags: MVideoAutomaticTagWithTag[] = []
+
+  for (const tag of automaticTags) {
+    const automaticTagInstance = await AutomaticTagModel.findOrCreateAutomaticTag({ tag: tag.name, transaction })
+
+    const [ videoAutomaticTag ] = await VideoAutomaticTagModel.upsert({
+      accountId: tag.accountId,
+      automaticTagId: automaticTagInstance.id,
+      videoId: video.id
+    }, { transaction })
+
+    videoAutomaticTag.AutomaticTag = automaticTagInstance
+
+    videoAutomaticTags.push(videoAutomaticTag)
+  }
+}
+
+export async function setAccountAutomaticTagsPolicy (options: {
+  account: MAccountId
+  tags: string[]
+  policy: AutomaticTagPolicyType
+  transaction?: Transaction
+}) {
+  const { account, policy, tags, transaction } = options
+
+  await AccountAutomaticTagPolicyModel.deleteOfAccount({ account, policy, transaction })
+
+  for (const tag of tags) {
+    const automaticTagInstance = await AutomaticTagModel.findOrCreateAutomaticTag({ tag, transaction })
+
+    await AccountAutomaticTagPolicyModel.create({
+      policy,
+      accountId: account.id,
+      automaticTagId: automaticTagInstance.id
+    }, { transaction })
+  }
+}
diff --git a/server/core/lib/job-queue/handlers/activitypub-cleaner.ts b/server/core/lib/job-queue/handlers/activitypub-cleaner.ts
index 7c8c68fdf..643a641f3 100644
--- a/server/core/lib/job-queue/handlers/activitypub-cleaner.ts
+++ b/server/core/lib/job-queue/handlers/activitypub-cleaner.ts
@@ -183,7 +183,7 @@ function commentOptionsFactory () {
     bodyValidator: (body: any) => sanitizeAndCheckVideoCommentObject(body),
 
     updater: async (url: string, newUrl: string) => {
-      const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(url)
+      const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(url)
       comment.url = newUrl
 
       await comment.save()
@@ -192,7 +192,7 @@ function commentOptionsFactory () {
     },
 
     deleter: async (url) => {
-      const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(url)
+      const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(url)
 
       await comment.destroy()
 
diff --git a/server/core/lib/job-queue/handlers/video-import.ts b/server/core/lib/job-queue/handlers/video-import.ts
index db8f84077..c791a402b 100644
--- a/server/core/lib/job-queue/handlers/video-import.ts
+++ b/server/core/lib/job-queue/handlers/video-import.ts
@@ -1,6 +1,8 @@
-import { Job } from 'bullmq'
-import { move, remove } from 'fs-extra/esm'
-import { stat } from 'fs/promises'
+import { buildAspectRatio } from '@peertube/peertube-core-utils'
+import {
+  ffprobePromise,
+  getChaptersFromContainer, getVideoStreamDuration
+} from '@peertube/peertube-ffmpeg'
 import {
   ThumbnailType,
   ThumbnailType_Type,
@@ -15,20 +17,24 @@ import {
 import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
 import { YoutubeDLWrapper } from '@server/helpers/youtube-dl/index.js'
 import { CONFIG } from '@server/initializers/config.js'
+import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js'
+import { setAndSaveVideoAutomaticTags } from '@server/lib/automatic-tags/automatic-tags.js'
 import { isPostImportVideoAccepted } from '@server/lib/moderation.js'
 import { Hooks } from '@server/lib/plugins/hooks.js'
 import { ServerConfigManager } from '@server/lib/server-config-manager.js'
 import { createOptimizeOrMergeAudioJobs } from '@server/lib/transcoding/create-transcoding-job.js'
 import { isUserQuotaValid } from '@server/lib/user.js'
+import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js'
+import { buildNewFile } from '@server/lib/video-file.js'
+import { buildMoveJob, buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
 import { VideoPathManager } from '@server/lib/video-path-manager.js'
 import { buildNextVideoState } from '@server/lib/video-state.js'
-import { buildMoveJob, buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js'
 import { MUserId, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
 import { MVideoImport, MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/types/models/video/video-import.js'
-import {
-  ffprobePromise,
-  getChaptersFromContainer, getVideoStreamDuration
-} from '@peertube/peertube-ffmpeg'
+import { Job } from 'bullmq'
+import { FfprobeData } from 'fluent-ffmpeg'
+import { move, remove } from 'fs-extra/esm'
+import { stat } from 'fs/promises'
 import { logger } from '../../../helpers/logger.js'
 import { getSecureTorrentName } from '../../../helpers/utils.js'
 import { createTorrentAndSetInfoHash, downloadWebTorrentVideo } from '../../../helpers/webtorrent.js'
@@ -41,10 +47,6 @@ import { federateVideoIfNeeded } from '../../activitypub/videos/index.js'
 import { Notifier } from '../../notifier/index.js'
 import { generateLocalVideoMiniature } from '../../thumbnail.js'
 import { JobQueue } from '../job-queue.js'
-import { replaceChaptersIfNotExist } from '@server/lib/video-chapters.js'
-import { FfprobeData } from 'fluent-ffmpeg'
-import { buildNewFile } from '@server/lib/video-file.js'
-import { buildAspectRatio } from '@peertube/peertube-core-utils'
 
 async function processVideoImport (job: Job): Promise<VideoImportPreventExceptionResult> {
   const payload = job.data as VideoImportPayload
@@ -209,6 +211,9 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
 
           await replaceChaptersIfNotExist({ video, chapters: containerChapters, transaction: t })
 
+          const automaticTags = await new AutomaticTagger().buildVideoAutomaticTags({ video, transaction: t })
+          await setAndSaveVideoAutomaticTags({ video, automaticTags, transaction: t })
+
           // Now we can federate the video (reload from database, we need more attributes)
           const videoForFederation = await VideoModel.loadFull(video.uuid, t)
           await federateVideoIfNeeded(videoForFederation, true, t)
diff --git a/server/core/lib/job-queue/handlers/video-live-ending.ts b/server/core/lib/job-queue/handlers/video-live-ending.ts
index 28b4aeb5c..496367eda 100644
--- a/server/core/lib/job-queue/handlers/video-live-ending.ts
+++ b/server/core/lib/job-queue/handlers/video-live-ending.ts
@@ -120,7 +120,7 @@ async function saveReplayToExternalVideo (options: {
     category: liveVideo.category,
     licence: liveVideo.licence,
     language: liveVideo.language,
-    commentsEnabled: liveVideo.commentsEnabled,
+    commentsPolicy: liveVideo.commentsPolicy,
     downloadEnabled: liveVideo.downloadEnabled,
     waitTranscoding: true,
     nsfw: liveVideo.nsfw,
diff --git a/server/core/lib/local-video-creator.ts b/server/core/lib/local-video-creator.ts
index d025a3e3f..4414a52a8 100644
--- a/server/core/lib/local-video-creator.ts
+++ b/server/core/lib/local-video-creator.ts
@@ -24,6 +24,8 @@ import { FfprobeData } from 'fluent-ffmpeg'
 import { move } from 'fs-extra/esm'
 import { getLocalVideoActivityPubUrl } from './activitypub/url.js'
 import { federateVideoIfNeeded } from './activitypub/videos/federate.js'
+import { AutomaticTagger } from './automatic-tags/automatic-tagger.js'
+import { setAndSaveVideoAutomaticTags } from './automatic-tags/automatic-tags.js'
 import { Hooks } from './plugins/hooks.js'
 import { generateLocalVideoMiniature, updateLocalVideoMiniatureFromExisting } from './thumbnail.js'
 import { autoBlacklistVideoIfNeeded } from './video-blacklist.js'
@@ -31,7 +33,7 @@ import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video
 import { buildNewFile, createVideoSource } from './video-file.js'
 import { addVideoJobsAfterCreation } from './video-jobs.js'
 import { VideoPathManager } from './video-path-manager.js'
-import { setVideoTags } from './video.js'
+import { buildCommentsPolicy, setVideoTags } from './video.js'
 
 type VideoAttributes = Omit<VideoCreate, 'channelId'> & {
   duration: number
@@ -143,6 +145,9 @@ export class LocalVideoCreator {
 
         await setVideoTags({ video: this.video, tags: this.videoAttributes.tags, transaction })
 
+        const automaticTags = await new AutomaticTagger().buildVideoAutomaticTags({ video: this.video, transaction })
+        await setAndSaveVideoAutomaticTags({ video: this.video, automaticTags, transaction })
+
         // Schedule an update in the future?
         if (this.videoAttributes.scheduleUpdate) {
           await ScheduleVideoUpdateModel.create({
@@ -271,7 +276,7 @@ export class LocalVideoCreator {
       category: videoInfo.category,
       licence: videoInfo.licence ?? CONFIG.DEFAULTS.PUBLISH.LICENCE,
       language: videoInfo.language,
-      commentsEnabled: videoInfo.commentsEnabled ?? CONFIG.DEFAULTS.PUBLISH.COMMENTS_ENABLED,
+      commentsPolicy: buildCommentsPolicy(videoInfo),
       downloadEnabled: videoInfo.downloadEnabled ?? CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED,
       waitTranscoding: videoInfo.waitTranscoding || false,
       nsfw: videoInfo.nsfw || false,
diff --git a/server/core/lib/notifier/notifier.ts b/server/core/lib/notifier/notifier.ts
index db835e89b..277f0afb7 100644
--- a/server/core/lib/notifier/notifier.ts
+++ b/server/core/lib/notifier/notifier.ts
@@ -54,6 +54,7 @@ class Notifier {
     publicationAfterScheduleUpdate: [ OwnedPublicationAfterScheduleUpdate ],
     publicationAfterAutoUnblacklist: [ OwnedPublicationAfterAutoUnblacklist ],
     newComment: [ CommentMention, NewCommentForVideoOwner ],
+    commentApproval: [ CommentMention ],
     newAbuse: [ NewAbuseForModerators ],
     newBlacklist: [ NewBlacklistForOwner ],
     unblacklist: [ UnblacklistForOwner ],
@@ -123,6 +124,15 @@ class Notifier {
       .catch(err => logger.error('Cannot notify of new comment %s.', comment.url, { err }))
   }
 
+  notifyOnNewCommentApproval (comment: MCommentOwnerVideo): void {
+    const models = this.notificationModels.commentApproval
+
+    logger.debug('Notify on comment approval', { comment: comment.url, ...lTags() })
+
+    this.sendNotifications(models, comment)
+      .catch(err => logger.error('Cannot notify on comment approval %s.', comment.url, { err }))
+  }
+
   notifyOnNewAbuse (payload: NewAbusePayload): void {
     const models = this.notificationModels.newAbuse
 
diff --git a/server/core/lib/notifier/shared/comment/comment-mention.ts b/server/core/lib/notifier/shared/comment/comment-mention.ts
index e6bba1eee..990f6c114 100644
--- a/server/core/lib/notifier/shared/comment/comment-mention.ts
+++ b/server/core/lib/notifier/shared/comment/comment-mention.ts
@@ -24,6 +24,10 @@ export class CommentMention extends AbstractNotification <MCommentOwnerVideo, MU
   private accountMutedHash: { [ id: number ]: boolean }
   private instanceMutedHash: { [ id: number ]: boolean }
 
+  isDisabled () {
+    return this.payload.heldForReview === true
+  }
+
   async prepare () {
     const extractedUsernames = this.payload.extractMentions()
     logger.debug(
@@ -52,7 +56,7 @@ export class CommentMention extends AbstractNotification <MCommentOwnerVideo, MU
   }
 
   log () {
-    logger.info('Notifying %d users of new comment %s.', this.users.length, this.payload.url)
+    logger.info('Notifying %d users of new comment mention %s.', this.users.length, this.payload.url)
   }
 
   getSetting (user: MUserNotifSettingAccount) {
diff --git a/server/core/lib/notifier/shared/comment/new-comment-for-video-owner.ts b/server/core/lib/notifier/shared/comment/new-comment-for-video-owner.ts
index eddb162f3..8e07c8491 100644
--- a/server/core/lib/notifier/shared/comment/new-comment-for-video-owner.ts
+++ b/server/core/lib/notifier/shared/comment/new-comment-for-video-owner.ts
@@ -50,10 +50,15 @@ export class NewCommentForVideoOwner extends AbstractNotification <MCommentOwner
   }
 
   createEmail (to: string) {
-    const video = this.payload.Video
-    const videoUrl = WEBSERVER.URL + this.payload.Video.getWatchStaticPath()
-    const commentUrl = WEBSERVER.URL + this.payload.getCommentStaticPath()
-    const commentHtml = toSafeHtml(this.payload.text)
+    const comment = this.payload
+
+    const video = comment.Video
+    const videoUrl = WEBSERVER.URL + comment.Video.getWatchStaticPath()
+    const commentHtml = toSafeHtml(comment.text)
+
+    const commentUrl = comment.heldForReview
+      ? WEBSERVER.URL + comment.getCommentUserReviewPath()
+      : WEBSERVER.URL + comment.getCommentStaticPath()
 
     return {
       template: 'video-comment-new',
@@ -66,8 +71,11 @@ export class NewCommentForVideoOwner extends AbstractNotification <MCommentOwner
         commentHtml,
         video,
         videoUrl,
+        requiresApproval: this.payload.heldForReview,
         action: {
-          text: 'View comment',
+          text: comment.heldForReview
+            ? 'Review comment'
+            : 'View comment',
           url: commentUrl
         }
       }
diff --git a/server/core/lib/server-config-manager.ts b/server/core/lib/server-config-manager.ts
index 5569b571e..523c2e554 100644
--- a/server/core/lib/server-config-manager.ts
+++ b/server/core/lib/server-config-manager.ts
@@ -3,6 +3,7 @@ import {
   RegisteredExternalAuthConfig,
   RegisteredIdAndPassAuthConfig,
   ServerConfig,
+  VideoCommentPolicy,
   VideoResolutionType
 } from '@peertube/peertube-models'
 import { getServerCommit } from '@server/helpers/version.js'
@@ -72,7 +73,11 @@ class ServerConfigManager {
       defaults: {
         publish: {
           downloadEnabled: CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED,
-          commentsEnabled: CONFIG.DEFAULTS.PUBLISH.COMMENTS_ENABLED,
+
+          commentsPolicy: CONFIG.DEFAULTS.PUBLISH.COMMENTS_POLICY,
+          // TODO: remove, deprecated in 6.2
+          commentsEnabled: CONFIG.DEFAULTS.PUBLISH.COMMENTS_POLICY !== VideoCommentPolicy.DISABLED,
+
           privacy: CONFIG.DEFAULTS.PUBLISH.PRIVACY,
           licence: CONFIG.DEFAULTS.PUBLISH.LICENCE
         },
diff --git a/server/core/lib/stat-manager.ts b/server/core/lib/stat-manager.ts
index 3a44a5f70..202502ef4 100644
--- a/server/core/lib/stat-manager.ts
+++ b/server/core/lib/stat-manager.ts
@@ -147,7 +147,9 @@ class StatsManager {
       Like: 0,
       Dislike: 0,
       Flag: 0,
-      View: 0
+      View: 0,
+      ApproveReply: 0,
+      RejectReply: 0
     }
   }
 
@@ -170,6 +172,8 @@ class StatsManager {
       totalActivityPubDislikeMessagesSuccesses: this.inboxMessages.successesPerType.Dislike,
       totalActivityPubFlagMessagesSuccesses: this.inboxMessages.successesPerType.Flag,
       totalActivityPubViewMessagesSuccesses: this.inboxMessages.successesPerType.View,
+      totalActivityPubApproveReplyMessagesSuccesses: this.inboxMessages.successesPerType.ApproveReply,
+      totalActivityPubRejectReplyMessagesSuccesses: this.inboxMessages.successesPerType.RejectReply,
 
       totalActivityPubCreateMessagesErrors: this.inboxMessages.errorsPerType.Create,
       totalActivityPubUpdateMessagesErrors: this.inboxMessages.errorsPerType.Update,
@@ -183,6 +187,8 @@ class StatsManager {
       totalActivityPubDislikeMessagesErrors: this.inboxMessages.errorsPerType.Dislike,
       totalActivityPubFlagMessagesErrors: this.inboxMessages.errorsPerType.Flag,
       totalActivityPubViewMessagesErrors: this.inboxMessages.errorsPerType.View,
+      totalActivityPubApproveReplyMessagesErrors: this.inboxMessages.errorsPerType.ApproveReply,
+      totalActivityPubRejectReplyMessagesErrors: this.inboxMessages.errorsPerType.RejectReply,
 
       totalActivityPubMessagesErrors: this.inboxMessages.errors,
 
diff --git a/server/core/lib/user-import-export/exporters/auto-tag-policies.ts b/server/core/lib/user-import-export/exporters/auto-tag-policies.ts
new file mode 100644
index 000000000..ded6eef8b
--- /dev/null
+++ b/server/core/lib/user-import-export/exporters/auto-tag-policies.ts
@@ -0,0 +1,18 @@
+import { AutoTagPoliciesJSON } from '@peertube/peertube-models'
+import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js'
+import { AbstractUserExporter } from './abstract-user-exporter.js'
+
+export class AutoTagPoliciesExporter extends AbstractUserExporter <AutoTagPoliciesJSON> {
+
+  async export () {
+    const data = await AutomaticTagger.getAutomaticTagPolicies(this.user.Account)
+
+    return {
+      json: {
+        reviewComments: data.review.map(name => ({ name }))
+      } as AutoTagPoliciesJSON,
+
+      staticFiles: []
+    }
+  }
+}
diff --git a/server/core/lib/user-import-export/exporters/comments-exporter.ts b/server/core/lib/user-import-export/exporters/comments-exporter.ts
index 47f850118..a2a57e07c 100644
--- a/server/core/lib/user-import-export/exporters/comments-exporter.ts
+++ b/server/core/lib/user-import-export/exporters/comments-exporter.ts
@@ -34,9 +34,9 @@ export class CommentsExporter extends AbstractUserExporter <CommentsExportJSON>
 
   private formatCommentsAP (comments: MCommentExport[]) {
     return Bluebird.mapSeries(comments, async ({ url }) => {
-      const comment = await VideoCommentModel.loadByUrlAndPopulateReplyAndVideoUrlAndAccount(url)
+      const comment = await VideoCommentModel.loadByUrlAndPopulateReplyAndVideoImmutableAndAccount(url)
 
-      const threadParentComments = await VideoCommentModel.listThreadParentComments(comment, undefined)
+      const threadParentComments = await VideoCommentModel.listThreadParentComments({ comment })
       let commentObject = comment.toActivityPubObject(threadParentComments) as VideoCommentObject
 
       const isPublic = true // Comments are always public
diff --git a/server/core/lib/user-import-export/exporters/index.ts b/server/core/lib/user-import-export/exporters/index.ts
index 21ec0b37c..5e0abe5f2 100644
--- a/server/core/lib/user-import-export/exporters/index.ts
+++ b/server/core/lib/user-import-export/exporters/index.ts
@@ -1,4 +1,6 @@
+export * from './abstract-user-exporter.js'
 export * from './account-exporter.js'
+export * from './auto-tag-policies.js'
 export * from './blocklist-exporter.js'
 export * from './channels-exporter.js'
 export * from './comments-exporter.js'
@@ -6,8 +8,8 @@ export * from './dislikes-exporter.js'
 export * from './followers-exporter.js'
 export * from './following-exporter.js'
 export * from './likes-exporter.js'
-export * from './abstract-user-exporter.js'
 export * from './user-settings-exporter.js'
 export * from './user-video-history-exporter.js'
 export * from './video-playlists-exporter.js'
 export * from './videos-exporter.js'
+export * from './watched-words-lists-exporter.js'
diff --git a/server/core/lib/user-import-export/exporters/videos-exporter.ts b/server/core/lib/user-import-export/exporters/videos-exporter.ts
index 887a5b0ef..d34c1fc62 100644
--- a/server/core/lib/user-import-export/exporters/videos-exporter.ts
+++ b/server/core/lib/user-import-export/exporters/videos-exporter.ts
@@ -1,5 +1,5 @@
 import { pick } from '@peertube/peertube-core-utils'
-import { ActivityCreate, FileStorage, VideoExportJSON, VideoObject, VideoPrivacy } from '@peertube/peertube-models'
+import { ActivityCreate, FileStorage, VideoCommentPolicy, VideoExportJSON, VideoObject, VideoPrivacy } from '@peertube/peertube-models'
 import { logger } from '@server/helpers/logger.js'
 import { USER_EXPORT_MAX_ITEMS } from '@server/initializers/constants.js'
 import { audiencify, getAudience } from '@server/lib/activitypub/audience.js'
@@ -151,7 +151,10 @@ export class VideosExporter extends AbstractUserExporter <VideoExportJSON> {
 
       nsfw: video.nsfw,
 
-      commentsEnabled: video.commentsEnabled,
+      commentsPolicy: video.commentsPolicy,
+      // TODO: remove, deprecated in 6.2
+      commentsEnabled: video.commentsPolicy !== VideoCommentPolicy.DISABLED,
+
       downloadEnabled: video.downloadEnabled,
 
       waitTranscoding: video.waitTranscoding,
diff --git a/server/core/lib/user-import-export/exporters/watched-words-lists-exporter.ts b/server/core/lib/user-import-export/exporters/watched-words-lists-exporter.ts
new file mode 100644
index 000000000..4bb5c0ec8
--- /dev/null
+++ b/server/core/lib/user-import-export/exporters/watched-words-lists-exporter.ts
@@ -0,0 +1,23 @@
+import { WatchedWordsListsJSON } from '@peertube/peertube-models'
+import { WatchedWordsListModel } from '@server/models/watched-words/watched-words-list.js'
+import { AbstractUserExporter } from './abstract-user-exporter.js'
+
+export class WatchedWordsListsExporter extends AbstractUserExporter <WatchedWordsListsJSON> {
+
+  async export () {
+    const data = await WatchedWordsListModel.listForExport({ accountId: this.user.Account.id })
+
+    return {
+      json: {
+        watchedWordLists: data.map(list => ({
+          listName: list.listName,
+          words: list.words,
+          createdAt: list.createdAt.toISOString(),
+          updatedAt: list.updatedAt.toISOString()
+        }))
+      } as WatchedWordsListsJSON,
+
+      staticFiles: []
+    }
+  }
+}
diff --git a/server/core/lib/user-import-export/importers/review-comments-tag-policies-importer.ts b/server/core/lib/user-import-export/importers/review-comments-tag-policies-importer.ts
new file mode 100644
index 000000000..c19030b0f
--- /dev/null
+++ b/server/core/lib/user-import-export/importers/review-comments-tag-policies-importer.ts
@@ -0,0 +1,31 @@
+import { AutoTagPoliciesJSON, AutomaticTagPolicy } from '@peertube/peertube-models'
+import { isWatchedWordListNameValid } from '@server/helpers/custom-validators/watched-words.js'
+import { setAccountAutomaticTagsPolicy } from '@server/lib/automatic-tags/automatic-tags.js'
+import { AbstractUserImporter } from './abstract-user-importer.js'
+
+type SanitizedObject = AutoTagPoliciesJSON['reviewComments']
+
+// eslint-disable-next-line max-len
+export class ReviewCommentsTagPoliciesImporter
+  extends AbstractUserImporter <AutoTagPoliciesJSON, AutoTagPoliciesJSON['reviewComments'] & { archiveFiles?: never }, SanitizedObject> {
+
+  protected getImportObjects (json: AutoTagPoliciesJSON) {
+    if (!json.reviewComments) return []
+
+    return [ json.reviewComments ]
+  }
+
+  protected sanitize (data: AutoTagPoliciesJSON['reviewComments']) {
+    return data.filter(d => isWatchedWordListNameValid(d.name))
+  }
+
+  protected async importObject (data: SanitizedObject) {
+    await setAccountAutomaticTagsPolicy({
+      account: this.user.Account,
+      policy: AutomaticTagPolicy.REVIEW_COMMENT,
+      tags: data.map(v => v.name)
+    })
+
+    return { duplicate: false }
+  }
+}
diff --git a/server/core/lib/user-import-export/importers/user-video-history-importer.ts b/server/core/lib/user-import-export/importers/user-video-history-importer.ts
index bdd7ae5d1..402330a28 100644
--- a/server/core/lib/user-import-export/importers/user-video-history-importer.ts
+++ b/server/core/lib/user-import-export/importers/user-video-history-importer.ts
@@ -1,14 +1,14 @@
-import { UserVideoHistoryExportJSON } from '@peertube/peertube-models'
-import { AbstractRatesImporter } from './abstract-rates-importer.js'
-import { isUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js'
 import { pick } from '@peertube/peertube-core-utils'
+import { UserVideoHistoryExportJSON } from '@peertube/peertube-models'
+import { isUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js'
 import { loadOrCreateVideoIfAllowedForUser } from '@server/lib/model-loaders/video.js'
 import { UserVideoHistoryModel } from '@server/models/user/user-video-history.js'
+import { AbstractUserImporter } from './abstract-user-importer.js'
 
-type SanitizedObject = Pick<UserVideoHistoryExportJSON['watchedVideos'][0], 'videoUrl' | 'lastTimecode'>
+type SanitizedObject = Pick<UserVideoHistoryExportJSON['watchedVideos'][0], 'videoUrl' | 'lastTimecode' | 'archiveFiles'>
 
 // eslint-disable-next-line max-len
-export class UserVideoHistoryImporter extends AbstractRatesImporter <UserVideoHistoryExportJSON, UserVideoHistoryExportJSON['watchedVideos'][0]> {
+export class UserVideoHistoryImporter extends AbstractUserImporter <UserVideoHistoryExportJSON, UserVideoHistoryExportJSON['watchedVideos'][0], SanitizedObject> {
 
   protected getImportObjects (json: UserVideoHistoryExportJSON) {
     return json.watchedVideos
diff --git a/server/core/lib/user-import-export/importers/videos-importer.ts b/server/core/lib/user-import-export/importers/videos-importer.ts
index b648ecc45..8877025b1 100644
--- a/server/core/lib/user-import-export/importers/videos-importer.ts
+++ b/server/core/lib/user-import-export/importers/videos-importer.ts
@@ -1,6 +1,13 @@
 import { pick } from '@peertube/peertube-core-utils'
 import { ffprobePromise, getVideoStreamDuration } from '@peertube/peertube-ffmpeg'
-import { LiveVideoLatencyMode, ThumbnailType, VideoExportJSON, VideoPrivacy, VideoState } from '@peertube/peertube-models'
+import {
+  LiveVideoLatencyMode,
+  ThumbnailType,
+  VideoCommentPolicy,
+  VideoExportJSON,
+  VideoPrivacy,
+  VideoState
+} from '@peertube/peertube-models'
 import { buildUUID, getFileSize } from '@peertube/peertube-node-utils'
 import { isArray, isBooleanValid, isUUIDValid } from '@server/helpers/custom-validators/misc.js'
 import { isVideoCaptionLanguageValid } from '@server/helpers/custom-validators/video-captions.js'
@@ -10,6 +17,7 @@ import { isLiveLatencyModeValid } from '@server/helpers/custom-validators/video-
 import {
   isPasswordValid,
   isVideoCategoryValid,
+  isVideoCommentsPolicyValid,
   isVideoDescriptionValid,
   isVideoDurationValid,
   isVideoLanguageValid,
@@ -42,7 +50,7 @@ const lTags = loggerTagsFactory('user-import')
 
 type ImportObject = VideoExportJSON['videos'][0]
 type SanitizedObject = Pick<ImportObject, 'name' | 'duration' | 'channel' | 'privacy' | 'archiveFiles' | 'captions' | 'category' |
-'licence' | 'language' | 'description' | 'support' | 'nsfw' | 'isLive' | 'commentsEnabled' | 'downloadEnabled' | 'waitTranscoding' |
+'licence' | 'language' | 'description' | 'support' | 'nsfw' | 'isLive' | 'commentsPolicy' | 'downloadEnabled' | 'waitTranscoding' |
 'originallyPublishedAt' | 'tags' | 'live' | 'passwords' | 'source' | 'chapters'>
 
 export class VideosImporter extends AbstractUserImporter <VideoExportJSON, ImportObject, SanitizedObject> {
@@ -59,17 +67,27 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor
     if (o.isLive !== true && !o.archiveFiles?.videoFile) return undefined
 
     if (!isVideoCategoryValid(o.category)) o.category = null
-    if (!isVideoLicenceValid(o.licence)) o.licence = CONFIG.DEFAULTS.PUBLISH.LICENCE
+    if (!o.licence || !isVideoLicenceValid(o.licence)) o.licence = CONFIG.DEFAULTS.PUBLISH.LICENCE
     if (!isVideoLanguageValid(o.language)) o.language = null
     if (!isVideoDescriptionValid(o.description)) o.description = null
     if (!isVideoSupportValid(o.support)) o.support = null
 
     if (!isBooleanValid(o.nsfw)) o.nsfw = false
     if (!isBooleanValid(o.isLive)) o.isLive = false
-    if (!isBooleanValid(o.commentsEnabled)) o.commentsEnabled = CONFIG.DEFAULTS.PUBLISH.COMMENTS_ENABLED
     if (!isBooleanValid(o.downloadEnabled)) o.downloadEnabled = CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED
     if (!isBooleanValid(o.waitTranscoding)) o.waitTranscoding = true
 
+    if (!o.commentsPolicy || !isVideoCommentsPolicyValid(o.commentsPolicy)) {
+      // Fallback to deprecated property
+      if (isBooleanValid(o.commentsEnabled)) {
+        o.commentsPolicy = o.commentsEnabled === true
+          ? VideoCommentPolicy.ENABLED
+          : VideoCommentPolicy.DISABLED
+      } else {
+        o.commentsPolicy = CONFIG.DEFAULTS.PUBLISH.COMMENTS_POLICY
+      }
+    }
+
     if (!isVideoSourceFilenameValid(o.source?.inputFilename)) o.source = undefined
 
     if (!isVideoOriginallyPublishedAtValid(o.originallyPublishedAt)) o.originallyPublishedAt = null
@@ -89,7 +107,7 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor
       if (!isBooleanValid(o.live.saveReplay)) o.live.saveReplay = false
       if (o.live.saveReplay && !isVideoReplayPrivacyValid(o.live.replaySettings.privacy)) return undefined
 
-      if (!isLiveLatencyModeValid(o.live.latencyMode)) o.live.latencyMode = LiveVideoLatencyMode.DEFAULT
+      if (!o.live.latencyMode || !isLiveLatencyModeValid(o.live.latencyMode)) o.live.latencyMode = LiveVideoLatencyMode.DEFAULT
 
       if (!o.live.streamKey) o.live.streamKey = buildUUID()
       else if (!isUUIDValid(o.live.streamKey)) return undefined
@@ -114,7 +132,7 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor
       'support',
       'nsfw',
       'isLive',
-      'commentsEnabled',
+      'commentsPolicy',
       'downloadEnabled',
       'waitTranscoding',
       'originallyPublishedAt',
@@ -204,7 +222,7 @@ export class VideosImporter extends AbstractUserImporter <VideoExportJSON, Impor
           'isLive',
           'nsfw',
           'tags',
-          'commentsEnabled',
+          'commentsPolicy',
           'downloadEnabled',
           'waitTranscoding',
           'originallyPublishedAt'
diff --git a/server/core/lib/user-import-export/importers/watched-words-lists-importer.ts b/server/core/lib/user-import-export/importers/watched-words-lists-importer.ts
new file mode 100644
index 000000000..3bd457035
--- /dev/null
+++ b/server/core/lib/user-import-export/importers/watched-words-lists-importer.ts
@@ -0,0 +1,35 @@
+import { pick } from '@peertube/peertube-core-utils'
+import { WatchedWordsListsJSON } from '@peertube/peertube-models'
+import { areWatchedWordsValid, isWatchedWordListNameValid } from '@server/helpers/custom-validators/watched-words.js'
+import { WatchedWordsListModel } from '@server/models/watched-words/watched-words-list.js'
+import { AbstractUserImporter } from './abstract-user-importer.js'
+
+type SanitizedObject = Pick<WatchedWordsListsJSON['watchedWordLists'][0], 'listName' | 'words'>
+
+// eslint-disable-next-line max-len
+export class WatchedWordsListsImporter extends AbstractUserImporter <WatchedWordsListsJSON, WatchedWordsListsJSON['watchedWordLists'][0], SanitizedObject> {
+
+  protected getImportObjects (json: WatchedWordsListsJSON) {
+    return json.watchedWordLists
+  }
+
+  protected sanitize (data: WatchedWordsListsJSON['watchedWordLists'][0]) {
+    if (!isWatchedWordListNameValid(data.listName)) return undefined
+    if (!areWatchedWordsValid(data.words)) return undefined
+
+    return pick(data, [ 'listName', 'words' ])
+  }
+
+  protected async importObject (data: SanitizedObject) {
+    const accountId = this.user.Account.id
+    const existing = await WatchedWordsListModel.loadByListName({ listName: data.listName, accountId })
+
+    if (existing) {
+      await existing.updateList({ listName: data.listName, words: data.words })
+    } else {
+      await WatchedWordsListModel.createList({ accountId, listName: data.listName, words: data.words })
+    }
+
+    return { duplicate: false }
+  }
+}
diff --git a/server/core/lib/user-import-export/user-exporter.ts b/server/core/lib/user-import-export/user-exporter.ts
index 4da1d03e5..e7cd1c9de 100644
--- a/server/core/lib/user-import-export/user-exporter.ts
+++ b/server/core/lib/user-import-export/user-exporter.ts
@@ -1,6 +1,23 @@
+import { FileStorage, UserExportState } from '@peertube/peertube-models'
+import { getFileSize } from '@peertube/peertube-node-utils'
+import { activityPubContextify } from '@server/helpers/activity-pub-utils.js'
+import { saveInTransactionWithRetries } from '@server/helpers/database-utils.js'
+import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
+import { UserModel } from '@server/models/user/user.js'
+import { MUserDefault, MUserExport } from '@server/types/models/index.js'
+import archiver, { Archiver } from 'archiver'
+import { createWriteStream } from 'fs'
+import { remove } from 'fs-extra/esm'
 import { join, parse } from 'path'
+import { PassThrough, Readable, Writable } from 'stream'
+import { activityPubCollection } from '../activitypub/collection.js'
+import { getContextFilter } from '../activitypub/context.js'
+import { getUserExportFileObjectStorageSize, removeUserExportObjectStorage, storeUserExportFile } from '../object-storage/user-export.js'
+import { getFSUserExportFilePath } from '../paths.js'
 import {
+  AbstractUserExporter,
   AccountExporter,
+  AutoTagPoliciesExporter,
   BlocklistExporter,
   ChannelsExporter,
   CommentsExporter,
@@ -8,27 +25,13 @@ import {
   ExportResult,
   FollowersExporter,
   FollowingExporter,
-  LikesExporter, AbstractUserExporter,
+  LikesExporter,
   UserSettingsExporter,
+  UserVideoHistoryExporter,
   VideoPlaylistsExporter,
   VideosExporter,
-  UserVideoHistoryExporter
+  WatchedWordsListsExporter
 } from './exporters/index.js'
-import { MUserDefault, MUserExport } from '@server/types/models/index.js'
-import archiver, { Archiver } from 'archiver'
-import { createWriteStream } from 'fs'
-import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
-import { PassThrough, Readable, Writable } from 'stream'
-import { activityPubContextify } from '@server/helpers/activity-pub-utils.js'
-import { getContextFilter } from '../activitypub/context.js'
-import { activityPubCollection } from '../activitypub/collection.js'
-import { FileStorage, UserExportState } from '@peertube/peertube-models'
-import { saveInTransactionWithRetries } from '@server/helpers/database-utils.js'
-import { UserModel } from '@server/models/user/user.js'
-import { getFSUserExportFilePath } from '../paths.js'
-import { getUserExportFileObjectStorageSize, removeUserExportObjectStorage, storeUserExportFile } from '../object-storage/user-export.js'
-import { getFileSize } from '@peertube/peertube-node-utils'
-import { remove } from 'fs-extra/esm'
 
 const lTags = loggerTagsFactory('user-export')
 
@@ -245,6 +248,14 @@ export class UserExporter {
       {
         jsonFilename: 'video-history.json',
         exporter: new UserVideoHistoryExporter(options)
+      },
+      {
+        jsonFilename: 'watched-words-lists.json',
+        exporter: new WatchedWordsListsExporter(options)
+      },
+      {
+        jsonFilename: 'automatic-tag-policies.json',
+        exporter: new AutoTagPoliciesExporter(options)
       }
     ] as { jsonFilename: string, exporter: AbstractUserExporter<any> }[]
   }
diff --git a/server/core/lib/user-import-export/user-importer.ts b/server/core/lib/user-import-export/user-importer.ts
index 4654f05d8..acbd6f2b5 100644
--- a/server/core/lib/user-import-export/user-importer.ts
+++ b/server/core/lib/user-import-export/user-importer.ts
@@ -1,23 +1,25 @@
-import { MUserDefault, MUserImport } from '@server/types/models/index.js'
-import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
 import { UserImportResultSummary, UserImportState } from '@peertube/peertube-models'
-import { saveInTransactionWithRetries } from '@server/helpers/database-utils.js'
-import { getFSUserImportFilePath } from '../paths.js'
-import { remove } from 'fs-extra/esm'
-import { unzip } from '@server/helpers/unzip.js'
 import { getFilenameWithoutExt } from '@peertube/peertube-node-utils'
-import { VideosImporter } from './importers/videos-importer.js'
+import { saveInTransactionWithRetries } from '@server/helpers/database-utils.js'
+import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
+import { unzip } from '@server/helpers/unzip.js'
 import { UserModel } from '@server/models/user/user.js'
+import { MUserDefault, MUserImport } from '@server/types/models/index.js'
+import { remove } from 'fs-extra/esm'
 import { dirname, join } from 'path'
-import { AccountImporter } from './importers/account-importer.js'
-import { UserSettingsImporter } from './importers/user-settings-importer.js'
-import { ChannelsImporter } from './importers/channels-importer.js'
+import { getFSUserImportFilePath } from '../paths.js'
 import { BlocklistImporter } from './importers/account-blocklist-importer.js'
+import { AccountImporter } from './importers/account-importer.js'
+import { ChannelsImporter } from './importers/channels-importer.js'
+import { DislikesImporter } from './importers/dislikes-importer.js'
 import { FollowingImporter } from './importers/following-importer.js'
 import { LikesImporter } from './importers/likes-importer.js'
-import { DislikesImporter } from './importers/dislikes-importer.js'
-import { VideoPlaylistsImporter } from './importers/video-playlists-importer.js'
+import { ReviewCommentsTagPoliciesImporter } from './importers/review-comments-tag-policies-importer.js'
+import { UserSettingsImporter } from './importers/user-settings-importer.js'
 import { UserVideoHistoryImporter } from './importers/user-video-history-importer.js'
+import { VideoPlaylistsImporter } from './importers/video-playlists-importer.js'
+import { VideosImporter } from './importers/videos-importer.js'
+import { WatchedWordsListsImporter } from './importers/watched-words-lists-importer.js'
 
 const lTags = loggerTagsFactory('user-import')
 
@@ -36,7 +38,9 @@ export class UserImporter {
         videos: this.buildSummary(),
         account: this.buildSummary(),
         userSettings: this.buildSummary(),
-        userVideoHistory: this.buildSummary()
+        userVideoHistory: this.buildSummary(),
+        watchedWordsLists: this.buildSummary(),
+        commentAutoTagPolicies: this.buildSummary()
       }
     }
 
@@ -133,6 +137,14 @@ export class UserImporter {
       {
         name: 'userVideoHistory' as 'userVideoHistory',
         importer: new UserVideoHistoryImporter(this.buildImporterOptions(user, 'video-history.json'))
+      },
+      {
+        name: 'watchedWordsLists' as 'watchedWordsLists',
+        importer: new WatchedWordsListsImporter(this.buildImporterOptions(user, 'watched-words-lists.json'))
+      },
+      {
+        name: 'commentAutoTagPolicies' as 'commentAutoTagPolicies',
+        importer: new ReviewCommentsTagPoliciesImporter(this.buildImporterOptions(user, 'automatic-tag-policies.json'))
       }
     ]
   }
diff --git a/server/core/lib/video-comment.ts b/server/core/lib/video-comment.ts
index f89215671..2c65b387e 100644
--- a/server/core/lib/video-comment.ts
+++ b/server/core/lib/video-comment.ts
@@ -1,27 +1,31 @@
-import { ResultList, VideoCommentThreadTree } from '@peertube/peertube-models'
+import { AutomaticTagPolicy, ResultList, UserRight, VideoCommentPolicy, VideoCommentThreadTree } from '@peertube/peertube-models'
 import { logger } from '@server/helpers/logger.js'
 import { sequelizeTypescript } from '@server/initializers/database.js'
+import { AccountModel } from '@server/models/account/account.js'
+import { AccountAutomaticTagPolicyModel } from '@server/models/automatic-tag/account-automatic-tag-policy.js'
 import express from 'express'
 import cloneDeep from 'lodash-es/cloneDeep.js'
-import * as Sequelize from 'sequelize'
+import { Transaction } from 'sequelize'
 import { VideoCommentModel } from '../models/video/video-comment.js'
 import {
-  MAccountDefault,
   MComment,
   MCommentFormattable,
   MCommentOwnerVideo,
-  MCommentOwnerVideoReply,
+  MCommentOwnerVideoReply, MUserAccountId, MVideoAccountLight,
   MVideoFullLight
 } from '../types/models/index.js'
-import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send/index.js'
+import { sendCreateVideoCommentIfNeeded, sendDeleteVideoComment, sendReplyApproval } from './activitypub/send/index.js'
 import { getLocalVideoCommentActivityPubUrl } from './activitypub/url.js'
+import { AutomaticTagger } from './automatic-tags/automatic-tagger.js'
+import { setAndSaveCommentAutomaticTags } from './automatic-tags/automatic-tags.js'
+import { Notifier } from './notifier/notifier.js'
 import { Hooks } from './plugins/hooks.js'
 
-async function removeComment (commentArg: MComment, req: express.Request, res: express.Response) {
+export async function removeComment (commentArg: MComment, req: express.Request, res: express.Response) {
   let videoCommentInstanceBefore: MCommentOwnerVideo
 
   await sequelizeTypescript.transaction(async t => {
-    const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(commentArg.url, t)
+    const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideoAndReply(commentArg.url, t)
 
     videoCommentInstanceBefore = cloneDeep(comment)
 
@@ -39,42 +43,83 @@ async function removeComment (commentArg: MComment, req: express.Request, res: e
   Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore, req, res })
 }
 
-async function createVideoComment (obj: {
+export async function approveComment (commentArg: MComment) {
+  await sequelizeTypescript.transaction(async t => {
+    const comment = await VideoCommentModel.loadByIdAndPopulateVideoAndAccountAndReply(commentArg.id, t)
+
+    const oldHeldForReview = comment.heldForReview
+
+    comment.heldForReview = false
+    await comment.save({ transaction: t })
+
+    if (comment.isOwned()) {
+      await sendCreateVideoCommentIfNeeded(comment, t)
+    } else {
+      sendReplyApproval(comment, 'ApproveReply')
+    }
+
+    if (oldHeldForReview !== comment.heldForReview) {
+      Notifier.Instance.notifyOnNewCommentApproval(comment)
+    }
+
+    logger.info('Video comment %d approved.', comment.id)
+  })
+}
+
+export async function createLocalVideoComment (options: {
   text: string
   inReplyToComment: MComment | null
   video: MVideoFullLight
-  account: MAccountDefault
-}, t: Sequelize.Transaction) {
+  user: MUserAccountId
+}) {
+  const { user, video, text, inReplyToComment } = options
+
   let originCommentId: number | null = null
   let inReplyToCommentId: number | null = null
 
-  if (obj.inReplyToComment && obj.inReplyToComment !== null) {
-    originCommentId = obj.inReplyToComment.originCommentId || obj.inReplyToComment.id
-    inReplyToCommentId = obj.inReplyToComment.id
+  if (inReplyToComment && inReplyToComment !== null) {
+    originCommentId = inReplyToComment.originCommentId || inReplyToComment.id
+    inReplyToCommentId = inReplyToComment.id
   }
 
-  const comment = await VideoCommentModel.create({
-    text: obj.text,
-    originCommentId,
-    inReplyToCommentId,
-    videoId: obj.video.id,
-    accountId: obj.account.id,
-    url: new Date().toISOString()
-  }, { transaction: t, validate: false })
+  return sequelizeTypescript.transaction(async transaction => {
+    const account = await AccountModel.load(user.Account.id, transaction)
 
-  comment.url = getLocalVideoCommentActivityPubUrl(obj.video, comment)
+    const automaticTags = await new AutomaticTagger().buildCommentsAutomaticTags({
+      ownerAccount: video.VideoChannel.Account,
+      text,
+      transaction
+    })
 
-  const savedComment: MCommentOwnerVideoReply = await comment.save({ transaction: t })
-  savedComment.InReplyToVideoComment = obj.inReplyToComment
-  savedComment.Video = obj.video
-  savedComment.Account = obj.account
+    const heldForReview = await shouldCommentBeHeldForReview({ user, video, automaticTags, transaction })
 
-  await sendCreateVideoComment(savedComment, t)
+    const comment = await VideoCommentModel.create({
+      text,
+      originCommentId,
+      inReplyToCommentId,
+      videoId: video.id,
+      accountId: account.id,
+      heldForReview,
+      url: new Date().toISOString()
+    }, { transaction, validate: false })
 
-  return savedComment
+    comment.url = getLocalVideoCommentActivityPubUrl(video, comment)
+
+    const savedComment: MCommentOwnerVideoReply = await comment.save({ transaction })
+
+    await setAndSaveCommentAutomaticTags({ comment: savedComment, automaticTags, transaction })
+
+    savedComment.InReplyToVideoComment = inReplyToComment
+    savedComment.Video = video
+    savedComment.Account = account
+
+    await sendCreateVideoCommentIfNeeded(savedComment, transaction)
+
+    return savedComment
+  })
 }
 
-function buildFormattedCommentTree (resultList: ResultList<MCommentFormattable>): VideoCommentThreadTree {
+export function buildFormattedCommentTree (resultList: ResultList<MCommentFormattable>): VideoCommentThreadTree {
   // Comments are sorted by id ASC
   const comments = resultList.data
 
@@ -106,10 +151,32 @@ function buildFormattedCommentTree (resultList: ResultList<MCommentFormattable>)
   return thread
 }
 
-// ---------------------------------------------------------------------------
+export async function shouldCommentBeHeldForReview (options: {
+  user: MUserAccountId
+  video: MVideoAccountLight
+  automaticTags: { name: string, accountId: number }[]
+  transaction?: Transaction
+}) {
+  const { user, video, transaction, automaticTags } = options
 
-export {
-  removeComment,
-  createVideoComment,
-  buildFormattedCommentTree
+  if (video.isOwned() && user) {
+    if (user.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT)) return false
+    if (user.Account.id === video.VideoChannel.accountId) return false
+  }
+
+  if (video.commentsPolicy === VideoCommentPolicy.REQUIRES_APPROVAL) return true
+  if (video.isOwned() !== true) return false
+
+  const ownerAccountTags = automaticTags
+    .filter(t => t.accountId === video.VideoChannel.accountId)
+    .map(t => t.name)
+
+  if (ownerAccountTags.length === 0) return false
+
+  return AccountAutomaticTagPolicyModel.hasPolicyOnTags({
+    accountId: video.VideoChannel.accountId,
+    policy: AutomaticTagPolicy.REVIEW_COMMENT,
+    tags: ownerAccountTags,
+    transaction
+  })
 }
diff --git a/server/core/lib/video-pre-import.ts b/server/core/lib/video-pre-import.ts
index 484474f92..ceca6580b 100644
--- a/server/core/lib/video-pre-import.ts
+++ b/server/core/lib/video-pre-import.ts
@@ -1,4 +1,3 @@
-import { remove } from 'fs-extra/esm'
 import {
   ThumbnailType,
   ThumbnailType_Type,
@@ -18,7 +17,7 @@ import { sequelizeTypescript } from '@server/initializers/database.js'
 import { Hooks } from '@server/lib/plugins/hooks.js'
 import { ServerConfigManager } from '@server/lib/server-config-manager.js'
 import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js'
-import { setVideoTags } from '@server/lib/video.js'
+import { buildCommentsPolicy, setVideoTags } from '@server/lib/video.js'
 import { VideoImportModel } from '@server/models/video/video-import.js'
 import { VideoPasswordModel } from '@server/models/video/video-password.js'
 import { VideoModel } from '@server/models/video/video.js'
@@ -34,10 +33,11 @@ import {
   MVideoThumbnail,
   MVideoWithBlacklistLight
 } from '@server/types/models/index.js'
+import { remove } from 'fs-extra/esm'
 import { getLocalVideoActivityPubUrl } from './activitypub/url.js'
 import { updateLocalVideoMiniatureFromExisting, updateLocalVideoMiniatureFromUrl } from './thumbnail.js'
-import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video-chapters.js'
 import { createLocalCaption } from './video-captions.js'
+import { replaceChapters, replaceChaptersFromDescriptionIfNeeded } from './video-chapters.js'
 
 class YoutubeDlImportError extends Error {
   code: YoutubeDlImportError.CODE
@@ -127,7 +127,7 @@ async function buildVideoFromImport ({ channelId, importData, importDataOverride
     category: importDataOverride?.category || importData.category,
     licence: importDataOverride?.licence ?? importData.licence ?? CONFIG.DEFAULTS.PUBLISH.LICENCE,
     language: importDataOverride?.language || importData.language,
-    commentsEnabled: importDataOverride?.commentsEnabled ?? CONFIG.DEFAULTS.PUBLISH.COMMENTS_ENABLED,
+    commentsPolicy: buildCommentsPolicy(importDataOverride),
     downloadEnabled: importDataOverride?.downloadEnabled ?? CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED,
     waitTranscoding: importDataOverride?.waitTranscoding ?? true,
     state: VideoState.TO_IMPORT,
@@ -272,10 +272,7 @@ async function buildYoutubeDLImport (options: {
 // ---------------------------------------------------------------------------
 
 export {
-  buildYoutubeDLImport,
-  YoutubeDlImportError,
-  insertFromImportIntoDB,
-  buildVideoFromImport
+  YoutubeDlImportError, buildVideoFromImport, buildYoutubeDLImport, insertFromImportIntoDB
 }
 
 // ---------------------------------------------------------------------------
diff --git a/server/core/lib/video.ts b/server/core/lib/video.ts
index 49b47a978..2e0d9e75b 100644
--- a/server/core/lib/video.ts
+++ b/server/core/lib/video.ts
@@ -1,9 +1,11 @@
-import memoizee from 'memoizee'
-import { Transaction } from 'sequelize'
+import { VideoCommentPolicy, VideoCommentPolicyType } from '@peertube/peertube-models'
+import { CONFIG } from '@server/initializers/config.js'
 import { MEMOIZE_LENGTH, MEMOIZE_TTL } from '@server/initializers/constants.js'
 import { TagModel } from '@server/models/video/tag.js'
 import { VideoModel } from '@server/models/video/video.js'
 import { MVideoTag } from '@server/types/models/index.js'
+import memoizee from 'memoizee'
+import { Transaction } from 'sequelize'
 
 // ---------------------------------------------------------------------------
 
@@ -15,7 +17,7 @@ export async function setVideoTags (options: {
   const { video, tags, transaction } = options
 
   const internalTags = tags || []
-  const tagInstances = await TagModel.findOrCreateTags(internalTags, transaction)
+  const tagInstances = await TagModel.findOrCreateMultiple({ tags: internalTags, transaction })
 
   await video.$set('Tags', tagInstances, { transaction })
   video.Tags = tagInstances
@@ -38,3 +40,17 @@ export const getCachedVideoDuration = memoizee(getVideoDuration, {
   max: MEMOIZE_LENGTH.VIDEO_DURATION,
   maxAge: MEMOIZE_TTL.VIDEO_DURATION
 })
+
+// ---------------------------------------------------------------------------
+
+export function buildCommentsPolicy (options: {
+  commentsEnabled?: boolean
+  commentsPolicy?: VideoCommentPolicyType
+}) {
+  if (options.commentsPolicy) return options.commentsPolicy
+
+  if (options.commentsEnabled === true) return VideoCommentPolicy.ENABLED
+  if (options.commentsEnabled === false) return VideoCommentPolicy.DISABLED
+
+  return CONFIG.DEFAULTS.PUBLISH.COMMENTS_POLICY
+}
diff --git a/server/core/middlewares/validators/automatic-tags.ts b/server/core/middlewares/validators/automatic-tags.ts
new file mode 100644
index 000000000..378fa8d5d
--- /dev/null
+++ b/server/core/middlewares/validators/automatic-tags.ts
@@ -0,0 +1,45 @@
+import { CommentAutomaticTagPoliciesUpdate } from '@peertube/peertube-models'
+import { isStringArray } from '@server/helpers/custom-validators/search.js'
+import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js'
+import express from 'express'
+import { body, param } from 'express-validator'
+import { doesAccountNameWithHostExist } from './shared/accounts.js'
+import { checkUserCanManageAccount } from './shared/users.js'
+import { areValidationErrors } from './shared/utils.js'
+
+export const manageAccountAutomaticTagsValidator = [
+  param('accountName')
+    .exists(),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (areValidationErrors(req, res)) return
+    if (!await doesAccountNameWithHostExist(req.params.accountName, res)) return
+    if (!checkUserCanManageAccount({ user: res.locals.oauth.token.User, account: res.locals.account, specialRight: null, res })) return
+
+    return next()
+  }
+]
+
+export const updateAutomaticTagPoliciesValidator = [
+  ...manageAccountAutomaticTagsValidator,
+
+  body('review')
+    .custom(isStringArray).withMessage('Should have a valid review array'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (areValidationErrors(req, res)) return
+
+    const body = req.body as CommentAutomaticTagPoliciesUpdate
+
+    const tagsObj = await AutomaticTagger.getAutomaticTagAvailable(res.locals.account)
+    const available = new Set(tagsObj.available.map(({ name }) => name))
+
+    for (const name of body.review) {
+      if (!available.has(name)) {
+        return res.fail({ message: `${name} is not an available automatic tag` })
+      }
+    }
+
+    return next()
+  }
+]
diff --git a/server/core/middlewares/validators/bulk.ts b/server/core/middlewares/validators/bulk.ts
index 3c25757c4..fed338449 100644
--- a/server/core/middlewares/validators/bulk.ts
+++ b/server/core/middlewares/validators/bulk.ts
@@ -17,7 +17,7 @@ const bulkRemoveCommentsOfValidator = [
     const user = res.locals.oauth.token.User
     const body = req.body as BulkRemoveCommentsOfBody
 
-    if (body.scope === 'instance' && user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) !== true) {
+    if (body.scope === 'instance' && user.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT) !== true) {
       return res.fail({
         status: HttpStatusCode.FORBIDDEN_403,
         message: 'User cannot remove any comments of this instance.'
diff --git a/server/core/middlewares/validators/shared/users.ts b/server/core/middlewares/validators/shared/users.ts
index 025f2a335..c2a18ffc6 100644
--- a/server/core/middlewares/validators/shared/users.ts
+++ b/server/core/middlewares/validators/shared/users.ts
@@ -1,20 +1,20 @@
-import express from 'express'
+import { forceNumber } from '@peertube/peertube-core-utils'
+import { HttpStatusCode, UserRightType } from '@peertube/peertube-models'
 import { ActorModel } from '@server/models/actor/actor.js'
 import { UserModel } from '@server/models/user/user.js'
-import { MUserDefault } from '@server/types/models/index.js'
-import { forceNumber } from '@peertube/peertube-core-utils'
-import { HttpStatusCode } from '@peertube/peertube-models'
+import { MAccountId, MUserAccountId, MUserDefault } from '@server/types/models/index.js'
+import express from 'express'
 
-function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) {
+export function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) {
   const id = forceNumber(idArg)
   return checkUserExist(() => UserModel.loadByIdWithChannels(id, withStats), res)
 }
 
-function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) {
+export function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) {
   return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse)
 }
 
-async function checkUserNameOrEmailDoNotAlreadyExist (username: string, email: string, res: express.Response) {
+export async function checkUserNameOrEmailDoNotAlreadyExist (username: string, email: string, res: express.Response) {
   const user = await UserModel.loadByUsernameOrEmail(username, email)
 
   if (user) {
@@ -37,7 +37,7 @@ async function checkUserNameOrEmailDoNotAlreadyExist (username: string, email: s
   return true
 }
 
-async function checkUserExist (finder: () => Promise<MUserDefault>, res: express.Response, abortResponse = true) {
+export async function checkUserExist (finder: () => Promise<MUserDefault>, res: express.Response, abortResponse = true) {
   const user = await finder()
 
   if (!user) {
@@ -55,9 +55,30 @@ async function checkUserExist (finder: () => Promise<MUserDefault>, res: express
   return true
 }
 
-export {
-  checkUserIdExist,
-  checkUserEmailExist,
-  checkUserNameOrEmailDoNotAlreadyExist,
-  checkUserExist
+export function checkUserCanManageAccount (options: {
+  user: MUserAccountId
+  account: MAccountId
+  specialRight: UserRightType
+  res: express.Response
+}) {
+  const { user, account, specialRight, res } = options
+
+  if (account.id === user.Account.id) return true
+  if (specialRight && user.hasRight(specialRight) === true) return true
+
+  if (!specialRight) {
+    res.fail({
+      status: HttpStatusCode.FORBIDDEN_403,
+      message: 'Only the owner of this account can manage this account resource.'
+    })
+
+    return false
+  }
+
+  res.fail({
+    status: HttpStatusCode.FORBIDDEN_403,
+    message: 'Only a user with sufficient right can access this account resource.'
+  })
+
+  return false
 }
diff --git a/server/core/middlewares/validators/shared/videos.ts b/server/core/middlewares/validators/shared/videos.ts
index 65b632cfa..e016fe968 100644
--- a/server/core/middlewares/validators/shared/videos.ts
+++ b/server/core/middlewares/validators/shared/videos.ts
@@ -287,7 +287,6 @@ function assignVideoTokenIfNeeded (req: Request, res: Response, video: MVideoUUI
 // ---------------------------------------------------------------------------
 
 export function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right: UserRightType, res: Response, onlyOwned = true) {
-  // Retrieve the user who did the request
   if (onlyOwned && video.isOwned() === false) {
     res.fail({
       status: HttpStatusCode.FORBIDDEN_403,
@@ -296,9 +295,6 @@ export function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight,
     return false
   }
 
-  // Check if the user can delete the video
-  // The user can delete it if he has the right
-  // Or if s/he is the video's account
   const account = video.VideoChannel.Account
   if (user.hasRight(right) === false && account.userId !== user.id) {
     res.fail({
diff --git a/server/core/middlewares/validators/sort.ts b/server/core/middlewares/validators/sort.ts
index 3f3b2eb4e..777c7cdee 100644
--- a/server/core/middlewares/validators/sort.ts
+++ b/server/core/middlewares/validators/sort.ts
@@ -30,6 +30,8 @@ export const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.
 export const videoChannelSyncsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_CHANNEL_SYNCS)
 export const videoPasswordsSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_PASSWORDS)
 
+export const watchedWordsListsSortValidator = checkSortFactory(SORTABLE_COLUMNS.WATCHED_WORDS_LISTS)
+
 export const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
 export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
 
diff --git a/server/core/middlewares/validators/users/users.ts b/server/core/middlewares/validators/users/users.ts
index fa047597f..c24f2521d 100644
--- a/server/core/middlewares/validators/users/users.ts
+++ b/server/core/middlewares/validators/users/users.ts
@@ -34,6 +34,7 @@ import {
   checkUserEmailExist,
   checkUserIdExist,
   checkUserNameOrEmailDoNotAlreadyExist,
+  checkUserCanManageAccount,
   doesVideoChannelIdExist,
   doesVideoExist,
   isValidVideoIdParam
@@ -421,12 +422,7 @@ const ensureAuthUserOwnsAccountValidator = [
   (req: express.Request, res: express.Response, next: express.NextFunction) => {
     const user = res.locals.oauth.token.User
 
-    if (res.locals.account.id !== user.Account.id) {
-      return res.fail({
-        status: HttpStatusCode.FORBIDDEN_403,
-        message: 'Only owner of this account can access this resource.'
-      })
-    }
+    if (!checkUserCanManageAccount({ user, account: res.locals.account, specialRight: null, res })) return
 
     return next()
   }
@@ -436,16 +432,8 @@ const ensureCanManageChannelOrAccount = [
   (req: express.Request, res: express.Response, next: express.NextFunction) => {
     const user = res.locals.oauth.token.user
     const account = res.locals.videoChannel?.Account ?? res.locals.account
-    const isUserOwner = account.userId === user.id
 
-    if (!isUserOwner && user.hasRight(UserRight.MANAGE_ANY_VIDEO_CHANNEL) === false) {
-      const message = `User ${user.username} does not have right this channel or account.`
-
-      return res.fail({
-        status: HttpStatusCode.FORBIDDEN_403,
-        message
-      })
-    }
+    if (!checkUserCanManageAccount({ account, user, res, specialRight: UserRight.MANAGE_ANY_VIDEO_CHANNEL })) return
 
     return next()
   }
@@ -461,7 +449,7 @@ const ensureCanModerateUser = [
 
     return res.fail({
       status: HttpStatusCode.FORBIDDEN_403,
-      message: 'A moderator can only manage users.'
+      message: 'Users can only be managed by moderators or admins.'
     })
   }
 ]
diff --git a/server/core/middlewares/validators/videos/video-comments.ts b/server/core/middlewares/validators/videos/video-comments.ts
index 7d92b16f1..836a46854 100644
--- a/server/core/middlewares/validators/videos/video-comments.ts
+++ b/server/core/middlewares/validators/videos/video-comments.ts
@@ -1,8 +1,19 @@
+import { arrayify } from '@peertube/peertube-core-utils'
+import { HttpStatusCode, UserRight, VideoCommentPolicy } from '@peertube/peertube-models'
+import { isStringArray } from '@server/helpers/custom-validators/search.js'
+import { canVideoBeFederated } from '@server/lib/activitypub/videos/federate.js'
+import { MUserAccountUrl } from '@server/types/models/index.js'
 import express from 'express'
 import { body, param, query } from 'express-validator'
-import { MUserAccountUrl } from '@server/types/models/index.js'
-import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
-import { exists, isBooleanValid, isIdValid, toBooleanOrNull } from '../../../helpers/custom-validators/misc.js'
+import {
+  exists,
+  isBooleanValid,
+  isIdOrUUIDValid,
+  isIdValid,
+  toBooleanOrNull,
+  toCompleteUUID,
+  toIntOrNull
+} from '../../../helpers/custom-validators/misc.js'
 import { isValidVideoCommentText } from '../../../helpers/custom-validators/video-comments.js'
 import { logger } from '../../../helpers/logger.js'
 import { AcceptResult, isLocalVideoCommentReplyAccepted, isLocalVideoThreadAccepted } from '../../../lib/moderation.js'
@@ -11,15 +22,19 @@ import { MCommentOwnerVideoReply, MVideo, MVideoFullLight } from '../../../types
 import {
   areValidationErrors,
   checkCanSeeVideo,
+  checkUserCanManageAccount,
+  checkUserCanManageVideo,
+  doesVideoChannelIdExist,
   doesVideoCommentExist,
   doesVideoCommentThreadExist,
   doesVideoExist,
   isValidVideoIdParam,
   isValidVideoPasswordHeader
 } from '../shared/index.js'
-import { canVideoBeFederated } from '@server/lib/activitypub/videos/federate.js'
 
-const listVideoCommentsValidator = [
+export const listAllVideoCommentsForAdminValidator = [
+  ...getCommonVideoCommentsValidators(),
+
   query('isLocal')
     .optional()
     .customSanitizer(toBooleanOrNull)
@@ -32,26 +47,46 @@ const listVideoCommentsValidator = [
     .custom(isBooleanValid)
     .withMessage('Should have a valid onLocalVideo boolean'),
 
-  query('search')
-    .optional()
-    .custom(exists),
-
-  query('searchAccount')
-    .optional()
-    .custom(exists),
-
-  query('searchVideo')
-    .optional()
-    .custom(exists),
-
-  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
     if (areValidationErrors(req, res)) return
 
+    if (req.query.videoId && !await doesVideoExist(req.query.videoId, res, 'unsafe-only-immutable-attributes')) return
+    if (req.query.videoChannelId && !await doesVideoChannelIdExist(req.query.videoChannelId, res)) return
+
     return next()
   }
 ]
 
-const listVideoCommentThreadsValidator = [
+export const listCommentsOnUserVideosValidator = [
+  ...getCommonVideoCommentsValidators(),
+
+  query('isHeldForReview')
+    .optional()
+    .customSanitizer(toBooleanOrNull)
+    .custom(isBooleanValid)
+    .withMessage('Should have a valid isHeldForReview boolean'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (areValidationErrors(req, res)) return
+
+    if (req.query.videoId && !await doesVideoExist(req.query.videoId, res, 'all')) return
+    if (req.query.videoChannelId && !await doesVideoChannelIdExist(req.query.videoChannelId, res)) return
+
+    const user = res.locals.oauth.token.User
+
+    const video = res.locals.videoAll
+    if (video && !checkUserCanManageVideo(user, video, UserRight.SEE_ALL_COMMENTS, res)) return
+
+    const channel = res.locals.videoChannel
+    if (channel && !checkUserCanManageAccount({ account: channel.Account, user, res, specialRight: UserRight.SEE_ALL_COMMENTS })) return
+
+    return next()
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+export const listVideoCommentThreadsValidator = [
   isValidVideoIdParam('videoId'),
   isValidVideoPasswordHeader(),
 
@@ -65,7 +100,7 @@ const listVideoCommentThreadsValidator = [
   }
 ]
 
-const listVideoThreadCommentsValidator = [
+export const listVideoThreadCommentsValidator = [
   isValidVideoIdParam('videoId'),
 
   param('threadId')
@@ -83,7 +118,7 @@ const listVideoThreadCommentsValidator = [
   }
 ]
 
-const addVideoCommentThreadValidator = [
+export const addVideoCommentThreadValidator = [
   isValidVideoIdParam('videoId'),
 
   body('text')
@@ -103,7 +138,7 @@ const addVideoCommentThreadValidator = [
   }
 ]
 
-const addVideoCommentReplyValidator = [
+export const addVideoCommentReplyValidator = [
   isValidVideoIdParam('videoId'),
 
   param('commentId').custom(isIdValid),
@@ -125,7 +160,7 @@ const addVideoCommentReplyValidator = [
   }
 ]
 
-const videoCommentGetValidator = [
+export const videoCommentGetValidator = [
   isValidVideoIdParam('videoId'),
 
   param('commentId')
@@ -143,7 +178,7 @@ const videoCommentGetValidator = [
   }
 ]
 
-const removeVideoCommentValidator = [
+export const removeVideoCommentValidator = [
   isValidVideoIdParam('videoId'),
 
   param('commentId')
@@ -154,29 +189,35 @@ const removeVideoCommentValidator = [
     if (!await doesVideoExist(req.params.videoId, res)) return
     if (!await doesVideoCommentExist(req.params.commentId, res.locals.videoAll, res)) return
 
-    // Check if the user who did the request is able to delete the video
     if (!checkUserCanDeleteVideoComment(res.locals.oauth.token.User, res.locals.videoCommentFull, res)) return
 
     return next()
   }
 ]
 
+export const approveVideoCommentValidator = [
+  isValidVideoIdParam('videoId'),
+
+  param('commentId')
+    .custom(isIdValid),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (areValidationErrors(req, res)) return
+    if (!await doesVideoExist(req.params.videoId, res)) return
+    if (!await doesVideoCommentExist(req.params.commentId, res.locals.videoAll, res)) return
+
+    if (!checkUserCanApproveVideoComment(res.locals.oauth.token.User, res.locals.videoCommentFull, res)) return
+
+    return next()
+  }
+]
+
 // ---------------------------------------------------------------------------
-
-export {
-  listVideoCommentThreadsValidator,
-  listVideoThreadCommentsValidator,
-  addVideoCommentThreadValidator,
-  listVideoCommentsValidator,
-  addVideoCommentReplyValidator,
-  videoCommentGetValidator,
-  removeVideoCommentValidator
-}
-
+// Private
 // ---------------------------------------------------------------------------
 
 function isVideoCommentsEnabled (video: MVideo, res: express.Response) {
-  if (video.commentsEnabled !== true) {
+  if (video.commentsPolicy === VideoCommentPolicy.DISABLED) {
     res.fail({
       status: HttpStatusCode.CONFLICT_409,
       message: 'Video comments are disabled for this video.'
@@ -196,10 +237,34 @@ function checkUserCanDeleteVideoComment (user: MUserAccountUrl, videoComment: MC
     return false
   }
 
+  return checkUserCanManageVideoComment(user, videoComment, res)
+}
+
+function checkUserCanApproveVideoComment (user: MUserAccountUrl, videoComment: MCommentOwnerVideoReply, res: express.Response) {
+  if (videoComment.isDeleted()) {
+    res.fail({
+      status: HttpStatusCode.CONFLICT_409,
+      message: 'This comment is deleted'
+    })
+    return false
+  }
+
+  if (videoComment.heldForReview !== true) {
+    res.fail({
+      status: HttpStatusCode.BAD_REQUEST_400,
+      message: 'This comment is not held for review'
+    })
+    return false
+  }
+
+  return checkUserCanManageVideoComment(user, videoComment, res)
+}
+
+function checkUserCanManageVideoComment (user: MUserAccountUrl, videoComment: MCommentOwnerVideoReply, res: express.Response) {
   const userAccount = user.Account
 
   if (
-    user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) === false && // Not a moderator
+    user.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT) === false && // Not a moderator
     videoComment.accountId !== userAccount.id && // Not the comment owner
     videoComment.Video.VideoChannel.accountId !== userAccount.id // Not the video owner
   ) {
@@ -251,3 +316,34 @@ async function isVideoCommentAccepted (req: express.Request, res: express.Respon
 
   return true
 }
+
+function getCommonVideoCommentsValidators () {
+  return [
+    query('search')
+      .optional()
+      .custom(exists),
+
+    query('searchAccount')
+      .optional()
+      .custom(exists),
+
+    query('searchVideo')
+      .optional()
+      .custom(exists),
+
+    query('videoId')
+      .optional()
+      .custom(toCompleteUUID)
+      .custom(isIdOrUUIDValid),
+
+    query('videoChannelId')
+      .optional()
+      .customSanitizer(toIntOrNull)
+      .custom(isIdValid),
+
+    query('autoTagOneOf')
+      .optional()
+      .customSanitizer(arrayify)
+      .custom(isStringArray).withMessage('Should have a valid autoTagOneOf array')
+  ]
+}
diff --git a/server/core/middlewares/validators/videos/videos.ts b/server/core/middlewares/validators/videos/videos.ts
index a3b4793fb..5c2f90d0c 100644
--- a/server/core/middlewares/validators/videos/videos.ts
+++ b/server/core/middlewares/validators/videos/videos.ts
@@ -23,6 +23,7 @@ import {
   isScheduleVideoUpdatePrivacyValid,
   isValidPasswordProtectedPrivacy,
   isVideoCategoryValid,
+  isVideoCommentsPolicyValid,
   isVideoDescriptionValid,
   isVideoImageValid,
   isVideoIncludeValid,
@@ -375,10 +376,15 @@ function getCommonVideoEditAttributes () {
         `Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
         `${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
       ),
+    // TODO: remove, deprecated in PeerTube 6.2
     body('commentsEnabled')
       .optional()
       .customSanitizer(toBooleanOrNull)
-      .custom(isBooleanValid).withMessage('Should have commentsEnabled boolean'),
+      .custom(isBooleanValid).withMessage('Should have valid commentsEnabled boolean'),
+    body('commentsPolicy')
+      .optional()
+      .custom(isVideoCommentsPolicyValid),
+
     body('downloadEnabled')
       .optional()
       .customSanitizer(toBooleanOrNull)
@@ -462,6 +468,10 @@ const commonVideosFiltersValidator = [
     .optional()
     .customSanitizer(toBooleanOrNull)
     .isBoolean().withMessage('Should be a valid excludeAlreadyWatched boolean'),
+  query('autoTagOneOf')
+    .optional()
+    .customSanitizer(arrayify)
+    .custom(isStringArray).withMessage('Should have a valid autoTagOneOf array'),
 
   (req: express.Request, res: express.Response, next: express.NextFunction) => {
     if (areValidationErrors(req, res)) return
@@ -469,10 +479,10 @@ const commonVideosFiltersValidator = [
     const user = res.locals.oauth?.token.User
 
     if ((!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) !== true)) {
-      if (req.query.include || req.query.privacyOneOf) {
+      if (req.query.include || req.query.privacyOneOf || req.query.autoTagOneOf) {
         return res.fail({
           status: HttpStatusCode.UNAUTHORIZED_401,
-          message: 'You are not allowed to see all videos or specify a custom include.'
+          message: 'You are not allowed to see all videos, specify a custom include or auto tags filter.'
         })
       }
     }
diff --git a/server/core/middlewares/validators/watched-words.ts b/server/core/middlewares/validators/watched-words.ts
new file mode 100644
index 000000000..3213d1b12
--- /dev/null
+++ b/server/core/middlewares/validators/watched-words.ts
@@ -0,0 +1,128 @@
+import { HttpStatusCode } from '@peertube/peertube-models'
+import { Awaitable } from '@peertube/peertube-typescript-utils'
+import { isIdValid } from '@server/helpers/custom-validators/misc.js'
+import { areWatchedWordsValid, isWatchedWordListNameValid } from '@server/helpers/custom-validators/watched-words.js'
+import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
+import { WatchedWordsListModel } from '@server/models/watched-words/watched-words-list.js'
+import { MAccountId, MWatchedWordsList } from '@server/types/models/index.js'
+import express from 'express'
+import { ValidationChain, body, param } from 'express-validator'
+import { doesAccountNameWithHostExist } from './shared/accounts.js'
+import { checkUserCanManageAccount } from './shared/users.js'
+import { areValidationErrors } from './shared/utils.js'
+
+export const manageAccountWatchedWordsListValidator = [
+  param('accountName')
+    .exists(),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (areValidationErrors(req, res)) return
+    if (!await doesAccountNameWithHostExist(req.params.accountName, res)) return
+    if (!checkUserCanManageAccount({ user: res.locals.oauth.token.User, account: res.locals.account, specialRight: null, res })) return
+
+    return next()
+  }
+]
+
+export function getWatchedWordsListValidatorFactory (accountGetter: (res: express.Response) => Awaitable<MAccountId>) {
+  return [
+    param('listId')
+      .custom(isIdValid),
+
+    async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+      if (areValidationErrors(req, res)) return
+
+      const watchedWordsList = await WatchedWordsListModel.load({ id: +req.params.listId, accountId: (await accountGetter(res)).id })
+      if (!watchedWordsList) {
+        return res.fail({
+          status: HttpStatusCode.NOT_FOUND_404,
+          message: 'Unknown watched words list id for this account'
+        })
+      }
+
+      res.locals.watchedWordsList = watchedWordsList
+
+      return next()
+    }
+  ]
+}
+
+function buildUpdateOrAddValidators ({ optional }: { optional: boolean }) {
+  const makeOptionalIfNeeded = (chain: ValidationChain) => {
+    if (optional) return chain.optional()
+
+    return chain
+  }
+
+  return [
+    makeOptionalIfNeeded(body('listName'))
+      .trim()
+      .custom(isWatchedWordListNameValid).withMessage(
+        `Should have a list name between ` +
+        `${CONSTRAINTS_FIELDS.WATCHED_WORDS.LIST_NAME.min} and ${CONSTRAINTS_FIELDS.WATCHED_WORDS.LIST_NAME.max} characters long`
+      ),
+
+    makeOptionalIfNeeded(body('words'))
+      .custom(areWatchedWordsValid)
+      .withMessage(
+        `Should have an array of up to ${CONSTRAINTS_FIELDS.WATCHED_WORDS.WORDS.max} words between ` +
+        `${CONSTRAINTS_FIELDS.WATCHED_WORDS.WORD.min} and ${CONSTRAINTS_FIELDS.WATCHED_WORDS.WORD.max} characters each`
+      )
+  ]
+}
+
+export function addWatchedWordsListValidatorFactory (accountGetter: (res: express.Response) => Awaitable<MAccountId>) {
+  return [
+    ...buildUpdateOrAddValidators({ optional: false }),
+
+    async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+      if (areValidationErrors(req, res)) return
+
+      const listName = req.body.listName
+      if (!await checkListNameIsUnique({ accountId: (await accountGetter(res)).id, listName, res })) return
+
+      return next()
+    }
+  ]
+}
+
+export function updateWatchedWordsListValidatorFactory (accountGetter: (res: express.Response) => Awaitable<MAccountId>) {
+  return [
+    ...buildUpdateOrAddValidators({ optional: true }),
+
+    async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+      if (areValidationErrors(req, res)) return
+
+      const currentList = res.locals.watchedWordsList
+      const listName = req.body.listName
+      if (listName && !await checkListNameIsUnique({ accountId: (await accountGetter(res)).id, listName, currentList, res })) return
+
+      return next()
+    }
+  ]
+}
+
+// ---------------------------------------------------------------------------
+// Private
+// ---------------------------------------------------------------------------
+
+async function checkListNameIsUnique (options: {
+  accountId: number
+  listName: string
+  res: express.Response
+  currentList?: MWatchedWordsList
+}) {
+  const { accountId, listName, currentList, res } = options
+
+  const existing = await WatchedWordsListModel.loadByListName({ accountId, listName })
+  if (existing && (!currentList || currentList.id !== existing.id)) {
+    res.fail({
+      status: HttpStatusCode.BAD_REQUEST_400,
+      message: `Watched words list with name ${listName} already exists`
+    })
+
+    return false
+  }
+
+  return true
+}
diff --git a/server/core/models/account/account.ts b/server/core/models/account/account.ts
index 518794ad7..f86a29062 100644
--- a/server/core/models/account/account.ts
+++ b/server/core/models/account/account.ts
@@ -1,9 +1,10 @@
-import { FindOptions, Includeable, IncludeOptions, Op, Transaction, WhereOptions } from 'sequelize'
+import { Account, AccountSummary } from '@peertube/peertube-models'
+import { ModelCache } from '@server/models/shared/model-cache.js'
+import { FindOptions, IncludeOptions, Includeable, Op, Transaction, WhereOptions } from 'sequelize'
 import {
   AllowNull,
   BeforeDestroy,
-  BelongsTo,
-  Column,
+  BelongsTo, Column,
   CreatedAt,
   DataType,
   Default,
@@ -14,8 +15,6 @@ import {
   Table,
   UpdatedAt
 } from 'sequelize-typescript'
-import { Account, AccountSummary } from '@peertube/peertube-models'
-import { ModelCache } from '@server/models/shared/model-cache.js'
 import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts.js'
 import { CONSTRAINTS_FIELDS, SERVER_ACTOR_NAME, WEBSERVER } from '../../initializers/constants.js'
 import { sendDeleteActor } from '../../lib/activitypub/send/send-delete.js'
@@ -31,9 +30,12 @@ import { ActorFollowModel } from '../actor/actor-follow.js'
 import { ActorImageModel } from '../actor/actor-image.js'
 import { ActorModel } from '../actor/actor.js'
 import { ApplicationModel } from '../application/application.js'
+import { AccountAutomaticTagPolicyModel } from '../automatic-tag/account-automatic-tag-policy.js'
+import { CommentAutomaticTagModel } from '../automatic-tag/comment-automatic-tag.js'
+import { VideoAutomaticTagModel } from '../automatic-tag/video-automatic-tag.js'
 import { ServerBlocklistModel } from '../server/server-blocklist.js'
 import { ServerModel } from '../server/server.js'
-import { buildSQLAttributes, getSort, SequelizeModel, throwIfNotValid } from '../shared/index.js'
+import { SequelizeModel, buildSQLAttributes, getSort, throwIfNotValid } from '../shared/index.js'
 import { UserModel } from '../user/user.js'
 import { VideoChannelModel } from '../video/video-channel.js'
 import { VideoCommentModel } from '../video/video-comment.js'
@@ -232,6 +234,27 @@ export class AccountModel extends SequelizeModel<AccountModel> {
   })
   BlockedBy: Awaited<AccountBlocklistModel>[]
 
+  @HasMany(() => AccountAutomaticTagPolicyModel, {
+    foreignKey: {
+      name: 'accountId',
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  AccountAutomaticTagPolicies: Awaited<AccountAutomaticTagPolicyModel>[]
+
+  @HasMany(() => CommentAutomaticTagModel, {
+    foreignKey: 'accountId',
+    onDelete: 'CASCADE'
+  })
+  CommentAutomaticTags: Awaited<CommentAutomaticTagModel>[]
+
+  @HasMany(() => VideoAutomaticTagModel, {
+    foreignKey: 'accountId',
+    onDelete: 'CASCADE'
+  })
+  VideoAutomaticTags: Awaited<VideoAutomaticTagModel>[]
+
   @BeforeDestroy
   static async sendDeleteIfOwned (instance: AccountModel, options) {
     if (!instance.Actor) {
diff --git a/server/core/models/actor/actor-follow.ts b/server/core/models/actor/actor-follow.ts
index a2e7311c6..8925a3f6d 100644
--- a/server/core/models/actor/actor-follow.ts
+++ b/server/core/models/actor/actor-follow.ts
@@ -228,7 +228,7 @@ export class ActorFollowModel extends SequelizeModel<ActorFollowModel> {
       `WHERE "actorId" = $followerActorId AND "targetActorId" = $actorId AND "state" = 'accepted' ` +
       `LIMIT 1`
 
-    return doesExist(this.sequelize, query, { actorId, followerActorId })
+    return doesExist({ sequelize: this.sequelize, query, bind: { actorId, followerActorId } })
   }
 
   static loadByActorAndTarget (actorId: number, targetActorId: number, t?: Transaction): Promise<MActorFollowActorsDefault> {
diff --git a/server/core/models/automatic-tag/account-automatic-tag-policy.ts b/server/core/models/automatic-tag/account-automatic-tag-policy.ts
new file mode 100644
index 000000000..141d0028f
--- /dev/null
+++ b/server/core/models/automatic-tag/account-automatic-tag-policy.ts
@@ -0,0 +1,96 @@
+import { type AutomaticTagPolicyType } from '@peertube/peertube-models'
+import { MAccountId } from '@server/types/models/index.js'
+import { Transaction } from 'sequelize'
+import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript'
+import { AccountModel } from '../account/account.js'
+import { SequelizeModel, createSafeIn, doesExist } from '../shared/index.js'
+import { AutomaticTagModel } from './automatic-tag.js'
+
+@Table({
+  tableName: 'accountAutomaticTagPolicy',
+  indexes: [
+    {
+      fields: [ 'accountId', 'policy', 'automaticTagId' ],
+      unique: true
+    }
+  ]
+})
+export class AccountAutomaticTagPolicyModel extends SequelizeModel<AccountAutomaticTagPolicyModel> {
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  @AllowNull(true)
+  @Column(DataType.INTEGER)
+  policy: AutomaticTagPolicyType
+
+  @ForeignKey(() => AccountModel)
+  @Column
+  accountId: number
+
+  @BelongsTo(() => AccountModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  Account: Awaited<AccountModel>
+
+  @ForeignKey(() => AutomaticTagModel)
+  @Column
+  automaticTagId: number
+
+  @BelongsTo(() => AutomaticTagModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  AutomaticTag: Awaited<AutomaticTagModel>
+
+  static async listOfAccount (account: MAccountId) {
+    const rows = await this.findAll({
+      where: { accountId: account.id },
+      include: [
+        {
+          model: AutomaticTagModel,
+          required: true
+        }
+      ]
+    })
+
+    return rows.map(r => ({ name: r.AutomaticTag.name, policy: r.policy }))
+  }
+
+  static deleteOfAccount (options: {
+    account: MAccountId
+    policy: AutomaticTagPolicyType
+    transaction?: Transaction
+  }) {
+    const { account, policy, transaction } = options
+
+    return this.destroy({
+      where: { accountId: account.id, policy },
+      transaction
+    })
+  }
+
+  static hasPolicyOnTags (options: {
+    accountId: number
+    tags: string[]
+    policy: AutomaticTagPolicyType
+    transaction: Transaction
+  }) {
+    const { accountId, tags, policy, transaction } = options
+
+    const query = `SELECT 1 FROM "accountAutomaticTagPolicy" ` +
+      `INNER JOIN "automaticTag" ON "automaticTag"."id" = "accountAutomaticTagPolicy"."automaticTagId" ` +
+      `WHERE "accountId" = $accountId AND "accountAutomaticTagPolicy"."policy" = $policy AND ` +
+        `"automaticTag"."name" IN (${createSafeIn(this.sequelize, tags)}) ` +
+      `LIMIT 1`
+
+    return doesExist({ sequelize: this.sequelize, query, bind: { accountId, policy }, transaction })
+  }
+}
diff --git a/server/core/models/automatic-tag/automatic-tag.ts b/server/core/models/automatic-tag/automatic-tag.ts
new file mode 100644
index 000000000..5f662f8c4
--- /dev/null
+++ b/server/core/models/automatic-tag/automatic-tag.ts
@@ -0,0 +1,69 @@
+import { MAutomaticTag } from '@server/types/models/index.js'
+import { Transaction, col, fn } from 'sequelize'
+import { AllowNull, Column, HasMany, Table } from 'sequelize-typescript'
+import { SequelizeModel } from '../shared/index.js'
+import { AccountAutomaticTagPolicyModel } from './account-automatic-tag-policy.js'
+import { CommentAutomaticTagModel } from './comment-automatic-tag.js'
+import { VideoAutomaticTagModel } from './video-automatic-tag.js'
+
+@Table({
+  tableName: 'automaticTag',
+  timestamps: false,
+  indexes: [
+    {
+      fields: [ 'name' ],
+      unique: true
+    },
+    {
+      name: 'automatic_tag_lower_name',
+      fields: [ fn('lower', col('name')) ]
+    }
+  ]
+})
+export class AutomaticTagModel extends SequelizeModel<AutomaticTagModel> {
+
+  @AllowNull(false)
+  @Column
+  name: string
+
+  @HasMany(() => CommentAutomaticTagModel, {
+    foreignKey: 'automaticTagId',
+    onDelete: 'CASCADE'
+  })
+  CommentAutomaticTags: Awaited<CommentAutomaticTagModel>[]
+
+  @HasMany(() => VideoAutomaticTagModel, {
+    foreignKey: 'automaticTagId',
+    onDelete: 'CASCADE'
+  })
+  VideoAutomaticTags: Awaited<VideoAutomaticTagModel>[]
+
+  @HasMany(() => AccountAutomaticTagPolicyModel, {
+    foreignKey: {
+      name: 'automaticTagId',
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  AccountAutomaticTagPolicies: Awaited<AccountAutomaticTagPolicyModel>[]
+
+  static findOrCreateAutomaticTag (options: {
+    tag: string
+    transaction?: Transaction
+  }): Promise<MAutomaticTag> {
+    const { tag, transaction } = options
+
+    const query = {
+      where: {
+        name: tag
+      },
+      defaults: {
+        name: tag
+      },
+      transaction
+    }
+
+    return this.findOrCreate(query)
+      .then(([ tagInstance ]) => tagInstance)
+  }
+}
diff --git a/server/core/models/automatic-tag/comment-automatic-tag.ts b/server/core/models/automatic-tag/comment-automatic-tag.ts
new file mode 100644
index 000000000..9c673cb11
--- /dev/null
+++ b/server/core/models/automatic-tag/comment-automatic-tag.ts
@@ -0,0 +1,76 @@
+import { BelongsTo, Column, CreatedAt, ForeignKey, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
+import { AccountModel } from '../account/account.js'
+import { SequelizeModel } from '../shared/index.js'
+import { VideoCommentModel } from '../video/video-comment.js'
+import { AutomaticTagModel } from './automatic-tag.js'
+import { Transaction } from 'sequelize'
+
+/**
+ *
+ * Sequelize doesn't seem to support many to many relation using BelongsToMany with 3 tables
+ * So we reproduce the behaviour with classic BelongsTo/HasMany relations
+ *
+ */
+
+@Table({
+  tableName: 'commentAutomaticTag'
+})
+export class CommentAutomaticTagModel extends SequelizeModel<CommentAutomaticTagModel> {
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  @ForeignKey(() => VideoCommentModel)
+  @PrimaryKey
+  @Column
+  commentId: number
+
+  @ForeignKey(() => AutomaticTagModel)
+  @PrimaryKey
+  @Column
+  automaticTagId: number
+
+  @ForeignKey(() => AccountModel)
+  @PrimaryKey
+  @Column
+  accountId: number
+
+  @BelongsTo(() => AccountModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'CASCADE'
+  })
+  Account: Awaited<AccountModel>
+
+  @BelongsTo(() => AutomaticTagModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'CASCADE'
+  })
+  AutomaticTag: Awaited<AutomaticTagModel>
+
+  @BelongsTo(() => VideoCommentModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'CASCADE'
+  })
+  VideoComment: Awaited<VideoCommentModel>
+
+  static deleteAllOfAccountAndComment (options: {
+    accountId: number
+    commentId: number
+    transaction: Transaction
+  }) {
+    const { accountId, commentId, transaction } = options
+
+    return this.destroy({
+      where: { accountId, commentId },
+      transaction
+    })
+  }
+}
diff --git a/server/core/models/automatic-tag/video-automatic-tag.ts b/server/core/models/automatic-tag/video-automatic-tag.ts
new file mode 100644
index 000000000..d16d3b229
--- /dev/null
+++ b/server/core/models/automatic-tag/video-automatic-tag.ts
@@ -0,0 +1,76 @@
+import { Transaction } from 'sequelize'
+import { BelongsTo, Column, CreatedAt, ForeignKey, PrimaryKey, Table, UpdatedAt } from 'sequelize-typescript'
+import { AccountModel } from '../account/account.js'
+import { SequelizeModel } from '../shared/index.js'
+import { VideoModel } from '../video/video.js'
+import { AutomaticTagModel } from './automatic-tag.js'
+
+/**
+ *
+ * Sequelize doesn't seem to support many to many relation using BelongsToMany with 3 tables
+ * So we reproduce the behaviour with classic BelongsTo/HasMany relations
+ *
+ */
+
+@Table({
+  tableName: 'videoAutomaticTag'
+})
+export class VideoAutomaticTagModel extends SequelizeModel<VideoAutomaticTagModel> {
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  @ForeignKey(() => VideoModel)
+  @PrimaryKey
+  @Column
+  videoId: number
+
+  @ForeignKey(() => AutomaticTagModel)
+  @PrimaryKey
+  @Column
+  automaticTagId: number
+
+  @ForeignKey(() => AccountModel)
+  @PrimaryKey
+  @Column
+  accountId: number
+
+  @BelongsTo(() => AccountModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'CASCADE'
+  })
+  Account: Awaited<AccountModel>
+
+  @BelongsTo(() => AutomaticTagModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'CASCADE'
+  })
+  AutomaticTag: Awaited<AutomaticTagModel>
+
+  @BelongsTo(() => VideoModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'CASCADE'
+  })
+  Video: Awaited<VideoModel>
+
+  static deleteAllOfAccountAndVideo (options: {
+    accountId: number
+    videoId: number
+    transaction: Transaction
+  }) {
+    const { accountId, videoId, transaction } = options
+
+    return this.destroy({
+      where: { accountId, videoId },
+      transaction
+    })
+  }
+}
diff --git a/server/core/models/shared/model-builder.ts b/server/core/models/shared/model-builder.ts
index c19ce2d56..26ccfa90f 100644
--- a/server/core/models/shared/model-builder.ts
+++ b/server/core/models/shared/model-builder.ts
@@ -1,6 +1,6 @@
-import isPlainObject from 'lodash-es/isPlainObject.js'
-import { Model as SequelizeModel, ModelStatic, Sequelize } from 'sequelize'
 import { logger } from '@server/helpers/logger.js'
+import isPlainObject from 'lodash-es/isPlainObject.js'
+import { ModelStatic, Sequelize, Model as SequelizeModel } from 'sequelize'
 
 /**
  *
@@ -49,7 +49,7 @@ export class ModelBuilder <T extends SequelizeModel> {
 
       // Child model
       if (isPlainObject(value)) {
-        const { created, model: subModel } = this.createModel(value, key, keyPath + '.' + json.id + '.' + key)
+        const { created, model: subModel } = this.createModel(value, key, `${keyPath}.${json.id}.${key}`)
         if (!created || !subModel) continue
 
         const Model = this.findModelBuilder(modelName)
@@ -108,6 +108,7 @@ export class ModelBuilder <T extends SequelizeModel> {
     if (modelName === 'ActorFollowing') return 'ActorModel'
     if (modelName === 'ActorFollower') return 'ActorModel'
     if (modelName === 'FlaggedAccount') return 'AccountModel'
+    if (modelName === 'CommentAutomaticTags') return 'CommentAutomaticTagModel'
 
     return modelName + 'Model'
   }
diff --git a/server/core/models/shared/query.ts b/server/core/models/shared/query.ts
index 0158454e8..60dd92b27 100644
--- a/server/core/models/shared/query.ts
+++ b/server/core/models/shared/query.ts
@@ -1,17 +1,26 @@
-import { BindOrReplacements, Op, QueryTypes, Sequelize } from 'sequelize'
+import { forceNumber } from '@peertube/peertube-core-utils'
+import { BindOrReplacements, Op, QueryOptionsWithType, QueryTypes, Sequelize, Transaction } from 'sequelize'
 import { Fn } from 'sequelize/types/utils'
 import validator from 'validator'
-import { forceNumber } from '@peertube/peertube-core-utils'
 
-function doesExist (sequelize: Sequelize, query: string, bind?: BindOrReplacements) {
-  const options = {
-    type: QueryTypes.SELECT as QueryTypes.SELECT,
+async function doesExist (options: {
+  sequelize: Sequelize
+  query: string
+  bind?: BindOrReplacements
+  transaction?: Transaction
+}) {
+  const { sequelize, query, bind, transaction } = options
+
+  const queryOptions: QueryOptionsWithType<QueryTypes.SELECT> = {
+    type: QueryTypes.SELECT,
     bind,
-    raw: true
+    raw: true,
+    transaction
   }
 
-  return sequelize.query(query, options)
-            .then(results => results.length === 1)
+  const results = await sequelize.query(query, queryOptions)
+
+  return results.length === 1
 }
 
 // FIXME: have to specify the result type to not break peertube typings generation
@@ -64,13 +73,8 @@ function searchAttribute (sourceField?: string, targetField?: string) {
 }
 
 export {
-  doesExist,
-  createSimilarityAttribute,
-  buildWhereIdOrUUID,
-  parseAggregateResult,
-  parseRowCountResult,
-  createSafeIn,
-  searchAttribute
+  buildWhereIdOrUUID, createSafeIn, createSimilarityAttribute, doesExist, parseAggregateResult,
+  parseRowCountResult, searchAttribute
 }
 
 // ---------------------------------------------------------------------------
diff --git a/server/core/models/shared/sql.ts b/server/core/models/shared/sql.ts
index 3f22dd4e3..b38c8e752 100644
--- a/server/core/models/shared/sql.ts
+++ b/server/core/models/shared/sql.ts
@@ -4,20 +4,20 @@ import { forceNumber } from '@peertube/peertube-core-utils'
 import { AttributesOnly } from '@peertube/peertube-typescript-utils'
 
 // FIXME: have to specify the result type to not break peertube typings generation
-function buildLocalAccountIdsIn (): Literal {
+export function buildLocalAccountIdsIn (): Literal {
   return literal(
     '(SELECT "account"."id" FROM "account" INNER JOIN "actor" ON "actor"."id" = "account"."actorId" AND "actor"."serverId" IS NULL)'
   )
 }
 
 // FIXME: have to specify the result type to not break peertube typings generation
-function buildLocalActorIdsIn (): Literal {
+export function buildLocalActorIdsIn (): Literal {
   return literal(
     '(SELECT "actor"."id" FROM "actor" WHERE "actor"."serverId" IS NULL)'
   )
 }
 
-function buildBlockedAccountSQL (blockerIds: number[]) {
+export function buildBlockedAccountSQL (blockerIds: number[]) {
   const blockerIdsString = blockerIds.join(', ')
 
   return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' +
@@ -27,7 +27,7 @@ function buildBlockedAccountSQL (blockerIds: number[]) {
     'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')'
 }
 
-function buildServerIdsFollowedBy (actorId: any) {
+export function buildServerIdsFollowedBy (actorId: any) {
   const actorIdNumber = forceNumber(actorId)
 
   return '(' +
@@ -37,18 +37,20 @@ function buildServerIdsFollowedBy (actorId: any) {
     ')'
 }
 
-function buildSQLAttributes<M extends Model> (options: {
+export function buildSQLAttributes<M extends Model> (options: {
   model: ModelStatic<M>
   tableName: string
 
   excludeAttributes?: Exclude<keyof AttributesOnly<M>, symbol>[]
   aliasPrefix?: string
+
+  idBuilder?: string[]
 }) {
-  const { model, tableName, aliasPrefix, excludeAttributes } = options
+  const { model, tableName, aliasPrefix = '', excludeAttributes, idBuilder } = options
 
   const attributes = Object.keys(model.getAttributes()) as Exclude<keyof AttributesOnly<M>, symbol>[]
 
-  return attributes
+  const builtAttributes = attributes
     .filter(a => {
       if (!excludeAttributes) return true
       if (excludeAttributes.includes(a)) return false
@@ -56,16 +58,15 @@ function buildSQLAttributes<M extends Model> (options: {
       return true
     })
     .map(a => {
-      return `"${tableName}"."${a}" AS "${aliasPrefix || ''}${a}"`
+      return `"${tableName}"."${a}" AS "${aliasPrefix}${a}"`
     })
-}
 
-// ---------------------------------------------------------------------------
+  if (idBuilder) {
+    const idSelect = idBuilder.map(a => `"${tableName}"."${a}"`)
+      .join(` || '-' || `)
 
-export {
-  buildSQLAttributes,
-  buildBlockedAccountSQL,
-  buildServerIdsFollowedBy,
-  buildLocalAccountIdsIn,
-  buildLocalActorIdsIn
+    builtAttributes.push(`${idSelect} AS "${aliasPrefix}id"`)
+  }
+
+  return builtAttributes
 }
diff --git a/server/core/models/user/sql/user-notitication-list-query-builder.ts b/server/core/models/user/sql/user-notitication-list-query-builder.ts
index ba3a2d70f..32f5ba73e 100644
--- a/server/core/models/user/sql/user-notitication-list-query-builder.ts
+++ b/server/core/models/user/sql/user-notitication-list-query-builder.ts
@@ -92,6 +92,7 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery {
       "Video->VideoChannel->Actor->Server"."host" AS "Video.VideoChannel.Actor.Server.host",
       "VideoComment"."id" AS "VideoComment.id",
       "VideoComment"."originCommentId" AS "VideoComment.originCommentId",
+      "VideoComment"."heldForReview" AS "VideoComment.heldForReview",
       "VideoComment->Account"."id" AS "VideoComment.Account.id",
       "VideoComment->Account"."name" AS "VideoComment.Account.name",
       "VideoComment->Account->Actor"."id" AS "VideoComment.Account.Actor.id",
diff --git a/server/core/models/user/user-notification.ts b/server/core/models/user/user-notification.ts
index 28a5bb894..0b41bf676 100644
--- a/server/core/models/user/user-notification.ts
+++ b/server/core/models/user/user-notification.ts
@@ -383,7 +383,8 @@ export class UserNotificationModel extends SequelizeModel<UserNotificationModel>
         id: this.VideoComment.id,
         threadId: this.VideoComment.getThreadId(),
         account: this.formatActor(this.VideoComment.Account),
-        video: this.formatVideo(this.VideoComment.Video)
+        video: this.formatVideo(this.VideoComment.Video),
+        heldForReview: this.VideoComment.heldForReview
       }
       : undefined
 
diff --git a/server/core/models/video/formatter/video-activity-pub-format.ts b/server/core/models/video/formatter/video-activity-pub-format.ts
index a95fbb3e3..0446b708f 100644
--- a/server/core/models/video/formatter/video-activity-pub-format.ts
+++ b/server/core/models/video/formatter/video-activity-pub-format.ts
@@ -1,15 +1,16 @@
-import { isArray } from '@server/helpers/custom-validators/misc.js'
-import { generateMagnetUri } from '@server/helpers/webtorrent.js'
-import { getActivityStreamDuration } from '@server/lib/activitypub/activity.js'
-import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls.js'
 import {
   ActivityIconObject,
   ActivityPlaylistUrlObject,
   ActivityPubStoryboard,
   ActivityTagObject,
   ActivityTrackerUrlObject,
-  ActivityUrlObject, VideoObject
+  ActivityUrlObject, VideoCommentPolicy, VideoObject
 } from '@peertube/peertube-models'
+import { getAPPublicValue } from '@server/helpers/activity-pub-utils.js'
+import { isArray } from '@server/helpers/custom-validators/misc.js'
+import { generateMagnetUri } from '@server/helpers/webtorrent.js'
+import { getActivityStreamDuration } from '@server/lib/activitypub/activity.js'
+import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls.js'
 import { WEBSERVER } from '../../../initializers/constants.js'
 import {
   getLocalVideoChaptersActivityPubUrl,
@@ -64,7 +65,14 @@ export function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
     waitTranscoding: video.waitTranscoding,
 
     state: video.state,
-    commentsEnabled: video.commentsEnabled,
+
+    commentsEnabled: video.commentsPolicy !== VideoCommentPolicy.DISABLED,
+    canReply: video.commentsPolicy === VideoCommentPolicy.ENABLED
+      ? null
+      : getAPPublicValue(), // Requires approval
+
+    commentsPolicy: video.commentsPolicy,
+
     downloadEnabled: video.downloadEnabled,
     published: video.publishedAt.toISOString(),
 
diff --git a/server/core/models/video/formatter/video-api-format.ts b/server/core/models/video/formatter/video-api-format.ts
index 13035b847..c5f254f17 100644
--- a/server/core/models/video/formatter/video-api-format.ts
+++ b/server/core/models/video/formatter/video-api-format.ts
@@ -1,6 +1,7 @@
 import {
   Video,
   VideoAdditionalAttributes,
+  VideoCommentPolicy,
   VideoDetails,
   VideoFile,
   VideoInclude,
@@ -13,7 +14,14 @@ import { tracer } from '@server/lib/opentelemetry/tracing.js'
 import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls.js'
 import { VideoViewsManager } from '@server/lib/views/video-views-manager.js'
 import { isArray } from '../../../helpers/custom-validators/misc.js'
-import { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES, VIDEO_STATES } from '../../../initializers/constants.js'
+import {
+  VIDEO_CATEGORIES,
+  VIDEO_COMMENTS_POLICY,
+  VIDEO_LANGUAGES,
+  VIDEO_LICENCES,
+  VIDEO_PRIVACIES,
+  VIDEO_STATES
+} from '../../../initializers/constants.js'
 import { MServer, MStreamingPlaylistRedundanciesOpt, MVideoFormattable, MVideoFormattableDetails } from '../../../types/models/index.js'
 import { MVideoFileRedundanciesOpt } from '../../../types/models/video/video-file.js'
 import { sortByResolutionDesc } from './shared/index.js'
@@ -29,6 +37,7 @@ export type VideoFormattingJSONOptions = {
     files?: boolean
     source?: boolean
     blockedOwner?: boolean
+    automaticTags?: boolean
   }
 }
 
@@ -43,7 +52,8 @@ export function guessAdditionalAttributesFromQuery (query: VideosCommonQueryAfte
       blacklistInfo: !!(query.include & VideoInclude.BLACKLISTED),
       files: !!(query.include & VideoInclude.FILES),
       source: !!(query.include & VideoInclude.SOURCE),
-      blockedOwner: !!(query.include & VideoInclude.BLOCKED_OWNER)
+      blockedOwner: !!(query.include & VideoInclude.BLOCKED_OWNER),
+      automaticTags: !!(query.include & VideoInclude.AUTOMATIC_TAGS)
     }
   }
 }
@@ -150,7 +160,14 @@ export function videoModelToFormattedDetailsJSON (video: MVideoFormattableDetail
     channel: video.VideoChannel.toFormattedJSON(),
     account: video.VideoChannel.Account.toFormattedJSON(),
     tags,
-    commentsEnabled: video.commentsEnabled,
+
+    // TODO: remove, deprecated in PeerTube 6.2
+    commentsEnabled: video.commentsPolicy !== VideoCommentPolicy.DISABLED,
+    commentsPolicy: {
+      id: video.commentsPolicy,
+      label: VIDEO_COMMENTS_POLICY[video.commentsPolicy]
+    },
+
     downloadEnabled: video.downloadEnabled,
     waitTranscoding: video.waitTranscoding,
     inputFileUpdatedAt: video.inputFileUpdatedAt,
@@ -316,5 +333,9 @@ function buildAdditionalAttributes (video: MVideoFormattable, options: VideoForm
     result.videoSource = video.VideoSource?.toFormattedJSON() || null
   }
 
+  if (add?.automaticTags === true) {
+    result.automaticTags = (video.VideoAutomaticTags || []).map(t => t.AutomaticTag.name)
+  }
+
   return result
 }
diff --git a/server/core/models/video/sql/comment/video-comment-list-query-builder.ts b/server/core/models/video/sql/comment/video-comment-list-query-builder.ts
index 5e6207056..a3f98e7eb 100644
--- a/server/core/models/video/sql/comment/video-comment-list-query-builder.ts
+++ b/server/core/models/video/sql/comment/video-comment-list-query-builder.ts
@@ -1,12 +1,14 @@
-import { Model, Sequelize, Transaction } from 'sequelize'
-import { AbstractRunQuery, ModelBuilder } from '@server/models/shared/index.js'
 import { ActorImageType, VideoPrivacy } from '@peertube/peertube-models'
+import { AbstractRunQuery, ModelBuilder } from '@server/models/shared/index.js'
+import { Model, Sequelize, Transaction } from 'sequelize'
 import { createSafeIn, getSort, parseRowCountResult } from '../../../shared/index.js'
 import { VideoCommentTableAttributes } from './video-comment-table-attributes.js'
 
 export interface ListVideoCommentsOptions {
   selectType: 'api' | 'feed' | 'comment-only'
 
+  autoTagOfAccountId?: number
+
   start?: number
   count?: number
   sort?: string
@@ -14,17 +16,24 @@ export interface ListVideoCommentsOptions {
   videoId?: number
   threadId?: number
   accountId?: number
-  videoChannelId?: number
 
   blockerAccountIds?: number[]
 
   isThread?: boolean
   notDeleted?: boolean
+
   isLocal?: boolean
   onLocalVideo?: boolean
+
   onPublicVideo?: boolean
+  videoChannelOwnerId?: number
   videoAccountOwnerId?: number
 
+  heldForReview: boolean
+  heldForReviewAccountIdException?: number
+
+  autoTagOneOf?: string[]
+
   search?: string
   searchAccount?: string
   searchVideo?: string
@@ -52,7 +61,8 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery {
     accountJoin: false,
     videoJoin: false,
     videoChannelJoin: false,
-    avatarJoin: false
+    avatarJoin: false,
+    automaticTagsJoin: false
   }
 
   constructor (
@@ -142,14 +152,6 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery {
       where.push('"VideoCommentModel"."accountId" = :accountId')
     }
 
-    if (this.options.videoChannelId) {
-      this.buildVideoChannelJoin()
-
-      this.replacements.videoChannelId = this.options.videoChannelId
-
-      where.push('"Account->VideoChannel"."id" = :videoChannelId')
-    }
-
     if (this.options.blockerAccountIds) {
       this.buildVideoChannelJoin()
 
@@ -164,6 +166,27 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery {
       where.push('"VideoCommentModel"."deletedAt" IS NULL')
     }
 
+    if (this.options.heldForReview === true) {
+      where.push('"VideoCommentModel"."heldForReview" IS TRUE')
+    } else if (this.options.heldForReview === false) {
+      const base = '"VideoCommentModel"."heldForReview" IS FALSE'
+
+      if (this.options.heldForReviewAccountIdException) {
+        this.replacements.heldForReviewAccountIdException = this.options.heldForReviewAccountIdException
+
+        where.push(`(${base} OR "VideoCommentModel"."accountId" = :heldForReviewAccountIdException)`)
+      } else {
+        where.push(base)
+      }
+    }
+
+    if (this.options.autoTagOneOf) {
+      const tags = this.options.autoTagOneOf.map(t => t.toLowerCase())
+      this.buildAutomaticTagsJoin()
+
+      where.push('lower("CommentAutomaticTags->AutomaticTag"."name") IN (' + createSafeIn(this.sequelize, tags) + ')')
+    }
+
     if (this.options.isLocal === true) {
       this.buildAccountJoin()
 
@@ -198,6 +221,14 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery {
       where.push(`"Video->VideoChannel"."accountId" = :videoAccountOwnerId`)
     }
 
+    if (this.options.videoChannelOwnerId) {
+      this.buildVideoChannelJoin()
+
+      this.replacements.videoChannelOwnerId = this.options.videoChannelOwnerId
+
+      where.push(`"Video->VideoChannel"."id" = :videoChannelOwnerId`)
+    }
+
     if (this.options.search) {
       this.buildVideoJoin()
       this.buildAccountJoin()
@@ -269,7 +300,6 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery {
   }
 
   private buildAvatarsJoin () {
-    if (this.options.selectType !== 'api' && this.options.selectType !== 'feed') return ''
     if (this.built.avatarJoin) return
 
     this.joins += `LEFT JOIN "actorImage" "Account->Actor->Avatars" ` +
@@ -279,6 +309,18 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery {
     this.built.avatarJoin = true
   }
 
+  private buildAutomaticTagsJoin () {
+    if (this.built.automaticTagsJoin) return
+
+    this.innerJoins += 'LEFT JOIN (' +
+      '"commentAutomaticTag" AS "CommentAutomaticTags" INNER JOIN "automaticTag" AS "CommentAutomaticTags->AutomaticTag" ' +
+        'ON "CommentAutomaticTags->AutomaticTag"."id" = "CommentAutomaticTags"."automaticTagId" ' +
+    ') ON "VideoCommentModel"."id" = "CommentAutomaticTags"."commentId" AND "CommentAutomaticTags"."accountId" = :autoTagOfAccountId'
+
+    this.replacements.autoTagOfAccountId = this.options.autoTagOfAccountId
+    this.built.automaticTagsJoin = true
+  }
+
   // ---------------------------------------------------------------------------
 
   private buildListSelect () {
@@ -308,6 +350,15 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery {
       ])
     }
 
+    if (this.options.autoTagOfAccountId && this.options.selectType === 'api') {
+      this.buildAutomaticTagsJoin()
+
+      toSelect = toSelect.concat([
+        this.tableAttributes.getCommentAutomaticTagAttributes(),
+        this.tableAttributes.getAutomaticTagAttributes()
+      ])
+    }
+
     if (this.options.includeReplyCounters === true) {
       this.buildTotalRepliesSelect()
       this.buildAuthorTotalRepliesSelect()
diff --git a/server/core/models/video/sql/comment/video-comment-table-attributes.ts b/server/core/models/video/sql/comment/video-comment-table-attributes.ts
index c7a8a9768..353e5f04f 100644
--- a/server/core/models/video/sql/comment/video-comment-table-attributes.ts
+++ b/server/core/models/video/sql/comment/video-comment-table-attributes.ts
@@ -1,9 +1,12 @@
 import { Memoize } from '@server/helpers/memoize.js'
 import { AccountModel } from '@server/models/account/account.js'
-import { ActorModel } from '@server/models/actor/actor.js'
 import { ActorImageModel } from '@server/models/actor/actor-image.js'
+import { ActorModel } from '@server/models/actor/actor.js'
 import { ServerModel } from '@server/models/server/server.js'
+import { buildSQLAttributes } from '@server/models/shared/sql.js'
+import { AutomaticTagModel } from '../../../automatic-tag/automatic-tag.js'
 import { VideoCommentModel } from '../../video-comment.js'
+import { CommentAutomaticTagModel } from '@server/models/automatic-tag/comment-automatic-tag.js'
 
 export class VideoCommentTableAttributes {
 
@@ -40,4 +43,23 @@ export class VideoCommentTableAttributes {
   getAvatarAttributes () {
     return ActorImageModel.getSQLAttributes('Account->Actor->Avatars', 'Account.Actor.Avatars.').join(', ')
   }
+
+  @Memoize()
+  getCommentAutomaticTagAttributes () {
+    return buildSQLAttributes({
+      model: CommentAutomaticTagModel,
+      tableName: 'CommentAutomaticTags',
+      aliasPrefix: 'CommentAutomaticTags.',
+      idBuilder: [ 'commentId', 'automaticTagId', 'accountId' ]
+    }).join(', ')
+  }
+
+  @Memoize()
+  getAutomaticTagAttributes () {
+    return buildSQLAttributes({
+      model: AutomaticTagModel,
+      tableName: 'CommentAutomaticTags->AutomaticTag',
+      aliasPrefix: 'CommentAutomaticTags.AutomaticTag.'
+    }).join(', ')
+  }
 }
diff --git a/server/core/models/video/sql/video/shared/abstract-video-query-builder.ts b/server/core/models/video/sql/video/shared/abstract-video-query-builder.ts
index 79d3feeea..8ba5b0a75 100644
--- a/server/core/models/video/sql/video/shared/abstract-video-query-builder.ts
+++ b/server/core/models/video/sql/video/shared/abstract-video-query-builder.ts
@@ -259,6 +259,24 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
     }
   }
 
+  protected includeAutomaticTags (autoTagOfAccountId: number) {
+    this.addJoin(
+      'LEFT JOIN (' +
+        '"videoAutomaticTag" AS "VideoAutomaticTags" INNER JOIN "automaticTag" AS "VideoAutomaticTags->AutomaticTag" ' +
+          'ON "VideoAutomaticTags->AutomaticTag"."id" = "VideoAutomaticTags"."automaticTagId" ' +
+      ') ON "video"."id" = "VideoAutomaticTags"."videoId" AND "VideoAutomaticTags"."accountId" = :autoTagOfAccountId'
+    )
+
+    this.replacements.autoTagOfAccountId = autoTagOfAccountId
+
+    this.attributes = {
+      ...this.attributes,
+
+      ...this.buildAttributesObject('VideoAutomaticTags', this.tables.getVideoAutoTagAttributes()),
+      ...this.buildAttributesObject('VideoAutomaticTags->AutomaticTag', this.tables.getAutoTagAttributes())
+    }
+  }
+
   protected includeTrackers () {
     this.addJoin(
       'LEFT OUTER JOIN (' +
diff --git a/server/core/models/video/sql/video/shared/video-model-builder.ts b/server/core/models/video/sql/video/shared/video-model-builder.ts
index 23445f228..cb182a38b 100644
--- a/server/core/models/video/sql/video/shared/video-model-builder.ts
+++ b/server/core/models/video/sql/video/shared/video-model-builder.ts
@@ -3,6 +3,8 @@ import { AccountBlocklistModel } from '@server/models/account/account-blocklist.
 import { AccountModel } from '@server/models/account/account.js'
 import { ActorImageModel } from '@server/models/actor/actor-image.js'
 import { ActorModel } from '@server/models/actor/actor.js'
+import { AutomaticTagModel } from '@server/models/automatic-tag/automatic-tag.js'
+import { VideoAutomaticTagModel } from '@server/models/automatic-tag/video-automatic-tag.js'
 import { VideoRedundancyModel } from '@server/models/redundancy/video-redundancy.js'
 import { ServerBlocklistModel } from '@server/models/server/server-blocklist.js'
 import { ServerModel } from '@server/models/server/server.js'
@@ -46,6 +48,7 @@ export class VideoModelBuilder {
 
   private trackersDone: Set<string>
   private tagsDone: Set<string>
+  private autoTagsDone: Set<string>
 
   private videos: VideoModel[]
 
@@ -114,6 +117,10 @@ export class VideoModelBuilder {
         if (include & VideoInclude.SOURCE) {
           this.setSource(row, videoModel)
         }
+
+        if (include & VideoInclude.AUTOMATIC_TAGS) {
+          this.addAutoTag(row, videoModel)
+        }
       }
     }
 
@@ -142,6 +149,7 @@ export class VideoModelBuilder {
 
     this.trackersDone = new Set()
     this.tagsDone = new Set()
+    this.autoTagsDone = new Set()
 
     this.videos = []
   }
@@ -184,6 +192,7 @@ export class VideoModelBuilder {
     videoModel.VideoFiles = []
     videoModel.VideoStreamingPlaylists = []
     videoModel.Tags = []
+    videoModel.VideoAutomaticTags = []
     videoModel.Trackers = []
 
     this.buildAccount(row, videoModel)
@@ -329,6 +338,23 @@ export class VideoModelBuilder {
     this.tagsDone.add(key)
   }
 
+  private addAutoTag (row: SQLRow, videoModel: VideoModel) {
+    if (!row['VideoAutomaticTags.AutomaticTag.id']) return
+
+    const key = `${row['VideoAutomaticTags.videoId']}-${row['VideoAutomaticTags.accountId']}-${row['VideoAutomaticTags.automaticTagId']}`
+    if (this.autoTagsDone.has(key)) return
+
+    const videoAutomaticTagAttributes = this.grab(row, this.tables.getVideoAutoTagAttributes(), 'VideoAutomaticTags')
+    const automaticTagModel = new VideoAutomaticTagModel(videoAutomaticTagAttributes, this.buildOpts)
+
+    const automaticTagAttributes = this.grab(row, this.tables.getAutoTagAttributes(), 'VideoAutomaticTags.AutomaticTag')
+    automaticTagModel.AutomaticTag = new AutomaticTagModel(automaticTagAttributes, this.buildOpts)
+
+    videoModel.VideoAutomaticTags.push(automaticTagModel)
+
+    this.autoTagsDone.add(key)
+  }
+
   private addTracker (row: SQLRow, videoModel: VideoModel) {
     if (!row['Trackers.id']) return
 
diff --git a/server/core/models/video/sql/video/shared/video-table-attributes.ts b/server/core/models/video/sql/video/shared/video-table-attributes.ts
index f4b06226b..fe84c5d33 100644
--- a/server/core/models/video/sql/video/shared/video-table-attributes.ts
+++ b/server/core/models/video/sql/video/shared/video-table-attributes.ts
@@ -18,6 +18,7 @@ export class VideoTableAttributes {
       'id',
       'name',
       'description',
+      'accountId',
       'actorId'
     ]
 
@@ -196,6 +197,14 @@ export class VideoTableAttributes {
     ]
   }
 
+  getVideoAutoTagAttributes () {
+    return [ 'videoId', 'accountId', 'automaticTagId' ]
+  }
+
+  getAutoTagAttributes () {
+    return [ 'id', 'name' ]
+  }
+
   getRedundancyAttributes () {
     return [ 'id', 'fileUrl' ]
   }
@@ -274,7 +283,7 @@ export class VideoTableAttributes {
       'isLive',
       'aspectRatio',
       'url',
-      'commentsEnabled',
+      'commentsPolicy',
       'downloadEnabled',
       'waitTranscoding',
       'state',
diff --git a/server/core/models/video/sql/video/videos-id-list-query-builder.ts b/server/core/models/video/sql/video/videos-id-list-query-builder.ts
index 1a8447b56..1f883eb40 100644
--- a/server/core/models/video/sql/video/videos-id-list-query-builder.ts
+++ b/server/core/models/video/sql/video/videos-id-list-query-builder.ts
@@ -40,10 +40,14 @@ export type BuildVideosListQueryOptions = {
   categoryOneOf?: number[]
   licenceOneOf?: number[]
   languageOneOf?: string[]
+
   tagsOneOf?: string[]
   tagsAllOf?: string[]
+
   privacyOneOf?: VideoPrivacyType[]
 
+  autoTagOneOf?: string[]
+
   uuids?: string[]
 
   hasFiles?: boolean
@@ -197,6 +201,10 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
       this.whereTagsAllOf(options.tagsAllOf)
     }
 
+    if (options.autoTagOneOf) {
+      this.whereAutoTagOneOf(options.autoTagOneOf)
+    }
+
     if (options.privacyOneOf) {
       this.wherePrivacyOneOf(options.privacyOneOf)
     } else {
@@ -448,6 +456,20 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
     this.joins.push('INNER JOIN "tagsOneOf" ON "video"."id" = "tagsOneOf"."videoId"')
   }
 
+  private whereAutoTagOneOf (autoTagOneOf: string[]) {
+    const tags = autoTagOneOf.map(t => t.toLowerCase())
+
+    this.cte.push(
+      '"autoTagsOneOf" AS (' +
+      '  SELECT "videoAutomaticTag"."videoId" AS "videoId" FROM "videoAutomaticTag" ' +
+      '  INNER JOIN "automaticTag" ON "automaticTag"."id" = "videoAutomaticTag"."automaticTagId" ' +
+      '  WHERE lower("automaticTag"."name") IN (' + createSafeIn(this.sequelize, tags) + ') ' +
+      ')'
+    )
+
+    this.joins.push('INNER JOIN "autoTagsOneOf" ON "video"."id" = "autoTagsOneOf"."videoId"')
+  }
+
   private whereTagsAllOf (tagsAllOf: string[]) {
     const tagsAllOfLower = tagsAllOf.map(t => t.toLowerCase())
 
diff --git a/server/core/models/video/sql/video/videos-model-list-query-builder.ts b/server/core/models/video/sql/video/videos-model-list-query-builder.ts
index 2dc83e18e..dc5df090a 100644
--- a/server/core/models/video/sql/video/videos-model-list-query-builder.ts
+++ b/server/core/models/video/sql/video/videos-model-list-query-builder.ts
@@ -5,6 +5,8 @@ import { AbstractVideoQueryBuilder } from './shared/abstract-video-query-builder
 import { VideoFileQueryBuilder } from './shared/video-file-query-builder.js'
 import { VideoModelBuilder } from './shared/video-model-builder.js'
 import { BuildVideosListQueryOptions, VideosIdListQueryBuilder } from './videos-id-list-query-builder.js'
+import { getServerActor } from '@server/models/application/application.js'
+import { MActorAccount } from '@server/types/models/index.js'
 
 /**
  *
@@ -32,8 +34,10 @@ export class VideosModelListQueryBuilder extends AbstractVideoQueryBuilder {
   }
 
   async queryVideos (options: BuildVideosListQueryOptions) {
+    const serverActor = await getServerActor()
+
     this.buildInnerQuery(options)
-    this.buildMainQuery(options)
+    this.buildMainQuery(options, serverActor)
 
     const rows = await this.runQuery()
 
@@ -69,7 +73,7 @@ export class VideosModelListQueryBuilder extends AbstractVideoQueryBuilder {
     this.innerSort = sort
   }
 
-  private buildMainQuery (options: BuildVideosListQueryOptions) {
+  private buildMainQuery (options: BuildVideosListQueryOptions, serverActor: MActorAccount) {
     this.attributes = {
       '"video".*': ''
     }
@@ -100,6 +104,10 @@ export class VideosModelListQueryBuilder extends AbstractVideoQueryBuilder {
       this.includeVideoSource()
     }
 
+    if (options.include & VideoInclude.AUTOMATIC_TAGS) {
+      this.includeAutomaticTags(serverActor.Account.id)
+    }
+
     const select = this.buildSelect()
 
     this.query = `${select} FROM (${this.innerQuery}) AS "tmp" ${this.joins} ${this.innerSort}`
diff --git a/server/core/models/video/tag.ts b/server/core/models/video/tag.ts
index 9d5e8b793..6dc53ae78 100644
--- a/server/core/models/video/tag.ts
+++ b/server/core/models/video/tag.ts
@@ -1,7 +1,7 @@
-import { col, fn, QueryTypes, Transaction } from 'sequelize'
-import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Table, UpdatedAt } from 'sequelize-typescript'
 import { VideoPrivacy, VideoState } from '@peertube/peertube-models'
-import { MTag } from '@server/types/models/index.js'
+import { MTag } from '@server/types/models/video/tag.js'
+import { QueryTypes, Transaction, col, fn } from 'sequelize'
+import { AllowNull, BelongsToMany, Column, Is, Table } from 'sequelize-typescript'
 import { isVideoTagValid } from '../../helpers/custom-validators/videos.js'
 import { SequelizeModel, throwIfNotValid } from '../shared/index.js'
 import { VideoTagModel } from './video-tag.js'
@@ -28,12 +28,6 @@ export class TagModel extends SequelizeModel<TagModel> {
   @Column
   name: string
 
-  @CreatedAt
-  createdAt: Date
-
-  @UpdatedAt
-  updatedAt: Date
-
   @BelongsToMany(() => VideoModel, {
     foreignKey: 'tagId',
     through: () => VideoTagModel,
@@ -41,29 +35,6 @@ export class TagModel extends SequelizeModel<TagModel> {
   })
   Videos: Awaited<VideoModel>[]
 
-  static findOrCreateTags (tags: string[], transaction: Transaction): Promise<MTag[]> {
-    if (tags === null) return Promise.resolve([])
-
-    const uniqueTags = new Set(tags)
-
-    const tasks = Array.from(uniqueTags).map(tag => {
-      const query = {
-        where: {
-          name: tag
-        },
-        defaults: {
-          name: tag
-        },
-        transaction
-      }
-
-      return TagModel.findOrCreate<MTag>(query)
-        .then(([ tagInstance ]) => tagInstance)
-    })
-
-    return Promise.all(tasks)
-  }
-
   // threshold corresponds to how many video the field should have to be returned
   static getRandomSamples (threshold: number, count: number): Promise<string[]> {
     const query = 'SELECT tag.name FROM tag ' +
@@ -82,4 +53,32 @@ export class TagModel extends SequelizeModel<TagModel> {
     return TagModel.sequelize.query<{ name: string }>(query, options)
                     .then(data => data.map(d => d.name))
   }
+
+  static findOrCreateMultiple (options: {
+    tags: string[]
+    transaction?: Transaction
+  }): Promise<MTag[]> {
+    const { tags, transaction } = options
+
+    if (tags === null) return Promise.resolve([])
+
+    const uniqueTags = new Set(tags)
+
+    const tasks = Array.from(uniqueTags).map(tag => {
+      const query = {
+        where: {
+          name: tag
+        },
+        defaults: {
+          name: tag
+        },
+        transaction
+      }
+
+      return this.findOrCreate(query)
+        .then(([ tagInstance ]) => tagInstance)
+    })
+
+    return Promise.all(tasks)
+  }
 }
diff --git a/server/core/models/video/video-comment.ts b/server/core/models/video/video-comment.ts
index d41d7974b..bf02a1c39 100644
--- a/server/core/models/video/video-comment.ts
+++ b/server/core/models/video/video-comment.ts
@@ -1,13 +1,20 @@
 import { pick } from '@peertube/peertube-core-utils'
-import { ActivityTagObject, ActivityTombstoneObject, VideoComment, VideoCommentAdmin, VideoCommentObject } from '@peertube/peertube-models'
+import {
+  ActivityTagObject,
+  ActivityTombstoneObject,
+  UserRight,
+  VideoComment,
+  VideoCommentForAdminOrUser,
+  VideoCommentObject
+} from '@peertube/peertube-models'
 import { extractMentions } from '@server/helpers/mentions.js'
+import { getLocalApproveReplyActivityPubUrl } from '@server/lib/activitypub/url.js'
 import { getServerActor } from '@server/models/application/application.js'
 import { MAccount, MAccountId, MUserAccountId } from '@server/types/models/index.js'
 import { Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize'
 import {
   AllowNull,
-  BelongsTo,
-  Column,
+  BelongsTo, Column,
   CreatedAt,
   DataType,
   ForeignKey,
@@ -21,20 +28,20 @@ import { CONSTRAINTS_FIELDS, USER_EXPORT_MAX_ITEMS } from '../../initializers/co
 import {
   MComment,
   MCommentAP,
-  MCommentAdminFormattable,
+  MCommentAdminOrUserFormattable,
   MCommentExport,
   MCommentFormattable,
   MCommentId,
   MCommentOwner,
-  MCommentOwnerReplyVideoLight,
-  MCommentOwnerVideo,
-  MCommentOwnerVideoFeed,
+  MCommentOwnerReplyVideoImmutable, MCommentOwnerVideoFeed,
   MCommentOwnerVideoReply,
+  MVideo,
   MVideoImmutable
 } from '../../types/models/video/index.js'
 import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse.js'
 import { AccountModel } from '../account/account.js'
 import { ActorModel } from '../actor/actor.js'
+import { CommentAutomaticTagModel } from '../automatic-tag/comment-automatic-tag.js'
 import { SequelizeModel, buildLocalAccountIdsIn, buildSQLAttributes, throwIfNotValid } from '../shared/index.js'
 import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder.js'
 import { VideoChannelModel } from './video-channel.js'
@@ -140,6 +147,14 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
   @Column(DataType.TEXT)
   text: string
 
+  @AllowNull(false)
+  @Column
+  heldForReview: boolean
+
+  @AllowNull(true)
+  @Column
+  replyApproval: string
+
   @ForeignKey(() => VideoCommentModel)
   @Column
   originCommentId: number
@@ -201,6 +216,12 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
   })
   CommentAbuses: Awaited<VideoCommentAbuseModel>[]
 
+  @HasMany(() => CommentAutomaticTagModel, {
+    foreignKey: 'commentId',
+    onDelete: 'CASCADE'
+  })
+  CommentAutomaticTags: Awaited<CommentAutomaticTagModel>[]
+
   // ---------------------------------------------------------------------------
 
   static getSQLAttributes (tableName: string, aliasPrefix = '') {
@@ -237,7 +258,9 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
       .findOne(query)
   }
 
-  static loadByUrlAndPopulateAccountAndVideo (url: string, transaction?: Transaction): Promise<MCommentOwnerVideo> {
+  // ---------------------------------------------------------------------------
+
+  static loadByUrlAndPopulateAccountAndVideoAndReply (url: string, transaction?: Transaction): Promise<MCommentOwnerVideoReply> {
     const query = {
       where: {
         url
@@ -245,17 +268,20 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
       transaction
     }
 
-    return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEO ]).findOne(query)
+    return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEO, ScopeNames.WITH_IN_REPLY_TO ]).findOne(query)
   }
 
-  static loadByUrlAndPopulateReplyAndVideoUrlAndAccount (url: string, transaction?: Transaction): Promise<MCommentOwnerReplyVideoLight> {
+  static loadByUrlAndPopulateReplyAndVideoImmutableAndAccount (
+    url: string,
+    transaction?: Transaction
+  ): Promise<MCommentOwnerReplyVideoImmutable> {
     const query = {
       where: {
         url
       },
       include: [
         {
-          attributes: [ 'id', 'url' ],
+          attributes: [ 'id', 'uuid', 'url', 'remote' ],
           model: VideoModel.unscoped()
         }
       ],
@@ -265,26 +291,56 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
     return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_ACCOUNT ]).findOne(query)
   }
 
+  // ---------------------------------------------------------------------------
+
   static listCommentsForApi (parameters: {
     start: number
     count: number
     sort: string
 
+    autoTagOfAccountId: number
+
+    videoAccountOwnerId?: number
+    videoChannelOwnerId?: number
+
     onLocalVideo?: boolean
     isLocal?: boolean
+
     search?: string
     searchAccount?: string
     searchVideo?: string
+
+    heldForReview: boolean
+
+    videoId?: number
+    videoChannelId?: number
+    autoTagOneOf?: string[]
   }) {
     const queryOptions: ListVideoCommentsOptions = {
-      ...pick(parameters, [ 'start', 'count', 'sort', 'isLocal', 'search', 'searchVideo', 'searchAccount', 'onLocalVideo' ]),
+      ...pick(parameters, [
+        'start',
+        'count',
+        'sort',
+        'isLocal',
+        'search',
+        'searchVideo',
+        'searchAccount',
+        'onLocalVideo',
+        'videoId',
+        'videoChannelId',
+        'autoTagOneOf',
+        'autoTagOfAccountId',
+        'videoAccountOwnerId',
+        'videoChannelOwnerId',
+        'heldForReview'
+      ]),
 
       selectType: 'api',
       notDeleted: true
     }
 
     return Promise.all([
-      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminOrUserFormattable>(),
       new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
     ]).then(([ rows, count ]) => {
       return { total: count, data: rows }
@@ -292,21 +348,25 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
   }
 
   static async listThreadsForApi (parameters: {
-    videoId: number
-    isVideoOwned: boolean
+    video: MVideo
     start: number
     count: number
     sort: string
     user?: MUserAccountId
   }) {
-    const { videoId, user } = parameters
+    const { video, user } = parameters
 
-    const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
+    const { blockerAccountIds, canSeeHeldForReview } = await VideoCommentModel.buildBlockerAccountIdsAndCanSeeHeldForReview({ user, video })
 
     const commonOptions: ListVideoCommentsOptions = {
       selectType: 'api',
-      videoId,
-      blockerAccountIds
+      videoId: video.id,
+      blockerAccountIds,
+
+      heldForReview: canSeeHeldForReview
+        ? undefined // Display all comments for video owner or moderator
+        : false,
+      heldForReviewAccountIdException: user?.Account?.id
     }
 
     const listOptions: ListVideoCommentsOptions = {
@@ -330,7 +390,7 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
     }
 
     return Promise.all([
-      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, listOptions).listComments<MCommentAdminFormattable>(),
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, listOptions).listComments<MCommentAdminOrUserFormattable>(),
       new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, countOptions).countComments(),
       new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, notDeletedCountOptions).countComments()
     ]).then(([ rows, count, totalNotDeletedComments ]) => {
@@ -339,33 +399,45 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
   }
 
   static async listThreadCommentsForApi (parameters: {
-    videoId: number
+    video: MVideo
     threadId: number
     user?: MUserAccountId
   }) {
-    const { user } = parameters
+    const { user, video, threadId } = parameters
 
-    const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
+    const { blockerAccountIds, canSeeHeldForReview } = await VideoCommentModel.buildBlockerAccountIdsAndCanSeeHeldForReview({ user, video })
 
     const queryOptions: ListVideoCommentsOptions = {
-      ...pick(parameters, [ 'videoId', 'threadId' ]),
+      threadId,
 
+      videoId: video.id,
       selectType: 'api',
       sort: 'createdAt',
 
       blockerAccountIds,
-      includeReplyCounters: true
+      includeReplyCounters: true,
+
+      heldForReview: canSeeHeldForReview
+        ? undefined // Display all comments for video owner or moderator
+        : false,
+      heldForReviewAccountIdException: user?.Account?.id
     }
 
     return Promise.all([
-      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
+      new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminOrUserFormattable>(),
       new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
     ]).then(([ rows, count ]) => {
       return { total: count, data: rows }
     })
   }
 
-  static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> {
+  static listThreadParentComments (options: {
+    comment: MCommentId
+    transaction?: Transaction
+    order?: 'ASC' | 'DESC'
+  }): Promise<MCommentOwner[]> {
+    const { comment, transaction, order = 'ASC' } = options
+
     const query = {
       order: [ [ 'createdAt', order ] ] as Order,
       where: {
@@ -382,7 +454,7 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
           [Op.ne]: comment.id
         }
       },
-      transaction: t
+      transaction
     }
 
     return VideoCommentModel
@@ -406,6 +478,8 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
       videoId: video.id,
       sort: 'createdAt',
 
+      heldForReview: false,
+
       blockerAccountIds
     }
 
@@ -421,19 +495,21 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
     start: number
     count: number
     videoId?: number
-    accountId?: number
-    videoChannelId?: number
+    videoAccountOwnerId?: number
+    videoChannelOwnerId?: number
   }) {
     const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
 
     const queryOptions: ListVideoCommentsOptions = {
-      ...pick(parameters, [ 'start', 'count', 'accountId', 'videoId', 'videoChannelId' ]),
+      ...pick(parameters, [ 'start', 'count', 'videoAccountOwnerId', 'videoId', 'videoChannelOwnerId' ]),
 
       selectType: 'feed',
 
       sort: '-createdAt',
       onPublicVideo: true,
+
       notDeleted: true,
+      heldForReview: false,
 
       blockerAccountIds
     }
@@ -448,6 +524,8 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
       accountId: ofAccount.id,
       videoAccountOwnerId: filter.onVideosOfAccount?.id,
 
+      heldForReview: undefined,
+
       notDeleted: true,
       count: 5000
     }
@@ -457,14 +535,14 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
 
   static listForExport (ofAccountId: number): Promise<MCommentExport[]> {
     return VideoCommentModel.findAll({
-      attributes: [ 'url', 'text', 'createdAt' ],
+      attributes: [ 'id', 'url', 'text', 'createdAt' ],
       where: {
         accountId: ofAccountId,
         deletedAt: null
       },
       include: [
         {
-          attributes: [ 'url' ],
+          attributes: [ 'id', 'uuid', 'url' ],
           required: true,
           model: VideoModel.unscoped()
         },
@@ -479,9 +557,12 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
     })
   }
 
+  // ---------------------------------------------------------------------------
+
   static async getStats () {
     const where = {
-      deletedAt: null
+      deletedAt: null,
+      heldForReview: false
     }
 
     const totalLocalVideoComments = await VideoCommentModel.count({
@@ -510,6 +591,8 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
     }
   }
 
+  // ---------------------------------------------------------------------------
+
   static listRemoteCommentUrlsOfLocalVideos () {
     const query = `SELECT "videoComment".url FROM "videoComment" ` +
       `INNER JOIN account ON account.id = "videoComment"."accountId" ` +
@@ -540,10 +623,16 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
     return VideoCommentModel.destroy(query)
   }
 
+  // ---------------------------------------------------------------------------
+
   getCommentStaticPath () {
     return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
   }
 
+  getCommentUserReviewPath () {
+    return '/my-account/videos/comments?search=heldForReview:true'
+  }
+
   getThreadId (): number {
     return this.originCommentId || this.id
   }
@@ -582,6 +671,8 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
       updatedAt: this.updatedAt,
       deletedAt: this.deletedAt,
 
+      heldForReview: this.heldForReview,
+
       isDeleted: this.isDeleted(),
 
       totalRepliesFromVideoAuthor: this.get('totalRepliesFromVideoAuthor') || 0,
@@ -593,7 +684,7 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
     } as VideoComment
   }
 
-  toFormattedAdminJSON (this: MCommentAdminFormattable) {
+  toFormattedForAdminOrUserJSON (this: MCommentAdminOrUserFormattable) {
     return {
       id: this.id,
       url: this.url,
@@ -606,6 +697,9 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
       createdAt: this.createdAt,
       updatedAt: this.updatedAt,
 
+      heldForReview: this.heldForReview,
+      automaticTags: (this.CommentAutomaticTags || []).map(m => m.AutomaticTag.name),
+
       video: {
         id: this.Video.id,
         uuid: this.Video.uuid,
@@ -615,17 +709,13 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
       account: this.Account
         ? this.Account.toFormattedJSON()
         : null
-    } as VideoCommentAdmin
+    } as VideoCommentForAdminOrUser
   }
 
   toActivityPubObject (this: MCommentAP, threadParentComments: MCommentOwner[]): VideoCommentObject | ActivityTombstoneObject {
-    let inReplyTo: string
-    // New thread, so in AS we reply to the video
-    if (this.inReplyToCommentId === null) {
-      inReplyTo = this.Video.url
-    } else {
-      inReplyTo = this.InReplyToVideoComment.url
-    }
+    const inReplyTo = this.inReplyToCommentId === null
+      ? this.Video.url // New thread, so we reply to the video
+      : this.InReplyToVideoComment.url
 
     if (this.isDeleted()) {
       return {
@@ -652,6 +742,11 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
       })
     }
 
+    let replyApproval = this.replyApproval
+    if (this.Video.isOwned() && !this.heldForReview) {
+      replyApproval = getLocalApproveReplyActivityPubUrl(this.Video, this)
+    }
+
     return {
       type: 'Note' as 'Note',
       id: this.url,
@@ -664,6 +759,7 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
       published: this.createdAt.toISOString(),
       url: this.url,
       attributedTo: this.Account.Actor.url,
+      replyApproval,
       tag
     }
   }
@@ -680,4 +776,27 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
 
     return blockerAccountIds
   }
+
+  private static buildBlockerAccountIdsAndCanSeeHeldForReview (options: {
+    video: MVideo
+    user: MUserAccountId
+  }) {
+    const { video, user } = options
+    const blockerAccountIdsPromise = this.buildBlockerAccountIds(options)
+
+    let canSeeHeldForReviewPromise: Promise<boolean>
+    if (user) {
+      if (user.hasRight(UserRight.SEE_ALL_COMMENTS)) {
+        canSeeHeldForReviewPromise = Promise.resolve(true)
+      } else {
+        canSeeHeldForReviewPromise = VideoChannelModel.loadAndPopulateAccount(video.channelId)
+          .then(c => c.accountId === user.Account.id)
+      }
+    } else {
+      canSeeHeldForReviewPromise = Promise.resolve(false)
+    }
+
+    return Promise.all([ blockerAccountIdsPromise, canSeeHeldForReviewPromise ])
+      .then(([ blockerAccountIds, canSeeHeldForReview ]) => ({ blockerAccountIds, canSeeHeldForReview }))
+  }
 }
diff --git a/server/core/models/video/video-file.ts b/server/core/models/video/video-file.ts
index dfb7eaedb..9c2b03519 100644
--- a/server/core/models/video/video-file.ts
+++ b/server/core/models/video/video-file.ts
@@ -270,7 +270,7 @@ export class VideoFileModel extends SequelizeModel<VideoFileModel> {
   static doesInfohashExist (infoHash: string) {
     const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
 
-    return doesExist(this.sequelize, query, { infoHash })
+    return doesExist({ sequelize: this.sequelize, query, bind: { infoHash } })
   }
 
   static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) {
@@ -286,14 +286,14 @@ export class VideoFileModel extends SequelizeModel<VideoFileModel> {
                   'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' +
                   'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webvideo"."id" IS NOT NULL) LIMIT 1'
 
-    return doesExist(this.sequelize, query, { filename })
+    return doesExist({ sequelize: this.sequelize, query, bind: { filename } })
   }
 
   static async doesOwnedWebVideoFileExist (filename: string) {
     const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' +
                   `WHERE "filename" = $filename AND "storage" = ${FileStorage.FILE_SYSTEM} LIMIT 1`
 
-    return doesExist(this.sequelize, query, { filename })
+    return doesExist({ sequelize: this.sequelize, query, bind: { filename } })
   }
 
   static loadByFilename (filename: string) {
diff --git a/server/core/models/video/video-streaming-playlist.ts b/server/core/models/video/video-streaming-playlist.ts
index c5d107e23..446c3b810 100644
--- a/server/core/models/video/video-streaming-playlist.ts
+++ b/server/core/models/video/video-streaming-playlist.ts
@@ -136,7 +136,7 @@ export class VideoStreamingPlaylistModel extends SequelizeModel<VideoStreamingPl
     // Don't add a LIMIT 1 here to prevent seq scan by PostgreSQL (not sure why id doesn't use the index when we add a LIMIT)
     const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE "p2pMediaLoaderInfohashes" @> $infoHash'
 
-    return doesExist(this.sequelize, query, { infoHash: `{${infoHash}}` }) // Transform infoHash in a PG array
+    return doesExist({ sequelize: this.sequelize, query, bind: { infoHash: `{${infoHash}}` } }) // Transform infoHash in a PG array
   }
 
   static buildP2PMediaLoaderInfoHashes (playlistUrl: string, files: unknown[]) {
@@ -235,7 +235,7 @@ export class VideoStreamingPlaylistModel extends SequelizeModel<VideoStreamingPl
       `AND "video"."remote" IS FALSE AND "video"."uuid" = $videoUUID ` +
       `AND "storage" = ${FileStorage.FILE_SYSTEM} LIMIT 1`
 
-    return doesExist(this.sequelize, query, { videoUUID })
+    return doesExist({ sequelize: this.sequelize, query, bind: { videoUUID } })
   }
 
   assignP2PMediaLoaderInfoHashes (video: MVideo, files: unknown[]) {
diff --git a/server/core/models/video/video.ts b/server/core/models/video/video.ts
index 9f43a75a2..ec96df0ed 100644
--- a/server/core/models/video/video.ts
+++ b/server/core/models/video/video.ts
@@ -15,6 +15,7 @@ import {
   VideoRateType,
   VideoState,
   VideoStreamingPlaylistType,
+  type VideoCommentPolicyType,
   type VideoPrivacyType,
   type VideoStateType
 } from '@peertube/peertube-models'
@@ -111,6 +112,7 @@ import { AccountVideoRateModel } from '../account/account-video-rate.js'
 import { AccountModel } from '../account/account.js'
 import { ActorImageModel } from '../actor/actor-image.js'
 import { ActorModel } from '../actor/actor.js'
+import { VideoAutomaticTagModel } from '../automatic-tag/video-automatic-tag.js'
 import { VideoRedundancyModel } from '../redundancy/video-redundancy.js'
 import { ServerModel } from '../server/server.js'
 import { TrackerModel } from '../server/tracker.js'
@@ -549,7 +551,7 @@ export class VideoModel extends SequelizeModel<VideoModel> {
 
   @AllowNull(false)
   @Column
-  commentsEnabled: boolean
+  commentsPolicy: VideoCommentPolicyType
 
   @AllowNull(false)
   @Column
@@ -777,6 +779,12 @@ export class VideoModel extends SequelizeModel<VideoModel> {
   })
   VideoPasswords: Awaited<VideoPasswordModel>[]
 
+  @HasMany(() => VideoAutomaticTagModel, {
+    foreignKey: 'videoId',
+    onDelete: 'CASCADE'
+  })
+  VideoAutomaticTags: Awaited<VideoAutomaticTagModel>[]
+
   @HasOne(() => VideoJobInfoModel, {
     foreignKey: {
       name: 'videoId',
@@ -1172,6 +1180,8 @@ export class VideoModel extends SequelizeModel<VideoModel> {
     search?: string
 
     excludeAlreadyWatched?: boolean
+
+    autoTagOneOf?: string[]
   }) {
     VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
     VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
@@ -1196,6 +1206,7 @@ export class VideoModel extends SequelizeModel<VideoModel> {
         'categoryOneOf',
         'licenceOneOf',
         'languageOneOf',
+        'autoTagOneOf',
         'tagsOneOf',
         'tagsAllOf',
         'privacyOneOf',
@@ -1264,6 +1275,8 @@ export class VideoModel extends SequelizeModel<VideoModel> {
     excludeAlreadyWatched?: boolean
 
     countVideos?: boolean
+
+    autoTagOneOf?: string[]
   }) {
     VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
     VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
@@ -1278,6 +1291,7 @@ export class VideoModel extends SequelizeModel<VideoModel> {
         'categoryOneOf',
         'licenceOneOf',
         'languageOneOf',
+        'autoTagOneOf',
         'tagsOneOf',
         'tagsAllOf',
         'privacyOneOf',
diff --git a/server/core/models/watched-words/watched-words-list.ts b/server/core/models/watched-words/watched-words-list.ts
new file mode 100644
index 000000000..c579e50f4
--- /dev/null
+++ b/server/core/models/watched-words/watched-words-list.ts
@@ -0,0 +1,206 @@
+import { WatchedWordsList } from '@peertube/peertube-models'
+import { logger } from '@server/helpers/logger.js'
+import { wordsToRegExp } from '@server/helpers/regexp.js'
+import { MAccountId, MWatchedWordsList } from '@server/types/models/index.js'
+import { LRUCache } from 'lru-cache'
+import { Transaction } from 'sequelize'
+import {
+  AllowNull,
+  BelongsTo,
+  Column,
+  CreatedAt,
+  DataType, ForeignKey, Table,
+  UpdatedAt
+} from 'sequelize-typescript'
+import { LRU_CACHE, USER_EXPORT_MAX_ITEMS } from '../../initializers/constants.js'
+import { AccountModel } from '../account/account.js'
+import { SequelizeModel, getSort } from '../shared/index.js'
+
+@Table({
+  tableName: 'watchedWordsList',
+  indexes: [
+    {
+      fields: [ 'listName', 'accountId' ],
+      unique: true
+    },
+    {
+      fields: [ 'accountId' ]
+    }
+  ]
+})
+export class WatchedWordsListModel extends SequelizeModel<WatchedWordsListModel> {
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  @AllowNull(false)
+  @Column
+  listName: string
+
+  @AllowNull(false)
+  @Column(DataType.ARRAY(DataType.STRING))
+  words: string[]
+
+  @ForeignKey(() => AccountModel)
+  @Column
+  accountId: number
+
+  @BelongsTo(() => AccountModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'CASCADE'
+  })
+  Account: Awaited<AccountModel>
+
+  // accountId => reg expressions
+  private static readonly regexCache = new LRUCache<number, { listName: string, regex: RegExp }[]>({
+    max: LRU_CACHE.WATCHED_WORDS_REGEX.MAX_SIZE,
+    ttl: LRU_CACHE.WATCHED_WORDS_REGEX.TTL
+  })
+
+  static load (options: {
+    id: number
+    accountId: number
+  }) {
+    const { id, accountId } = options
+
+    const query = {
+      where: { id, accountId }
+    }
+
+    return this.findOne(query)
+  }
+
+  static loadByListName (options: {
+    listName: string
+    accountId: number
+  }) {
+    const { listName, accountId } = options
+
+    const query = {
+      where: { listName, accountId }
+    }
+
+    return this.findOne(query)
+  }
+
+  // ---------------------------------------------------------------------------
+
+  static listNamesOf (account: MAccountId) {
+    const query = {
+      raw: true,
+      attributes: [ 'listName' ],
+      where: { accountId: account.id }
+    }
+
+    return WatchedWordsListModel.findAll(query)
+      .then(rows => rows.map(r => r.listName))
+  }
+
+  static listForAPI (options: {
+    accountId: number
+    start: number
+    count: number
+    sort: string
+  }) {
+    const { accountId, start, count, sort } = options
+
+    const query = {
+      offset: start,
+      limit: count,
+      order: getSort(sort),
+      where: { accountId }
+    }
+
+    return Promise.all([
+      WatchedWordsListModel.count(query),
+      WatchedWordsListModel.findAll(query)
+    ]).then(([ total, data ]) => ({ total, data }))
+  }
+
+  static listForExport (options: {
+    accountId: number
+  }) {
+    const { accountId } = options
+
+    return WatchedWordsListModel.findAll({
+      limit: USER_EXPORT_MAX_ITEMS,
+      order: getSort('createdAt'),
+      where: { accountId }
+    })
+  }
+
+  // ---------------------------------------------------------------------------
+
+  static async buildWatchedWordsRegexp (options: {
+    accountId: number
+    transaction: Transaction
+  }) {
+    const { accountId, transaction } = options
+
+    if (WatchedWordsListModel.regexCache.has(accountId)) {
+      return WatchedWordsListModel.regexCache.get(accountId)
+    }
+
+    const models = await WatchedWordsListModel.findAll<MWatchedWordsList>({
+      where: { accountId },
+      transaction
+    })
+
+    const result = models.map(m => ({ listName: m.listName, regex: wordsToRegExp(m.words) }))
+
+    this.regexCache.set(accountId, result)
+
+    logger.debug('Will cache watched words regex', { accountId, result, tags: [ 'watched-words' ] })
+
+    return result
+  }
+
+  static createList (options: {
+    accountId: number
+
+    listName: string
+    words: string[]
+  }) {
+    WatchedWordsListModel.regexCache.delete(options.accountId)
+
+    return super.create(options)
+  }
+
+  updateList (options: {
+    listName: string
+    words?: string[]
+  }) {
+    const { listName, words } = options
+
+    if (words && words.length === 0) {
+      throw new Error('Cannot update watched words with an empty list')
+    }
+
+    if (words) this.words = words
+    if (listName) this.listName = listName
+
+    WatchedWordsListModel.regexCache.delete(this.accountId)
+
+    return this.save()
+  }
+
+  destroy () {
+    WatchedWordsListModel.regexCache.delete(this.accountId)
+
+    return super.destroy()
+  }
+
+  toFormattedJSON (): WatchedWordsList {
+    return {
+      id: this.id,
+      listName: this.listName,
+      words: this.words,
+      updatedAt: this.updatedAt,
+      createdAt: this.createdAt
+    }
+  }
+}
diff --git a/server/core/types/express.d.ts b/server/core/types/express.d.ts
index 8bd3a829a..a1455c4a0 100644
--- a/server/core/types/express.d.ts
+++ b/server/core/types/express.d.ts
@@ -21,7 +21,8 @@ import {
   MVideoPassword,
   MVideoPlaylistFull,
   MVideoPlaylistFullSummary,
-  MVideoThumbnailBlacklist
+  MVideoThumbnailBlacklist,
+  MWatchedWordsList
 } from '@server/types/models/index.js'
 import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token.js'
 import { MPlugin, MServer, MServerBlocklist } from '@server/types/models/server.js'
@@ -231,6 +232,8 @@ declare module 'express' {
       runnerJob?: MRunnerJobRunner
 
       userExport?: MUserExport
+
+      watchedWordsList?: MWatchedWordsList
     }
   }
 }
diff --git a/server/core/types/models/automatic-tag/account-automatic-tag-policy.ts b/server/core/types/models/automatic-tag/account-automatic-tag-policy.ts
new file mode 100644
index 000000000..7f6ea518a
--- /dev/null
+++ b/server/core/types/models/automatic-tag/account-automatic-tag-policy.ts
@@ -0,0 +1,3 @@
+import { AccountAutomaticTagPolicyModel } from '@server/models/automatic-tag/account-automatic-tag-policy.js'
+
+export type MAccountAutomaticTagPolicy = Omit<AccountAutomaticTagPolicyModel, 'Account' | 'AutomaticTag'>
diff --git a/server/core/types/models/automatic-tag/automatic-tag.ts b/server/core/types/models/automatic-tag/automatic-tag.ts
new file mode 100644
index 000000000..41fb9a071
--- /dev/null
+++ b/server/core/types/models/automatic-tag/automatic-tag.ts
@@ -0,0 +1,3 @@
+import { AutomaticTagModel } from '@server/models/automatic-tag/automatic-tag.js'
+
+export type MAutomaticTag = Omit<AutomaticTagModel, 'Videos' | 'VideoComments'>
diff --git a/server/core/types/models/automatic-tag/comment-automatic-tag.ts b/server/core/types/models/automatic-tag/comment-automatic-tag.ts
new file mode 100644
index 000000000..13ff9af79
--- /dev/null
+++ b/server/core/types/models/automatic-tag/comment-automatic-tag.ts
@@ -0,0 +1,15 @@
+import { PickWith } from '@peertube/peertube-typescript-utils'
+import { CommentAutomaticTagModel } from '@server/models/automatic-tag/comment-automatic-tag.js'
+import { MAutomaticTag } from './automatic-tag.js'
+
+type Use<K extends keyof CommentAutomaticTagModel, M> = PickWith<CommentAutomaticTagModel, K, M>
+
+// ############################################################################
+
+export type MCommentAutomaticTag = Omit<CommentAutomaticTagModel, 'Account' | 'VideoComment' | 'AutomaticTag'>
+
+// ############################################################################
+
+export type MCommentAutomaticTagWithTag =
+  MCommentAutomaticTag &
+  Use<'AutomaticTag', MAutomaticTag>
diff --git a/server/core/types/models/automatic-tag/index.ts b/server/core/types/models/automatic-tag/index.ts
new file mode 100644
index 000000000..e10f99b8b
--- /dev/null
+++ b/server/core/types/models/automatic-tag/index.ts
@@ -0,0 +1,4 @@
+export * from './account-automatic-tag-policy.js'
+export * from './automatic-tag.js'
+export * from './comment-automatic-tag.js'
+export * from './video-automatic-tag.js'
diff --git a/server/core/types/models/automatic-tag/video-automatic-tag.ts b/server/core/types/models/automatic-tag/video-automatic-tag.ts
new file mode 100644
index 000000000..7d8d53694
--- /dev/null
+++ b/server/core/types/models/automatic-tag/video-automatic-tag.ts
@@ -0,0 +1,15 @@
+import { PickWith } from '@peertube/peertube-typescript-utils'
+import { VideoAutomaticTagModel } from '@server/models/automatic-tag/video-automatic-tag.js'
+import { MAutomaticTag } from './automatic-tag.js'
+
+type Use<K extends keyof VideoAutomaticTagModel, M> = PickWith<VideoAutomaticTagModel, K, M>
+
+// ############################################################################
+
+export type MVideoAutomaticTag = Omit<VideoAutomaticTagModel, 'Account' | 'Video' | 'AutomaticTag'>
+
+// ############################################################################
+
+export type MVideoAutomaticTagWithTag =
+  MVideoAutomaticTag &
+  Use<'AutomaticTag', MAutomaticTag>
diff --git a/server/core/types/models/index.ts b/server/core/types/models/index.ts
index 8c90db53c..eccab5c81 100644
--- a/server/core/types/models/index.ts
+++ b/server/core/types/models/index.ts
@@ -2,7 +2,9 @@ export * from './abuse/index.js'
 export * from './account/index.js'
 export * from './actor/index.js'
 export * from './application/index.js'
+export * from './automatic-tag/index.js'
 export * from './oauth/index.js'
 export * from './server/index.js'
 export * from './user/index.js'
 export * from './video/index.js'
+export * from './watched-words/index.js'
diff --git a/server/core/types/models/user/user-notification.ts b/server/core/types/models/user/user-notification.ts
index 5a0b57e7a..ca19f8f09 100644
--- a/server/core/types/models/user/user-notification.ts
+++ b/server/core/types/models/user/user-notification.ts
@@ -45,7 +45,7 @@ export module UserNotificationIncludes {
     PickWith<AccountModel, 'Actor', ActorInclude>
 
   export type VideoCommentInclude =
-    Pick<VideoCommentModel, 'id' | 'originCommentId' | 'getThreadId'> &
+    Pick<VideoCommentModel, 'id' | 'originCommentId' | 'getThreadId' | 'heldForReview'> &
     PickWith<VideoCommentModel, 'Account', AccountIncludeActor> &
     PickWith<VideoCommentModel, 'Video', VideoInclude>
 
diff --git a/server/core/types/models/video/video-channel.ts b/server/core/types/models/video/video-channel.ts
index b3a74aa53..d29090f95 100644
--- a/server/core/types/models/video/video-channel.ts
+++ b/server/core/types/models/video/video-channel.ts
@@ -1,11 +1,10 @@
 import { FunctionProperties, PickWith, PickWithOpt } from '@peertube/peertube-typescript-utils'
 import { VideoChannelModel } from '../../../models/video/video-channel.js'
 import {
-  MAccountActor,
   MAccountAPI,
+  MAccountActor,
   MAccountDefault,
-  MAccountFormattable,
-  MAccountId,
+  MAccountFormattable, MAccountIdActorId,
   MAccountLight,
   MAccountSummaryBlocks,
   MAccountSummaryFormattable,
@@ -14,9 +13,9 @@ import {
 } from '../account/index.js'
 import {
   MActor,
-  MActorAccountChannelId,
   MActorAPChannel,
   MActorAPI,
+  MActorAccountChannelId,
   MActorDefault,
   MActorDefaultBanner,
   MActorDefaultLight,
@@ -54,7 +53,7 @@ export type MChannelUserId =
 export type MChannelAccountIdUrl =
   Pick<MChannel, 'id' | 'accountId'> &
   Use<'Actor', MActorUrl & MActorId> &
-  Use<'Account', MAccountId & MAccountUrl>
+  Use<'Account', MAccountIdActorId & MAccountUrl>
 
 export type MChannelActor =
   MChannel &
diff --git a/server/core/types/models/video/video-comment.ts b/server/core/types/models/video/video-comment.ts
index 79c54a1e1..503672177 100644
--- a/server/core/types/models/video/video-comment.ts
+++ b/server/core/types/models/video/video-comment.ts
@@ -1,13 +1,16 @@
 import { PickWith, PickWithOpt } from '@peertube/peertube-typescript-utils'
 import { VideoCommentModel } from '../../../models/video/video-comment.js'
 import { MAccountDefault, MAccountFormattable, MAccountUrl } from '../account/index.js'
-import { MVideo, MVideoAccountIdUrl, MVideoAccountLight, MVideoFeed, MVideoIdUrl, MVideoUrl } from './video.js'
+import { MCommentAutomaticTagWithTag } from '../automatic-tag/comment-automatic-tag.js'
+import { MVideo, MVideoAccountIdUrl, MVideoAccountLight, MVideoFeed, MVideoIdUrl, MVideoImmutable, MVideoUUID } from './video.js'
 
 type Use<K extends keyof VideoCommentModel, M> = PickWith<VideoCommentModel, K, M>
 
 // ############################################################################
 
-export type MComment = Omit<VideoCommentModel, 'OriginVideoComment' | 'InReplyToVideoComment' | 'Video' | 'Account'>
+export type MComment =
+  Omit<VideoCommentModel, 'OriginVideoComment' | 'InReplyToVideoComment' | 'Video' | 'Account' | 'CommentAutomaticTags'>
+
 export type MCommentTotalReplies = MComment & { totalReplies?: number }
 export type MCommentId = Pick<MComment, 'id'>
 export type MCommentUrl = Pick<MComment, 'url'>
@@ -15,8 +18,8 @@ export type MCommentUrl = Pick<MComment, 'url'>
 // ---------------------------------------------------------------------------
 
 export type MCommentExport =
-  Pick<MComment, 'url' | 'text' | 'createdAt'> &
-  Use<'Video', MVideoAccountLight> &
+  Pick<MComment, 'id' | 'url' | 'text' | 'createdAt'> &
+  Use<'Video', MVideoIdUrl & MVideoUUID> &
   Use<'InReplyToVideoComment', MCommentUrl>
 
 // ############################################################################
@@ -44,11 +47,11 @@ export type MCommentOwnerVideoReply =
   Use<'Video', MVideoAccountIdUrl> &
   Use<'InReplyToVideoComment', MComment>
 
-export type MCommentOwnerReplyVideoLight =
+export type MCommentOwnerReplyVideoImmutable =
   MComment &
   Use<'Account', MAccountDefault> &
   Use<'InReplyToVideoComment', MComment> &
-  Use<'Video', MVideoIdUrl>
+  Use<'Video', MVideoImmutable>
 
 export type MCommentOwnerVideoFeed =
   MCommentOwner &
@@ -66,13 +69,14 @@ export type MCommentFormattable =
   MCommentTotalReplies &
   Use<'Account', MAccountFormattable>
 
-export type MCommentAdminFormattable =
+export type MCommentAdminOrUserFormattable =
   MComment &
   Use<'Account', MAccountFormattable> &
-  Use<'Video', MVideo>
+  Use<'Video', MVideo> &
+  Use<'CommentAutomaticTags', MCommentAutomaticTagWithTag[]>
 
 export type MCommentAP =
   MComment &
   Use<'Account', MAccountUrl> &
-  PickWithOpt<VideoCommentModel, 'Video', MVideoUrl> &
+  PickWithOpt<VideoCommentModel, 'Video', MVideoImmutable> &
   PickWithOpt<VideoCommentModel, 'InReplyToVideoComment', MCommentUrl>
diff --git a/server/core/types/models/video/video.ts b/server/core/types/models/video/video.ts
index 010009af2..cd08d6f24 100644
--- a/server/core/types/models/video/video.ts
+++ b/server/core/types/models/video/video.ts
@@ -34,7 +34,8 @@ type Use<K extends keyof VideoModel, M> = PickWith<VideoModel, K, M>
 export type MVideo =
   Omit<VideoModel, 'VideoChannel' | 'Tags' | 'Thumbnails' | 'VideoPlaylistElements' | 'VideoAbuses' |
   'VideoFiles' | 'VideoStreamingPlaylists' | 'VideoShares' | 'AccountVideoRates' | 'VideoComments' | 'VideoViews' | 'UserVideoHistories' |
-  'ScheduleVideoUpdate' | 'VideoBlacklist' | 'VideoImport' | 'VideoCaptions' | 'VideoLive' | 'Trackers' | 'VideoPasswords' | 'Storyboard'>
+  'ScheduleVideoUpdate' | 'VideoBlacklist' | 'VideoImport' | 'VideoCaptions' | 'VideoLive' | 'Trackers' | 'VideoPasswords' | 'Storyboard' |
+  'AutomaticTags'>
 
 // ############################################################################
 
diff --git a/server/core/types/models/watched-words/index.ts b/server/core/types/models/watched-words/index.ts
new file mode 100644
index 000000000..822e0e3c2
--- /dev/null
+++ b/server/core/types/models/watched-words/index.ts
@@ -0,0 +1 @@
+export * from './watched-words-list.js'
diff --git a/server/core/types/models/watched-words/watched-words-list.ts b/server/core/types/models/watched-words/watched-words-list.ts
new file mode 100644
index 000000000..1abac63ba
--- /dev/null
+++ b/server/core/types/models/watched-words/watched-words-list.ts
@@ -0,0 +1,3 @@
+import { WatchedWordsListModel } from '@server/models/watched-words/watched-words-list.js'
+
+export type MWatchedWordsList = Omit<WatchedWordsListModel, 'Account'>
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index bfdf78bf9..1a20af0d2 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -273,6 +273,10 @@ tags:
       its root comment thread.
   - name: Video Blocks
     description: Operations dealing with blocking videos (removing them from view and preventing interactions).
+  - name: Automatic Tags
+    description: Automatic tags set on objects (like comments or videos) by specific rules (external link, watched words, etc.)
+  - name: Watched Words
+    description: Manage list of watched words to detect patterns on objects (like comments of videos)
   - name: Video Rates
     description: Like/dislike a video.
   - name: Video Playlists
@@ -359,6 +363,8 @@ x-tagGroups:
       - Video Blocks
       - Account Blocks
       - Server Blocks
+      - Automatic Tags
+      - Watched Words
   - name: Instance
     tags:
       - Config
@@ -467,27 +473,27 @@ paths:
               - json1
         - name: videoId
           in: query
-          description: 'limit listing to a specific video'
+          description: 'limit listing comments to a specific video'
           schema:
             type: string
         - name: accountId
           in: query
-          description: 'limit listing to a specific account'
+          description: 'limit listing comments to videos of a specific account'
           schema:
             type: string
         - name: accountName
           in: query
-          description: 'limit listing to a specific account'
+          description: 'limit listing comments to videos of a specific account'
           schema:
             type: string
         - name: videoChannelId
           in: query
-          description: 'limit listing to a specific video channel'
+          description: 'limit listing comments to videos of a specific video channel'
           schema:
             type: string
         - name: videoChannelName
           in: query
-          description: 'limit listing to a specific video channel'
+          description: 'limit listing comments to videos of a specific video channel'
           schema:
             type: string
       responses:
@@ -755,6 +761,7 @@ paths:
         - $ref: '#/components/parameters/tagsAllOf'
         - $ref: '#/components/parameters/licenceOneOf'
         - $ref: '#/components/parameters/languageOneOf'
+        - $ref: '#/components/parameters/autoTagOneOfVideo'
         - $ref: '#/components/parameters/nsfw'
         - $ref: '#/components/parameters/isLocal'
         - $ref: '#/components/parameters/include'
@@ -1994,6 +2001,38 @@ paths:
               $ref: '#/components/schemas/UpdateMe'
         required: true
 
+  '/api/v1/users/me/videos/comments':
+    get:
+      summary: List comments on user's videos
+      description: "**PeerTube >= 6.2**"
+      security:
+        - OAuth2: []
+      tags:
+        - Video Comments
+      parameters:
+        - $ref: '#/components/parameters/search'
+        - $ref: '#/components/parameters/searchAccountForComments'
+        - $ref: '#/components/parameters/searchVideoForComments'
+        - $ref: '#/components/parameters/videoId'
+        - $ref: '#/components/parameters/videoChannelId'
+        - $ref: '#/components/parameters/autoTagOneOfComment'
+        - $ref: '#/components/parameters/isHeldForReview'
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  total:
+                    type: integer
+                    example: 1
+                  data:
+                    type: array
+                    items:
+                      $ref: '#/components/schemas/VideoCommentForOwnerOrAdmin'
+
   /api/v1/users/me/videos/imports:
     get:
       summary: Get video imports of my user
@@ -2185,6 +2224,7 @@ paths:
         - $ref: '#/components/parameters/tagsAllOf'
         - $ref: '#/components/parameters/licenceOneOf'
         - $ref: '#/components/parameters/languageOneOf'
+        - $ref: '#/components/parameters/autoTagOneOfVideo'
         - $ref: '#/components/parameters/nsfw'
         - $ref: '#/components/parameters/isLocal'
         - $ref: '#/components/parameters/include'
@@ -2730,6 +2770,7 @@ paths:
         - $ref: '#/components/parameters/tagsAllOf'
         - $ref: '#/components/parameters/licenceOneOf'
         - $ref: '#/components/parameters/languageOneOf'
+        - $ref: '#/components/parameters/autoTagOneOfVideo'
         - $ref: '#/components/parameters/nsfw'
         - $ref: '#/components/parameters/isLocal'
         - $ref: '#/components/parameters/include'
@@ -2888,8 +2929,11 @@ paths:
                     minLength: 2
                     maxLength: 30
                 commentsEnabled:
-                  description: Enable or disable comments for this video
+                  deprecated: true
+                  description: 'Deprecated in 6.2, use commentsPolicy instead'
                   type: boolean
+                commentsPolicy:
+                  $ref: '#/components/schemas/VideoCommentsPolicySet'
                 downloadEnabled:
                   description: Enable or disable downloading for this video
                   type: boolean
@@ -3442,8 +3486,11 @@ paths:
                     minLength: 2
                     maxLength: 30
                 commentsEnabled:
-                  description: Enable or disable comments for this live video/replay
+                  deprecated: true
+                  description: 'Deprecated in 6.2, use commentsPolicy instead'
                   type: boolean
+                commentsPolicy:
+                  $ref: '#/components/schemas/VideoCommentsPolicySet'
                 downloadEnabled:
                   description: Enable or disable downloading for the replay of this live video
                   type: boolean
@@ -4389,6 +4436,7 @@ paths:
         - $ref: '#/components/parameters/tagsAllOf'
         - $ref: '#/components/parameters/licenceOneOf'
         - $ref: '#/components/parameters/languageOneOf'
+        - $ref: '#/components/parameters/autoTagOneOfVideo'
         - $ref: '#/components/parameters/nsfw'
         - $ref: '#/components/parameters/isLocal'
         - $ref: '#/components/parameters/include'
@@ -5168,6 +5216,41 @@ paths:
               schema:
                 $ref: '#/components/schemas/VideoCommentThreadTree'
 
+  '/api/v1/videos/comments':
+    get:
+      summary: List instance comments
+      security:
+        - OAuth2:
+          - admin
+          - moderator
+      tags:
+        - Video Comments
+      parameters:
+        - $ref: '#/components/parameters/search'
+        - $ref: '#/components/parameters/searchAccountForComments'
+        - $ref: '#/components/parameters/searchVideoForComments'
+        - $ref: '#/components/parameters/videoId'
+        - $ref: '#/components/parameters/videoChannelId'
+        - $ref: '#/components/parameters/autoTagOneOfComment'
+        - $ref: '#/components/parameters/isLocal'
+        - $ref: '#/components/parameters/onLocalVideo'
+
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  total:
+                    type: integer
+                    example: 1
+                  data:
+                    type: array
+                    items:
+                      $ref: '#/components/schemas/VideoCommentForOwnerOrAdmin'
+
   '/api/v1/videos/{id}/comments/{commentId}':
     post:
       summary: Reply to a thread of a video
@@ -5220,6 +5303,21 @@ paths:
         '409':
           description: comment is already deleted
 
+  '/api/v1/videos/{id}/comments/{commentId}/approve':
+    post:
+      summary: Approve a comment
+      description: "**PeerTube >= 6.2** Approve a comment that requires a review"
+      security:
+        - OAuth2: []
+      tags:
+        - Video Comments
+      parameters:
+        - $ref: '#/components/parameters/idOrUUID'
+        - $ref: '#/components/parameters/commentId'
+      responses:
+        '204':
+          description: successful operation
+
   '/api/v1/videos/{id}/rate':
     put:
       summary: Like/dislike a video
@@ -5341,6 +5439,7 @@ paths:
         - $ref: '#/components/parameters/tagsAllOf'
         - $ref: '#/components/parameters/licenceOneOf'
         - $ref: '#/components/parameters/languageOneOf'
+        - $ref: '#/components/parameters/autoTagOneOfVideo'
         - $ref: '#/components/parameters/nsfw'
         - $ref: '#/components/parameters/isLocal'
         - $ref: '#/components/parameters/include'
@@ -6542,6 +6641,334 @@ paths:
                     items:
                       $ref: '#/components/schemas/RunnerJobAdmin'
 
+  /api/v1/automatic-tags/policies/accounts/{accountName}/comments:
+    get:
+      tags:
+        - Automatic Tags
+      summary: Get account auto tag policies on comments
+      description: "**PeerTube >= 6.2**"
+      security:
+        - OAuth2: []
+      parameters:
+        - name: accountName
+          in: path
+          required: true
+          description: account name to get auto tag policies
+          schema:
+            type: string
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/CommentAutoTagPolicies'
+    put:
+      tags:
+        - Automatic Tags
+      summary: Update account auto tag policies on comments
+      description: "**PeerTube >= 6.2**"
+      security:
+        - OAuth2: []
+      parameters:
+        - name: accountName
+          in: path
+          required: true
+          description: account name to update auto tag policies
+          schema:
+            type: string
+      requestBody:
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                review:
+                  description: 'Auto tags that automatically set the comment in review state'
+                  type: array
+                  items:
+                    type: string
+      responses:
+        '204':
+          description: successful operation
+
+  /api/v1/automatic-tags/accounts/{accountName}/available:
+    get:
+      tags:
+        - Automatic Tags
+      summary: Get account available auto tags
+      description: "**PeerTube >= 6.2**"
+      security:
+        - OAuth2: []
+      parameters:
+        - name: accountName
+          in: path
+          required: true
+          description: account name to get auto tag policies
+          schema:
+            type: string
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/AutomaticTagAvailable'
+  /api/v1/automatic-tags/server/available:
+    get:
+      tags:
+        - Automatic Tags
+      summary: Get server available auto tags
+      description: "**PeerTube >= 6.2**"
+      security:
+        - OAuth2:
+          - admin
+          - moderator
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/AutomaticTagAvailable'
+
+  /api/v1/watched-words/accounts/{accountName}/lists:
+    get:
+      tags:
+        - Watched Words
+      summary: List account watched words
+      description: "**PeerTube >= 6.2**"
+      security:
+        - OAuth2: []
+      parameters:
+        - name: accountName
+          in: path
+          required: true
+          description: account name to list watched words
+          schema:
+            type: string
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                properties:
+                  total:
+                    type: integer
+                    example: 1
+                  data:
+                    type: array
+                    items:
+                      $ref: '#/components/schemas/WatchedWordsLists'
+    post:
+      tags:
+        - Watched Words
+      summary: Add account watched words
+      description: "**PeerTube >= 6.2**"
+      security:
+        - OAuth2: []
+      parameters:
+        - name: accountName
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                listName:
+                  type: string
+                words:
+                  type: array
+                  items:
+                    type: string
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  watchedWordsList:
+                    type: object
+                    properties:
+                      id:
+                        type: integer
+
+  /api/v1/watched-words/accounts/{accountName}/lists/{listId}:
+    put:
+      tags:
+        - Watched Words
+      summary: Update account watched words
+      description: "**PeerTube >= 6.2**"
+      security:
+        - OAuth2: []
+      parameters:
+        - name: accountName
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: listId
+          in: path
+          required: true
+          description: list of watched words to update
+          schema:
+            type: string
+      requestBody:
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                listName:
+                  type: string
+                words:
+                  type: array
+                  items:
+                    type: string
+      responses:
+        '204':
+          description: successful operation
+    delete:
+      tags:
+        - Watched Words
+      summary: Delete account watched words
+      description: "**PeerTube >= 6.2**"
+      security:
+        - OAuth2: []
+      parameters:
+        - name: accountName
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: listId
+          in: path
+          required: true
+          description: list of watched words to delete
+          schema:
+            type: string
+      responses:
+        '204':
+          description: successful operation
+
+  /api/v1/watched-words/server/lists:
+    get:
+      tags:
+        - Watched Words
+      summary: List server watched words
+      description: "**PeerTube >= 6.2**"
+      security:
+        - OAuth2:
+          - admin
+          - moderator
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                properties:
+                  total:
+                    type: integer
+                    example: 1
+                  data:
+                    type: array
+                    items:
+                      $ref: '#/components/schemas/WatchedWordsLists'
+    post:
+      tags:
+        - Watched Words
+      summary: Add server watched words
+      description: "**PeerTube >= 6.2**"
+      security:
+        - OAuth2:
+          - admin
+          - moderator
+      requestBody:
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                listName:
+                  type: string
+                words:
+                  type: array
+                  items:
+                    type: string
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  watchedWordsList:
+                    type: object
+                    properties:
+                      id:
+                        type: integer
+
+  /api/v1/watched-words/server/lists/{listId}:
+    put:
+      tags:
+        - Watched Words
+      summary: Update server watched words
+      description: "**PeerTube >= 6.2**"
+      security:
+        - OAuth2:
+          - admin
+          - moderator
+      parameters:
+        - name: listId
+          in: path
+          required: true
+          description: list of watched words to update
+          schema:
+            type: string
+      requestBody:
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                listName:
+                  type: string
+                words:
+                  type: array
+                  items:
+                    type: string
+      responses:
+        '204':
+          description: successful operation
+    delete:
+      tags:
+        - Watched Words
+      summary: Delete server watched words
+      description: "**PeerTube >= 6.2**"
+      security:
+        - OAuth2:
+          - admin
+          - moderator
+      parameters:
+        - name: listId
+          in: path
+          required: true
+          description: list of watched words to delete
+          schema:
+            type: string
+      responses:
+        '204':
+          description: successful operation
+
 servers:
   - url: 'https://peertube2.cpy.re'
     description: Live Test Server (live data - latest nightly version)
@@ -6926,6 +7353,39 @@ components:
             $ref: '#/components/schemas/VideoLanguageSet'
       style: form
       explode: true
+    autoTagOneOfVideo:
+      name: autoTagOneOf
+      in: query
+      required: false
+      description: "**PeerTube >= 6.2** **Admins and moderators only** filter on videos that contain one of these automatic tags"
+      schema:
+        oneOf:
+        - type: string
+        - type: array
+          items:
+            type: string
+      style: form
+      explode: true
+    autoTagOneOfComment:
+      name: autoTagOneOf
+      in: query
+      required: false
+      description: "**PeerTube >= 6.2** filter on comments that contain one of these automatic tags"
+      schema:
+        oneOf:
+        - type: string
+        - type: array
+          items:
+            type: string
+      style: form
+      explode: true
+    isHeldForReview:
+      name: isHeldForReview
+      in: query
+      required: false
+      description: only display comments that are held for review
+      schema:
+        type: boolean
     licenceOneOf:
       name: licenceOneOf
       in: query
@@ -6966,7 +7426,7 @@ components:
       required: false
       schema:
         type: boolean
-      description: '**PeerTube >= 4.0** Display only local or remote videos'
+      description: '**PeerTube >= 4.0** Display only local or remote objects'
     hasHLSFiles:
       name: hasHLSFiles
       in: query
@@ -7157,6 +7617,41 @@ components:
       description: The video password id
       schema:
         $ref: '#/components/schemas/id'
+    onLocalVideo:
+      name: onLocalVideo
+      in: query
+      required: false
+      schema:
+        type: boolean
+      description: 'Display only objects of local or remote videos'
+    videoChannelId:
+      name: videoChannelId
+      in: query
+      required: false
+      schema:
+        type: integer
+      description: 'Limit results on this specific video channel'
+    videoId:
+      name: videoId
+      in: query
+      required: false
+      schema:
+        type: integer
+      description: 'Limit results on this specific video'
+    searchVideoForComments:
+      name: searchVideo
+      in: query
+      required: false
+      description: Filter comments by searching on the video
+      schema:
+        type: string
+    searchAccountForComments:
+      name: searchAccount
+      in: query
+      required: false
+      description: Filter comments by searching on the account
+      schema:
+        type: string
     videoPasswordHeader:
       name: x-peertube-video-password
       description: Required on password protected video
@@ -7353,6 +7848,20 @@ components:
         label:
           type: string
 
+    VideoCommentsPolicySet:
+      type: integer
+      enum:
+        - 1
+        - 2
+        - 3
+      description: Comments policy of the video (Enabled = `1`, Disabled = `2`, Requires Approval = `3`)
+    VideoCommentsPolicyConstant:
+      properties:
+        id:
+          $ref: '#/components/schemas/VideoCommentsPolicySet'
+        label:
+          type: string
+
     BlockStatus:
       properties:
         accounts:
@@ -7822,7 +8331,11 @@ components:
                 minLength: 2
                 maxLength: 30
             commentsEnabled:
+              deprecated: true
+              description: 'Deprecated in 6.2, use commentsPolicy instead'
               type: boolean
+            commentsPolicy:
+              $ref: '#/components/schemas/VideoCommentsPolicyConstant'
             downloadEnabled:
               type: boolean
             inputFileUpdatedAt:
@@ -7903,6 +8416,30 @@ components:
               type: array
               items:
                 $ref: '#/components/schemas/FileRedundancyInformation'
+    CommentAutoTagPolicies:
+      properties:
+        review:
+          type: array
+          description: 'Auto tags that automatically set the comment in review state'
+          items:
+            type: string
+    AutomaticTagAvailable:
+      properties:
+        available:
+          type: array
+          description: 'Available auto tags that can be used to filter objects or set a comment in review state'
+          items:
+            type: object
+            properties:
+              name:
+                type: string
+                description: tag name
+              type:
+                type: string
+                enum:
+                  - 'core'
+                  - 'watched-words-list'
+
     VideoImportStateConstant:
       properties:
         id:
@@ -8135,6 +8672,8 @@ components:
         isDeleted:
           type: boolean
           default: false
+        heldForReview:
+          type: boolean
         totalRepliesFromVideoAuthor:
           type: integer
           minimum: 0
@@ -8151,6 +8690,33 @@ components:
           type: array
           items:
             $ref: '#/components/schemas/VideoCommentThreadTree'
+    VideoCommentForOwnerOrAdmin:
+      properties:
+        id:
+          $ref: '#/components/schemas/id'
+        url:
+          $ref: '#/components/schemas/VideoComment/properties/url'
+        text:
+          $ref: '#/components/schemas/VideoComment/properties/text'
+        heldForReview:
+          $ref: '#/components/schemas/VideoComment/properties/heldForReview'
+        threadId:
+          $ref: '#/components/schemas/VideoComment/properties/threadId'
+        inReplyToCommentId:
+          $ref: '#/components/schemas/VideoComment/properties/inReplyToCommentId'
+        createdAt:
+          $ref: '#/components/schemas/VideoComment/properties/createdAt'
+        updatedAt:
+          $ref: '#/components/schemas/VideoComment/properties/updatedAt'
+        account:
+          $ref: '#/components/schemas/VideoComment/properties/account'
+        video:
+          $ref: '#/components/schemas/VideoInfo'
+        automaticTags:
+          type: array
+          items:
+            type: string
+
     Storyboard:
       properties:
         storyboardPath:
@@ -9118,8 +9684,11 @@ components:
             minLength: 2
             maxLength: 30
         commentsEnabled:
-          description: Enable or disable comments for this video
+          deprecated: true
+          description: 'Deprecated in 6.2, use commentsPolicy instead'
           type: boolean
+        commentsPolicy:
+          $ref: '#/components/schemas/VideoCommentsPolicySet'
         downloadEnabled:
           description: Enable or disable downloading for this video
           type: boolean
@@ -9997,6 +10566,12 @@ components:
             - `17` NEW_PLUGIN_VERSION
 
             - `18` NEW_PEERTUBE_VERSION
+
+            - `19` MY_VIDEO_STUDIO_EDITION_FINISHED
+
+            - `20` NEW_USER_REGISTRATION_REQUEST
+
+            - `21` NEW_LIVE_FROM_SUBSCRIPTION
         read:
           type: boolean
         video:
@@ -10037,6 +10612,8 @@ components:
               $ref: '#/components/schemas/VideoInfo'
             account:
               $ref: '#/components/schemas/ActorInfo'
+            heldForReview:
+              type: boolean
         videoAbuse:
           nullable: true
           type: object
@@ -10546,6 +11123,25 @@ components:
             privatePayload:
               type: object
 
+    WatchedWordsLists:
+      properties:
+        id:
+          $ref: '#/components/schemas/id'
+        listName:
+          type: string
+        words:
+          type: array
+          items:
+            type: string
+        updatedAt:
+          type: string
+          format: date-time
+          example: 2021-05-04T08:01:01.502Z
+        createdAt:
+          type: string
+          format: date-time
+          example: 2021-05-04T08:01:01.502Z
+
     VideoPassword:
       properties:
         id:
diff --git a/yarn.lock b/yarn.lock
index 220d4cd1b..22a34b4b2 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2557,6 +2557,11 @@
   dependencies:
     "@types/node" "*"
 
+"@types/linkify-it@^3.0.5":
+  version "3.0.5"
+  resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-3.0.5.tgz#1e78a3ac2428e6d7e6c05c1665c242023a4601d8"
+  integrity sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==
+
 "@types/lodash-es@^4.17.8":
   version "4.17.12"
   resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.12.tgz#65f6d1e5f80539aa7cfbfc962de5def0cf4f341b"