Implement avatar miniatures (#4639)

* client: remove unused file

* refactor(client/my-actor-avatar): size from input

Read size from component input instead of scss, to make it possible to
use smaller avatar images when implemented.

* implement avatar miniatures

close #4560

* fix(test): max file size

* fix(search-index): normalize res acc to avatarMini

* refactor avatars to an array

* client/search: resize channel avatar to 120

* refactor(client/videos): remove unused function

* client(actor-avatar): set default size

* fix tests and avatars full result

When findOne is used only an array containting one avatar is returned.

* update migration version and version notations

* server/search: harmonize normalizing

* Cleanup avatar miniature PR

Co-authored-by: Chocobozzz <me@florianbigard.com>
pull/4805/head
kontrollanten 2022-02-28 08:34:43 +01:00 committed by GitHub
parent 5cad2ca9db
commit d0800f7661
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
150 changed files with 2027 additions and 1276 deletions

View File

@ -9,8 +9,11 @@
<div class="channel-avatar-row">
<my-actor-avatar
[channel]="videoChannel" [internalHref]="getVideoChannelLink(videoChannel)"
i18n-title title="See this video channel"
[channel]="videoChannel"
[internalHref]="getVideoChannelLink(videoChannel)"
i18n-title
title="See this video channel"
size="75"
></my-actor-avatar>
<h2>

View File

@ -29,7 +29,6 @@
grid-template-rows: auto 1fr;
my-actor-avatar {
@include actor-avatar-size(75px);
@include margin-right(15px);
grid-column: 1;

View File

@ -2,7 +2,7 @@
<div class="account-info">
<div class="account-avatar-row">
<my-actor-avatar class="main-avatar" [account]="account"></my-actor-avatar>
<my-actor-avatar class="main-avatar" [account]="account" size="120"></my-actor-avatar>
<div>
<div class="section-label" i18n>ACCOUNT</div>

View File

@ -1,56 +0,0 @@
<p-table
[value]="blockedAccounts" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
[sortField]="sort.field" [sortOrder]="sort.order"
[lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false"
[showCurrentPageReport]="true" i18n-currentPageReportTemplate
currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} muted accounts"
>
<ng-template pTemplate="caption">
<div class="caption">
<div class="ml-auto">
<my-advanced-input-filter (search)="onSearch($event)"></my-advanced-input-filter>
</div>
</div>
</ng-template>
<ng-template pTemplate="header">
<tr>
<th style="width: 150px;">Action</th> <!-- column for action buttons -->
<th style="width: calc(100% - 300px);" i18n>Account</th>
<th style="width: 150px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-accountBlock>
<tr>
<td class="action-cell">
<button class="unblock-button" (click)="unblockAccount(accountBlock)" i18n>Unmute</button>
</td>
<td>
<a [href]="accountBlock.blockedAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
<div class="chip two-lines">
<my-actor-avatar [account]="accountBlock.blockedAccount"></my-actor-avatar>
<div>
{{ accountBlock.blockedAccount.displayName }}
<span class="text-muted">{{ accountBlock.blockedAccount.nameWithHost }}</span>
</div>
</div>
</a>
</td>
<td>{{ accountBlock.createdAt | date: 'short' }}</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="6">
<div class="no-results">
<ng-container *ngIf="search" i18n>No account found matching current filters.</ng-container>
<ng-container *ngIf="!search" i18n>No account found.</ng-container>
</div>
</td>
</tr>
</ng-template>
</p-table>

View File

@ -66,7 +66,7 @@
<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 [account]="videoComment.account"></my-actor-avatar>
<my-actor-avatar [account]="videoComment.account" size="32"></my-actor-avatar>
<div>
{{ videoComment.account.displayName }}
<span>{{ videoComment.by }}</span>

View File

@ -111,7 +111,7 @@ export class VideoChannelUpdateComponent extends VideoChannelEdit implements OnI
next: data => {
this.notifier.success($localize`Avatar changed.`)
this.videoChannel.updateAvatar(data.avatar)
this.videoChannel.updateAvatar(data.avatars)
},
error: (err: HttpErrorResponse) => genericUploadErrorHandler({
@ -141,7 +141,7 @@ export class VideoChannelUpdateComponent extends VideoChannelEdit implements OnI
next: data => {
this.notifier.success($localize`Banner changed.`)
this.videoChannel.updateBanner(data.banner)
this.videoChannel.updateBanner(data.banners)
},
error: (err: HttpErrorResponse) => genericUploadErrorHandler({

View File

@ -43,7 +43,7 @@ export class MyAccountSettingsComponent implements OnInit, AfterViewChecked {
next: data => {
this.notifier.success($localize`Avatar changed.`)
this.user.updateAccountAvatar(data.avatar)
this.user.updateAccountAvatar(data.avatars)
},
error: (err: HttpErrorResponse) => genericUploadErrorHandler({

View File

@ -19,7 +19,7 @@
<div class="video-channels">
<div *ngFor="let videoChannel of videoChannels; let i = index" class="video-channel">
<my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]"></my-actor-avatar>
<my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]" size="80"></my-actor-avatar>
<div class="video-channel-info">
<a [routerLink]="[ '/c', videoChannel.nameWithHost ]" class="video-channel-names" i18n-title title="Channel page">

View File

@ -24,7 +24,6 @@ my-edit-button {
padding-bottom: 0;
my-actor-avatar {
@include actor-avatar-size(80px);
@include margin-right(10px);
}
}

View File

@ -14,7 +14,7 @@
<div class="actors" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
<div *ngFor="let follow of follows" class="actor">
<my-actor-avatar [account]="follow.follower" [href]="follow.follower.url"></my-actor-avatar>
<my-actor-avatar [account]="follow.follower" [href]="follow.follower.url" size="40"></my-actor-avatar>
<div class="actor-info">
<a [href]="follow.follower.url" class="actor-names" rel="noopener noreferrer" target="_blank" i18n-title title="Follower page">

View File

@ -12,7 +12,7 @@ input[type=text] {
}
.actor {
@include actor-row($avatar-size: 40px, $min-height: auto, $separator: true);
@include actor-row($min-height: auto, $separator: true);
.actor-display-name {
font-size: 16px;

View File

@ -14,7 +14,7 @@
<div class="actors" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
<div *ngFor="let videoChannel of videoChannels" class="actor">
<my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]"></my-actor-avatar>
<my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]" size="80"></my-actor-avatar>
<div class="actor-info">
<a [routerLink]="[ '/c', videoChannel.nameWithHost ]" class="actor-names" i18n-title title="Channel page">

View File

@ -1,7 +1,7 @@
import { SelectChannelItem } from 'src/types/select-options-item.model'
import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
import { AuthService, Notifier } from '@app/core'
import { listUserChannels } from '@app/helpers'
import { listUserChannelsForSelect } from '@app/helpers'
import { OWNERSHIP_CHANGE_CHANNEL_VALIDATOR } from '@app/shared/form-validators/video-ownership-change-validators'
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
import { VideoOwnershipService } from '@app/shared/shared-main'
@ -36,7 +36,7 @@ export class MyAcceptOwnershipComponent extends FormReactive implements OnInit {
ngOnInit () {
this.videoChannels = []
listUserChannels(this.authService)
listUserChannelsForSelect(this.authService)
.subscribe(channels => this.videoChannels = channels)
this.buildForm({

View File

@ -37,7 +37,7 @@
<td>
<a [href]="videoChangeOwnership.initiatorAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
<div class="chip two-lines">
<my-actor-avatar [account]="videoChangeOwnership.initiatorAccount"></my-actor-avatar>
<my-actor-avatar [account]="videoChangeOwnership.initiatorAccount" size="32"></my-actor-avatar>
<div>
{{ videoChangeOwnership.initiatorAccount.displayName }}
<span class="text-muted">{{ videoChangeOwnership.initiatorAccount.nameWithHost }}</span>

View File

@ -1,7 +1,7 @@
import { Component, OnInit } from '@angular/core'
import { Router } from '@angular/router'
import { AuthService, Notifier, ServerService } from '@app/core'
import { listUserChannels } from '@app/helpers'
import { listUserChannelsForSelect } from '@app/helpers'
import {
setPlaylistChannelValidator,
VIDEO_PLAYLIST_CHANNEL_ID_VALIDATOR,
@ -46,7 +46,7 @@ export class MyVideoPlaylistCreateComponent extends MyVideoPlaylistEdit implemen
setPlaylistChannelValidator(this.form.get('videoChannelId'), privacy)
})
listUserChannels(this.authService)
listUserChannelsForSelect(this.authService)
.subscribe(channels => this.userVideoChannels = channels)
this.serverService.getVideoPlaylistPrivacies()

View File

@ -3,7 +3,7 @@ import { map, switchMap } from 'rxjs/operators'
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, Notifier, ServerService } from '@app/core'
import { listUserChannels } from '@app/helpers'
import { listUserChannelsForSelect } from '@app/helpers'
import {
setPlaylistChannelValidator,
VIDEO_PLAYLIST_CHANNEL_ID_VALIDATOR,
@ -51,7 +51,7 @@ export class MyVideoPlaylistUpdateComponent extends MyVideoPlaylistEdit implemen
setPlaylistChannelValidator(this.form.get('videoChannelId'), privacy)
})
listUserChannels(this.authService)
listUserChannelsForSelect(this.authService)
.subscribe(channels => this.userVideoChannels = channels)
this.paramsSub = this.route.params

View File

@ -36,7 +36,7 @@
<ng-container *ngFor="let result of results">
<div *ngIf="isVideoChannel(result)" class="entry video-channel">
<my-actor-avatar [channel]="result" [internalHref]="getInternalChannelUrl(result)" [href]="getExternalChannelUrl(result)"></my-actor-avatar>
<my-actor-avatar [channel]="result" [internalHref]="getInternalChannelUrl(result)" [href]="getExternalChannelUrl(result)" size="120"></my-actor-avatar>
<div class="video-channel-info">
<a *ngIf="!isExternalChannelUrl()" [routerLink]="getInternalChannelUrl(result)" class="video-channel-names">

View File

@ -58,10 +58,6 @@
max-width: 800px;
}
.video-channel my-actor-avatar {
@include build-channel-img-size($video-thumbnail-width);
}
.video-channel-info {
flex-grow: 1;
margin: 0 10px;

View File

@ -23,7 +23,7 @@
<div class="section-label" i18n>OWNER ACCOUNT</div>
<div class="avatar-row">
<my-actor-avatar class="account-avatar" [account]="ownerAccount" [internalHref]="getAccountUrl()"></my-actor-avatar>
<my-actor-avatar class="account-avatar" [account]="ownerAccount" [internalHref]="getAccountUrl()" size="48"></my-actor-avatar>
<div class="actor-info">
<h4>
@ -51,7 +51,7 @@
</ng-template>
<div class="channel-avatar-row">
<my-actor-avatar class="main-avatar" [channel]="videoChannel"></my-actor-avatar>
<my-actor-avatar class="main-avatar" [channel]="videoChannel" size="120"></my-actor-avatar>
<div>
<div class="section-label" i18n>VIDEO CHANNEL</div>

View File

@ -107,10 +107,6 @@
display: flex;
margin-bottom: 15px;
.account-avatar {
@include actor-avatar-size(48px);
}
.actor-info {
@include margin-left(15px);
}

View File

@ -2,7 +2,7 @@ import { catchError, switchMap, tap } from 'rxjs/operators'
import { SelectChannelItem } from 'src/types/select-options-item.model'
import { Directive, EventEmitter, OnInit } from '@angular/core'
import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core'
import { listUserChannels } from '@app/helpers'
import { listUserChannelsForSelect } from '@app/helpers'
import { FormReactive } from '@app/shared/shared-forms'
import { VideoCaptionEdit, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
import { LoadingBarService } from '@ngx-loading-bar/core'
@ -38,7 +38,7 @@ export abstract class VideoSend extends FormReactive implements OnInit {
ngOnInit () {
this.buildForm({})
listUserChannels(this.authService)
listUserChannelsForSelect(this.authService)
.subscribe(channels => {
this.userVideoChannels = channels
this.firstStepChannelId = this.userVideoChannels[0].id

View File

@ -3,7 +3,7 @@ import { map, switchMap } from 'rxjs/operators'
import { Injectable } from '@angular/core'
import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
import { AuthService } from '@app/core'
import { listUserChannels } from '@app/helpers'
import { listUserChannelsForSelect } from '@app/helpers'
import { VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main'
import { LiveVideoService } from '@app/shared/shared-video-live'
@ -33,7 +33,7 @@ export class VideoUpdateResolver implements Resolve<any> {
.loadCompleteDescription(video.descriptionPath)
.pipe(map(description => Object.assign(video, { description }))),
listUserChannels(this.authService),
listUserChannelsForSelect(this.authService),
this.videoCaptionService
.listCaptions(video.id)

View File

@ -1,6 +1,6 @@
<div *ngIf="isCommentDisplayed()" class="root-comment" [ngClass]="{ 'is-child': isChild() }">
<div class="left">
<my-actor-avatar *ngIf="!comment.isDeleted" [href]="comment.account.url" [account]="comment.account"></my-actor-avatar>
<my-actor-avatar *ngIf="!comment.isDeleted" [href]="comment.account.url" [account]="comment.account" [size]="isChild() ? '25' : '36'"></my-actor-avatar>
<div class="vertical-border"></div>
</div>

View File

@ -25,10 +25,6 @@
}
}
my-actor-avatar {
@include actor-avatar-size(36px);
}
.comment {
flex-grow: 1;
// Fix word-wrap with flex
@ -160,11 +156,6 @@ my-video-comment-add {
}
.is-child {
// Reduce avatars size for replies
my-actor-avatar {
@include actor-avatar-size(25px);
}
.left {
@include margin-right(6px);
}

View File

@ -2,19 +2,19 @@
<my-actor-avatar
*ngIf="showChannel"
class="channel"
[class.main-avatar]="showChannel"
[channel]="video.channel"
[internalHref]="[ '/c', video.byVideoChannel ]"
[title]="channelLinkTitle"
size="35"
></my-actor-avatar>
<my-actor-avatar
*ngIf="showAccount"
class="account"
[class.main-avatar]="!showChannel"
[class.second-avatar]="showChannel"
[account]="video.account"
[internalHref]="[ '/a', video.byAccount ]"
[title]="accountLinkTitle">
[title]="accountLinkTitle"
size="35">
</my-actor-avatar>
</div>

View File

@ -1,9 +1,5 @@
@use '_mixins' as *;
@mixin main {
@include actor-avatar-size(35px);
}
@mixin secondary {
height: 60%;
width: 60%;
@ -14,16 +10,11 @@
}
.wrapper {
@include actor-avatar-size(35px);
@include margin-right(5px);
position: relative;
margin-bottom: 5px;
.main-avatar {
@include main();
}
.second-avatar {
@include secondary();
}

View File

@ -20,8 +20,4 @@ export class VideoAvatarChannelComponent implements OnInit {
this.channelLinkTitle = $localize`${this.video.account.name} (channel page)`
this.accountLinkTitle = $localize`${this.video.byAccount} (account page)`
}
isChannelAvatarNull () {
return this.video.channel.avatar === null
}
}

View File

@ -33,7 +33,7 @@
<div class="section channel videos" *ngFor="let object of overview.channels">
<div class="section-title">
<a [routerLink]="[ '/c', buildVideoChannelBy(object) ]">
<my-actor-avatar [channel]="buildVideoChannel(object)"></my-actor-avatar>
<my-actor-avatar [channel]="buildVideoChannel(object)" size="28"></my-actor-avatar>
<h2 class="section-title">{{ object.channel.displayName }}</h2>
</a>

View File

@ -52,7 +52,6 @@
align-items: center;
my-actor-avatar {
@include actor-avatar-size(28px);
@include margin-right(8px);
font-size: initial;

View File

@ -132,8 +132,8 @@ export class User implements UserServerModel {
}
}
updateAccountAvatar (newAccountAvatar?: ActorImage) {
if (newAccountAvatar) this.account.updateAvatar(newAccountAvatar)
updateAccountAvatar (newAccountAvatars?: ActorImage[]) {
if (newAccountAvatars) this.account.updateAvatar(newAccountAvatars)
else this.account.resetAvatar()
}

View File

@ -118,7 +118,7 @@ export class UserService {
changeAvatar (avatarForm: FormData) {
const url = UserService.BASE_USERS_URL + 'me/avatar/pick'
return this.authHttp.post<{ avatar: ActorImage }>(url, avatarForm)
return this.authHttp.post<{ avatars: ActorImage[] }>(url, avatarForm)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}

View File

@ -1,8 +1,9 @@
import { minBy } from 'lodash-es'
import { first, map } from 'rxjs/operators'
import { SelectChannelItem } from 'src/types/select-options-item.model'
import { AuthService } from '../../core/auth'
function listUserChannels (authService: AuthService) {
function listUserChannelsForSelect (authService: AuthService) {
return authService.userInformationLoaded
.pipe(
first(),
@ -23,12 +24,12 @@ function listUserChannels (authService: AuthService) {
id: c.id,
label: c.displayName,
support: c.support,
avatarPath: c.avatar?.path
avatarPath: minBy(c.avatars, 'width')[0]?.path
}) as SelectChannelItem)
})
)
}
export {
listUserChannels
listUserChannelsForSelect
}

View File

@ -32,7 +32,7 @@ export class AccountSetupWarningModalComponent {
}
hasAccountAvatar (user: User) {
return !!user.account.avatar
return user.account.avatars.length !== 0
}
hasAccountDescription (user: User) {

View File

@ -43,7 +43,7 @@
<td *ngIf="isAdminView()">
<a *ngIf="abuse.reporterAccount" [href]="abuse.reporterAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
<div class="chip two-lines">
<my-actor-avatar [account]="abuse.reporterAccount"></my-actor-avatar>
<my-actor-avatar [account]="abuse.reporterAccount" size="32"></my-actor-avatar>
<div>
{{ abuse.reporterAccount.displayName }}
<span>{{ abuse.reporterAccount.nameWithHost }}</span>

View File

@ -72,7 +72,7 @@ export class ActorAvatarEditComponent implements OnInit {
}
hasAvatar () {
return !!this.preview || !!this.actor.avatar
return !!this.preview || this.actor.avatars.length !== 0
}
isChannel () {

View File

@ -20,38 +20,23 @@
}
}
$sizes: '18', '25', '28', '32', '34', '35', '36', '40', '48', '75', '80', '100', '120';
@each $size in $sizes {
.avatar-#{$size} {
--avatarSize: #{$size}px;
}
}
.avatar-18 {
--avatarSize: 18px;
--initialFontSize: 13px;
}
.avatar-25 {
--avatarSize: 25px;
}
.avatar-32 {
--avatarSize: 32px;
}
.avatar-34 {
--avatarSize: 34px;
}
.avatar-36 {
--avatarSize: 36px;
}
.avatar-40 {
--avatarSize: 40px;
}
.avatar-100 {
--avatarSize: 100px;
--initialFontSize: 40px;
}
.avatar-120 {
--avatarSize: 120px;
--initialFontSize: 46px;
}

View File

@ -4,11 +4,11 @@ import { Account } from '../shared-main/account/account.model'
type ActorInput = {
name: string
avatar?: { url?: string, path: string }
avatars: { width: number, url?: string, path: string }[]
url: string
}
export type ActorAvatarSize = '18' | '25' | '32' | '34' | '36' | '40' | '100' | '120'
export type ActorAvatarSize = '18' | '25' | '28' | '32' | '34' | '35' | '36' | '40' | '48' | '75' | '80' | '100' | '120'
@Component({
selector: 'my-actor-avatar',
@ -23,7 +23,7 @@ export class ActorAvatarComponent {
@Input() previewImage: string
@Input() size: ActorAvatarSize
@Input() size: ActorAvatarSize = '32'
// Use an external link
@Input() href: string
@ -50,14 +50,13 @@ export class ActorAvatarComponent {
}
get defaultAvatarUrl () {
if (this.channel) return VideoChannel.GET_DEFAULT_AVATAR_URL()
return Account.GET_DEFAULT_AVATAR_URL()
if (this.account) return Account.GET_DEFAULT_AVATAR_URL(+this.size)
if (this.channel) return VideoChannel.GET_DEFAULT_AVATAR_URL(+this.size)
}
get avatarUrl () {
if (this.account) return Account.GET_ACTOR_AVATAR_URL(this.account)
if (this.channel) return VideoChannel.GET_ACTOR_AVATAR_URL(this.channel)
if (this.account) return Account.GET_ACTOR_AVATAR_URL(this.account, +this.size)
if (this.channel) return VideoChannel.GET_ACTOR_AVATAR_URL(this.channel, +this.size)
return ''
}

View File

@ -1,7 +1,7 @@
<div *ngIf="channel" class="channel">
<div class="channel-avatar-row">
<my-actor-avatar [channel]="channel" [internalHref]="getVideoChannelLink()" i18n-title title="See this video channel"></my-actor-avatar>
<my-actor-avatar [channel]="channel" [internalHref]="getVideoChannelLink()" i18n-title title="See this video channel" size="75"></my-actor-avatar>
<h6>
<a [routerLink]="getVideoChannelLink()" i18n-title title="See this video channel">

View File

@ -26,8 +26,6 @@
}
my-actor-avatar {
@include actor-avatar-size(75px);
grid-column: 1;
grid-row: 1 / 4;
}

View File

@ -31,7 +31,7 @@ export class SelectChannelComponent implements ControlValueAccessor, OnChanges {
this.channels = this.items.map(c => {
const avatarPath = c.avatarPath
? c.avatarPath
: VideoChannel.GET_DEFAULT_AVATAR_URL()
: VideoChannel.GET_DEFAULT_AVATAR_URL(20)
return Object.assign({}, c, { avatarPath })
})

View File

@ -17,11 +17,15 @@ export class Account extends Actor implements ServerAccount {
userId?: number
static GET_ACTOR_AVATAR_URL (actor: { avatar?: { url?: string, path: string } }) {
return Actor.GET_ACTOR_AVATAR_URL(actor)
static GET_ACTOR_AVATAR_URL (actor: { avatars: { width: number, url?: string, path: string }[] }, size: number) {
return Actor.GET_ACTOR_AVATAR_URL(actor, size)
}
static GET_DEFAULT_AVATAR_URL () {
static GET_DEFAULT_AVATAR_URL (size: number) {
if (size <= 48) {
return `${window.location.origin}/client/assets/images/default-avatar-account-48x48.png`
}
return `${window.location.origin}/client/assets/images/default-avatar-account.png`
}
@ -42,12 +46,12 @@ export class Account extends Actor implements ServerAccount {
this.mutedServerByInstance = false
}
updateAvatar (newAvatar: ActorImage) {
this.avatar = newAvatar
updateAvatar (newAvatars: ActorImage[]) {
this.avatars = newAvatars
}
resetAvatar () {
this.avatar = null
this.avatars = []
}
updateBlockStatus (blockStatus: BlockStatus) {

View File

@ -13,20 +13,22 @@ export abstract class Actor implements ServerActor {
createdAt: Date | string
avatar: ActorImage
// TODO: remove, deprecated in 4.2
avatar: never
avatars: ActorImage[]
isLocal: boolean
static GET_ACTOR_AVATAR_URL (actor: { avatar?: { url?: string, path: string } }) {
if (actor?.avatar?.url) return actor.avatar.url
static GET_ACTOR_AVATAR_URL (actor: { avatars: { width: number, url?: string, path: string }[] }, size: number) {
const avatar = actor.avatars.sort((a, b) => a.width - b.width).find(a => a.width >= size)
if (actor?.avatar) {
const absoluteAPIUrl = getAbsoluteAPIUrl()
if (!avatar) return ''
if (avatar.url) return avatar.url
return absoluteAPIUrl + actor.avatar.path
}
const absoluteAPIUrl = getAbsoluteAPIUrl()
return ''
return absoluteAPIUrl + avatar.path
}
static CREATE_BY_STRING (accountName: string, host: string, forceHostname = false) {
@ -55,7 +57,7 @@ export abstract class Actor implements ServerActor {
if (hash.createdAt) this.createdAt = new Date(hash.createdAt.toString())
this.avatar = hash.avatar
this.avatars = hash.avatars
this.isLocal = Actor.IS_LOCAL(this.host)
}
}

View File

@ -19,7 +19,7 @@ export class ChannelsSetupMessageComponent implements OnInit {
hasChannelNotConfigured () {
if (!this.user.videoChannels) return false
return this.user.videoChannels.filter((channel: VideoChannel) => (!channel.avatar || !channel.description)).length > 0
return this.user.videoChannels.filter((channel: VideoChannel) => (channel.avatars.length === 0 || !channel.description)).length > 0
}
ngOnInit () {

View File

@ -254,11 +254,11 @@ export class UserNotification implements UserNotificationServer {
return [ this.buildVideoUrl(comment.video), { threadId: comment.threadId } ]
}
private setAccountAvatarUrl (actor: { avatarUrl?: string, avatar?: { url?: string, path: string } }) {
actor.avatarUrl = Account.GET_ACTOR_AVATAR_URL(actor) || Account.GET_DEFAULT_AVATAR_URL()
private setAccountAvatarUrl (actor: { avatarUrl?: string, avatars: { width: number, url?: string, path: string }[] }) {
actor.avatarUrl = VideoChannel.GET_ACTOR_AVATAR_URL(actor, 48) || Account.GET_DEFAULT_AVATAR_URL(48)
}
private setVideoChannelAvatarUrl (actor: { avatarUrl?: string, avatar?: { url?: string, path: string } }) {
actor.avatarUrl = VideoChannel.GET_ACTOR_AVATAR_URL(actor) || VideoChannel.GET_DEFAULT_AVATAR_URL()
private setVideoChannelAvatarUrl (actor: { avatarUrl?: string, avatars: { width: number, url?: string, path: string }[] }) {
actor.avatarUrl = VideoChannel.GET_ACTOR_AVATAR_URL(actor, 48) || VideoChannel.GET_DEFAULT_AVATAR_URL(48)
}
}

View File

@ -12,7 +12,11 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
nameWithHost: string
nameWithHostForced: string
banner: ActorImage
// TODO: remove, deprecated in 4.2
banner: never
banners: ActorImage[]
bannerUrl: string
updatedAt: Date | string
@ -24,23 +28,25 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
viewsPerDay?: ViewsPerDate[]
static GET_ACTOR_AVATAR_URL (actor: { avatar?: { url?: string, path: string } }) {
return Actor.GET_ACTOR_AVATAR_URL(actor)
static GET_ACTOR_AVATAR_URL (actor: { avatars: { width: number, url?: string, path: string }[] }, size: number) {
return Actor.GET_ACTOR_AVATAR_URL(actor, size)
}
static GET_ACTOR_BANNER_URL (channel: ServerVideoChannel) {
if (channel?.banner?.url) return channel.banner.url
if (!channel) return ''
if (channel?.banner) {
const absoluteAPIUrl = getAbsoluteAPIUrl()
const banner = channel.banners[0]
if (!banner) return ''
return absoluteAPIUrl + channel.banner.path
}
return ''
if (banner.url) return banner.url
return getAbsoluteAPIUrl() + banner.path
}
static GET_DEFAULT_AVATAR_URL () {
static GET_DEFAULT_AVATAR_URL (size: number) {
if (size <= 48) {
return `${window.location.origin}/client/assets/images/default-avatar-video-channel-48x48.png`
}
return `${window.location.origin}/client/assets/images/default-avatar-video-channel.png`
}
@ -51,7 +57,7 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
this.description = hash.description
this.support = hash.support
this.banner = hash.banner
this.banners = hash.banners
this.isLocal = hash.isLocal
@ -74,24 +80,24 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
this.updateComputedAttributes()
}
updateAvatar (newAvatar: ActorImage) {
this.avatar = newAvatar
updateAvatar (newAvatars: ActorImage[]) {
this.avatars = newAvatars
this.updateComputedAttributes()
}
resetAvatar () {
this.updateAvatar(null)
this.updateAvatar([])
}
updateBanner (newBanner: ActorImage) {
this.banner = newBanner
updateBanner (newBanners: ActorImage[]) {
this.banners = newBanners
this.updateComputedAttributes()
}
resetBanner () {
this.updateBanner(null)
this.updateBanner([])
}
updateComputedAttributes () {

View File

@ -80,7 +80,7 @@ export class VideoChannelService {
changeVideoChannelImage (videoChannelName: string, avatarForm: FormData, type: 'avatar' | 'banner') {
const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/' + type + '/pick'
return this.authHttp.post<{ avatar?: ActorImage, banner?: ActorImage }>(url, avatarForm)
return this.authHttp.post<{ avatars?: ActorImage[], banners?: ActorImage[] }>(url, avatarForm)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}

View File

@ -84,7 +84,11 @@ export class Video implements VideoServerModel {
displayName: string
url: string
host: string
avatar?: ActorImage
// TODO: remove, deprecated in 4.2
avatar: ActorImage
avatars: ActorImage[]
}
channel: {
@ -93,7 +97,11 @@ export class Video implements VideoServerModel {
displayName: string
url: string
host: string
avatar?: ActorImage
// TODO: remove, deprecated in 4.2
avatar: ActorImage
avatars: ActorImage[]
}
userHistory?: {

View File

@ -33,7 +33,7 @@
<td>
<a [href]="accountBlock.blockedAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
<div class="chip two-lines">
<my-actor-avatar [account]="accountBlock.blockedAccount"></my-actor-avatar>
<my-actor-avatar [account]="accountBlock.blockedAccount" size="32"></my-actor-avatar>
<div>
{{ accountBlock.blockedAccount.displayName }}
<span class="text-muted">{{ accountBlock.blockedAccount.nameWithHost }}</span>

View File

@ -13,11 +13,13 @@
<my-actor-avatar
*ngIf="displayOptions.avatar && displayOwnerVideoChannel() && !displayAsRow" [title]="channelLinkTitle"
[channel]="video.channel" [size]="actorImageSize" [internalHref]="[ '/c', video.byVideoChannel ]"
size="32"
></my-actor-avatar>
<my-actor-avatar
*ngIf="displayOptions.avatar && displayOwnerAccount() && !displayAsRow" [title]="channelLinkTitle"
[account]="video.account" [size]="actorImageSize" [internalHref]="[ '/c', video.byVideoChannel ]"
size="32"
></my-actor-avatar>
<div class="w-100 d-flex flex-column">

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 B

View File

@ -26,10 +26,6 @@
grid-column: 1;
margin-bottom: 30px;
.main-avatar {
@include actor-avatar-size(120px);
}
> div {
@include margin-left($img-margin);

View File

@ -1,12 +1,10 @@
@use '_variables' as *;
@use '_mixins' as *;
@mixin actor-row ($avatar-size: 80px, $avatar-margin-right: 10px, $min-height: 130px, $separator: true) {
@mixin actor-row ($avatar-margin-right: 10px, $min-height: 130px, $separator: true) {
@include row-blocks($min-height: $min-height, $separator: $separator);
> my-actor-avatar {
@include actor-avatar-size($avatar-size);
@include margin-right($avatar-margin-right);
}

View File

@ -887,7 +887,7 @@
height: $avatar-height;
my-actor-avatar {
@include actor-avatar-size($avatar-height);
display: inline-block;
}
div {

View File

@ -0,0 +1,106 @@
import { minBy } from 'lodash'
import { join } from 'path'
import { processImage } from '@server/helpers/image-utils'
import { CONFIG } from '@server/initializers/config'
import { ACTOR_IMAGES_SIZE } from '@server/initializers/constants'
import { updateActorImages } from '@server/lib/activitypub/actors'
import { sendUpdateActor } from '@server/lib/activitypub/send'
import { getBiggestActorImage } from '@server/lib/actor-image'
import { JobQueue } from '@server/lib/job-queue'
import { AccountModel } from '@server/models/account/account'
import { ActorModel } from '@server/models/actor/actor'
import { VideoChannelModel } from '@server/models/video/video-channel'
import { MAccountDefault, MActorDefault, MChannelDefault } from '@server/types/models'
import { getLowercaseExtension } from '@shared/core-utils'
import { buildUUID } from '@shared/extra-utils'
import { ActorImageType } from '@shared/models'
import { initDatabaseModels } from '../../server/initializers/database'
run()
.then(() => process.exit(0))
.catch(err => {
console.error(err)
process.exit(-1)
})
async function run () {
console.log('Generate avatar miniatures from existing avatars.')
await initDatabaseModels(true)
JobQueue.Instance.init(true)
const accounts: AccountModel[] = await AccountModel.findAll({
include: [
{
model: ActorModel,
required: true,
where: {
serverId: null
}
},
{
model: VideoChannelModel,
include: [
{
model: AccountModel
}
]
}
]
})
for (const account of accounts) {
try {
await generateSmallerAvatarIfNeeded(account)
} catch (err) {
console.error(`Cannot process account avatar ${account.name}`, err)
}
for (const videoChannel of account.VideoChannels) {
try {
await generateSmallerAvatarIfNeeded(videoChannel)
} catch (err) {
console.error(`Cannot process channel avatar ${videoChannel.name}`, err)
}
}
}
console.log('Generation finished!')
}
async function generateSmallerAvatarIfNeeded (accountOrChannel: MAccountDefault | MChannelDefault) {
const avatars = accountOrChannel.Actor.Avatars
if (avatars.length !== 1) {
return
}
console.log(`Processing ${accountOrChannel.name}.`)
await generateSmallerAvatar(accountOrChannel.Actor)
accountOrChannel.Actor = Object.assign(accountOrChannel.Actor, { Server: null })
return sendUpdateActor(accountOrChannel, undefined)
}
async function generateSmallerAvatar (actor: MActorDefault) {
const bigAvatar = getBiggestActorImage(actor.Avatars)
const imageSize = minBy(ACTOR_IMAGES_SIZE[ActorImageType.AVATAR], 'width')
const sourceFilename = bigAvatar.filename
const newImageName = buildUUID() + getLowercaseExtension(sourceFilename)
const source = join(CONFIG.STORAGE.ACTOR_IMAGES, sourceFilename)
const destination = join(CONFIG.STORAGE.ACTOR_IMAGES, newImageName)
await processImage(source, destination, imageSize, true)
const actorImageInfo = {
name: newImageName,
fileUrl: null,
height: imageSize.height,
width: imageSize.width,
onDisk: true
}
await updateActorImages(actor, ActorImageType.AVATAR, [ actorImageInfo ], undefined)
}

View File

@ -18,10 +18,10 @@ import {
} from '../../lib/activitypub/url'
import {
asyncMiddleware,
ensureIsLocalChannel,
executeIfActivityPub,
localAccountValidator,
videoChannelsNameWithHostValidator,
ensureIsLocalChannel,
videosCustomGetValidator,
videosShareValidator
} from '../../middlewares'
@ -265,8 +265,8 @@ async function videoAnnouncesController (req: express.Request, res: express.Resp
const handler = async (start: number, count: number) => {
const result = await VideoShareModel.listAndCountByVideoId(video.id, start, count)
return {
total: result.count,
data: result.rows.map(r => r.url)
total: result.total,
data: result.data.map(r => r.url)
}
}
const json = await activityPubCollectionPagination(getLocalVideoSharesActivityPubUrl(video), handler, req.query.page)
@ -301,9 +301,10 @@ async function videoCommentsController (req: express.Request, res: express.Respo
const handler = async (start: number, count: number) => {
const result = await VideoCommentModel.listAndCountByVideoForAP(video, start, count)
return {
total: result.count,
data: result.rows.map(r => r.url)
total: result.total,
data: result.data.map(r => r.url)
}
}
const json = await activityPubCollectionPagination(getLocalVideoCommentsActivityPubUrl(video), handler, req.query.page)
@ -425,8 +426,8 @@ function videoRates (req: express.Request, rateType: VideoRateType, video: MVide
const handler = async (start: number, count: number) => {
const result = await AccountVideoRateModel.listAndCountAccountUrlsByVideoId(rateType, video.id, start, count)
return {
total: result.count,
data: result.rows.map(r => r.url)
total: result.total,
data: result.data.map(r => r.url)
}
}
return activityPubCollectionPagination(url, handler, req.query.page)

View File

@ -213,7 +213,7 @@ async function listAccountRatings (req: express.Request, res: express.Response)
sort: req.query.sort,
type: req.query.rating
})
return res.json(getFormattedObjects(resultList.rows, resultList.count))
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function listAccountFollowers (req: express.Request, res: express.Response) {

View File

@ -1,7 +1,9 @@
import 'multer'
import express from 'express'
import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '@server/helpers/audit-logger'
import { getBiggestActorImage } from '@server/lib/actor-image'
import { Hooks } from '@server/lib/plugins/hooks'
import { pick } from '@shared/core-utils'
import { ActorImageType, HttpStatusCode, UserUpdateMe, UserVideoQuota, UserVideoRate as FormattedUserVideoRate } from '@shared/models'
import { AttributesOnly } from '@shared/typescript-utils'
import { createReqFiles } from '../../../helpers/express-utils'
@ -10,7 +12,7 @@ import { CONFIG } from '../../../initializers/config'
import { MIMETYPES } from '../../../initializers/constants'
import { sequelizeTypescript } from '../../../initializers/database'
import { sendUpdateActor } from '../../../lib/activitypub/send'
import { deleteLocalActorImageFile, updateLocalActorImageFile } from '../../../lib/local-actor'
import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '../../../lib/local-actor'
import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user'
import {
asyncMiddleware,
@ -30,7 +32,6 @@ import { AccountVideoRateModel } from '../../../models/account/account-video-rat
import { UserModel } from '../../../models/user/user'
import { VideoModel } from '../../../models/video/video'
import { VideoImportModel } from '../../../models/video/video-import'
import { pick } from '@shared/core-utils'
const auditLogger = auditLoggerFactory('users')
@ -253,9 +254,17 @@ async function updateMyAvatar (req: express.Request, res: express.Response) {
const userAccount = await AccountModel.load(user.Account.id)
const avatar = await updateLocalActorImageFile(userAccount, avatarPhysicalFile, ActorImageType.AVATAR)
const avatars = await updateLocalActorImageFiles(
userAccount,
avatarPhysicalFile,
ActorImageType.AVATAR
)
return res.json({ avatar: avatar.toFormattedJSON() })
return res.json({
// TODO: remove, deprecated in 4.2
avatar: getBiggestActorImage(avatars).toFormattedJSON(),
avatars: avatars.map(avatar => avatar.toFormattedJSON())
})
}
async function deleteMyAvatar (req: express.Request, res: express.Response) {
@ -264,5 +273,5 @@ async function deleteMyAvatar (req: express.Request, res: express.Response) {
const userAccount = await AccountModel.load(user.Account.id)
await deleteLocalActorImageFile(userAccount, ActorImageType.AVATAR)
return res.status(HttpStatusCode.NO_CONTENT_204).end()
return res.json({ avatars: [] })
}

View File

@ -3,7 +3,6 @@ import express from 'express'
import { UserNotificationModel } from '@server/models/user/user-notification'
import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
import { UserNotificationSetting } from '../../../../shared/models/users'
import { getFormattedObjects } from '../../../helpers/utils'
import {
asyncMiddleware,
asyncRetryTransactionMiddleware,
@ -20,6 +19,7 @@ import {
} from '../../../middlewares/validators/user-notifications'
import { UserNotificationSettingModel } from '../../../models/user/user-notification-setting'
import { meRouter } from './me'
import { getFormattedObjects } from '@server/helpers/utils'
const myNotificationsRouter = express.Router()

View File

@ -1,5 +1,6 @@
import express from 'express'
import { pickCommonVideoQuery } from '@server/helpers/query'
import { getBiggestActorImage } from '@server/lib/actor-image'
import { Hooks } from '@server/lib/plugins/hooks'
import { ActorFollowModel } from '@server/models/actor/actor-follow'
import { getServerActor } from '@server/models/application/application'
@ -16,7 +17,7 @@ import { MIMETYPES } from '../../initializers/constants'
import { sequelizeTypescript } from '../../initializers/database'
import { sendUpdateActor } from '../../lib/activitypub/send'
import { JobQueue } from '../../lib/job-queue'
import { deleteLocalActorImageFile, updateLocalActorImageFile } from '../../lib/local-actor'
import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '../../lib/local-actor'
import { createLocalVideoChannel, federateAllVideosOfChannel } from '../../lib/video-channel'
import {
asyncMiddleware,
@ -186,11 +187,15 @@ async function updateVideoChannelBanner (req: express.Request, res: express.Resp
const videoChannel = res.locals.videoChannel
const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON())
const banner = await updateLocalActorImageFile(videoChannel, bannerPhysicalFile, ActorImageType.BANNER)
const banners = await updateLocalActorImageFiles(videoChannel, bannerPhysicalFile, ActorImageType.BANNER)
auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys)
return res.json({ banner: banner.toFormattedJSON() })
return res.json({
// TODO: remove, deprecated in 4.2
banner: getBiggestActorImage(banners).toFormattedJSON(),
banners: banners.map(b => b.toFormattedJSON())
})
}
async function updateVideoChannelAvatar (req: express.Request, res: express.Response) {
@ -198,11 +203,14 @@ async function updateVideoChannelAvatar (req: express.Request, res: express.Resp
const videoChannel = res.locals.videoChannel
const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON())
const avatar = await updateLocalActorImageFile(videoChannel, avatarPhysicalFile, ActorImageType.AVATAR)
const avatars = await updateLocalActorImageFiles(videoChannel, avatarPhysicalFile, ActorImageType.AVATAR)
auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys)
return res.json({ avatar: avatar.toFormattedJSON() })
return res.json({
// TODO: remove, deprecated in 4.2
avatar: getBiggestActorImage(avatars).toFormattedJSON(),
avatars: avatars.map(a => a.toFormattedJSON())
})
}
async function deleteVideoChannelAvatar (req: express.Request, res: express.Response) {

View File

@ -68,7 +68,9 @@ const staticClientOverrides = [
'assets/images/icons/icon-512x512.png',
'assets/images/default-playlist.jpg',
'assets/images/default-avatar-account.png',
'assets/images/default-avatar-video-channel.png'
'assets/images/default-avatar-account-48x48.png',
'assets/images/default-avatar-video-channel.png',
'assets/images/default-avatar-video-channel-48x48.png'
]
for (const staticClientOverride of staticClientOverrides) {

View File

@ -64,7 +64,15 @@ async function getActorImage (req: express.Request, res: express.Response, next:
logger.info('Lazy serve remote actor image %s.', image.fileUrl)
try {
await pushActorImageProcessInQueue({ filename: image.filename, fileUrl: image.fileUrl, type: image.type })
await pushActorImageProcessInQueue({
filename: image.filename,
fileUrl: image.fileUrl,
size: {
height: image.height,
width: image.width
},
type: image.type
})
} catch (err) {
logger.warn('Cannot process remote actor image %s.', image.fileUrl, { err })
return res.status(HttpStatusCode.NOT_FOUND_404).end()

View File

@ -38,6 +38,9 @@ function getContextData (type: ContextType) {
sensitive: 'as:sensitive',
language: 'sc:inLanguage',
// TODO: remove in a few versions, introduced in 4.2
icons: 'as:icon',
isLiveBroadcast: 'sc:isLiveBroadcast',
liveSaveReplay: {
'@type': 'sc:Boolean',

View File

@ -14,7 +14,7 @@ import {
VideoTranscodingFPS
} from '../../shared/models'
import { ActivityPubActorType } from '../../shared/models/activitypub'
import { FollowState } from '../../shared/models/actors'
import { ActorImageType, FollowState } from '../../shared/models/actors'
import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
import { VideoPlaylistPrivacy } from '../../shared/models/videos/playlist/video-playlist-privacy.model'
import { VideoPlaylistType } from '../../shared/models/videos/playlist/video-playlist-type.model'
@ -24,7 +24,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
// ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 680
const LAST_MIGRATION_VERSION = 685
// ---------------------------------------------------------------------------
@ -633,15 +633,23 @@ const PREVIEWS_SIZE = {
height: 480,
minWidth: 400
}
const ACTOR_IMAGES_SIZE = {
AVATARS: {
width: 120,
height: 120
},
BANNERS: {
width: 1920,
height: 317 // 6/1 ratio
}
const ACTOR_IMAGES_SIZE: { [key in ActorImageType]: { width: number, height: number }[]} = {
[ActorImageType.AVATAR]: [
{
width: 120,
height: 120
},
{
width: 48,
height: 48
}
],
[ActorImageType.BANNER]: [
{
width: 1920,
height: 317 // 6/1 ratio
}
]
}
const EMBED_SIZE = {

View File

@ -0,0 +1,62 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
db: any
}): Promise<void> {
{
await utils.queryInterface.addColumn('actorImage', 'actorId', {
type: Sequelize.INTEGER,
defaultValue: null,
allowNull: true,
references: {
model: 'actor',
key: 'id'
},
onDelete: 'CASCADE'
}, { transaction: utils.transaction })
// Avatars
{
const query = `UPDATE "actorImage" SET "actorId" = (SELECT "id" FROM "actor" WHERE "actor"."avatarId" = "actorImage"."id") ` +
`WHERE "type" = 1`
await utils.sequelize.query(query, { type: Sequelize.QueryTypes.UPDATE, transaction: utils.transaction })
}
// Banners
{
const query = `UPDATE "actorImage" SET "actorId" = (SELECT "id" FROM "actor" WHERE "actor"."bannerId" = "actorImage"."id") ` +
`WHERE "type" = 2`
await utils.sequelize.query(query, { type: Sequelize.QueryTypes.UPDATE, transaction: utils.transaction })
}
// Remove orphans
{
const query = `DELETE FROM "actorImage" WHERE id NOT IN (` +
`SELECT "bannerId" FROM actor WHERE "bannerId" IS NOT NULL ` +
`UNION select "avatarId" FROM actor WHERE "avatarId" IS NOT NULL` +
`);`
await utils.sequelize.query(query, { type: Sequelize.QueryTypes.DELETE, transaction: utils.transaction })
}
await utils.queryInterface.changeColumn('actorImage', 'actorId', {
type: Sequelize.INTEGER,
allowNull: false
}, { transaction: utils.transaction })
await utils.queryInterface.removeColumn('actor', 'avatarId', { transaction: utils.transaction })
await utils.queryInterface.removeColumn('actor', 'bannerId', { transaction: utils.transaction })
}
}
function down () {
throw new Error('Not implemented.')
}
export {
up,
down
}

View File

@ -12,53 +12,52 @@ type ImageInfo = {
onDisk?: boolean
}
async function updateActorImageInstance (actor: MActorImages, type: ActorImageType, imageInfo: ImageInfo | null, t: Transaction) {
const oldImageModel = type === ActorImageType.AVATAR
? actor.Avatar
: actor.Banner
async function updateActorImages (actor: MActorImages, type: ActorImageType, imagesInfo: ImageInfo[], t: Transaction) {
const avatarsOrBanners = type === ActorImageType.AVATAR
? actor.Avatars
: actor.Banners
if (oldImageModel) {
// Don't update the avatar if the file URL did not change
if (imageInfo?.fileUrl && oldImageModel.fileUrl === imageInfo.fileUrl) return actor
try {
await oldImageModel.destroy({ transaction: t })
setActorImage(actor, type, null)
} catch (err) {
logger.error('Cannot remove old actor image of actor %s.', actor.url, { err })
}
if (imagesInfo.length === 0) {
await deleteActorImages(actor, type, t)
}
if (imageInfo) {
for (const imageInfo of imagesInfo) {
const oldImageModel = (avatarsOrBanners || []).find(i => i.width === imageInfo.width)
if (oldImageModel) {
// Don't update the avatar if the file URL did not change
if (imageInfo?.fileUrl && oldImageModel.fileUrl === imageInfo.fileUrl) {
continue
}
await safeDeleteActorImage(actor, oldImageModel, type, t)
}
const imageModel = await ActorImageModel.create({
filename: imageInfo.name,
onDisk: imageInfo.onDisk ?? false,
fileUrl: imageInfo.fileUrl,
height: imageInfo.height,
width: imageInfo.width,
type
type,
actorId: actor.id
}, { transaction: t })
setActorImage(actor, type, imageModel)
addActorImage(actor, type, imageModel)
}
return actor
}
async function deleteActorImageInstance (actor: MActorImages, type: ActorImageType, t: Transaction) {
async function deleteActorImages (actor: MActorImages, type: ActorImageType, t: Transaction) {
try {
if (type === ActorImageType.AVATAR) {
await actor.Avatar.destroy({ transaction: t })
const association = buildAssociationName(type)
actor.avatarId = null
actor.Avatar = null
} else {
await actor.Banner.destroy({ transaction: t })
actor.bannerId = null
actor.Banner = null
for (const image of actor[association]) {
await image.destroy({ transaction: t })
}
actor[association] = []
} catch (err) {
logger.error('Cannot remove old image of actor %s.', actor.url, { err })
}
@ -66,29 +65,37 @@ async function deleteActorImageInstance (actor: MActorImages, type: ActorImageTy
return actor
}
async function safeDeleteActorImage (actor: MActorImages, toDelete: MActorImage, type: ActorImageType, t: Transaction) {
try {
await toDelete.destroy({ transaction: t })
const association = buildAssociationName(type)
actor[association] = actor[association].filter(image => image.id !== toDelete.id)
} catch (err) {
logger.error('Cannot remove old actor image of actor %s.', actor.url, { err })
}
}
// ---------------------------------------------------------------------------
export {
ImageInfo,
updateActorImageInstance,
deleteActorImageInstance
updateActorImages,
deleteActorImages
}
// ---------------------------------------------------------------------------
function setActorImage (actorModel: MActorImages, type: ActorImageType, imageModel: MActorImage) {
const id = imageModel
? imageModel.id
: null
function addActorImage (actor: MActorImages, type: ActorImageType, imageModel: MActorImage) {
const association = buildAssociationName(type)
if (!actor[association]) actor[association] = []
if (type === ActorImageType.AVATAR) {
actorModel.avatarId = id
actorModel.Avatar = imageModel
} else {
actorModel.bannerId = id
actorModel.Banner = imageModel
}
return actorModel
actor[association].push(imageModel)
}
function buildAssociationName (type: ActorImageType) {
return type === ActorImageType.AVATAR
? 'Avatars'
: 'Banners'
}

View File

@ -6,8 +6,8 @@ import { ServerModel } from '@server/models/server/server'
import { VideoChannelModel } from '@server/models/video/video-channel'
import { MAccount, MAccountDefault, MActor, MActorFullActor, MActorId, MActorImages, MChannel, MServer } from '@server/types/models'
import { ActivityPubActor, ActorImageType } from '@shared/models'
import { updateActorImageInstance } from '../image'
import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImageInfoFromObject } from './object-to-model-attributes'
import { updateActorImages } from '../image'
import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImagesInfoFromObject } from './object-to-model-attributes'
import { fetchActorFollowsCount } from './url-to-object'
export class APActorCreator {
@ -27,11 +27,11 @@ export class APActorCreator {
return sequelizeTypescript.transaction(async t => {
const server = await this.setServer(actorInstance, t)
await this.setImageIfNeeded(actorInstance, ActorImageType.AVATAR, t)
await this.setImageIfNeeded(actorInstance, ActorImageType.BANNER, t)
const { actorCreated, created } = await this.saveActor(actorInstance, t)
await this.setImageIfNeeded(actorCreated, ActorImageType.AVATAR, t)
await this.setImageIfNeeded(actorCreated, ActorImageType.BANNER, t)
await this.tryToFixActorUrlIfNeeded(actorCreated, actorInstance, created, t)
if (actorCreated.type === 'Person' || actorCreated.type === 'Application') { // Account or PeerTube instance
@ -71,10 +71,10 @@ export class APActorCreator {
}
private async setImageIfNeeded (actor: MActor, type: ActorImageType, t: Transaction) {
const imageInfo = getImageInfoFromObject(this.actorObject, type)
if (!imageInfo) return
const imagesInfo = getImagesInfoFromObject(this.actorObject, type)
if (imagesInfo.length === 0) return
return updateActorImageInstance(actor as MActorImages, type, imageInfo, t)
return updateActorImages(actor as MActorImages, type, imagesInfo, t)
}
private async saveActor (actor: MActor, t: Transaction) {

View File

@ -4,7 +4,7 @@ import { ActorModel } from '@server/models/actor/actor'
import { FilteredModelAttributes } from '@server/types'
import { getLowercaseExtension } from '@shared/core-utils'
import { buildUUID } from '@shared/extra-utils'
import { ActivityPubActor, ActorImageType } from '@shared/models'
import { ActivityIconObject, ActivityPubActor, ActorImageType } from '@shared/models'
function getActorAttributesFromObject (
actorObject: ActivityPubActor,
@ -30,33 +30,36 @@ function getActorAttributesFromObject (
}
}
function getImageInfoFromObject (actorObject: ActivityPubActor, type: ActorImageType) {
const mimetypes = MIMETYPES.IMAGE
const icon = type === ActorImageType.AVATAR
? actorObject.icon
function getImagesInfoFromObject (actorObject: ActivityPubActor, type: ActorImageType) {
const iconsOrImages = type === ActorImageType.AVATAR
? actorObject.icons || actorObject.icon
: actorObject.image
if (!icon || icon.type !== 'Image' || !isActivityPubUrlValid(icon.url)) return undefined
return normalizeIconOrImage(iconsOrImages).map(iconOrImage => {
const mimetypes = MIMETYPES.IMAGE
let extension: string
if (iconOrImage.type !== 'Image' || !isActivityPubUrlValid(iconOrImage.url)) return undefined
if (icon.mediaType) {
extension = mimetypes.MIMETYPE_EXT[icon.mediaType]
} else {
const tmp = getLowercaseExtension(icon.url)
let extension: string
if (mimetypes.EXT_MIMETYPE[tmp] !== undefined) extension = tmp
}
if (iconOrImage.mediaType) {
extension = mimetypes.MIMETYPE_EXT[iconOrImage.mediaType]
} else {
const tmp = getLowercaseExtension(iconOrImage.url)
if (!extension) return undefined
if (mimetypes.EXT_MIMETYPE[tmp] !== undefined) extension = tmp
}
return {
name: buildUUID() + extension,
fileUrl: icon.url,
height: icon.height,
width: icon.width,
type
}
if (!extension) return undefined
return {
name: buildUUID() + extension,
fileUrl: iconOrImage.url,
height: iconOrImage.height,
width: iconOrImage.width,
type
}
})
}
function getActorDisplayNameFromObject (actorObject: ActivityPubActor) {
@ -65,6 +68,15 @@ function getActorDisplayNameFromObject (actorObject: ActivityPubActor) {
export {
getActorAttributesFromObject,
getImageInfoFromObject,
getImagesInfoFromObject,
getActorDisplayNameFromObject
}
// ---------------------------------------------------------------------------
function normalizeIconOrImage (icon: ActivityIconObject | ActivityIconObject[]): ActivityIconObject[] {
if (Array.isArray(icon)) return icon
if (icon) return [ icon ]
return []
}

View File

@ -5,9 +5,9 @@ import { VideoChannelModel } from '@server/models/video/video-channel'
import { MAccount, MActor, MActorFull, MChannel } from '@server/types/models'
import { ActivityPubActor, ActorImageType } from '@shared/models'
import { getOrCreateAPOwner } from './get'
import { updateActorImageInstance } from './image'
import { updateActorImages } from './image'
import { fetchActorFollowsCount } from './shared'
import { getImageInfoFromObject } from './shared/object-to-model-attributes'
import { getImagesInfoFromObject } from './shared/object-to-model-attributes'
export class APActorUpdater {
@ -29,8 +29,8 @@ export class APActorUpdater {
}
async update () {
const avatarInfo = getImageInfoFromObject(this.actorObject, ActorImageType.AVATAR)
const bannerInfo = getImageInfoFromObject(this.actorObject, ActorImageType.BANNER)
const avatarsInfo = getImagesInfoFromObject(this.actorObject, ActorImageType.AVATAR)
const bannersInfo = getImagesInfoFromObject(this.actorObject, ActorImageType.BANNER)
try {
await this.updateActorInstance(this.actor, this.actorObject)
@ -47,8 +47,8 @@ export class APActorUpdater {
}
await runInReadCommittedTransaction(async t => {
await updateActorImageInstance(this.actor, ActorImageType.AVATAR, avatarInfo, t)
await updateActorImageInstance(this.actor, ActorImageType.BANNER, bannerInfo, t)
await updateActorImages(this.actor, ActorImageType.BANNER, bannersInfo, t)
await updateActorImages(this.actor, ActorImageType.AVATAR, avatarsInfo, t)
})
await runInReadCommittedTransaction(async t => {

14
server/lib/actor-image.ts Normal file
View File

@ -0,0 +1,14 @@
import maxBy from 'lodash/maxBy'
function getBiggestActorImage <T extends { width: number }> (images: T[]) {
const image = maxBy(images, 'width')
// If width is null, maxBy won't return a value
if (!image) return images[0]
return image
}
export {
getBiggestActorImage
}

View File

@ -3,6 +3,7 @@ import { readFile } from 'fs-extra'
import { join } from 'path'
import validator from 'validator'
import { toCompleteUUID } from '@server/helpers/custom-validators/misc'
import { ActorImageModel } from '@server/models/actor/actor-image'
import { root } from '@shared/core-utils'
import { escapeHTML } from '@shared/core-utils/renderer'
import { sha256 } from '@shared/extra-utils'
@ -16,7 +17,6 @@ import { mdToOneLinePlainText } from '../helpers/markdown'
import { CONFIG } from '../initializers/config'
import {
ACCEPT_HEADERS,
ACTOR_IMAGES_SIZE,
CUSTOM_HTML_TAG_COMMENTS,
EMBED_SIZE,
FILES_CONTENT_HASH,
@ -29,6 +29,7 @@ import { VideoModel } from '../models/video/video'
import { VideoChannelModel } from '../models/video/video-channel'
import { VideoPlaylistModel } from '../models/video/video-playlist'
import { MAccountActor, MChannelActor } from '../types/models'
import { getBiggestActorImage } from './actor-image'
import { ServerConfigManager } from './server-config-manager'
type Tags = {
@ -273,10 +274,11 @@ class ClientHtml {
const siteName = CONFIG.INSTANCE.NAME
const title = entity.getDisplayName()
const avatar = getBiggestActorImage(entity.Actor.Avatars)
const image = {
url: entity.Actor.getAvatarUrl(),
width: ACTOR_IMAGES_SIZE.AVATARS.width,
height: ACTOR_IMAGES_SIZE.AVATARS.height
url: ActorImageModel.getImageUrl(avatar),
width: avatar?.width,
height: avatar?.height
}
const ogType = 'website'

View File

@ -1,5 +1,5 @@
import 'multer'
import { queue } from 'async'
import { remove } from 'fs-extra'
import LRUCache from 'lru-cache'
import { join } from 'path'
import { ActorModel } from '@server/models/actor/actor'
@ -13,7 +13,7 @@ import { CONFIG } from '../initializers/config'
import { ACTOR_IMAGES_SIZE, LRU_CACHE, QUEUE_CONCURRENCY, WEBSERVER } from '../initializers/constants'
import { sequelizeTypescript } from '../initializers/database'
import { MAccountDefault, MActor, MChannelDefault } from '../types/models'
import { deleteActorImageInstance, updateActorImageInstance } from './activitypub/actors'
import { deleteActorImages, updateActorImages } from './activitypub/actors'
import { sendUpdateActor } from './activitypub/send'
function buildActorInstance (type: ActivityPubActorType, url: string, preferredUsername: string) {
@ -33,64 +33,69 @@ function buildActorInstance (type: ActivityPubActorType, url: string, preferredU
}) as MActor
}
async function updateLocalActorImageFile (
async function updateLocalActorImageFiles (
accountOrChannel: MAccountDefault | MChannelDefault,
imagePhysicalFile: Express.Multer.File,
type: ActorImageType
) {
const imageSize = type === ActorImageType.AVATAR
? ACTOR_IMAGES_SIZE.AVATARS
: ACTOR_IMAGES_SIZE.BANNERS
const processImageSize = async (imageSize: { width: number, height: number }) => {
const extension = getLowercaseExtension(imagePhysicalFile.filename)
const extension = getLowercaseExtension(imagePhysicalFile.filename)
const imageName = buildUUID() + extension
const destination = join(CONFIG.STORAGE.ACTOR_IMAGES, imageName)
await processImage(imagePhysicalFile.path, destination, imageSize, true)
const imageName = buildUUID() + extension
const destination = join(CONFIG.STORAGE.ACTOR_IMAGES, imageName)
await processImage(imagePhysicalFile.path, destination, imageSize)
return {
imageName,
imageSize
}
}
return retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async t => {
const actorImageInfo = {
name: imageName,
fileUrl: null,
height: imageSize.height,
width: imageSize.width,
onDisk: true
}
const processedImages = await Promise.all(ACTOR_IMAGES_SIZE[type].map(processImageSize))
await remove(imagePhysicalFile.path)
const updatedActor = await updateActorImageInstance(accountOrChannel.Actor, type, actorImageInfo, t)
await updatedActor.save({ transaction: t })
return retryTransactionWrapper(() => sequelizeTypescript.transaction(async t => {
const actorImagesInfo = processedImages.map(({ imageName, imageSize }) => ({
name: imageName,
fileUrl: null,
height: imageSize.height,
width: imageSize.width,
onDisk: true
}))
await sendUpdateActor(accountOrChannel, t)
const updatedActor = await updateActorImages(accountOrChannel.Actor, type, actorImagesInfo, t)
await updatedActor.save({ transaction: t })
return type === ActorImageType.AVATAR
? updatedActor.Avatar
: updatedActor.Banner
})
})
await sendUpdateActor(accountOrChannel, t)
return type === ActorImageType.AVATAR
? updatedActor.Avatars
: updatedActor.Banners
}))
}
async function deleteLocalActorImageFile (accountOrChannel: MAccountDefault | MChannelDefault, type: ActorImageType) {
return retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async t => {
const updatedActor = await deleteActorImageInstance(accountOrChannel.Actor, type, t)
const updatedActor = await deleteActorImages(accountOrChannel.Actor, type, t)
await updatedActor.save({ transaction: t })
await sendUpdateActor(accountOrChannel, t)
return updatedActor.Avatar
return updatedActor.Avatars
})
})
}
type DownloadImageQueueTask = { fileUrl: string, filename: string, type: ActorImageType }
type DownloadImageQueueTask = {
fileUrl: string
filename: string
type: ActorImageType
size: typeof ACTOR_IMAGES_SIZE[ActorImageType][0]
}
const downloadImageQueue = queue<DownloadImageQueueTask, Error>((task, cb) => {
const size = task.type === ActorImageType.AVATAR
? ACTOR_IMAGES_SIZE.AVATARS
: ACTOR_IMAGES_SIZE.BANNERS
downloadImage(task.fileUrl, CONFIG.STORAGE.ACTOR_IMAGES, task.filename, size)
downloadImage(task.fileUrl, CONFIG.STORAGE.ACTOR_IMAGES, task.filename, task.size)
.then(() => cb())
.catch(err => cb(err))
}, QUEUE_CONCURRENCY.ACTOR_PROCESS_IMAGE)
@ -110,7 +115,7 @@ const actorImagePathUnsafeCache = new LRUCache<string, string>({ max: LRU_CACHE.
export {
actorImagePathUnsafeCache,
updateLocalActorImageFile,
updateLocalActorImageFiles,
deleteLocalActorImageFile,
pushActorImageProcessInQueue,
buildActorInstance

View File

@ -77,7 +77,7 @@ export class CommentMention extends AbstractNotification <MCommentOwnerVideo, MU
userId: user.id,
commentId: this.payload.id
})
notification.Comment = this.payload
notification.VideoComment = this.payload
return notification
}

View File

@ -44,7 +44,7 @@ export class NewCommentForVideoOwner extends AbstractNotification <MCommentOwner
userId: user.id,
commentId: this.payload.id
})
notification.Comment = this.payload
notification.VideoComment = this.payload
return notification
}

View File

@ -1,11 +1,12 @@
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { isAbuseMessageValid } from '@server/helpers/custom-validators/abuses'
import { MAbuseMessage, MAbuseMessageFormattable } from '@server/types/models'
import { AttributesOnly } from '@shared/typescript-utils'
import { AbuseMessage } from '@shared/models'
import { AttributesOnly } from '@shared/typescript-utils'
import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
import { getSort, throwIfNotValid } from '../utils'
import { AbuseModel } from './abuse'
import { FindOptions } from 'sequelize/dist'
@Table({
tableName: 'abuseMessage',
@ -62,21 +63,28 @@ export class AbuseMessageModel extends Model<Partial<AttributesOnly<AbuseMessage
Abuse: AbuseModel
static listForApi (abuseId: number) {
const options = {
where: { abuseId },
const getQuery = (forCount: boolean) => {
const query: FindOptions = {
where: { abuseId },
order: getSort('createdAt')
}
order: getSort('createdAt'),
if (forCount !== true) {
query.include = [
{
model: AccountModel.scope(AccountScopeNames.SUMMARY),
required: false
}
]
}
include: [
{
model: AccountModel.scope(AccountScopeNames.SUMMARY),
required: false
}
]
return query
}
return AbuseMessageModel.findAndCountAll(options)
.then(({ rows, count }) => ({ data: rows, total: count }))
return Promise.all([
AbuseMessageModel.count(getQuery(true)),
AbuseMessageModel.findAll(getQuery(false))
]).then(([ total, data ]) => ({ total, data }))
}
static loadByIdAndAbuseId (messageId: number, abuseId: number): Promise<MAbuseMessage> {

View File

@ -1,7 +1,7 @@
import { Op, QueryTypes } from 'sequelize'
import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
import { FindOptions, Op, QueryTypes } from 'sequelize'
import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { handlesToNameAndHost } from '@server/helpers/actors'
import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/types/models'
import { MAccountBlocklist, MAccountBlocklistFormattable } from '@server/types/models'
import { AttributesOnly } from '@shared/typescript-utils'
import { AccountBlock } from '../../../shared/models'
import { ActorModel } from '../actor/actor'
@ -9,27 +9,6 @@ import { ServerModel } from '../server/server'
import { createSafeIn, getSort, searchAttribute } from '../utils'
import { AccountModel } from './account'
enum ScopeNames {
WITH_ACCOUNTS = 'WITH_ACCOUNTS'
}
@Scopes(() => ({
[ScopeNames.WITH_ACCOUNTS]: {
include: [
{
model: AccountModel,
required: true,
as: 'ByAccount'
},
{
model: AccountModel,
required: true,
as: 'BlockedAccount'
}
]
}
}))
@Table({
tableName: 'accountBlocklist',
indexes: [
@ -123,33 +102,45 @@ export class AccountBlocklistModel extends Model<Partial<AttributesOnly<AccountB
}) {
const { start, count, sort, search, accountId } = parameters
const query = {
offset: start,
limit: count,
order: getSort(sort)
}
const getQuery = (forCount: boolean) => {
const query: FindOptions = {
offset: start,
limit: count,
order: getSort(sort),
where: { accountId }
}
const where = {
accountId
}
if (search) {
Object.assign(query.where, {
[Op.or]: [
searchAttribute(search, '$BlockedAccount.name$'),
searchAttribute(search, '$BlockedAccount.Actor.url$')
]
})
}
if (search) {
Object.assign(where, {
[Op.or]: [
searchAttribute(search, '$BlockedAccount.name$'),
searchAttribute(search, '$BlockedAccount.Actor.url$')
if (forCount !== true) {
query.include = [
{
model: AccountModel,
required: true,
as: 'ByAccount'
},
{
model: AccountModel,
required: true,
as: 'BlockedAccount'
}
]
})
}
return query
}
Object.assign(query, { where })
return AccountBlocklistModel
.scope([ ScopeNames.WITH_ACCOUNTS ])
.findAndCountAll<MAccountBlocklistAccounts>(query)
.then(({ rows, count }) => {
return { total: count, data: rows }
})
return Promise.all([
AccountBlocklistModel.count(getQuery(true)),
AccountBlocklistModel.findAll(getQuery(false))
]).then(([ total, data ]) => ({ total, data }))
}
static listHandlesBlockedBy (accountIds: number[]): Promise<string[]> {

View File

@ -121,29 +121,40 @@ export class AccountVideoRateModel extends Model<Partial<AttributesOnly<AccountV
type?: string
accountId: number
}) {
const query: FindOptions = {
offset: options.start,
limit: options.count,
order: getSort(options.sort),
where: {
accountId: options.accountId
},
include: [
{
model: VideoModel,
required: true,
include: [
{
model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }),
required: true
}
]
const getQuery = (forCount: boolean) => {
const query: FindOptions = {
offset: options.start,
limit: options.count,
order: getSort(options.sort),
where: {
accountId: options.accountId
}
]
}
if (options.type) query.where['type'] = options.type
}
return AccountVideoRateModel.findAndCountAll(query)
if (options.type) query.where['type'] = options.type
if (forCount !== true) {
query.include = [
{
model: VideoModel,
required: true,
include: [
{
model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }),
required: true
}
]
}
]
}
return query
}
return Promise.all([
AccountVideoRateModel.count(getQuery(true)),
AccountVideoRateModel.findAll(getQuery(false))
]).then(([ total, data ]) => ({ total, data }))
}
static listRemoteRateUrlsOfLocalVideos () {
@ -232,7 +243,10 @@ export class AccountVideoRateModel extends Model<Partial<AttributesOnly<AccountV
]
}
return AccountVideoRateModel.findAndCountAll<MAccountVideoRateAccountUrl>(query)
return Promise.all([
AccountVideoRateModel.count(query),
AccountVideoRateModel.findAll<MAccountVideoRateAccountUrl>(query)
]).then(([ total, data ]) => ({ total, data }))
}
static cleanOldRatesOf (videoId: number, type: VideoRateType, beforeUpdatedAt: Date) {

View File

@ -54,6 +54,7 @@ export type SummaryOptions = {
whereActor?: WhereOptions
whereServer?: WhereOptions
withAccountBlockerIds?: number[]
forCount?: boolean
}
@DefaultScope(() => ({
@ -73,22 +74,24 @@ export type SummaryOptions = {
where: options.whereServer
}
const queryInclude: Includeable[] = [
{
attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
model: ActorModel.unscoped(),
required: options.actorRequired ?? true,
where: options.whereActor,
include: [
serverInclude,
const actorInclude: Includeable = {
attributes: [ 'id', 'preferredUsername', 'url', 'serverId' ],
model: ActorModel.unscoped(),
required: options.actorRequired ?? true,
where: options.whereActor,
include: [ serverInclude ]
}
{
model: ActorImageModel.unscoped(),
as: 'Avatar',
required: false
}
]
}
if (options.forCount !== true) {
actorInclude.include.push({
model: ActorImageModel,
as: 'Avatars',
required: false
})
}
const queryInclude: Includeable[] = [
actorInclude
]
const query: FindOptions = {
@ -349,13 +352,10 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
order: getSort(sort)
}
return AccountModel.findAndCountAll(query)
.then(({ rows, count }) => {
return {
data: rows,
total: count
}
})
return Promise.all([
AccountModel.count(),
AccountModel.findAll(query)
]).then(([ total, data ]) => ({ total, data }))
}
static loadAccountIdFromVideo (videoId: number): Promise<MAccount> {
@ -407,16 +407,15 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
}
toFormattedJSON (this: MAccountFormattable): Account {
const actor = this.Actor.toFormattedJSON()
const account = {
return {
...this.Actor.toFormattedJSON(),
id: this.id,
displayName: this.getDisplayName(),
description: this.description,
updatedAt: this.updatedAt,
userId: this.userId ? this.userId : undefined
userId: this.userId ?? undefined
}
return Object.assign(actor, account)
}
toFormattedSummaryJSON (this: MAccountSummaryFormattable): AccountSummary {
@ -424,10 +423,14 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
return {
id: this.id,
name: actor.name,
displayName: this.getDisplayName(),
name: actor.name,
url: actor.url,
host: actor.host,
avatars: actor.avatars,
// TODO: remove, deprecated in 4.2
avatar: actor.avatar
}
}

View File

@ -1,5 +1,5 @@
import { difference, values } from 'lodash'
import { IncludeOptions, Op, QueryTypes, Transaction, WhereOptions } from 'sequelize'
import { Includeable, IncludeOptions, literal, Op, QueryTypes, Transaction, WhereOptions } from 'sequelize'
import {
AfterCreate,
AfterDestroy,
@ -30,12 +30,12 @@ import {
MActorFollowFormattable,
MActorFollowSubscriptions
} from '@server/types/models'
import { AttributesOnly } from '@shared/typescript-utils'
import { ActivityPubActorType } from '@shared/models'
import { AttributesOnly } from '@shared/typescript-utils'
import { FollowState } from '../../../shared/models/actors'
import { ActorFollow } from '../../../shared/models/actors/follow.model'
import { logger } from '../../helpers/logger'
import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAME } from '../../initializers/constants'
import { ACTOR_FOLLOW_SCORE, CONSTRAINTS_FIELDS, FOLLOW_STATES, SERVER_ACTOR_NAME, SORTABLE_COLUMNS } from '../../initializers/constants'
import { AccountModel } from '../account/account'
import { ServerModel } from '../server/server'
import { doesExist } from '../shared/query'
@ -375,43 +375,46 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
Object.assign(followingWhere, { type: actorType })
}
const query = {
distinct: true,
offset: start,
limit: count,
order: getFollowsSort(sort),
where: followWhere,
include: [
{
model: ActorModel,
required: true,
as: 'ActorFollower',
where: {
id
}
},
{
model: ActorModel,
as: 'ActorFollowing',
required: true,
where: followingWhere,
include: [
{
model: ServerModel,
required: true
const getQuery = (forCount: boolean) => {
const actorModel = forCount
? ActorModel.unscoped()
: ActorModel
return {
distinct: true,
offset: start,
limit: count,
order: getFollowsSort(sort),
where: followWhere,
include: [
{
model: actorModel,
required: true,
as: 'ActorFollower',
where: {
id
}
]
}
]
},
{
model: actorModel,
as: 'ActorFollowing',
required: true,
where: followingWhere,
include: [
{
model: ServerModel,
required: true
}
]
}
]
}
}
return ActorFollowModel.findAndCountAll<MActorFollowActorsDefault>(query)
.then(({ rows, count }) => {
return {
data: rows,
total: count
}
})
return Promise.all([
ActorFollowModel.count(getQuery(true)),
ActorFollowModel.findAll<MActorFollowActorsDefault>(getQuery(false))
]).then(([ total, data ]) => ({ total, data }))
}
static listFollowersForApi (options: {
@ -429,11 +432,17 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
const followerWhere: WhereOptions = {}
if (search) {
Object.assign(followWhere, {
[Op.or]: [
searchAttribute(search, '$ActorFollower.preferredUsername$'),
searchAttribute(search, '$ActorFollower.Server.host$')
]
const escapedSearch = ActorFollowModel.sequelize.escape('%' + search + '%')
Object.assign(followerWhere, {
id: {
[Op.in]: literal(
`(` +
`SELECT "actor".id FROM actor LEFT JOIN server on server.id = actor."serverId" ` +
`WHERE "preferredUsername" ILIKE ${escapedSearch} OR "host" ILIKE ${escapedSearch}` +
`)`
)
}
})
}
@ -441,39 +450,43 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
Object.assign(followerWhere, { type: actorType })
}
const query = {
distinct: true,
offset: start,
limit: count,
order: getFollowsSort(sort),
where: followWhere,
include: [
{
model: ActorModel,
required: true,
as: 'ActorFollower',
where: followerWhere
},
{
model: ActorModel,
as: 'ActorFollowing',
required: true,
where: {
id: {
[Op.in]: actorIds
const getQuery = (forCount: boolean) => {
const actorModel = forCount
? ActorModel.unscoped()
: ActorModel
return {
distinct: true,
offset: start,
limit: count,
order: getFollowsSort(sort),
where: followWhere,
include: [
{
model: actorModel,
required: true,
as: 'ActorFollower',
where: followerWhere
},
{
model: actorModel,
as: 'ActorFollowing',
required: true,
where: {
id: {
[Op.in]: actorIds
}
}
}
}
]
]
}
}
return ActorFollowModel.findAndCountAll<MActorFollowActorsDefault>(query)
.then(({ rows, count }) => {
return {
data: rows,
total: count
}
})
return Promise.all([
ActorFollowModel.count(getQuery(true)),
ActorFollowModel.findAll<MActorFollowActorsDefault>(getQuery(false))
]).then(([ total, data ]) => ({ total, data }))
}
static listSubscriptionsForApi (options: {
@ -497,58 +510,68 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
})
}
const query = {
attributes: [],
distinct: true,
offset: start,
limit: count,
order: getSort(sort),
where,
include: [
{
attributes: [ 'id' ],
model: ActorModel.unscoped(),
as: 'ActorFollowing',
required: true,
include: [
{
model: VideoChannelModel.unscoped(),
required: true,
include: [
{
attributes: {
exclude: unusedActorAttributesForAPI
},
model: ActorModel,
required: true
const getQuery = (forCount: boolean) => {
let channelInclude: Includeable[] = []
if (forCount !== true) {
channelInclude = [
{
attributes: {
exclude: unusedActorAttributesForAPI
},
model: ActorModel,
required: true
},
{
model: AccountModel.unscoped(),
required: true,
include: [
{
attributes: {
exclude: unusedActorAttributesForAPI
},
{
model: AccountModel.unscoped(),
required: true,
include: [
{
attributes: {
exclude: unusedActorAttributesForAPI
},
model: ActorModel,
required: true
}
]
}
]
}
]
}
]
model: ActorModel,
required: true
}
]
}
]
}
return {
attributes: forCount === true
? []
: SORTABLE_COLUMNS.USER_SUBSCRIPTIONS,
distinct: true,
offset: start,
limit: count,
order: getSort(sort),
where,
include: [
{
attributes: [ 'id' ],
model: ActorModel.unscoped(),
as: 'ActorFollowing',
required: true,
include: [
{
model: VideoChannelModel.unscoped(),
required: true,
include: channelInclude
}
]
}
]
}
}
return ActorFollowModel.findAndCountAll<MActorFollowSubscriptions>(query)
.then(({ rows, count }) => {
return {
data: rows.map(r => r.ActorFollowing.VideoChannel),
total: count
}
})
return Promise.all([
ActorFollowModel.count(getQuery(true)),
ActorFollowModel.findAll<MActorFollowSubscriptions>(getQuery(false))
]).then(([ total, rows ]) => ({
total,
data: rows.map(r => r.ActorFollowing.VideoChannel)
}))
}
static async keepUnfollowedInstance (hosts: string[]) {

View File

@ -1,15 +1,29 @@
import { remove } from 'fs-extra'
import { join } from 'path'
import { AfterDestroy, AllowNull, Column, CreatedAt, Default, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { MActorImageFormattable } from '@server/types/models'
import {
AfterDestroy,
AllowNull,
BelongsTo,
Column,
CreatedAt,
Default,
ForeignKey,
Is,
Model,
Table,
UpdatedAt
} from 'sequelize-typescript'
import { MActorImage, MActorImageFormattable } from '@server/types/models'
import { getLowercaseExtension } from '@shared/core-utils'
import { ActivityIconObject, ActorImageType } from '@shared/models'
import { AttributesOnly } from '@shared/typescript-utils'
import { ActorImageType } from '@shared/models'
import { ActorImage } from '../../../shared/models/actors/actor-image.model'
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
import { logger } from '../../helpers/logger'
import { CONFIG } from '../../initializers/config'
import { LAZY_STATIC_PATHS } from '../../initializers/constants'
import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants'
import { throwIfNotValid } from '../utils'
import { ActorModel } from './actor'
@Table({
tableName: 'actorImage',
@ -17,6 +31,10 @@ import { throwIfNotValid } from '../utils'
{
fields: [ 'filename' ],
unique: true
},
{
fields: [ 'actorId', 'type', 'width' ],
unique: true
}
]
})
@ -55,6 +73,18 @@ export class ActorImageModel extends Model<Partial<AttributesOnly<ActorImageMode
@UpdatedAt
updatedAt: Date
@ForeignKey(() => ActorModel)
@Column
actorId: number
@BelongsTo(() => ActorModel, {
foreignKey: {
allowNull: false
},
onDelete: 'CASCADE'
})
Actor: ActorModel
@AfterDestroy
static removeFilesAndSendDelete (instance: ActorImageModel) {
logger.info('Removing actor image file %s.', instance.filename)
@ -74,20 +104,41 @@ export class ActorImageModel extends Model<Partial<AttributesOnly<ActorImageMode
return ActorImageModel.findOne(query)
}
static getImageUrl (image: MActorImage) {
if (!image) return undefined
return WEBSERVER.URL + image.getStaticPath()
}
toFormattedJSON (this: MActorImageFormattable): ActorImage {
return {
width: this.width,
path: this.getStaticPath(),
createdAt: this.createdAt,
updatedAt: this.updatedAt
}
}
getStaticPath () {
if (this.type === ActorImageType.AVATAR) {
return join(LAZY_STATIC_PATHS.AVATARS, this.filename)
}
toActivityPubObject (): ActivityIconObject {
const extension = getLowercaseExtension(this.filename)
return join(LAZY_STATIC_PATHS.BANNERS, this.filename)
return {
type: 'Image',
mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension],
height: this.height,
width: this.width,
url: ActorImageModel.getImageUrl(this)
}
}
getStaticPath () {
switch (this.type) {
case ActorImageType.AVATAR:
return join(LAZY_STATIC_PATHS.AVATARS, this.filename)
case ActorImageType.BANNER:
return join(LAZY_STATIC_PATHS.BANNERS, this.filename)
}
}
getPath () {

View File

@ -16,11 +16,11 @@ import {
Table,
UpdatedAt
} from 'sequelize-typescript'
import { getBiggestActorImage } from '@server/lib/actor-image'
import { ModelCache } from '@server/models/model-cache'
import { getLowercaseExtension } from '@shared/core-utils'
import { ActivityIconObject, ActivityPubActorType, ActorImageType } from '@shared/models'
import { AttributesOnly } from '@shared/typescript-utils'
import { ActivityIconObject, ActivityPubActorType } from '../../../shared/models/activitypub'
import { ActorImage } from '../../../shared/models/actors/actor-image.model'
import { activityPubContextify } from '../../helpers/activitypub'
import {
isActorFollowersCountValid,
@ -81,7 +81,7 @@ export const unusedActorAttributesForAPI = [
},
{
model: ActorImageModel,
as: 'Avatar',
as: 'Avatars',
required: false
}
]
@ -109,12 +109,12 @@ export const unusedActorAttributesForAPI = [
},
{
model: ActorImageModel,
as: 'Avatar',
as: 'Avatars',
required: false
},
{
model: ActorImageModel,
as: 'Banner',
as: 'Banners',
required: false
}
]
@ -152,9 +152,6 @@ export const unusedActorAttributesForAPI = [
{
fields: [ 'serverId' ]
},
{
fields: [ 'avatarId' ]
},
{
fields: [ 'followersUrl' ]
}
@ -231,35 +228,31 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
@UpdatedAt
updatedAt: Date
@ForeignKey(() => ActorImageModel)
@Column
avatarId: number
@ForeignKey(() => ActorImageModel)
@Column
bannerId: number
@BelongsTo(() => ActorImageModel, {
@HasMany(() => ActorImageModel, {
as: 'Avatars',
onDelete: 'cascade',
hooks: true,
foreignKey: {
name: 'avatarId',
allowNull: true
allowNull: false
},
as: 'Avatar',
onDelete: 'set null',
hooks: true
scope: {
type: ActorImageType.AVATAR
}
})
Avatar: ActorImageModel
Avatars: ActorImageModel[]
@BelongsTo(() => ActorImageModel, {
@HasMany(() => ActorImageModel, {
as: 'Banners',
onDelete: 'cascade',
hooks: true,
foreignKey: {
name: 'bannerId',
allowNull: true
allowNull: false
},
as: 'Banner',
onDelete: 'set null',
hooks: true
scope: {
type: ActorImageType.BANNER
}
})
Banner: ActorImageModel
Banners: ActorImageModel[]
@HasMany(() => ActorFollowModel, {
foreignKey: {
@ -386,8 +379,7 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
transaction
}
return ActorModel.scope(ScopeNames.FULL)
.findOne(query)
return ActorModel.scope(ScopeNames.FULL).findOne(query)
}
return ModelCache.Instance.doCache({
@ -410,8 +402,7 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
transaction
}
return ActorModel.unscoped()
.findOne(query)
return ActorModel.unscoped().findOne(query)
}
return ModelCache.Instance.doCache({
@ -532,55 +523,50 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
}
toFormattedSummaryJSON (this: MActorSummaryFormattable) {
let avatar: ActorImage = null
if (this.Avatar) {
avatar = this.Avatar.toFormattedJSON()
}
return {
url: this.url,
name: this.preferredUsername,
host: this.getHost(),
avatar
avatars: (this.Avatars || []).map(a => a.toFormattedJSON()),
// TODO: remove, deprecated in 4.2
avatar: this.hasImage(ActorImageType.AVATAR)
? this.Avatars[0].toFormattedJSON()
: undefined
}
}
toFormattedJSON (this: MActorFormattable) {
const base = this.toFormattedSummaryJSON()
return {
...this.toFormattedSummaryJSON(),
let banner: ActorImage = null
if (this.Banner) {
banner = this.Banner.toFormattedJSON()
}
return Object.assign(base, {
id: this.id,
hostRedundancyAllowed: this.getRedundancyAllowed(),
followingCount: this.followingCount,
followersCount: this.followersCount,
banner,
createdAt: this.getCreatedAt()
})
createdAt: this.getCreatedAt(),
banners: (this.Banners || []).map(b => b.toFormattedJSON()),
// TODO: remove, deprecated in 4.2
banner: this.hasImage(ActorImageType.BANNER)
? this.Banners[0].toFormattedJSON()
: undefined
}
}
toActivityPubObject (this: MActorAPChannel | MActorAPAccount, name: string) {
let icon: ActivityIconObject
let icons: ActivityIconObject[]
let image: ActivityIconObject
if (this.avatarId) {
const extension = getLowercaseExtension(this.Avatar.filename)
icon = {
type: 'Image',
mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension],
height: this.Avatar.height,
width: this.Avatar.width,
url: this.getAvatarUrl()
}
if (this.hasImage(ActorImageType.AVATAR)) {
icon = getBiggestActorImage(this.Avatars).toActivityPubObject()
icons = this.Avatars.map(a => a.toActivityPubObject())
}
if (this.bannerId) {
const banner = (this as MActorAPChannel).Banner
if (this.hasImage(ActorImageType.BANNER)) {
const banner = getBiggestActorImage((this as MActorAPChannel).Banners)
const extension = getLowercaseExtension(banner.filename)
image = {
@ -588,7 +574,7 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension],
height: banner.height,
width: banner.width,
url: this.getBannerUrl()
url: ActorImageModel.getImageUrl(banner)
}
}
@ -612,7 +598,10 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
publicKeyPem: this.publicKey
},
published: this.getCreatedAt().toISOString(),
icon,
icons,
image
}
@ -677,16 +666,12 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
return this.Server ? this.Server.redundancyAllowed : false
}
getAvatarUrl () {
if (!this.avatarId) return undefined
hasImage (type: ActorImageType) {
const images = type === ActorImageType.AVATAR
? this.Avatars
: this.Banners
return WEBSERVER.URL + this.Avatar.getStaticPath()
}
getBannerUrl () {
if (!this.bannerId) return undefined
return WEBSERVER.URL + this.Banner.getStaticPath()
return Array.isArray(images) && images.length !== 0
}
isOutdated () {

View File

@ -239,11 +239,10 @@ export class PluginModel extends Model<Partial<AttributesOnly<PluginModel>>> {
if (options.pluginType) query.where['type'] = options.pluginType
return PluginModel
.findAndCountAll<MPlugin>(query)
.then(({ rows, count }) => {
return { total: count, data: rows }
})
return Promise.all([
PluginModel.count(query),
PluginModel.findAll<MPlugin>(query)
]).then(([ total, data ]) => ({ total, data }))
}
static listInstalled (): Promise<MPlugin[]> {

View File

@ -1,8 +1,8 @@
import { Op, QueryTypes } from 'sequelize'
import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models'
import { AttributesOnly } from '@shared/typescript-utils'
import { ServerBlock } from '@shared/models'
import { AttributesOnly } from '@shared/typescript-utils'
import { AccountModel } from '../account/account'
import { createSafeIn, getSort, searchAttribute } from '../utils'
import { ServerModel } from './server'
@ -169,16 +169,15 @@ export class ServerBlocklistModel extends Model<Partial<AttributesOnly<ServerBlo
order: getSort(sort),
where: {
accountId,
...searchAttribute(search, '$BlockedServer.host$')
}
}
return ServerBlocklistModel
.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_SERVER ])
.findAndCountAll<MServerBlocklistAccountServer>(query)
.then(({ rows, count }) => {
return { total: count, data: rows }
})
return Promise.all([
ServerBlocklistModel.scope(ScopeNames.WITH_SERVER).count(query),
ServerBlocklistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_SERVER ]).findAll<MServerBlocklistAccountServer>(query)
]).then(([ total, data ]) => ({ total, data }))
}
toFormattedJSON (this: MServerBlocklistFormattable): ServerBlock {

View File

@ -1,2 +1,3 @@
export * from './model-builder'
export * from './query'
export * from './update'

View File

@ -0,0 +1,101 @@
import { isPlainObject } from 'lodash'
import { Model as SequelizeModel, Sequelize } from 'sequelize'
import { logger } from '@server/helpers/logger'
export class ModelBuilder <T extends SequelizeModel> {
private readonly modelRegistry = new Map<string, T>()
constructor (private readonly sequelize: Sequelize) {
}
createModels (jsonArray: any[], baseModelName: string): T[] {
const result: T[] = []
for (const json of jsonArray) {
const { created, model } = this.createModel(json, baseModelName, json.id + '.' + baseModelName)
if (created) result.push(model)
}
return result
}
private createModel (json: any, modelName: string, keyPath: string) {
if (!json.id) return { created: false, model: null }
const { created, model } = this.createOrFindModel(json, modelName, keyPath)
for (const key of Object.keys(json)) {
const value = json[key]
if (!value) continue
// Child model
if (isPlainObject(value)) {
const { created, model: subModel } = this.createModel(value, key, keyPath + '.' + json.id + '.' + key)
if (!created || !subModel) continue
const Model = this.findModelBuilder(modelName)
const association = Model.associations[key]
if (!association) {
logger.error('Cannot find association %s of model %s', key, modelName, { associations: Object.keys(Model.associations) })
continue
}
if (association.isMultiAssociation) {
if (!Array.isArray(model[key])) model[key] = []
model[key].push(subModel)
} else {
model[key] = subModel
}
}
}
return { created, model }
}
private createOrFindModel (json: any, modelName: string, keyPath: string) {
const registryKey = this.getModelRegistryKey(json, keyPath)
if (this.modelRegistry.has(registryKey)) {
return {
created: false,
model: this.modelRegistry.get(registryKey)
}
}
const Model = this.findModelBuilder(modelName)
if (!Model) {
logger.error(
'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName),
{ existing: this.sequelize.modelManager.all.map(m => m.name) }
)
return undefined
}
// FIXME: typings
const model = new (Model as any)(json)
this.modelRegistry.set(registryKey, model)
return { created: true, model }
}
private findModelBuilder (modelName: string) {
return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName))
}
private buildSequelizeModelName (modelName: string) {
if (modelName === 'Avatars') return 'ActorImageModel'
if (modelName === 'ActorFollowing') return 'ActorModel'
if (modelName === 'ActorFollower') return 'ActorModel'
if (modelName === 'FlaggedAccount') return 'AccountModel'
return modelName + 'Model'
}
private getModelRegistryKey (json: any, keyPath: string) {
return keyPath + json.id
}
}

View File

@ -0,0 +1,269 @@
import { QueryTypes, Sequelize } from 'sequelize'
import { ModelBuilder } from '@server/models/shared'
import { getSort } from '@server/models/utils'
import { UserNotificationModelForApi } from '@server/types/models'
import { ActorImageType } from '@shared/models'
export interface ListNotificationsOptions {
userId: number
unread?: boolean
sort: string
offset: number
limit: number
sequelize: Sequelize
}
export class UserNotificationListQueryBuilder {
private innerQuery: string
private replacements: any = {}
private query: string
constructor (private readonly options: ListNotificationsOptions) {
}
async listNotifications () {
this.buildQuery()
const results = await this.options.sequelize.query(this.query, {
replacements: this.replacements,
type: QueryTypes.SELECT,
nest: true
})
const modelBuilder = new ModelBuilder<UserNotificationModelForApi>(this.options.sequelize)
return modelBuilder.createModels(results, 'UserNotification')
}
private buildInnerQuery () {
this.innerQuery = `SELECT * FROM "userNotification" AS "UserNotificationModel" ` +
`${this.getWhere()} ` +
`${this.getOrder()} ` +
`LIMIT :limit OFFSET :offset `
this.replacements.limit = this.options.limit
this.replacements.offset = this.options.offset
}
private buildQuery () {
this.buildInnerQuery()
this.query = `
${this.getSelect()}
FROM (${this.innerQuery}) "UserNotificationModel"
${this.getJoins()}
${this.getOrder()}`
}
private getWhere () {
let base = '"UserNotificationModel"."userId" = :userId '
this.replacements.userId = this.options.userId
if (this.options.unread === true) {
base += 'AND "UserNotificationModel"."read" IS FALSE '
} else if (this.options.unread === false) {
base += 'AND "UserNotificationModel"."read" IS TRUE '
}
return `WHERE ${base}`
}
private getOrder () {
const orders = getSort(this.options.sort)
return 'ORDER BY ' + orders.map(o => `"UserNotificationModel"."${o[0]}" ${o[1]}`).join(', ')
}
private getSelect () {
return `SELECT
"UserNotificationModel"."id",
"UserNotificationModel"."type",
"UserNotificationModel"."read",
"UserNotificationModel"."createdAt",
"UserNotificationModel"."updatedAt",
"Video"."id" AS "Video.id",
"Video"."uuid" AS "Video.uuid",
"Video"."name" AS "Video.name",
"Video->VideoChannel"."id" AS "Video.VideoChannel.id",
"Video->VideoChannel"."name" AS "Video.VideoChannel.name",
"Video->VideoChannel->Actor"."id" AS "Video.VideoChannel.Actor.id",
"Video->VideoChannel->Actor"."preferredUsername" AS "Video.VideoChannel.Actor.preferredUsername",
"Video->VideoChannel->Actor->Avatars"."id" AS "Video.VideoChannel.Actor.Avatars.id",
"Video->VideoChannel->Actor->Avatars"."width" AS "Video.VideoChannel.Actor.Avatars.width",
"Video->VideoChannel->Actor->Avatars"."filename" AS "Video.VideoChannel.Actor.Avatars.filename",
"Video->VideoChannel->Actor->Server"."id" AS "Video.VideoChannel.Actor.Server.id",
"Video->VideoChannel->Actor->Server"."host" AS "Video.VideoChannel.Actor.Server.host",
"VideoComment"."id" AS "VideoComment.id",
"VideoComment"."originCommentId" AS "VideoComment.originCommentId",
"VideoComment->Account"."id" AS "VideoComment.Account.id",
"VideoComment->Account"."name" AS "VideoComment.Account.name",
"VideoComment->Account->Actor"."id" AS "VideoComment.Account.Actor.id",
"VideoComment->Account->Actor"."preferredUsername" AS "VideoComment.Account.Actor.preferredUsername",
"VideoComment->Account->Actor->Avatars"."id" AS "VideoComment.Account.Actor.Avatars.id",
"VideoComment->Account->Actor->Avatars"."width" AS "VideoComment.Account.Actor.Avatars.width",
"VideoComment->Account->Actor->Avatars"."filename" AS "VideoComment.Account.Actor.Avatars.filename",
"VideoComment->Account->Actor->Server"."id" AS "VideoComment.Account.Actor.Server.id",
"VideoComment->Account->Actor->Server"."host" AS "VideoComment.Account.Actor.Server.host",
"VideoComment->Video"."id" AS "VideoComment.Video.id",
"VideoComment->Video"."uuid" AS "VideoComment.Video.uuid",
"VideoComment->Video"."name" AS "VideoComment.Video.name",
"Abuse"."id" AS "Abuse.id",
"Abuse"."state" AS "Abuse.state",
"Abuse->VideoAbuse"."id" AS "Abuse.VideoAbuse.id",
"Abuse->VideoAbuse->Video"."id" AS "Abuse.VideoAbuse.Video.id",
"Abuse->VideoAbuse->Video"."uuid" AS "Abuse.VideoAbuse.Video.uuid",
"Abuse->VideoAbuse->Video"."name" AS "Abuse.VideoAbuse.Video.name",
"Abuse->VideoCommentAbuse"."id" AS "Abuse.VideoCommentAbuse.id",
"Abuse->VideoCommentAbuse->VideoComment"."id" AS "Abuse.VideoCommentAbuse.VideoComment.id",
"Abuse->VideoCommentAbuse->VideoComment"."originCommentId" AS "Abuse.VideoCommentAbuse.VideoComment.originCommentId",
"Abuse->VideoCommentAbuse->VideoComment->Video"."id" AS "Abuse.VideoCommentAbuse.VideoComment.Video.id",
"Abuse->VideoCommentAbuse->VideoComment->Video"."name" AS "Abuse.VideoCommentAbuse.VideoComment.Video.name",
"Abuse->VideoCommentAbuse->VideoComment->Video"."uuid" AS "Abuse.VideoCommentAbuse.VideoComment.Video.uuid",
"Abuse->FlaggedAccount"."id" AS "Abuse.FlaggedAccount.id",
"Abuse->FlaggedAccount"."name" AS "Abuse.FlaggedAccount.name",
"Abuse->FlaggedAccount"."description" AS "Abuse.FlaggedAccount.description",
"Abuse->FlaggedAccount"."actorId" AS "Abuse.FlaggedAccount.actorId",
"Abuse->FlaggedAccount"."userId" AS "Abuse.FlaggedAccount.userId",
"Abuse->FlaggedAccount"."applicationId" AS "Abuse.FlaggedAccount.applicationId",
"Abuse->FlaggedAccount"."createdAt" AS "Abuse.FlaggedAccount.createdAt",
"Abuse->FlaggedAccount"."updatedAt" AS "Abuse.FlaggedAccount.updatedAt",
"Abuse->FlaggedAccount->Actor"."id" AS "Abuse.FlaggedAccount.Actor.id",
"Abuse->FlaggedAccount->Actor"."preferredUsername" AS "Abuse.FlaggedAccount.Actor.preferredUsername",
"Abuse->FlaggedAccount->Actor->Avatars"."id" AS "Abuse.FlaggedAccount.Actor.Avatars.id",
"Abuse->FlaggedAccount->Actor->Avatars"."width" AS "Abuse.FlaggedAccount.Actor.Avatars.width",
"Abuse->FlaggedAccount->Actor->Avatars"."filename" AS "Abuse.FlaggedAccount.Actor.Avatars.filename",
"Abuse->FlaggedAccount->Actor->Server"."id" AS "Abuse.FlaggedAccount.Actor.Server.id",
"Abuse->FlaggedAccount->Actor->Server"."host" AS "Abuse.FlaggedAccount.Actor.Server.host",
"VideoBlacklist"."id" AS "VideoBlacklist.id",
"VideoBlacklist->Video"."id" AS "VideoBlacklist.Video.id",
"VideoBlacklist->Video"."uuid" AS "VideoBlacklist.Video.uuid",
"VideoBlacklist->Video"."name" AS "VideoBlacklist.Video.name",
"VideoImport"."id" AS "VideoImport.id",
"VideoImport"."magnetUri" AS "VideoImport.magnetUri",
"VideoImport"."targetUrl" AS "VideoImport.targetUrl",
"VideoImport"."torrentName" AS "VideoImport.torrentName",
"VideoImport->Video"."id" AS "VideoImport.Video.id",
"VideoImport->Video"."uuid" AS "VideoImport.Video.uuid",
"VideoImport->Video"."name" AS "VideoImport.Video.name",
"Plugin"."id" AS "Plugin.id",
"Plugin"."name" AS "Plugin.name",
"Plugin"."type" AS "Plugin.type",
"Plugin"."latestVersion" AS "Plugin.latestVersion",
"Application"."id" AS "Application.id",
"Application"."latestPeerTubeVersion" AS "Application.latestPeerTubeVersion",
"ActorFollow"."id" AS "ActorFollow.id",
"ActorFollow"."state" AS "ActorFollow.state",
"ActorFollow->ActorFollower"."id" AS "ActorFollow.ActorFollower.id",
"ActorFollow->ActorFollower"."preferredUsername" AS "ActorFollow.ActorFollower.preferredUsername",
"ActorFollow->ActorFollower->Account"."id" AS "ActorFollow.ActorFollower.Account.id",
"ActorFollow->ActorFollower->Account"."name" AS "ActorFollow.ActorFollower.Account.name",
"ActorFollow->ActorFollower->Avatars"."id" AS "ActorFollow.ActorFollower.Avatars.id",
"ActorFollow->ActorFollower->Avatars"."width" AS "ActorFollow.ActorFollower.Avatars.width",
"ActorFollow->ActorFollower->Avatars"."filename" AS "ActorFollow.ActorFollower.Avatars.filename",
"ActorFollow->ActorFollower->Server"."id" AS "ActorFollow.ActorFollower.Server.id",
"ActorFollow->ActorFollower->Server"."host" AS "ActorFollow.ActorFollower.Server.host",
"ActorFollow->ActorFollowing"."id" AS "ActorFollow.ActorFollowing.id",
"ActorFollow->ActorFollowing"."preferredUsername" AS "ActorFollow.ActorFollowing.preferredUsername",
"ActorFollow->ActorFollowing"."type" AS "ActorFollow.ActorFollowing.type",
"ActorFollow->ActorFollowing->VideoChannel"."id" AS "ActorFollow.ActorFollowing.VideoChannel.id",
"ActorFollow->ActorFollowing->VideoChannel"."name" AS "ActorFollow.ActorFollowing.VideoChannel.name",
"ActorFollow->ActorFollowing->Account"."id" AS "ActorFollow.ActorFollowing.Account.id",
"ActorFollow->ActorFollowing->Account"."name" AS "ActorFollow.ActorFollowing.Account.name",
"ActorFollow->ActorFollowing->Server"."id" AS "ActorFollow.ActorFollowing.Server.id",
"ActorFollow->ActorFollowing->Server"."host" AS "ActorFollow.ActorFollowing.Server.host",
"Account"."id" AS "Account.id",
"Account"."name" AS "Account.name",
"Account->Actor"."id" AS "Account.Actor.id",
"Account->Actor"."preferredUsername" AS "Account.Actor.preferredUsername",
"Account->Actor->Avatars"."id" AS "Account.Actor.Avatars.id",
"Account->Actor->Avatars"."width" AS "Account.Actor.Avatars.width",
"Account->Actor->Avatars"."filename" AS "Account.Actor.Avatars.filename",
"Account->Actor->Server"."id" AS "Account.Actor.Server.id",
"Account->Actor->Server"."host" AS "Account.Actor.Server.host"`
}
private getJoins () {
return `
LEFT JOIN (
"video" AS "Video"
INNER JOIN "videoChannel" AS "Video->VideoChannel" ON "Video"."channelId" = "Video->VideoChannel"."id"
INNER JOIN "actor" AS "Video->VideoChannel->Actor" ON "Video->VideoChannel"."actorId" = "Video->VideoChannel->Actor"."id"
LEFT JOIN "actorImage" AS "Video->VideoChannel->Actor->Avatars"
ON "Video->VideoChannel->Actor"."id" = "Video->VideoChannel->Actor->Avatars"."actorId"
AND "Video->VideoChannel->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
LEFT JOIN "server" AS "Video->VideoChannel->Actor->Server"
ON "Video->VideoChannel->Actor"."serverId" = "Video->VideoChannel->Actor->Server"."id"
) ON "UserNotificationModel"."videoId" = "Video"."id"
LEFT JOIN (
"videoComment" AS "VideoComment"
INNER JOIN "account" AS "VideoComment->Account" ON "VideoComment"."accountId" = "VideoComment->Account"."id"
INNER JOIN "actor" AS "VideoComment->Account->Actor" ON "VideoComment->Account"."actorId" = "VideoComment->Account->Actor"."id"
LEFT JOIN "actorImage" AS "VideoComment->Account->Actor->Avatars"
ON "VideoComment->Account->Actor"."id" = "VideoComment->Account->Actor->Avatars"."actorId"
AND "VideoComment->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
LEFT JOIN "server" AS "VideoComment->Account->Actor->Server"
ON "VideoComment->Account->Actor"."serverId" = "VideoComment->Account->Actor->Server"."id"
INNER JOIN "video" AS "VideoComment->Video" ON "VideoComment"."videoId" = "VideoComment->Video"."id"
) ON "UserNotificationModel"."commentId" = "VideoComment"."id"
LEFT JOIN "abuse" AS "Abuse" ON "UserNotificationModel"."abuseId" = "Abuse"."id"
LEFT JOIN "videoAbuse" AS "Abuse->VideoAbuse" ON "Abuse"."id" = "Abuse->VideoAbuse"."abuseId"
LEFT JOIN "video" AS "Abuse->VideoAbuse->Video" ON "Abuse->VideoAbuse"."videoId" = "Abuse->VideoAbuse->Video"."id"
LEFT JOIN "commentAbuse" AS "Abuse->VideoCommentAbuse" ON "Abuse"."id" = "Abuse->VideoCommentAbuse"."abuseId"
LEFT JOIN "videoComment" AS "Abuse->VideoCommentAbuse->VideoComment"
ON "Abuse->VideoCommentAbuse"."videoCommentId" = "Abuse->VideoCommentAbuse->VideoComment"."id"
LEFT JOIN "video" AS "Abuse->VideoCommentAbuse->VideoComment->Video"
ON "Abuse->VideoCommentAbuse->VideoComment"."videoId" = "Abuse->VideoCommentAbuse->VideoComment->Video"."id"
LEFT JOIN (
"account" AS "Abuse->FlaggedAccount"
INNER JOIN "actor" AS "Abuse->FlaggedAccount->Actor" ON "Abuse->FlaggedAccount"."actorId" = "Abuse->FlaggedAccount->Actor"."id"
LEFT JOIN "actorImage" AS "Abuse->FlaggedAccount->Actor->Avatars"
ON "Abuse->FlaggedAccount->Actor"."id" = "Abuse->FlaggedAccount->Actor->Avatars"."actorId"
AND "Abuse->FlaggedAccount->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
LEFT JOIN "server" AS "Abuse->FlaggedAccount->Actor->Server"
ON "Abuse->FlaggedAccount->Actor"."serverId" = "Abuse->FlaggedAccount->Actor->Server"."id"
) ON "Abuse"."flaggedAccountId" = "Abuse->FlaggedAccount"."id"
LEFT JOIN (
"videoBlacklist" AS "VideoBlacklist"
INNER JOIN "video" AS "VideoBlacklist->Video" ON "VideoBlacklist"."videoId" = "VideoBlacklist->Video"."id"
) ON "UserNotificationModel"."videoBlacklistId" = "VideoBlacklist"."id"
LEFT JOIN "videoImport" AS "VideoImport" ON "UserNotificationModel"."videoImportId" = "VideoImport"."id"
LEFT JOIN "video" AS "VideoImport->Video" ON "VideoImport"."videoId" = "VideoImport->Video"."id"
LEFT JOIN "plugin" AS "Plugin" ON "UserNotificationModel"."pluginId" = "Plugin"."id"
LEFT JOIN "application" AS "Application" ON "UserNotificationModel"."applicationId" = "Application"."id"
LEFT JOIN (
"actorFollow" AS "ActorFollow"
INNER JOIN "actor" AS "ActorFollow->ActorFollower" ON "ActorFollow"."actorId" = "ActorFollow->ActorFollower"."id"
INNER JOIN "account" AS "ActorFollow->ActorFollower->Account"
ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Account"."actorId"
LEFT JOIN "actorImage" AS "ActorFollow->ActorFollower->Avatars"
ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Avatars"."actorId"
AND "ActorFollow->ActorFollower->Avatars"."type" = ${ActorImageType.AVATAR}
LEFT JOIN "server" AS "ActorFollow->ActorFollower->Server"
ON "ActorFollow->ActorFollower"."serverId" = "ActorFollow->ActorFollower->Server"."id"
INNER JOIN "actor" AS "ActorFollow->ActorFollowing" ON "ActorFollow"."targetActorId" = "ActorFollow->ActorFollowing"."id"
LEFT JOIN "videoChannel" AS "ActorFollow->ActorFollowing->VideoChannel"
ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->VideoChannel"."actorId"
LEFT JOIN "account" AS "ActorFollow->ActorFollowing->Account"
ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->Account"."actorId"
LEFT JOIN "server" AS "ActorFollow->ActorFollowing->Server"
ON "ActorFollow->ActorFollowing"."serverId" = "ActorFollow->ActorFollowing->Server"."id"
) ON "UserNotificationModel"."actorFollowId" = "ActorFollow"."id"
LEFT JOIN (
"account" AS "Account"
INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id"
LEFT JOIN "actorImage" AS "Account->Actor->Avatars"
ON "Account->Actor"."id" = "Account->Actor->Avatars"."actorId"
AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}
LEFT JOIN "server" AS "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"
) ON "UserNotificationModel"."accountId" = "Account"."id"`
}
}

View File

@ -1,5 +1,6 @@
import { FindOptions, ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
import { ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { getBiggestActorImage } from '@server/lib/actor-image'
import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user'
import { uuidToShort } from '@shared/extra-utils'
import { UserNotification, UserNotificationType } from '@shared/models'
@ -7,207 +8,18 @@ import { AttributesOnly } from '@shared/typescript-utils'
import { isBooleanValid } from '../../helpers/custom-validators/misc'
import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
import { AbuseModel } from '../abuse/abuse'
import { VideoAbuseModel } from '../abuse/video-abuse'
import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
import { AccountModel } from '../account/account'
import { ActorModel } from '../actor/actor'
import { ActorFollowModel } from '../actor/actor-follow'
import { ActorImageModel } from '../actor/actor-image'
import { ApplicationModel } from '../application/application'
import { PluginModel } from '../server/plugin'
import { ServerModel } from '../server/server'
import { getSort, throwIfNotValid } from '../utils'
import { throwIfNotValid } from '../utils'
import { VideoModel } from '../video/video'
import { VideoBlacklistModel } from '../video/video-blacklist'
import { VideoChannelModel } from '../video/video-channel'
import { VideoCommentModel } from '../video/video-comment'
import { VideoImportModel } from '../video/video-import'
import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder'
import { UserModel } from './user'
enum ScopeNames {
WITH_ALL = 'WITH_ALL'
}
function buildActorWithAvatarInclude () {
return {
attributes: [ 'preferredUsername' ],
model: ActorModel.unscoped(),
required: true,
include: [
{
attributes: [ 'filename' ],
as: 'Avatar',
model: ActorImageModel.unscoped(),
required: false
},
{
attributes: [ 'host' ],
model: ServerModel.unscoped(),
required: false
}
]
}
}
function buildVideoInclude (required: boolean) {
return {
attributes: [ 'id', 'uuid', 'name' ],
model: VideoModel.unscoped(),
required
}
}
function buildChannelInclude (required: boolean, withActor = false) {
return {
required,
attributes: [ 'id', 'name' ],
model: VideoChannelModel.unscoped(),
include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
}
}
function buildAccountInclude (required: boolean, withActor = false) {
return {
required,
attributes: [ 'id', 'name' ],
model: AccountModel.unscoped(),
include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
}
}
@Scopes(() => ({
[ScopeNames.WITH_ALL]: {
include: [
Object.assign(buildVideoInclude(false), {
include: [ buildChannelInclude(true, true) ]
}),
{
attributes: [ 'id', 'originCommentId' ],
model: VideoCommentModel.unscoped(),
required: false,
include: [
buildAccountInclude(true, true),
buildVideoInclude(true)
]
},
{
attributes: [ 'id', 'state' ],
model: AbuseModel.unscoped(),
required: false,
include: [
{
attributes: [ 'id' ],
model: VideoAbuseModel.unscoped(),
required: false,
include: [ buildVideoInclude(false) ]
},
{
attributes: [ 'id' ],
model: VideoCommentAbuseModel.unscoped(),
required: false,
include: [
{
attributes: [ 'id', 'originCommentId' ],
model: VideoCommentModel.unscoped(),
required: false,
include: [
{
attributes: [ 'id', 'name', 'uuid' ],
model: VideoModel.unscoped(),
required: false
}
]
}
]
},
{
model: AccountModel,
as: 'FlaggedAccount',
required: false,
include: [ buildActorWithAvatarInclude() ]
}
]
},
{
attributes: [ 'id' ],
model: VideoBlacklistModel.unscoped(),
required: false,
include: [ buildVideoInclude(true) ]
},
{
attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ],
model: VideoImportModel.unscoped(),
required: false,
include: [ buildVideoInclude(false) ]
},
{
attributes: [ 'id', 'name', 'type', 'latestVersion' ],
model: PluginModel.unscoped(),
required: false
},
{
attributes: [ 'id', 'latestPeerTubeVersion' ],
model: ApplicationModel.unscoped(),
required: false
},
{
attributes: [ 'id', 'state' ],
model: ActorFollowModel.unscoped(),
required: false,
include: [
{
attributes: [ 'preferredUsername' ],
model: ActorModel.unscoped(),
required: true,
as: 'ActorFollower',
include: [
{
attributes: [ 'id', 'name' ],
model: AccountModel.unscoped(),
required: true
},
{
attributes: [ 'filename' ],
as: 'Avatar',
model: ActorImageModel.unscoped(),
required: false
},
{
attributes: [ 'host' ],
model: ServerModel.unscoped(),
required: false
}
]
},
{
attributes: [ 'preferredUsername', 'type' ],
model: ActorModel.unscoped(),
required: true,
as: 'ActorFollowing',
include: [
buildChannelInclude(false),
buildAccountInclude(false),
{
attributes: [ 'host' ],
model: ServerModel.unscoped(),
required: false
}
]
}
]
},
buildAccountInclude(false, true)
]
}
}))
@Table({
tableName: 'userNotification',
indexes: [
@ -342,7 +154,7 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
},
onDelete: 'cascade'
})
Comment: VideoCommentModel
VideoComment: VideoCommentModel
@ForeignKey(() => AbuseModel)
@Column
@ -431,11 +243,14 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
const where = { userId }
const query: FindOptions = {
const query = {
userId,
unread,
offset: start,
limit: count,
order: getSort(sort),
where
sort,
where,
sequelize: this.sequelize
}
if (unread !== undefined) query.where['read'] = !unread
@ -445,8 +260,8 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
.then(count => count || 0),
count === 0
? []
: UserNotificationModel.scope(ScopeNames.WITH_ALL).findAll(query)
? [] as UserNotificationModelForApi[]
: new UserNotificationListQueryBuilder(query).listNotifications()
]).then(([ total, data ]) => ({ total, data }))
}
@ -524,25 +339,31 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
toFormattedJSON (this: UserNotificationModelForApi): UserNotification {
const video = this.Video
? Object.assign(this.formatVideo(this.Video), { channel: this.formatActor(this.Video.VideoChannel) })
? {
...this.formatVideo(this.Video),
channel: this.formatActor(this.Video.VideoChannel)
}
: undefined
const videoImport = this.VideoImport
? {
id: this.VideoImport.id,
video: this.VideoImport.Video ? this.formatVideo(this.VideoImport.Video) : undefined,
video: this.VideoImport.Video
? this.formatVideo(this.VideoImport.Video)
: undefined,
torrentName: this.VideoImport.torrentName,
magnetUri: this.VideoImport.magnetUri,
targetUrl: this.VideoImport.targetUrl
}
: undefined
const comment = this.Comment
const comment = this.VideoComment
? {
id: this.Comment.id,
threadId: this.Comment.getThreadId(),
account: this.formatActor(this.Comment.Account),
video: this.formatVideo(this.Comment.Video)
id: this.VideoComment.id,
threadId: this.VideoComment.getThreadId(),
account: this.formatActor(this.VideoComment.Account),
video: this.formatVideo(this.VideoComment.Video)
}
: undefined
@ -570,8 +391,9 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
id: this.ActorFollow.ActorFollower.Account.id,
displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
name: this.ActorFollow.ActorFollower.preferredUsername,
avatar: this.ActorFollow.ActorFollower.Avatar ? { path: this.ActorFollow.ActorFollower.Avatar.getStaticPath() } : undefined,
host: this.ActorFollow.ActorFollower.getHost()
host: this.ActorFollow.ActorFollower.getHost(),
...this.formatAvatars(this.ActorFollow.ActorFollower.Avatars)
},
following: {
type: actorFollowingType[this.ActorFollow.ActorFollowing.type],
@ -612,7 +434,7 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
}
}
formatVideo (this: UserNotificationModelForApi, video: UserNotificationIncludes.VideoInclude) {
formatVideo (video: UserNotificationIncludes.VideoInclude) {
return {
id: video.id,
uuid: video.uuid,
@ -621,7 +443,7 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
}
}
formatAbuse (this: UserNotificationModelForApi, abuse: UserNotificationIncludes.AbuseInclude) {
formatAbuse (abuse: UserNotificationIncludes.AbuseInclude) {
const commentAbuse = abuse.VideoCommentAbuse?.VideoComment
? {
threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
@ -637,9 +459,13 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
}
: undefined
const videoAbuse = abuse.VideoAbuse?.Video ? this.formatVideo(abuse.VideoAbuse.Video) : undefined
const videoAbuse = abuse.VideoAbuse?.Video
? this.formatVideo(abuse.VideoAbuse.Video)
: undefined
const accountAbuse = (!commentAbuse && !videoAbuse && abuse.FlaggedAccount) ? this.formatActor(abuse.FlaggedAccount) : undefined
const accountAbuse = (!commentAbuse && !videoAbuse && abuse.FlaggedAccount)
? this.formatActor(abuse.FlaggedAccount)
: undefined
return {
id: abuse.id,
@ -651,19 +477,32 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
}
formatActor (
this: UserNotificationModelForApi,
accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
) {
const avatar = accountOrChannel.Actor.Avatar
? { path: accountOrChannel.Actor.Avatar.getStaticPath() }
: undefined
return {
id: accountOrChannel.id,
displayName: accountOrChannel.getDisplayName(),
name: accountOrChannel.Actor.preferredUsername,
host: accountOrChannel.Actor.getHost(),
avatar
...this.formatAvatars(accountOrChannel.Actor.Avatars)
}
}
formatAvatars (avatars: UserNotificationIncludes.ActorImageInclude[]) {
if (!avatars || avatars.length === 0) return { avatar: undefined, avatars: [] }
return {
avatar: this.formatAvatar(getBiggestActorImage(avatars)),
avatars: avatars.map(a => this.formatAvatar(a))
}
}
formatAvatar (a: UserNotificationIncludes.ActorImageInclude) {
return {
path: a.getStaticPath(),
width: a.width
}
}
}

View File

@ -106,7 +106,7 @@ enum ScopeNames {
include: [
{
model: ActorImageModel,
as: 'Banner',
as: 'Banners',
required: false
}
]
@ -495,13 +495,10 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
where
}
return UserModel.findAndCountAll(query)
.then(({ rows, count }) => {
return {
data: rows,
total: count
}
})
return Promise.all([
UserModel.unscoped().count(query),
UserModel.findAll(query)
]).then(([ total, data ]) => ({ total, data }))
}
static listWithRight (right: UserRight): Promise<MUserDefault[]> {

View File

@ -181,7 +181,7 @@ function buildServerIdsFollowedBy (actorId: any) {
'SELECT "actor"."serverId" FROM "actorFollow" ' +
'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' +
'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
')'
')'
}
function buildWhereIdOrUUID (id: number | string) {

View File

@ -0,0 +1,3 @@
export * from './video-model-get-query-builder'
export * from './videos-id-list-query-builder'
export * from './videos-model-list-query-builder'

View File

@ -1,5 +1,6 @@
import { createSafeIn } from '@server/models/utils'
import { MUserAccountId } from '@server/types/models'
import { ActorImageType } from '@shared/models'
import validator from 'validator'
import { AbstractRunQuery } from './abstract-run-query'
import { VideoTableAttributes } from './video-table-attributes'
@ -42,8 +43,9 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
)
this.addJoin(
'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Actor->Avatar" ' +
'ON "VideoChannel->Actor"."avatarId" = "VideoChannel->Actor->Avatar"."id"'
'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Actor->Avatars" ' +
'ON "VideoChannel->Actor"."id" = "VideoChannel->Actor->Avatars"."actorId" ' +
`AND "VideoChannel->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
)
this.attributes = {
@ -51,7 +53,7 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
...this.buildAttributesObject('VideoChannel', this.tables.getChannelAttributes()),
...this.buildActorInclude('VideoChannel->Actor'),
...this.buildAvatarInclude('VideoChannel->Actor->Avatar'),
...this.buildAvatarInclude('VideoChannel->Actor->Avatars'),
...this.buildServerInclude('VideoChannel->Actor->Server')
}
}
@ -68,8 +70,9 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
)
this.addJoin(
'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Account->Actor->Avatar" ' +
'ON "VideoChannel->Account->Actor"."avatarId" = "VideoChannel->Account->Actor->Avatar"."id"'
'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Account->Actor->Avatars" ' +
'ON "VideoChannel->Account"."actorId"= "VideoChannel->Account->Actor->Avatars"."actorId" ' +
`AND "VideoChannel->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
)
this.attributes = {
@ -77,7 +80,7 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
...this.buildAttributesObject('VideoChannel->Account', this.tables.getAccountAttributes()),
...this.buildActorInclude('VideoChannel->Account->Actor'),
...this.buildAvatarInclude('VideoChannel->Account->Actor->Avatar'),
...this.buildAvatarInclude('VideoChannel->Account->Actor->Avatars'),
...this.buildServerInclude('VideoChannel->Account->Actor->Server')
}
}

View File

@ -9,15 +9,15 @@ import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
import { TrackerModel } from '@server/models/server/tracker'
import { UserVideoHistoryModel } from '@server/models/user/user-video-history'
import { VideoInclude } from '@shared/models'
import { ScheduleVideoUpdateModel } from '../../schedule-video-update'
import { TagModel } from '../../tag'
import { ThumbnailModel } from '../../thumbnail'
import { VideoModel } from '../../video'
import { VideoBlacklistModel } from '../../video-blacklist'
import { VideoChannelModel } from '../../video-channel'
import { VideoFileModel } from '../../video-file'
import { VideoLiveModel } from '../../video-live'
import { VideoStreamingPlaylistModel } from '../../video-streaming-playlist'
import { ScheduleVideoUpdateModel } from '../../../schedule-video-update'
import { TagModel } from '../../../tag'
import { ThumbnailModel } from '../../../thumbnail'
import { VideoModel } from '../../../video'
import { VideoBlacklistModel } from '../../../video-blacklist'
import { VideoChannelModel } from '../../../video-channel'
import { VideoFileModel } from '../../../video-file'
import { VideoLiveModel } from '../../../video-live'
import { VideoStreamingPlaylistModel } from '../../../video-streaming-playlist'
import { VideoTableAttributes } from './video-table-attributes'
type SQLRow = { [id: string]: string | number }
@ -34,6 +34,7 @@ export class VideoModelBuilder {
private videoFileMemo: { [ id: number ]: VideoFileModel }
private thumbnailsDone: Set<any>
private actorImagesDone: Set<any>
private historyDone: Set<any>
private blacklistDone: Set<any>
private accountBlocklistDone: Set<any>
@ -69,11 +70,21 @@ export class VideoModelBuilder {
for (const row of rows) {
this.buildVideoAndAccount(row)
const videoModel = this.videosMemo[row.id]
const videoModel = this.videosMemo[row.id as number]
this.setUserHistory(row, videoModel)
this.addThumbnail(row, videoModel)
const channelActor = videoModel.VideoChannel?.Actor
if (channelActor) {
this.addActorAvatar(row, 'VideoChannel.Actor', channelActor)
}
const accountActor = videoModel.VideoChannel?.Account?.Actor
if (accountActor) {
this.addActorAvatar(row, 'VideoChannel.Account.Actor', accountActor)
}
if (!rowsWebTorrentFiles) {
this.addWebTorrentFile(row, videoModel)
}
@ -113,6 +124,7 @@ export class VideoModelBuilder {
this.videoFileMemo = {}
this.thumbnailsDone = new Set()
this.actorImagesDone = new Set()
this.historyDone = new Set()
this.blacklistDone = new Set()
this.liveDone = new Set()
@ -195,13 +207,8 @@ export class VideoModelBuilder {
private buildActor (row: SQLRow, prefix: string) {
const actorPrefix = `${prefix}.Actor`
const avatarPrefix = `${actorPrefix}.Avatar`
const serverPrefix = `${actorPrefix}.Server`
const avatarModel = row[`${avatarPrefix}.id`] !== null
? new ActorImageModel(this.grab(row, this.tables.getAvatarAttributes(), avatarPrefix), this.buildOpts)
: null
const serverModel = row[`${serverPrefix}.id`] !== null
? new ServerModel(this.grab(row, this.tables.getServerAttributes(), serverPrefix), this.buildOpts)
: null
@ -209,8 +216,8 @@ export class VideoModelBuilder {
if (serverModel) serverModel.BlockedBy = []
const actorModel = new ActorModel(this.grab(row, this.tables.getActorAttributes(), actorPrefix), this.buildOpts)
actorModel.Avatar = avatarModel
actorModel.Server = serverModel
actorModel.Avatars = []
return actorModel
}
@ -226,6 +233,18 @@ export class VideoModelBuilder {
this.historyDone.add(id)
}
private addActorAvatar (row: SQLRow, actorPrefix: string, actor: ActorModel) {
const avatarPrefix = `${actorPrefix}.Avatar`
const id = row[`${avatarPrefix}.id`]
if (!id || this.actorImagesDone.has(id)) return
const attributes = this.grab(row, this.tables.getAvatarAttributes(), avatarPrefix)
const avatarModel = new ActorImageModel(attributes, this.buildOpts)
actor.Avatars.push(avatarModel)
this.actorImagesDone.add(id)
}
private addThumbnail (row: SQLRow, videoModel: VideoModel) {
const id = row['Thumbnails.id']
if (!id || this.thumbnailsDone.has(id)) return

View File

@ -186,8 +186,7 @@ export class VideoTableAttributes {
'id',
'preferredUsername',
'url',
'serverId',
'avatarId'
'serverId'
]
if (this.mode === 'get') {
@ -212,6 +211,7 @@ export class VideoTableAttributes {
getAvatarAttributes () {
let attributeKeys = [
'id',
'width',
'filename',
'type',
'fileUrl',

View File

@ -31,6 +31,7 @@ import {
import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
import { sendDeleteActor } from '../../lib/activitypub/send'
import {
MChannel,
MChannelActor,
MChannelAP,
MChannelBannerAccountDefault,
@ -62,6 +63,7 @@ type AvailableForListOptions = {
search?: string
host?: string
handles?: string[]
forCount?: boolean
}
type AvailableWithStatsOptions = {
@ -116,70 +118,91 @@ export type SummaryOptions = {
})
}
let rootWhere: WhereOptions
if (options.handles) {
const or: WhereOptions[] = []
if (Array.isArray(options.handles) && options.handles.length !== 0) {
const or: string[] = []
for (const handle of options.handles || []) {
const [ preferredUsername, host ] = handle.split('@')
if (!host || host === WEBSERVER.HOST) {
or.push({
'$Actor.preferredUsername$': preferredUsername,
'$Actor.serverId$': null
})
or.push(`("preferredUsername" = ${VideoChannelModel.sequelize.escape(preferredUsername)} AND "serverId" IS NULL)`)
} else {
or.push({
'$Actor.preferredUsername$': preferredUsername,
'$Actor.Server.host$': host
})
or.push(
`(` +
`"preferredUsername" = ${VideoChannelModel.sequelize.escape(preferredUsername)} ` +
`AND "host" = ${VideoChannelModel.sequelize.escape(host)}` +
`)`
)
}
}
rootWhere = {
[Op.or]: or
}
whereActorAnd.push({
id: {
[Op.in]: literal(`(SELECT "actor".id FROM actor LEFT JOIN server on server.id = actor."serverId" WHERE ${or.join(' OR ')})`)
}
})
}
const channelInclude: Includeable[] = []
const accountInclude: Includeable[] = []
if (options.forCount !== true) {
accountInclude.push({
model: ServerModel,
required: false
})
accountInclude.push({
model: ActorImageModel,
as: 'Avatars',
required: false
})
channelInclude.push({
model: ActorImageModel,
as: 'Avatars',
required: false
})
channelInclude.push({
model: ActorImageModel,
as: 'Banners',
required: false
})
}
if (options.forCount !== true || serverRequired) {
channelInclude.push({
model: ServerModel,
duplicating: false,
required: serverRequired,
where: whereServer
})
}
return {
where: rootWhere,
include: [
{
attributes: {
exclude: unusedActorAttributesForAPI
},
model: ActorModel,
model: ActorModel.unscoped(),
where: {
[Op.and]: whereActorAnd
},
include: [
{
model: ServerModel,
required: serverRequired,
where: whereServer
},
{
model: ActorImageModel,
as: 'Avatar',
required: false
},
{
model: ActorImageModel,
as: 'Banner',
required: false
}
]
include: channelInclude
},
{
model: AccountModel,
model: AccountModel.unscoped(),
required: true,
include: [
{
attributes: {
exclude: unusedActorAttributesForAPI
},
model: ActorModel, // Default scope includes avatar and server
required: true
model: ActorModel.unscoped(),
required: true,
include: accountInclude
}
]
}
@ -189,7 +212,7 @@ export type SummaryOptions = {
[ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => {
const include: Includeable[] = [
{
attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
attributes: [ 'id', 'preferredUsername', 'url', 'serverId' ],
model: ActorModel.unscoped(),
required: options.actorRequired ?? true,
include: [
@ -199,8 +222,8 @@ export type SummaryOptions = {
required: false
},
{
model: ActorImageModel.unscoped(),
as: 'Avatar',
model: ActorImageModel,
as: 'Avatars',
required: false
}
]
@ -245,7 +268,7 @@ export type SummaryOptions = {
{
model: ActorImageModel,
required: false,
as: 'Banner'
as: 'Banners'
}
]
}
@ -474,14 +497,14 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
order: getSort(parameters.sort)
}
return VideoChannelModel
.scope({
method: [ ScopeNames.FOR_API, { actorId } as AvailableForListOptions ]
})
.findAndCountAll(query)
.then(({ rows, count }) => {
return { total: count, data: rows }
})
const getScope = (forCount: boolean) => {
return { method: [ ScopeNames.FOR_API, { actorId, forCount } as AvailableForListOptions ] }
}
return Promise.all([
VideoChannelModel.scope(getScope(true)).count(),
VideoChannelModel.scope(getScope(false)).findAll(query)
]).then(([ total, data ]) => ({ total, data }))
}
static searchForApi (options: Pick<AvailableForListOptions, 'actorId' | 'search' | 'host' | 'handles'> & {
@ -519,14 +542,22 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
where
}
return VideoChannelModel
.scope({
method: [ ScopeNames.FOR_API, pick(options, [ 'actorId', 'host', 'handles' ]) as AvailableForListOptions ]
})
.findAndCountAll(query)
.then(({ rows, count }) => {
return { total: count, data: rows }
})
const getScope = (forCount: boolean) => {
return {
method: [
ScopeNames.FOR_API, {
...pick(options, [ 'actorId', 'host', 'handles' ]),
forCount
} as AvailableForListOptions
]
}
}
return Promise.all([
VideoChannelModel.scope(getScope(true)).count(query),
VideoChannelModel.scope(getScope(false)).findAll(query)
]).then(([ total, data ]) => ({ total, data }))
}
static listByAccountForAPI (options: {
@ -552,20 +583,26 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
}
: null
const query = {
offset: options.start,
limit: options.count,
order: getSort(options.sort),
include: [
{
model: AccountModel,
where: {
id: options.accountId
},
required: true
}
],
where
const getQuery = (forCount: boolean) => {
const accountModel = forCount
? AccountModel.unscoped()
: AccountModel
return {
offset: options.start,
limit: options.count,
order: getSort(options.sort),
include: [
{
model: accountModel,
where: {
id: options.accountId
},
required: true
}
],
where
}
}
const scopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR_BANNER ]
@ -576,21 +613,19 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
})
}
return VideoChannelModel
.scope(scopes)
.findAndCountAll(query)
.then(({ rows, count }) => {
return { total: count, data: rows }
})
return Promise.all([
VideoChannelModel.scope(scopes).count(getQuery(true)),
VideoChannelModel.scope(scopes).findAll(getQuery(false))
]).then(([ total, data ]) => ({ total, data }))
}
static listAllByAccount (accountId: number) {
static listAllByAccount (accountId: number): Promise<MChannel[]> {
const query = {
limit: CONFIG.VIDEO_CHANNELS.MAX_PER_USER,
include: [
{
attributes: [],
model: AccountModel,
model: AccountModel.unscoped(),
where: {
id: accountId
},
@ -621,7 +656,7 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
{
model: ActorImageModel,
required: false,
as: 'Banner'
as: 'Banners'
}
]
}
@ -655,7 +690,7 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
{
model: ActorImageModel,
required: false,
as: 'Banner'
as: 'Banners'
}
]
}
@ -685,7 +720,7 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
{
model: ActorImageModel,
required: false,
as: 'Banner'
as: 'Banners'
}
]
}
@ -706,6 +741,9 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
displayName: this.getDisplayName(),
url: actor.url,
host: actor.host,
avatars: actor.avatars,
// TODO: remove, deprecated in 4.2
avatar: actor.avatar
}
}
@ -736,9 +774,16 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
support: this.support,
isLocal: this.Actor.isOwned(),
updatedAt: this.updatedAt,
ownerAccount: undefined,
videosCount,
viewsPerDay
viewsPerDay,
avatars: actor.avatars,
// TODO: remove, deprecated in 4.2
avatar: actor.avatar
}
if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON()

View File

@ -1,5 +1,5 @@
import { uniq } from 'lodash'
import { FindAndCountOptions, FindOptions, Op, Order, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
import { FindOptions, Op, Order, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
import {
AllowNull,
BelongsTo,
@ -16,8 +16,8 @@ import {
} from 'sequelize-typescript'
import { getServerActor } from '@server/models/application/application'
import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
import { AttributesOnly } from '@shared/typescript-utils'
import { VideoPrivacy } from '@shared/models'
import { AttributesOnly } from '@shared/typescript-utils'
import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
import { VideoComment, VideoCommentAdmin } from '../../../shared/models/videos/comment/video-comment.model'
@ -363,40 +363,43 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
Object.assign(whereVideo, searchAttribute(searchVideo, 'name'))
}
const query: FindAndCountOptions = {
offset: start,
limit: count,
order: getCommentSort(sort),
where,
include: [
{
model: AccountModel.unscoped(),
required: true,
where: whereAccount,
include: [
{
attributes: {
exclude: unusedActorAttributesForAPI
},
model: ActorModel, // Default scope includes avatar and server
required: true,
where: whereActor
}
]
},
{
model: VideoModel.unscoped(),
required: true,
where: whereVideo
}
]
const getQuery = (forCount: boolean) => {
return {
offset: start,
limit: count,
order: getCommentSort(sort),
where,
include: [
{
model: AccountModel.unscoped(),
required: true,
where: whereAccount,
include: [
{
attributes: {
exclude: unusedActorAttributesForAPI
},
model: forCount === true
? ActorModel.unscoped() // Default scope includes avatar and server
: ActorModel,
required: true,
where: whereActor
}
]
},
{
model: VideoModel.unscoped(),
required: true,
where: whereVideo
}
]
}
}
return VideoCommentModel
.findAndCountAll(query)
.then(({ rows, count }) => {
return { total: count, data: rows }
})
return Promise.all([
VideoCommentModel.count(getQuery(true)),
VideoCommentModel.findAll(getQuery(false))
]).then(([ total, data ]) => ({ total, data }))
}
static async listThreadsForApi (parameters: {
@ -443,14 +446,20 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
}
}
const scopesList: (string | ScopeOptions)[] = [
const findScopesList: (string | ScopeOptions)[] = [
ScopeNames.WITH_ACCOUNT_FOR_API,
{
method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
}
]
const queryCount = {
const countScopesList: ScopeOptions[] = [
{
method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
}
]
const notDeletedQueryCount = {
where: {
videoId,
deletedAt: null,
@ -459,9 +468,10 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
}
return Promise.all([
VideoCommentModel.scope(scopesList).findAndCountAll(queryList),
VideoCommentModel.count(queryCount)
]).then(([ { rows, count }, totalNotDeletedComments ]) => {
VideoCommentModel.scope(findScopesList).findAll(queryList),
VideoCommentModel.scope(countScopesList).count(queryList),
VideoCommentModel.count(notDeletedQueryCount)
]).then(([ rows, count, totalNotDeletedComments ]) => {
return { total: count, data: rows, totalNotDeletedComments }
})
}
@ -512,11 +522,10 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
}
]
return VideoCommentModel.scope(scopes)
.findAndCountAll(query)
.then(({ rows, count }) => {
return { total: count, data: rows }
})
return Promise.all([
VideoCommentModel.count(query),
VideoCommentModel.scope(scopes).findAll(query)
]).then(([ total, data ]) => ({ total, data }))
}
static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> {
@ -565,7 +574,10 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
transaction: t
}
return VideoCommentModel.findAndCountAll<MComment>(query)
return Promise.all([
VideoCommentModel.count(query),
VideoCommentModel.findAll<MComment>(query)
]).then(([ total, data ]) => ({ total, data }))
}
static async listForFeed (parameters: {

Some files were not shown because too many files have changed in this diff Show More