1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-03 09:49:20 +02:00

Add Comment Count to Video Preview Components (#6635)

* WIP: Add backend functionality to store comment count per video and update on comment visibility actions

* WIP: Display image icon and comment count on video miniature component

* Probably don't need to index the comment count

* Added comment count back to mini video component

* Added basic tests

* Sort by comments, more robust comments count

---------

Co-authored-by: Chocobozzz <me@florianbigard.com>
This commit is contained in:
RF9A5V 2025-04-02 07:29:22 -07:00 committed by GitHub
parent 75d7c2a9dc
commit 25a9f37ded
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 255 additions and 56 deletions

View file

@ -78,14 +78,15 @@
<p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox> <p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
</th> </th>
<th scope="col" *ngIf="isSelected('duration')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="duration" i18n>{{ getColumn('duration').label }} <p-sortIcon field="duration"></p-sortIcon></th> <th scope="col" *ngIf="isSelected('duration')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="duration">{{ getColumn('duration').label }} <p-sortIcon field="duration"></p-sortIcon></th>
<th scope="col" *ngIf="isSelected('name')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="name" i18n>{{ getColumn('name').label }} <p-sortIcon field="name"></p-sortIcon></th> <th scope="col" *ngIf="isSelected('name')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="name">{{ getColumn('name').label }} <p-sortIcon field="name"></p-sortIcon></th>
<th scope="col" *ngIf="isSelected('privacy')" i18n>{{ getColumn('privacy').label }}</th> <th scope="col" *ngIf="isSelected('privacy')">{{ getColumn('privacy').label }}</th>
<th scope="col" *ngIf="isSelected('sensitive')" i18n>{{ getColumn('sensitive').label }}</th> <th scope="col" *ngIf="isSelected('sensitive')">{{ getColumn('sensitive').label }}</th>
<th scope="col" *ngIf="isSelected('playlists')" i18n>{{ getColumn('playlists').label }}</th> <th scope="col" *ngIf="isSelected('playlists')">{{ getColumn('playlists').label }}</th>
<th scope="col" *ngIf="isSelected('insights')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="views" i18n>{{ getColumn('insights').label }} <p-sortIcon field="views"></p-sortIcon></th> <th scope="col" *ngIf="isSelected('insights')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="views">{{ getColumn('insights').label }} <p-sortIcon field="views"></p-sortIcon></th>
<th scope="col" *ngIf="isSelected('comments')" [ngbTooltip]="sortTooltip" container="body" pSortableColumn="comments">{{ getColumn('comments').label }} <p-sortIcon field="comments"></p-sortIcon></th>
<th scope="col" *ngIf="isSelected('published')" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="publishedAt">{{ getColumn('published').label }} <p-sortIcon field="publishedAt"></p-sortIcon></th> <th scope="col" *ngIf="isSelected('published')" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="publishedAt">{{ getColumn('published').label }} <p-sortIcon field="publishedAt"></p-sortIcon></th>
<th scope="col" *ngIf="isSelected('state')" i18n>{{ getColumn('state').label }}</th> <th scope="col" *ngIf="isSelected('state')">{{ getColumn('state').label }}</th>
<th scope="col" width="250px" class="action-head"> <th scope="col" width="250px" class="action-head">
<div class="d-flex align-items-center justify-content-between"> <div class="d-flex align-items-center justify-content-between">
@ -162,6 +163,10 @@
</a> </a>
</td> </td>
<td *ngIf="isSelected('comments')">
<span i18n>{video.comments, plural, =0 {No comments} =1 {1 comment} other {{{ video.comments | myNumberFormatter }} comments}}</span>
</td>
<td *ngIf="isSelected('published')"> <td *ngIf="isSelected('published')">
{{ video.publishedAt | ptDate: 'short' }} {{ video.publishedAt | ptDate: 'short' }}
</td> </td>

View file

@ -52,7 +52,7 @@ import { VideoPrivacyBadgeComponent } from '../../shared/shared-video/video-priv
import { VideoStateBadgeComponent } from '../../shared/shared-video/video-state-badge.component' import { VideoStateBadgeComponent } from '../../shared/shared-video/video-state-badge.component'
import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component' import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
type Column = 'duration' | 'name' | 'privacy' | 'sensitive' | 'playlists' | 'insights' | 'published' | 'state' type Column = 'duration' | 'name' | 'privacy' | 'sensitive' | 'playlists' | 'insights' | 'published' | 'state' | 'comments'
type CommonFilter = 'live' | 'vod' | 'private' | 'internal' | 'unlisted' | 'password-protected' | 'public' type CommonFilter = 'live' | 'vod' | 'private' | 'internal' | 'unlisted' | 'password-protected' | 'public'
@Component({ @Component({
@ -157,6 +157,7 @@ export class MyVideosComponent extends RestTable<Video> implements OnInit, OnDes
{ id: 'sensitive', label: $localize`Sensitive`, selected: true }, { id: 'sensitive', label: $localize`Sensitive`, selected: true },
{ id: 'playlists', label: $localize`Playlists`, selected: true }, { id: 'playlists', label: $localize`Playlists`, selected: true },
{ id: 'insights', label: $localize`Insights`, selected: true }, { id: 'insights', label: $localize`Insights`, selected: true },
{ id: 'comments', label: $localize`Comments`, selected: false },
{ id: 'published', label: $localize`Published`, selected: true }, { id: 'published', label: $localize`Published`, selected: true },
{ id: 'state', label: $localize`State`, selected: true } { id: 'state', label: $localize`State`, selected: true }
] ]

View file

@ -116,6 +116,8 @@ export class Video implements VideoServerModel {
automaticTags?: string[] automaticTags?: string[]
comments: number
static buildWatchUrl (video: Partial<Pick<Video, 'uuid' | 'shortUUID'>>) { static buildWatchUrl (video: Partial<Pick<Video, 'uuid' | 'shortUUID'>>) {
return buildVideoWatchPath({ shortUUID: video.shortUUID || video.uuid }) return buildVideoWatchPath({ shortUUID: video.shortUUID || video.uuid })
} }
@ -211,6 +213,8 @@ export class Video implements VideoServerModel {
this.aspectRatio = hash.aspectRatio this.aspectRatio = hash.aspectRatio
this.automaticTags = hash.automaticTags this.automaticTags = hash.automaticTags
this.comments = hash.comments
} }
isVideoNSFWForUser (user: User, serverConfig: HTMLServerConfig) { isVideoNSFWForUser (user: User, serverConfig: HTMLServerConfig) {

View file

@ -6,9 +6,9 @@ import {
LOCALE_ID, LOCALE_ID,
OnInit, OnInit,
booleanAttribute, booleanAttribute,
numberAttribute,
inject, inject,
input, input,
numberAttribute,
output output
} from '@angular/core' } from '@angular/core'
import { RouterLink } from '@angular/router' import { RouterLink } from '@angular/router'
@ -24,8 +24,8 @@ import { VideoService } from '../shared-main/video/video.service'
import { VideoThumbnailComponent } from '../shared-thumbnail/video-thumbnail.component' import { VideoThumbnailComponent } from '../shared-thumbnail/video-thumbnail.component'
import { VideoPlaylistService } from '../shared-video-playlist/video-playlist.service' import { VideoPlaylistService } from '../shared-video-playlist/video-playlist.service'
import { VideoViewsCounterComponent } from '../shared-video/video-views-counter.component' import { VideoViewsCounterComponent } from '../shared-video/video-views-counter.component'
import { VideoActionsDisplayType, VideoActionsDropdownComponent } from './video-actions-dropdown.component'
import { ActorHostComponent } from '../standalone-actor/actor-host.component' import { ActorHostComponent } from '../standalone-actor/actor-host.component'
import { VideoActionsDisplayType, VideoActionsDropdownComponent } from './video-actions-dropdown.component'
export type MiniatureDisplayOptions = { export type MiniatureDisplayOptions = {
date?: boolean date?: boolean

View file

@ -51,6 +51,8 @@ export interface Video extends Partial<VideoAdditionalAttributes> {
likes: number likes: number
dislikes: number dislikes: number
comments: number
nsfw: boolean nsfw: boolean
account: AccountSummary account: AccountSummary

View file

@ -12,9 +12,12 @@ import {
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
import { import {
PeerTubeServer, PeerTubeServer,
cleanupTests, createMultipleServers, cleanupTests,
createMultipleServers,
doubleFollow, doubleFollow,
makeActivityPubGetRequest, makeActivityPubRawRequest, setAccessTokensToServers, makeActivityPubGetRequest,
makeActivityPubRawRequest,
setAccessTokensToServers,
setDefaultAccountAvatar, setDefaultAccountAvatar,
waitJobs waitJobs
} from '@peertube/peertube-server-commands' } from '@peertube/peertube-server-commands'
@ -546,6 +549,68 @@ describe('Test comments approval', function () {
}) })
}) })
describe('Comment count with moderation', function () {
let videoId: string
before(async function () {
videoId = await createVideo(VideoCommentPolicy.REQUIRES_APPROVAL)
})
it('Should not increment comment count when comment is held for review', async function () {
await servers[0].comments.createThread({ token: anotherUserToken, videoId, text: 'held comment' })
await waitJobs(servers)
const video = await servers[0].videos.get({ id: videoId })
expect(video.comments).to.equal(0)
})
it('Should increment comment count after approving comment', async function () {
// Create a new comment that will be held for review
await servers[0].comments.createThread({ token: anotherUserToken, videoId, text: 'test comment' })
await waitJobs(servers)
// Get the held comment
const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
const heldComment = data.find(c => c.text === 'test comment')
expect(heldComment.heldForReview).to.be.true
// Verify initial count is 0
let video = await servers[0].videos.get({ id: videoId })
expect(video.comments).to.equal(0)
// Approve the comment
await servers[0].comments.approve({ token: userToken, videoId, commentId: heldComment.id })
await waitJobs(servers)
// Verify count incremented after approval
video = await servers[0].videos.get({ id: videoId })
expect(video.comments).to.equal(1)
})
it('Should not increment comment count when deleting held comment', async function () {
// Get initial comment count
let video = await servers[0].videos.get({ id: videoId })
const initialCount = video.comments
// Create a new comment that will be held for review
await servers[0].comments.createThread({ token: anotherUserToken, videoId, text: 'to be deleted' })
await waitJobs(servers)
// Get the held comment
const { data } = await servers[0].comments.listCommentsOnMyVideos({ token: userToken })
const heldComment = data.find(c => c.text === 'to be deleted')
expect(heldComment.heldForReview).to.be.true
// Delete the held comment
await servers[0].comments.delete({ token: userToken, videoId, commentId: heldComment.id })
await waitJobs(servers)
// Verify count remains unchanged after deleting held comment
video = await servers[0].videos.get({ id: videoId })
expect(video.comments).to.equal(initialCount)
})
})
after(async function () { after(async function () {
await cleanupTests(servers) await cleanupTests(servers)
}) })

View file

@ -45,7 +45,6 @@ describe('Test video comments', function () {
}) })
describe('User comments', function () { describe('User comments', function () {
it('Should not have threads on this video', async function () { it('Should not have threads on this video', async function () {
const body = await command.listThreads({ videoId: videoUUID }) const body = await command.listThreads({ videoId: videoUUID })
@ -256,11 +255,10 @@ describe('Test video comments', function () {
}) })
describe('Listing comments on my videos and in admin', function () { describe('Listing comments on my videos and in admin', function () {
const listFunctions = () => [
const listFunctions = () => ([
command.listForAdmin.bind(command), command.listForAdmin.bind(command),
command.listCommentsOnMyVideos.bind(command) command.listCommentsOnMyVideos.bind(command)
]) ]
it('Should list comments', async function () { it('Should list comments', async function () {
for (const fn of listFunctions()) { for (const fn of listFunctions()) {
@ -401,6 +399,38 @@ describe('Test video comments', function () {
// Auto tags filter is checked auto tags test file // Auto tags filter is checked auto tags test file
}) })
describe('Video comment count', function () {
let testVideoUUID: string
before(async function () {
const { uuid } = await server.videos.upload()
testVideoUUID = uuid
})
it('Should start with 0 comments', async function () {
const video = await server.videos.get({ id: testVideoUUID })
expect(video.commentsEnabled).to.be.true
expect(video.comments).to.equal(0)
})
it('Should increment comment count when adding comment', async function () {
await command.createThread({ videoId: testVideoUUID, text: 'test comment' })
const video = await server.videos.get({ id: testVideoUUID })
expect(video.comments).to.equal(1)
})
it('Should decrement count when deleting comment', async function () {
const { data } = await command.listThreads({ videoId: testVideoUUID })
const commentToDelete = data[0]
await command.delete({ videoId: testVideoUUID, commentId: commentToDelete.id })
const video = await server.videos.get({ id: testVideoUUID })
expect(video.comments).to.equal(0)
})
})
after(async function () { after(async function () {
await cleanupTests([ server ]) await cleanupTests([ server ])
}) })

View file

@ -46,7 +46,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const LAST_MIGRATION_VERSION = 875 export const LAST_MIGRATION_VERSION = 880
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -119,6 +119,7 @@ export const SORTABLE_COLUMNS = {
'originallyPublishedAt', 'originallyPublishedAt',
'views', 'views',
'likes', 'likes',
'comments',
'trending', 'trending',
'hot', 'hot',
'best', 'best',
@ -172,7 +173,7 @@ export const ACTOR_FOLLOW_SCORE = {
MAX: 10000 MAX: 10000
} }
export const FOLLOW_STATES: { [ id: string ]: FollowState } = { export const FOLLOW_STATES: { [id: string]: FollowState } = {
PENDING: 'pending', PENDING: 'pending',
ACCEPTED: 'accepted', ACCEPTED: 'accepted',
REJECTED: 'rejected' REJECTED: 'rejected'
@ -274,7 +275,7 @@ export const JOB_TTL: { [id in JobType]: number } = {
'import-user-archive': 60000 * 60 * 24, // 24 hours 'import-user-archive': 60000 * 60 * 24, // 24 hours
'video-transcription': 1000 * 3600 * 6 // 6 hours 'video-transcription': 1000 * 3600 * 6 // 6 hours
} }
export const REPEAT_JOBS: { [ id in JobType ]?: RepeatOptions } = { export const REPEAT_JOBS: { [id in JobType]?: RepeatOptions } = {
'videos-views-stats': { 'videos-views-stats': {
pattern: randomInt(1, 20) + ' * * * *' // Between 1-20 minutes past the hour pattern: randomInt(1, 20) + ' * * * *' // Between 1-20 minutes past the hour
}, },
@ -528,7 +529,7 @@ export let CONTACT_FORM_LIFETIME = 60000 * 60 // 1 hour
export const DEFAULT_AUDIO_RESOLUTION = VideoResolution.H_480P export const DEFAULT_AUDIO_RESOLUTION = VideoResolution.H_480P
export const DEFAULT_AUDIO_MERGE_RESOLUTION = 25 export const DEFAULT_AUDIO_MERGE_RESOLUTION = 25
export const VIDEO_RATE_TYPES: { [ id: string ]: VideoRateType } = { export const VIDEO_RATE_TYPES: { [id: string]: VideoRateType } = {
LIKE: 'like', LIKE: 'like',
DISLIKE: 'dislike' DISLIKE: 'dislike'
} }
@ -579,7 +580,7 @@ export const VIDEO_LICENCES = {
export const VIDEO_LANGUAGES: { [id: string]: string } = {} export const VIDEO_LANGUAGES: { [id: string]: string } = {}
export const VIDEO_PRIVACIES: { [ id in VideoPrivacyType ]: string } = { export const VIDEO_PRIVACIES: { [id in VideoPrivacyType]: string } = {
[VideoPrivacy.PUBLIC]: 'Public', [VideoPrivacy.PUBLIC]: 'Public',
[VideoPrivacy.UNLISTED]: 'Unlisted', [VideoPrivacy.UNLISTED]: 'Unlisted',
[VideoPrivacy.PRIVATE]: 'Private', [VideoPrivacy.PRIVATE]: 'Private',
@ -587,7 +588,7 @@ export const VIDEO_PRIVACIES: { [ id in VideoPrivacyType ]: string } = {
[VideoPrivacy.PASSWORD_PROTECTED]: 'Password protected' [VideoPrivacy.PASSWORD_PROTECTED]: 'Password protected'
} }
export const VIDEO_STATES: { [ id in VideoStateType ]: string } = { export const VIDEO_STATES: { [id in VideoStateType]: string } = {
[VideoState.PUBLISHED]: 'Published', [VideoState.PUBLISHED]: 'Published',
[VideoState.TO_TRANSCODE]: 'To transcode', [VideoState.TO_TRANSCODE]: 'To transcode',
[VideoState.TO_IMPORT]: 'To import', [VideoState.TO_IMPORT]: 'To import',
@ -601,7 +602,7 @@ export const VIDEO_STATES: { [ id in VideoStateType ]: string } = {
[VideoState.TO_MOVE_TO_FILE_SYSTEM_FAILED]: 'Move to file system failed' [VideoState.TO_MOVE_TO_FILE_SYSTEM_FAILED]: 'Move to file system failed'
} }
export const VIDEO_IMPORT_STATES: { [ id in VideoImportStateType ]: string } = { export const VIDEO_IMPORT_STATES: { [id in VideoImportStateType]: string } = {
[VideoImportState.FAILED]: 'Failed', [VideoImportState.FAILED]: 'Failed',
[VideoImportState.PENDING]: 'Pending', [VideoImportState.PENDING]: 'Pending',
[VideoImportState.SUCCESS]: 'Success', [VideoImportState.SUCCESS]: 'Success',
@ -610,37 +611,37 @@ export const VIDEO_IMPORT_STATES: { [ id in VideoImportStateType ]: string } = {
[VideoImportState.PROCESSING]: 'Processing' [VideoImportState.PROCESSING]: 'Processing'
} }
export const VIDEO_CHANNEL_SYNC_STATE: { [ id in VideoChannelSyncStateType ]: string } = { export const VIDEO_CHANNEL_SYNC_STATE: { [id in VideoChannelSyncStateType]: string } = {
[VideoChannelSyncState.FAILED]: 'Failed', [VideoChannelSyncState.FAILED]: 'Failed',
[VideoChannelSyncState.SYNCED]: 'Synchronized', [VideoChannelSyncState.SYNCED]: 'Synchronized',
[VideoChannelSyncState.PROCESSING]: 'Processing', [VideoChannelSyncState.PROCESSING]: 'Processing',
[VideoChannelSyncState.WAITING_FIRST_RUN]: 'Waiting first run' [VideoChannelSyncState.WAITING_FIRST_RUN]: 'Waiting first run'
} }
export const ABUSE_STATES: { [ id in AbuseStateType ]: string } = { export const ABUSE_STATES: { [id in AbuseStateType]: string } = {
[AbuseState.PENDING]: 'Pending', [AbuseState.PENDING]: 'Pending',
[AbuseState.REJECTED]: 'Rejected', [AbuseState.REJECTED]: 'Rejected',
[AbuseState.ACCEPTED]: 'Accepted' [AbuseState.ACCEPTED]: 'Accepted'
} }
export const USER_REGISTRATION_STATES: { [ id in UserRegistrationStateType ]: string } = { export const USER_REGISTRATION_STATES: { [id in UserRegistrationStateType]: string } = {
[UserRegistrationState.PENDING]: 'Pending', [UserRegistrationState.PENDING]: 'Pending',
[UserRegistrationState.REJECTED]: 'Rejected', [UserRegistrationState.REJECTED]: 'Rejected',
[UserRegistrationState.ACCEPTED]: 'Accepted' [UserRegistrationState.ACCEPTED]: 'Accepted'
} }
export const VIDEO_PLAYLIST_PRIVACIES: { [ id in VideoPlaylistPrivacyType ]: string } = { export const VIDEO_PLAYLIST_PRIVACIES: { [id in VideoPlaylistPrivacyType]: string } = {
[VideoPlaylistPrivacy.PUBLIC]: 'Public', [VideoPlaylistPrivacy.PUBLIC]: 'Public',
[VideoPlaylistPrivacy.UNLISTED]: 'Unlisted', [VideoPlaylistPrivacy.UNLISTED]: 'Unlisted',
[VideoPlaylistPrivacy.PRIVATE]: 'Private' [VideoPlaylistPrivacy.PRIVATE]: 'Private'
} }
export const VIDEO_PLAYLIST_TYPES: { [ id in VideoPlaylistType_Type ]: string } = { export const VIDEO_PLAYLIST_TYPES: { [id in VideoPlaylistType_Type]: string } = {
[VideoPlaylistType.REGULAR]: 'Regular', [VideoPlaylistType.REGULAR]: 'Regular',
[VideoPlaylistType.WATCH_LATER]: 'Watch later' [VideoPlaylistType.WATCH_LATER]: 'Watch later'
} }
export const RUNNER_JOB_STATES: { [ id in RunnerJobStateType ]: string } = { export const RUNNER_JOB_STATES: { [id in RunnerJobStateType]: string } = {
[RunnerJobState.PROCESSING]: 'Processing', [RunnerJobState.PROCESSING]: 'Processing',
[RunnerJobState.COMPLETED]: 'Completed', [RunnerJobState.COMPLETED]: 'Completed',
[RunnerJobState.COMPLETING]: 'Completing', [RunnerJobState.COMPLETING]: 'Completing',
@ -652,21 +653,21 @@ export const RUNNER_JOB_STATES: { [ id in RunnerJobStateType ]: string } = {
[RunnerJobState.PARENT_CANCELLED]: 'Parent job cancelled' [RunnerJobState.PARENT_CANCELLED]: 'Parent job cancelled'
} }
export const USER_EXPORT_STATES: { [ id in UserExportStateType ]: string } = { export const USER_EXPORT_STATES: { [id in UserExportStateType]: string } = {
[UserExportState.PENDING]: 'Pending', [UserExportState.PENDING]: 'Pending',
[UserExportState.PROCESSING]: 'Processing', [UserExportState.PROCESSING]: 'Processing',
[UserExportState.COMPLETED]: 'Completed', [UserExportState.COMPLETED]: 'Completed',
[UserExportState.ERRORED]: 'Failed' [UserExportState.ERRORED]: 'Failed'
} }
export const USER_IMPORT_STATES: { [ id in UserImportStateType ]: string } = { export const USER_IMPORT_STATES: { [id in UserImportStateType]: string } = {
[UserImportState.PENDING]: 'Pending', [UserImportState.PENDING]: 'Pending',
[UserImportState.PROCESSING]: 'Processing', [UserImportState.PROCESSING]: 'Processing',
[UserImportState.COMPLETED]: 'Completed', [UserImportState.COMPLETED]: 'Completed',
[UserImportState.ERRORED]: 'Failed' [UserImportState.ERRORED]: 'Failed'
} }
export const VIDEO_COMMENTS_POLICY: { [ id in VideoCommentPolicyType ]: string } = { export const VIDEO_COMMENTS_POLICY: { [id in VideoCommentPolicyType]: string } = {
[VideoCommentPolicy.DISABLED]: 'Disabled', [VideoCommentPolicy.DISABLED]: 'Disabled',
[VideoCommentPolicy.ENABLED]: 'Enabled', [VideoCommentPolicy.ENABLED]: 'Enabled',
[VideoCommentPolicy.REQUIRES_APPROVAL]: 'Requires approval' [VideoCommentPolicy.REQUIRES_APPROVAL]: 'Requires approval'
@ -699,12 +700,12 @@ export const MIMETYPES = {
'audio/vnd.dolby.dd-raw': '.ac3', 'audio/vnd.dolby.dd-raw': '.ac3',
'audio/ac3': '.ac3' 'audio/ac3': '.ac3'
}, },
EXT_MIMETYPE: null as { [ id: string ]: string } EXT_MIMETYPE: null as { [id: string]: string }
}, },
VIDEO: { VIDEO: {
MIMETYPE_EXT: null as { [ id: string ]: string | string[] }, MIMETYPE_EXT: null as { [id: string]: string | string[] },
MIMETYPES_REGEX: null as string, MIMETYPES_REGEX: null as string,
EXT_MIMETYPE: null as { [ id: string ]: string } EXT_MIMETYPE: null as { [id: string]: string }
}, },
IMAGE: { IMAGE: {
MIMETYPE_EXT: { MIMETYPE_EXT: {
@ -714,7 +715,7 @@ export const MIMETYPES = {
'image/jpg': '.jpg', 'image/jpg': '.jpg',
'image/jpeg': '.jpg' 'image/jpeg': '.jpg'
}, },
EXT_MIMETYPE: null as { [ id: string ]: string } EXT_MIMETYPE: null as { [id: string]: string }
}, },
VIDEO_CAPTIONS: { VIDEO_CAPTIONS: {
MIMETYPE_EXT: { MIMETYPE_EXT: {
@ -722,7 +723,7 @@ export const MIMETYPES = {
'application/x-subrip': '.srt', 'application/x-subrip': '.srt',
'text/plain': '.srt' 'text/plain': '.srt'
}, },
EXT_MIMETYPE: null as { [ id: string ]: string } EXT_MIMETYPE: null as { [id: string]: string }
}, },
TORRENT: { TORRENT: {
MIMETYPE_EXT: { MIMETYPE_EXT: {
@ -792,7 +793,7 @@ export const ACTIVITY_PUB = {
VIDEO_PLAYLIST_REFRESH_INTERVAL: 3600 * 24 * 1000 * 2 // 2 days VIDEO_PLAYLIST_REFRESH_INTERVAL: 3600 * 24 * 1000 * 2 // 2 days
} }
export const ACTIVITY_PUB_ACTOR_TYPES: { [ id: string ]: ActivityPubActorType } = { export const ACTIVITY_PUB_ACTOR_TYPES: { [id: string]: ActivityPubActorType } = {
GROUP: 'Group', GROUP: 'Group',
PERSON: 'Person', PERSON: 'Person',
APPLICATION: 'Application', APPLICATION: 'Application',
@ -830,7 +831,7 @@ export let JWT_TOKEN_USER_EXPORT_FILE_LIFETIME: `${number} minutes` | `${number}
export const EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes export const EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes
export const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = { export const NSFW_POLICY_TYPES: { [id: string]: NSFWPolicyType } = {
DO_NOT_LIST: 'do_not_list', DO_NOT_LIST: 'do_not_list',
BLUR: 'blur', BLUR: 'blur',
DISPLAY: 'display' DISPLAY: 'display'
@ -1286,7 +1287,9 @@ export async function buildLanguages () {
return (l.iso6391 !== undefined && l.type === 'living') || return (l.iso6391 !== undefined && l.type === 'living') ||
additionalLanguages[l.iso6393] === true additionalLanguages[l.iso6393] === true
}) })
.forEach(l => { languages[l.iso6391 || l.iso6393] = l.name }) .forEach(l => {
languages[l.iso6391 || l.iso6393] = l.name
})
// Override Occitan label // Override Occitan label
languages['oc'] = 'Occitan' languages['oc'] = 'Occitan'
@ -1411,7 +1414,7 @@ function updateWebserverConfig () {
CONSTRAINTS_FIELDS.VIDEOS.EXTNAME = Object.keys(MIMETYPES.VIDEO.EXT_MIMETYPE) CONSTRAINTS_FIELDS.VIDEOS.EXTNAME = Object.keys(MIMETYPES.VIDEO.EXT_MIMETYPE)
} }
function buildVideoExtMimetype (obj: { [ id: string ]: string | string[] }) { function buildVideoExtMimetype (obj: { [id: string]: string | string[] }) {
const result: { [id: string]: string } = {} const result: { [id: string]: string } = {}
for (const mimetype of Object.keys(obj)) { for (const mimetype of Object.keys(obj)) {

View file

@ -0,0 +1,31 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
}): Promise<void> {
const { transaction } = utils
{
await utils.queryInterface.addColumn('video', 'comments', {
type: Sequelize.INTEGER,
defaultValue: 0,
allowNull: false
}, { transaction })
}
{
const query = 'UPDATE "video" SET "comments" = (SELECT COUNT(*) FROM "videoComment" WHERE "videoComment"."videoId" = "video"."id")'
await utils.sequelize.query(query, { transaction })
}
}
function down (options) {
throw new Error('Not implemented.')
}
export {
down,
up
}

View file

@ -127,6 +127,8 @@ export function videoModelToFormattedJSON (video: MVideoFormattable, options: Vi
? { currentTime: userHistory.currentTime } ? { currentTime: userHistory.currentTime }
: undefined, : undefined,
comments: video.comments,
// Can be added by external plugins // Can be added by external plugins
pluginData: (video as any).pluginData, pluginData: (video as any).pluginData,

View file

@ -1,12 +1,8 @@
/** /**
*
* Class to build video attributes/join names we want to fetch from the database * Class to build video attributes/join names we want to fetch from the database
*
*/ */
export class VideoTableAttributes { export class VideoTableAttributes {
constructor (private readonly mode: 'get' | 'list') { constructor (private readonly mode: 'get' | 'list') {
} }
getChannelAttributesForUser () { getChannelAttributesForUser () {
@ -295,7 +291,8 @@ export class VideoTableAttributes {
'channelId', 'channelId',
'createdAt', 'createdAt',
'updatedAt', 'updatedAt',
'moveJobsRunning' 'moveJobsRunning',
'comments'
] ]
} }
} }

View file

@ -7,19 +7,27 @@ import {
VideoCommentForAdminOrUser, VideoCommentForAdminOrUser,
VideoCommentObject VideoCommentObject
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
import { afterCommitIfTransaction, retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { logger } from '@server/helpers/logger.js'
import { extractMentions } from '@server/helpers/mentions.js' import { extractMentions } from '@server/helpers/mentions.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { getLocalApproveReplyActivityPubUrl } from '@server/lib/activitypub/url.js' import { getLocalApproveReplyActivityPubUrl } from '@server/lib/activitypub/url.js'
import { getServerActor } from '@server/models/application/application.js' import { getServerActor } from '@server/models/application/application.js'
import { MAccount, MAccountId, MUserAccountId } from '@server/types/models/index.js' import { MAccount, MAccountId, MUserAccountId } from '@server/types/models/index.js'
import { Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize' import { Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize'
import { import {
AfterCreate,
AfterDestroy,
AfterUpdate,
AllowNull, AllowNull,
BelongsTo, Column, BelongsTo,
Column,
CreatedAt, CreatedAt,
DataType, DataType,
ForeignKey, ForeignKey,
HasMany, HasMany,
Is, Scopes, Is,
Scopes,
Table, Table,
UpdatedAt UpdatedAt
} from 'sequelize-typescript' } from 'sequelize-typescript'
@ -27,13 +35,14 @@ import { isActivityPubUrlValid } from '../../helpers/custom-validators/activityp
import { CONSTRAINTS_FIELDS, USER_EXPORT_MAX_ITEMS } from '../../initializers/constants.js' import { CONSTRAINTS_FIELDS, USER_EXPORT_MAX_ITEMS } from '../../initializers/constants.js'
import { import {
MComment, MComment,
MCommentAP,
MCommentAdminOrUserFormattable, MCommentAdminOrUserFormattable,
MCommentAP,
MCommentExport, MCommentExport,
MCommentFormattable, MCommentFormattable,
MCommentId, MCommentId,
MCommentOwner, MCommentOwner,
MCommentOwnerReplyVideoImmutable, MCommentOwnerVideoFeed, MCommentOwnerReplyVideoImmutable,
MCommentOwnerVideoFeed,
MCommentOwnerVideoReply, MCommentOwnerVideoReply,
MVideo, MVideo,
MVideoImmutable MVideoImmutable
@ -42,7 +51,7 @@ import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse.js'
import { AccountModel } from '../account/account.js' import { AccountModel } from '../account/account.js'
import { ActorModel } from '../actor/actor.js' import { ActorModel } from '../actor/actor.js'
import { CommentAutomaticTagModel } from '../automatic-tag/comment-automatic-tag.js' import { CommentAutomaticTagModel } from '../automatic-tag/comment-automatic-tag.js'
import { SequelizeModel, buildLocalAccountIdsIn, buildSQLAttributes, throwIfNotValid } from '../shared/index.js' import { buildLocalAccountIdsIn, buildSQLAttributes, SequelizeModel, throwIfNotValid } from '../shared/index.js'
import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder.js' import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder.js'
import { VideoChannelModel } from './video-channel.js' import { VideoChannelModel } from './video-channel.js'
import { VideoModel } from './video.js' import { VideoModel } from './video.js'
@ -222,6 +231,43 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
}) })
CommentAutomaticTags: Awaited<CommentAutomaticTagModel>[] CommentAutomaticTags: Awaited<CommentAutomaticTagModel>[]
@AfterCreate
@AfterDestroy
static incrementCommentCount (instance: VideoCommentModel, options: any) {
if (instance.heldForReview) return // Don't count held comments
return afterCommitIfTransaction(options.transaction, () => this.rebuildCommentsCount(instance.videoId))
}
@AfterUpdate
static updateCommentCountOnHeldStatusChange (instance: VideoCommentModel, options: any) {
return afterCommitIfTransaction(options.transaction, () => this.rebuildCommentsCount(instance.videoId))
}
// ---------------------------------------------------------------------------
static async rebuildCommentsCount (videoId: number) {
try {
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async transaction => {
const video = await VideoModel.load(videoId, transaction)
video.comments = await new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, {
selectType: 'comment-only',
videoId: video.id,
heldForReview: false,
notDeleted: true,
transaction
}).countComments()
await video.save({ transaction })
})
})
} catch (err) {
logger.error('Cannot rebuild comments count for video ' + videoId, { err })
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static getSQLAttributes (tableName: string, aliasPrefix = '') { static getSQLAttributes (tableName: string, aliasPrefix = '') {
@ -442,7 +488,8 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
order: [ [ 'createdAt', order ] ] as Order, order: [ [ 'createdAt', order ] ] as Order,
where: { where: {
id: { id: {
[Op.in]: Sequelize.literal('(' + [Op.in]: Sequelize.literal(
'(' +
'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' + 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
`SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` + `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
'UNION ' + 'UNION ' +
@ -450,7 +497,8 @@ export class VideoCommentModel extends SequelizeModel<VideoCommentModel> {
'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' + 'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
') ' + ') ' +
'SELECT id FROM children' + 'SELECT id FROM children' +
')'), ')'
),
[Op.ne]: comment.id [Op.ne]: comment.id
} }
}, },

View file

@ -507,6 +507,13 @@ export class VideoModel extends SequelizeModel<VideoModel> {
@Column @Column
dislikes: number dislikes: number
@AllowNull(false)
@Default(0)
@IsInt
@Min(0)
@Column
comments: number
@AllowNull(false) @AllowNull(false)
@Column @Column
remote: boolean remote: boolean

View file

@ -7186,6 +7186,7 @@ components:
- -publishedAt - -publishedAt
- -views - -views
- -likes - -likes
- -comments
- -trending - -trending
- -hot - -hot
- -best - -best
@ -8445,6 +8446,9 @@ components:
dislikes: dislikes:
type: integer type: integer
example: 7 example: 7
comments:
description: "**PeerTube >= 7.2** Number of comments on the video"
type: integer
nsfw: nsfw:
type: boolean type: boolean
waitTranscoding: waitTranscoding: