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>) { 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 @@ + + + + {{ video.commentCount }} + 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, 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 { 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..5c57b819411 --- /dev/null +++ b/server/core/initializers/migrations/0870-video-comment-count.ts @@ -0,0 +1,112 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize +}): Promise { + 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 }) + } +} + +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 { }) CommentAutomaticTags: Awaited[] + @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 { @Column originallyPublishedAt: Date + @AllowNull(false) + @Default(0) + @Column + commentCount: number + @ForeignKey(() => VideoChannelModel) @Column channelId: number