From c8804274fe396232966cdeb5eb89e8df69863779 Mon Sep 17 00:00:00 2001
From: Your Name <you@example.com>
Date: Fri, 27 Sep 2024 11:17:08 -0400
Subject: [PATCH 1/3] WIP: Add backend functionality to store comment count per
 video and update on comment visibility actions

---
 packages/models/src/videos/video.model.ts     |   4 +-
 server/core/initializers/constants.ts         |   2 +-
 .../migrations/0870-video-comment-count.ts    | 130 ++++++++++++++++++
 .../video/formatter/video-api-format.ts       |   2 +
 .../video/shared/video-table-attributes.ts    |   3 +-
 server/core/models/video/video-comment.ts     |  37 +++++
 server/core/models/video/video.ts             |   5 +
 7 files changed, 180 insertions(+), 3 deletions(-)
 create mode 100644 server/core/initializers/migrations/0870-video-comment-count.ts

diff --git a/packages/models/src/videos/video.model.ts b/packages/models/src/videos/video.model.ts
index 5b37e0a4391..f1db058fe15 100644
--- a/packages/models/src/videos/video.model.ts
+++ b/packages/models/src/videos/video.model.ts
@@ -60,7 +60,9 @@ export interface Video extends Partial<VideoAdditionalAttributes> {
     currentTime: number
   }
 
-  pluginData?: any
+  pluginData?: any,
+
+  commentCount: number
 }
 
 // Not included by default, needs query params
diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts
index 6b5699130bd..f8d81f20f0d 100644
--- a/server/core/initializers/constants.ts
+++ b/server/core/initializers/constants.ts
@@ -46,7 +46,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'
 
 // ---------------------------------------------------------------------------
 
-export const LAST_MIGRATION_VERSION = 865
+export const LAST_MIGRATION_VERSION = 870
 
 // ---------------------------------------------------------------------------
 
diff --git a/server/core/initializers/migrations/0870-video-comment-count.ts b/server/core/initializers/migrations/0870-video-comment-count.ts
new file mode 100644
index 00000000000..32a07546aec
--- /dev/null
+++ b/server/core/initializers/migrations/0870-video-comment-count.ts
@@ -0,0 +1,130 @@
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+  transaction: Sequelize.Transaction
+  queryInterface: Sequelize.QueryInterface
+  sequelize: Sequelize.Sequelize
+}): Promise<void> {
+  const { transaction } = utils
+
+  {
+    // 1. Add the commentCount column as nullable without a default value
+    await utils.queryInterface.addColumn('video', 'commentCount', {
+      type: Sequelize.INTEGER,
+      allowNull: true // Initially allow nulls
+    }, { transaction })
+  }
+
+  {
+    // 2. Backfill the commentCount data in small batches
+    const batchSize = 1000
+    let offset = 0
+    let hasMore = true
+
+    while (hasMore) {
+      const [videos] = await utils.sequelize.query(
+        `
+          SELECT v.id
+          FROM video v
+          ORDER BY v.id
+          LIMIT ${batchSize} OFFSET ${offset}
+        `,
+        {
+          transaction,
+          // Sequelize v6 defaults to SELECT type, so no need to specify QueryTypes.SELECT
+        }
+      )
+
+      if (videos.length === 0) {
+        hasMore = false
+        break
+      }
+
+      const videoIds = videos.map((v: any) => v.id)
+
+      // Get comment counts for this batch
+      const [counts] = await utils.sequelize.query(
+        `
+          SELECT "videoId", COUNT(*) AS count
+          FROM "videoComment"
+          WHERE "videoId" IN (:videoIds)
+          GROUP BY "videoId"
+        `,
+        {
+          transaction,
+          replacements: { videoIds }
+        }
+      )
+
+      // Create a map of videoId to count
+      const countMap = counts.reduce((map: any, item: any) => {
+        map[item.videoId] = parseInt(item.count, 10)
+        return map
+      }, {})
+
+      // Update videos in this batch
+      const updatePromises = videoIds.map((id: number) => {
+        const count = countMap[id] || 0
+        return utils.sequelize.query(
+          `
+            UPDATE video
+            SET "commentCount" = :count
+            WHERE id = :id
+          `,
+          {
+            transaction,
+            replacements: { count, id }
+          }
+        )
+      })
+
+      await Promise.all(updatePromises)
+
+      offset += batchSize
+    }
+  }
+
+  {
+    // 3. Set the default value to 0 for future inserts
+    await utils.queryInterface.changeColumn('video', 'commentCount', {
+      type: Sequelize.INTEGER,
+      allowNull: true,
+      defaultValue: 0
+    }, { transaction })
+  }
+
+  {
+    // 4. Alter the column to be NOT NULL now that data is backfilled
+    await utils.queryInterface.changeColumn('video', 'commentCount', {
+      type: Sequelize.INTEGER,
+      allowNull: false,
+      defaultValue: 0
+    }, { transaction })
+  }
+
+  {
+    // 5. Create the index - check if we are inside a transaction
+    const isInTransaction = !!transaction;
+    if (isInTransaction) {
+      // Create the index without 'concurrently' if in a transaction
+      await utils.queryInterface.addIndex('videoComment', ['videoId'], {
+        name: 'comments_video_id_idx',
+        transaction
+      });
+    } else {
+      // Create the index concurrently if no transaction
+      await utils.queryInterface.addIndex('videoComment', ['videoId'], {
+        concurrently: true,
+        name: 'comments_video_id_idx'
+      });
+    }
+  }
+}
+
+function down (options) {
+  throw new Error('Not implemented.')
+}
+
+export {
+  down, up
+}
diff --git a/server/core/models/video/formatter/video-api-format.ts b/server/core/models/video/formatter/video-api-format.ts
index b95b7565efd..aa0110765c2 100644
--- a/server/core/models/video/formatter/video-api-format.ts
+++ b/server/core/models/video/formatter/video-api-format.ts
@@ -126,6 +126,8 @@ export function videoModelToFormattedJSON (video: MVideoFormattable, options: Vi
       ? { currentTime: userHistory.currentTime }
       : undefined,
 
+    commentCount: video.commentCount,
+
     // Can be added by external plugins
     pluginData: (video as any).pluginData,
 
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 61908fd94d2..cb29951c6a8 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
@@ -295,7 +295,8 @@ export class VideoTableAttributes {
       'channelId',
       'createdAt',
       'updatedAt',
-      'moveJobsRunning'
+      'moveJobsRunning',
+      'commentCount'
     ]
   }
 }
diff --git a/server/core/models/video/video-comment.ts b/server/core/models/video/video-comment.ts
index bf02a1c3968..081b1b8d7db 100644
--- a/server/core/models/video/video-comment.ts
+++ b/server/core/models/video/video-comment.ts
@@ -13,6 +13,9 @@ 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 {
+  AfterCreate,
+  AfterDestroy,
+  AfterUpdate,
   AllowNull,
   BelongsTo, Column,
   CreatedAt,
@@ -222,6 +225,40 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
   })
   CommentAutomaticTags: Awaited<CommentAutomaticTagModel>[]
 
+  @AfterCreate
+  static async incrementCommentCount(instance: VideoCommentModel, options: any) {
+    if (instance.heldForReview) return // Don't count held comments
+
+    await VideoModel.increment('commentCount', {
+      by: 1,
+      where: { id: instance.videoId },
+      transaction: options.transaction
+    })
+  }
+
+  @AfterDestroy
+  static async decrementCommentCount(instance: VideoCommentModel, options: any) {
+    if (instance.heldForReview) return // Don't count held comments
+
+    await VideoModel.decrement('commentCount', {
+      by: 1,
+      where: { id: instance.videoId },
+      transaction: options.transaction
+    })
+  }
+
+  @AfterUpdate
+  static async updateCommentCountOnHeldStatusChange(instance: VideoCommentModel, options: any) {
+    if (instance.changed('heldForReview')) {
+      const method = instance.heldForReview ? 'decrement' : 'increment'
+      await VideoModel[method]('commentCount', {
+        by: 1,
+        where: { id: instance.videoId },
+        transaction: options.transaction
+      })
+    }
+  }
+
   // ---------------------------------------------------------------------------
 
   static getSQLAttributes (tableName: string, aliasPrefix = '') {
diff --git a/server/core/models/video/video.ts b/server/core/models/video/video.ts
index b6aa854869a..3dab94c1e29 100644
--- a/server/core/models/video/video.ts
+++ b/server/core/models/video/video.ts
@@ -596,6 +596,11 @@ export class VideoModel extends SequelizeModel<VideoModel> {
   @Column
   originallyPublishedAt: Date
 
+  @AllowNull(false)
+  @Default(0)
+  @Column
+  commentCount: number
+
   @ForeignKey(() => VideoChannelModel)
   @Column
   channelId: number

From 19a35e606030aaa734858836f649b74f1d6d5de2 Mon Sep 17 00:00:00 2001
From: Your Name <you@example.com>
Date: Fri, 27 Sep 2024 12:40:43 -0400
Subject: [PATCH 2/3] WIP: Display image icon and comment count on video
 miniature component

---
 client/src/app/shared/shared-main/video/video.model.ts   | 4 ++++
 .../video-miniature.component.html                       | 5 +++++
 .../video-miniature.component.scss                       | 9 +++++++++
 .../shared-video-miniature/video-miniature.component.ts  | 8 ++++++--
 4 files changed, 24 insertions(+), 2 deletions(-)

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 1063499a7f8..5a93cb5ea06 100644
--- a/client/src/app/shared/shared-main/video/video.model.ts
+++ b/client/src/app/shared/shared-main/video/video.model.ts
@@ -116,6 +116,8 @@ export class Video implements VideoServerModel {
 
   automaticTags?: string[]
 
+  commentCount: number
+
   static buildWatchUrl (video: Partial<Pick<Video, 'uuid' | 'shortUUID'>>) {
     return buildVideoWatchPath({ shortUUID: video.shortUUID || video.uuid })
   }
@@ -209,6 +211,8 @@ export class Video implements VideoServerModel {
     this.aspectRatio = hash.aspectRatio
 
     this.automaticTags = hash.automaticTags
+
+    this.commentCount = hash.commentCount
   }
 
   isVideoNSFWForUser (user: User, serverConfig: HTMLServerConfig) {
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.html b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
index 6cb865dce4a..a77595462be 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.html
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
@@ -41,6 +41,11 @@
 
               <my-video-views-counter *ngIf="displayOptions.views" [video]="video"></my-video-views-counter>
             </span>
+
+            <span class="comment-count" *ngIf="displayOptions.commentCount">
+              <my-global-icon iconName="message-circle"></my-global-icon>
+              <span>{{ video.commentCount }}</span>
+            </span>
           </span>
 
           <a *ngIf="displayOptions.by" class="video-miniature-account" [routerLink]="[ '/c', video.byVideoChannel ]">
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.scss b/client/src/app/shared/shared-video-miniature/video-miniature.component.scss
index fd86c162ee6..6e3a6584d9a 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.scss
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.scss
@@ -109,6 +109,15 @@ my-actor-avatar {
 
   .video-miniature-created-at-views {
     display: block;
+
+    .comment-count {
+      float: right;
+      text-align: right;
+
+      span {
+        margin-left: 5px;
+      }
+    }
   }
 
   .video-actions {
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
index bafe6f44361..94a32ebaee8 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
@@ -25,6 +25,7 @@ import { NgClass, NgIf, NgFor } from '@angular/common'
 import { Video } from '../shared-main/video/video.model'
 import { VideoService } from '../shared-main/video/video.service'
 import { VideoPlaylistService } from '../shared-video-playlist/video-playlist.service'
+import { GlobalIconComponent } from '../shared-icons/global-icon.component'
 
 export type MiniatureDisplayOptions = {
   date?: boolean
@@ -37,7 +38,8 @@ export type MiniatureDisplayOptions = {
   nsfw?: boolean
 
   by?: boolean
-  forceChannelInBy?: boolean
+  forceChannelInBy?: boolean,
+  commentCount?: boolean
 }
 @Component({
   selector: 'my-video-miniature',
@@ -55,7 +57,8 @@ export type MiniatureDisplayOptions = {
     VideoViewsCounterComponent,
     RouterLink,
     NgFor,
-    VideoActionsDropdownComponent
+    VideoActionsDropdownComponent,
+    GlobalIconComponent
   ]
 })
 export class VideoMiniatureComponent implements OnInit {
@@ -67,6 +70,7 @@ export class VideoMiniatureComponent implements OnInit {
     date: true,
     views: true,
     by: true,
+    commentCount: true,
     avatar: false,
     privacyLabel: false,
     privacyText: false,

From db3478a4731a398bd2fe2af4728ff0b1665309e5 Mon Sep 17 00:00:00 2001
From: Your Name <you@example.com>
Date: Fri, 27 Sep 2024 12:52:55 -0400
Subject: [PATCH 3/3] Probably don't need to index the comment count

---
 .../migrations/0870-video-comment-count.ts     | 18 ------------------
 1 file changed, 18 deletions(-)

diff --git a/server/core/initializers/migrations/0870-video-comment-count.ts b/server/core/initializers/migrations/0870-video-comment-count.ts
index 32a07546aec..5c57b819411 100644
--- a/server/core/initializers/migrations/0870-video-comment-count.ts
+++ b/server/core/initializers/migrations/0870-video-comment-count.ts
@@ -101,24 +101,6 @@ async function up (utils: {
       defaultValue: 0
     }, { transaction })
   }
-
-  {
-    // 5. Create the index - check if we are inside a transaction
-    const isInTransaction = !!transaction;
-    if (isInTransaction) {
-      // Create the index without 'concurrently' if in a transaction
-      await utils.queryInterface.addIndex('videoComment', ['videoId'], {
-        name: 'comments_video_id_idx',
-        transaction
-      });
-    } else {
-      // Create the index concurrently if no transaction
-      await utils.queryInterface.addIndex('videoComment', ['videoId'], {
-        concurrently: true,
-        name: 'comments_video_id_idx'
-      });
-    }
-  }
 }
 
 function down (options) {