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>
This commit is contained in:
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"> <div class="channel-avatar-row">
<my-actor-avatar <my-actor-avatar
[channel]="videoChannel" [internalHref]="getVideoChannelLink(videoChannel)" [channel]="videoChannel"
i18n-title title="See this video channel" [internalHref]="getVideoChannelLink(videoChannel)"
i18n-title
title="See this video channel"
size="75"
></my-actor-avatar> ></my-actor-avatar>
<h2> <h2>

View File

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

View File

@ -2,7 +2,7 @@
<div class="account-info"> <div class="account-info">
<div class="account-avatar-row"> <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>
<div class="section-label" i18n>ACCOUNT</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> <td>
<a [href]="videoComment.account.localUrl" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer"> <a [href]="videoComment.account.localUrl" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
<div class="chip two-lines"> <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> <div>
{{ videoComment.account.displayName }} {{ videoComment.account.displayName }}
<span>{{ videoComment.by }}</span> <span>{{ videoComment.by }}</span>

View File

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

View File

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

View File

@ -19,7 +19,7 @@
<div class="video-channels"> <div class="video-channels">
<div *ngFor="let videoChannel of videoChannels; let i = index" class="video-channel"> <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"> <div class="video-channel-info">
<a [routerLink]="[ '/c', videoChannel.nameWithHost ]" class="video-channel-names" i18n-title title="Channel page"> <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; padding-bottom: 0;
my-actor-avatar { my-actor-avatar {
@include actor-avatar-size(80px);
@include margin-right(10px); @include margin-right(10px);
} }
} }

View File

@ -14,7 +14,7 @@
<div class="actors" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> <div class="actors" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
<div *ngFor="let follow of follows" class="actor"> <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"> <div class="actor-info">
<a [href]="follow.follower.url" class="actor-names" rel="noopener noreferrer" target="_blank" i18n-title title="Follower page"> <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 { .actor {
@include actor-row($avatar-size: 40px, $min-height: auto, $separator: true); @include actor-row($min-height: auto, $separator: true);
.actor-display-name { .actor-display-name {
font-size: 16px; font-size: 16px;

View File

@ -14,7 +14,7 @@
<div class="actors" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> <div class="actors" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
<div *ngFor="let videoChannel of videoChannels" class="actor"> <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"> <div class="actor-info">
<a [routerLink]="[ '/c', videoChannel.nameWithHost ]" class="actor-names" i18n-title title="Channel page"> <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 { SelectChannelItem } from 'src/types/select-options-item.model'
import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
import { AuthService, Notifier } from '@app/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 { OWNERSHIP_CHANGE_CHANNEL_VALIDATOR } from '@app/shared/form-validators/video-ownership-change-validators'
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
import { VideoOwnershipService } from '@app/shared/shared-main' import { VideoOwnershipService } from '@app/shared/shared-main'
@ -36,7 +36,7 @@ export class MyAcceptOwnershipComponent extends FormReactive implements OnInit {
ngOnInit () { ngOnInit () {
this.videoChannels = [] this.videoChannels = []
listUserChannels(this.authService) listUserChannelsForSelect(this.authService)
.subscribe(channels => this.videoChannels = channels) .subscribe(channels => this.videoChannels = channels)
this.buildForm({ this.buildForm({

View File

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

View File

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

View File

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

View File

@ -36,7 +36,7 @@
<ng-container *ngFor="let result of results"> <ng-container *ngFor="let result of results">
<div *ngIf="isVideoChannel(result)" class="entry video-channel"> <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"> <div class="video-channel-info">
<a *ngIf="!isExternalChannelUrl()" [routerLink]="getInternalChannelUrl(result)" class="video-channel-names"> <a *ngIf="!isExternalChannelUrl()" [routerLink]="getInternalChannelUrl(result)" class="video-channel-names">

View File

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

View File

@ -23,7 +23,7 @@
<div class="section-label" i18n>OWNER ACCOUNT</div> <div class="section-label" i18n>OWNER ACCOUNT</div>
<div class="avatar-row"> <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"> <div class="actor-info">
<h4> <h4>
@ -51,7 +51,7 @@
</ng-template> </ng-template>
<div class="channel-avatar-row"> <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>
<div class="section-label" i18n>VIDEO CHANNEL</div> <div class="section-label" i18n>VIDEO CHANNEL</div>

View File

@ -107,10 +107,6 @@
display: flex; display: flex;
margin-bottom: 15px; margin-bottom: 15px;
.account-avatar {
@include actor-avatar-size(48px);
}
.actor-info { .actor-info {
@include margin-left(15px); @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 { SelectChannelItem } from 'src/types/select-options-item.model'
import { Directive, EventEmitter, OnInit } from '@angular/core' import { Directive, EventEmitter, OnInit } from '@angular/core'
import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/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 { FormReactive } from '@app/shared/shared-forms'
import { VideoCaptionEdit, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' import { VideoCaptionEdit, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
import { LoadingBarService } from '@ngx-loading-bar/core' import { LoadingBarService } from '@ngx-loading-bar/core'
@ -38,7 +38,7 @@ export abstract class VideoSend extends FormReactive implements OnInit {
ngOnInit () { ngOnInit () {
this.buildForm({}) this.buildForm({})
listUserChannels(this.authService) listUserChannelsForSelect(this.authService)
.subscribe(channels => { .subscribe(channels => {
this.userVideoChannels = channels this.userVideoChannels = channels
this.firstStepChannelId = this.userVideoChannels[0].id this.firstStepChannelId = this.userVideoChannels[0].id

View File

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

View File

@ -1,6 +1,6 @@
<div *ngIf="isCommentDisplayed()" class="root-comment" [ngClass]="{ 'is-child': isChild() }"> <div *ngIf="isCommentDisplayed()" class="root-comment" [ngClass]="{ 'is-child': isChild() }">
<div class="left"> <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 class="vertical-border"></div>
</div> </div>

View File

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

View File

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

View File

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

View File

@ -20,8 +20,4 @@ export class VideoAvatarChannelComponent implements OnInit {
this.channelLinkTitle = $localize`${this.video.account.name} (channel page)` this.channelLinkTitle = $localize`${this.video.account.name} (channel page)`
this.accountLinkTitle = $localize`${this.video.byAccount} (account 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 channel videos" *ngFor="let object of overview.channels">
<div class="section-title"> <div class="section-title">
<a [routerLink]="[ '/c', buildVideoChannelBy(object) ]"> <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> <h2 class="section-title">{{ object.channel.displayName }}</h2>
</a> </a>

View File

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

View File

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

View File

@ -118,7 +118,7 @@ export class UserService {
changeAvatar (avatarForm: FormData) { changeAvatar (avatarForm: FormData) {
const url = UserService.BASE_USERS_URL + 'me/avatar/pick' 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))) .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 { first, map } from 'rxjs/operators'
import { SelectChannelItem } from 'src/types/select-options-item.model' import { SelectChannelItem } from 'src/types/select-options-item.model'
import { AuthService } from '../../core/auth' import { AuthService } from '../../core/auth'
function listUserChannels (authService: AuthService) { function listUserChannelsForSelect (authService: AuthService) {
return authService.userInformationLoaded return authService.userInformationLoaded
.pipe( .pipe(
first(), first(),
@ -23,12 +24,12 @@ function listUserChannels (authService: AuthService) {
id: c.id, id: c.id,
label: c.displayName, label: c.displayName,
support: c.support, support: c.support,
avatarPath: c.avatar?.path avatarPath: minBy(c.avatars, 'width')[0]?.path
}) as SelectChannelItem) }) as SelectChannelItem)
}) })
) )
} }
export { export {
listUserChannels listUserChannelsForSelect
} }

View File

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

View File

@ -43,7 +43,7 @@
<td *ngIf="isAdminView()"> <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"> <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"> <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> <div>
{{ abuse.reporterAccount.displayName }} {{ abuse.reporterAccount.displayName }}
<span>{{ abuse.reporterAccount.nameWithHost }}</span> <span>{{ abuse.reporterAccount.nameWithHost }}</span>

View File

@ -72,7 +72,7 @@ export class ActorAvatarEditComponent implements OnInit {
} }
hasAvatar () { hasAvatar () {
return !!this.preview || !!this.actor.avatar return !!this.preview || this.actor.avatars.length !== 0
} }
isChannel () { 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 { .avatar-18 {
--avatarSize: 18px;
--initialFontSize: 13px; --initialFontSize: 13px;
} }
.avatar-25 {
--avatarSize: 25px;
}
.avatar-32 {
--avatarSize: 32px;
}
.avatar-34 {
--avatarSize: 34px;
}
.avatar-36 {
--avatarSize: 36px;
}
.avatar-40 {
--avatarSize: 40px;
}
.avatar-100 { .avatar-100 {
--avatarSize: 100px;
--initialFontSize: 40px; --initialFontSize: 40px;
} }
.avatar-120 { .avatar-120 {
--avatarSize: 120px;
--initialFontSize: 46px; --initialFontSize: 46px;
} }

View File

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

View File

@ -1,7 +1,7 @@
<div *ngIf="channel" class="channel"> <div *ngIf="channel" class="channel">
<div class="channel-avatar-row"> <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> <h6>
<a [routerLink]="getVideoChannelLink()" i18n-title title="See this video channel"> <a [routerLink]="getVideoChannelLink()" i18n-title title="See this video channel">

View File

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

View File

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@ export class ChannelsSetupMessageComponent implements OnInit {
hasChannelNotConfigured () { hasChannelNotConfigured () {
if (!this.user.videoChannels) return false 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 () { ngOnInit () {

View File

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

View File

@ -80,7 +80,7 @@ export class VideoChannelService {
changeVideoChannelImage (videoChannelName: string, avatarForm: FormData, type: 'avatar' | 'banner') { changeVideoChannelImage (videoChannelName: string, avatarForm: FormData, type: 'avatar' | 'banner') {
const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/' + type + '/pick' 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))) .pipe(catchError(err => this.restExtractor.handleError(err)))
} }

View File

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

View File

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

View File

@ -13,11 +13,13 @@
<my-actor-avatar <my-actor-avatar
*ngIf="displayOptions.avatar && displayOwnerVideoChannel() && !displayAsRow" [title]="channelLinkTitle" *ngIf="displayOptions.avatar && displayOwnerVideoChannel() && !displayAsRow" [title]="channelLinkTitle"
[channel]="video.channel" [size]="actorImageSize" [internalHref]="[ '/c', video.byVideoChannel ]" [channel]="video.channel" [size]="actorImageSize" [internalHref]="[ '/c', video.byVideoChannel ]"
size="32"
></my-actor-avatar> ></my-actor-avatar>
<my-actor-avatar <my-actor-avatar
*ngIf="displayOptions.avatar && displayOwnerAccount() && !displayAsRow" [title]="channelLinkTitle" *ngIf="displayOptions.avatar && displayOwnerAccount() && !displayAsRow" [title]="channelLinkTitle"
[account]="video.account" [size]="actorImageSize" [internalHref]="[ '/c', video.byVideoChannel ]" [account]="video.account" [size]="actorImageSize" [internalHref]="[ '/c', video.byVideoChannel ]"
size="32"
></my-actor-avatar> ></my-actor-avatar>
<div class="w-100 d-flex flex-column"> <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; grid-column: 1;
margin-bottom: 30px; margin-bottom: 30px;
.main-avatar {
@include actor-avatar-size(120px);
}
> div { > div {
@include margin-left($img-margin); @include margin-left($img-margin);

View File

@ -1,12 +1,10 @@
@use '_variables' as *; @use '_variables' as *;
@use '_mixins' 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); @include row-blocks($min-height: $min-height, $separator: $separator);
> my-actor-avatar { > my-actor-avatar {
@include actor-avatar-size($avatar-size);
@include margin-right($avatar-margin-right); @include margin-right($avatar-margin-right);
} }

View File

@ -887,7 +887,7 @@
height: $avatar-height; height: $avatar-height;
my-actor-avatar { my-actor-avatar {
@include actor-avatar-size($avatar-height); display: inline-block;
} }
div { 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' } from '../../lib/activitypub/url'
import { import {
asyncMiddleware, asyncMiddleware,
ensureIsLocalChannel,
executeIfActivityPub, executeIfActivityPub,
localAccountValidator, localAccountValidator,
videoChannelsNameWithHostValidator, videoChannelsNameWithHostValidator,
ensureIsLocalChannel,
videosCustomGetValidator, videosCustomGetValidator,
videosShareValidator videosShareValidator
} from '../../middlewares' } from '../../middlewares'
@ -265,8 +265,8 @@ async function videoAnnouncesController (req: express.Request, res: express.Resp
const handler = async (start: number, count: number) => { const handler = async (start: number, count: number) => {
const result = await VideoShareModel.listAndCountByVideoId(video.id, start, count) const result = await VideoShareModel.listAndCountByVideoId(video.id, start, count)
return { return {
total: result.count, total: result.total,
data: result.rows.map(r => r.url) data: result.data.map(r => r.url)
} }
} }
const json = await activityPubCollectionPagination(getLocalVideoSharesActivityPubUrl(video), handler, req.query.page) 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 handler = async (start: number, count: number) => {
const result = await VideoCommentModel.listAndCountByVideoForAP(video, start, count) const result = await VideoCommentModel.listAndCountByVideoForAP(video, start, count)
return { return {
total: result.count, total: result.total,
data: result.rows.map(r => r.url) data: result.data.map(r => r.url)
} }
} }
const json = await activityPubCollectionPagination(getLocalVideoCommentsActivityPubUrl(video), handler, req.query.page) 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 handler = async (start: number, count: number) => {
const result = await AccountVideoRateModel.listAndCountAccountUrlsByVideoId(rateType, video.id, start, count) const result = await AccountVideoRateModel.listAndCountAccountUrlsByVideoId(rateType, video.id, start, count)
return { return {
total: result.count, total: result.total,
data: result.rows.map(r => r.url) data: result.data.map(r => r.url)
} }
} }
return activityPubCollectionPagination(url, handler, req.query.page) 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, sort: req.query.sort,
type: req.query.rating 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) { async function listAccountFollowers (req: express.Request, res: express.Response) {

View File

@ -1,7 +1,9 @@
import 'multer' import 'multer'
import express from 'express' import express from 'express'
import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '@server/helpers/audit-logger' import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '@server/helpers/audit-logger'
import { getBiggestActorImage } from '@server/lib/actor-image'
import { Hooks } from '@server/lib/plugins/hooks' 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 { ActorImageType, HttpStatusCode, UserUpdateMe, UserVideoQuota, UserVideoRate as FormattedUserVideoRate } from '@shared/models'
import { AttributesOnly } from '@shared/typescript-utils' import { AttributesOnly } from '@shared/typescript-utils'
import { createReqFiles } from '../../../helpers/express-utils' import { createReqFiles } from '../../../helpers/express-utils'
@ -10,7 +12,7 @@ import { CONFIG } from '../../../initializers/config'
import { MIMETYPES } from '../../../initializers/constants' import { MIMETYPES } from '../../../initializers/constants'
import { sequelizeTypescript } from '../../../initializers/database' import { sequelizeTypescript } from '../../../initializers/database'
import { sendUpdateActor } from '../../../lib/activitypub/send' 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 { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user'
import { import {
asyncMiddleware, asyncMiddleware,
@ -30,7 +32,6 @@ import { AccountVideoRateModel } from '../../../models/account/account-video-rat
import { UserModel } from '../../../models/user/user' import { UserModel } from '../../../models/user/user'
import { VideoModel } from '../../../models/video/video' import { VideoModel } from '../../../models/video/video'
import { VideoImportModel } from '../../../models/video/video-import' import { VideoImportModel } from '../../../models/video/video-import'
import { pick } from '@shared/core-utils'
const auditLogger = auditLoggerFactory('users') 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 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) { 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) const userAccount = await AccountModel.load(user.Account.id)
await deleteLocalActorImageFile(userAccount, ActorImageType.AVATAR) 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 { UserNotificationModel } from '@server/models/user/user-notification'
import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
import { UserNotificationSetting } from '../../../../shared/models/users' import { UserNotificationSetting } from '../../../../shared/models/users'
import { getFormattedObjects } from '../../../helpers/utils'
import { import {
asyncMiddleware, asyncMiddleware,
asyncRetryTransactionMiddleware, asyncRetryTransactionMiddleware,
@ -20,6 +19,7 @@ import {
} from '../../../middlewares/validators/user-notifications' } from '../../../middlewares/validators/user-notifications'
import { UserNotificationSettingModel } from '../../../models/user/user-notification-setting' import { UserNotificationSettingModel } from '../../../models/user/user-notification-setting'
import { meRouter } from './me' import { meRouter } from './me'
import { getFormattedObjects } from '@server/helpers/utils'
const myNotificationsRouter = express.Router() const myNotificationsRouter = express.Router()

View File

@ -1,5 +1,6 @@
import express from 'express' import express from 'express'
import { pickCommonVideoQuery } from '@server/helpers/query' import { pickCommonVideoQuery } from '@server/helpers/query'
import { getBiggestActorImage } from '@server/lib/actor-image'
import { Hooks } from '@server/lib/plugins/hooks' import { Hooks } from '@server/lib/plugins/hooks'
import { ActorFollowModel } from '@server/models/actor/actor-follow' import { ActorFollowModel } from '@server/models/actor/actor-follow'
import { getServerActor } from '@server/models/application/application' import { getServerActor } from '@server/models/application/application'
@ -16,7 +17,7 @@ import { MIMETYPES } from '../../initializers/constants'
import { sequelizeTypescript } from '../../initializers/database' import { sequelizeTypescript } from '../../initializers/database'
import { sendUpdateActor } from '../../lib/activitypub/send' import { sendUpdateActor } from '../../lib/activitypub/send'
import { JobQueue } from '../../lib/job-queue' 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 { createLocalVideoChannel, federateAllVideosOfChannel } from '../../lib/video-channel'
import { import {
asyncMiddleware, asyncMiddleware,
@ -186,11 +187,15 @@ async function updateVideoChannelBanner (req: express.Request, res: express.Resp
const videoChannel = res.locals.videoChannel const videoChannel = res.locals.videoChannel
const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON()) 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) 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) { 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 videoChannel = res.locals.videoChannel
const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON()) 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) 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) { 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/icons/icon-512x512.png',
'assets/images/default-playlist.jpg', 'assets/images/default-playlist.jpg',
'assets/images/default-avatar-account.png', '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) { 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) logger.info('Lazy serve remote actor image %s.', image.fileUrl)
try { 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) { } catch (err) {
logger.warn('Cannot process remote actor image %s.', image.fileUrl, { err }) logger.warn('Cannot process remote actor image %s.', image.fileUrl, { err })
return res.status(HttpStatusCode.NOT_FOUND_404).end() return res.status(HttpStatusCode.NOT_FOUND_404).end()

View File

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

View File

@ -14,7 +14,7 @@ import {
VideoTranscodingFPS VideoTranscodingFPS
} from '../../shared/models' } from '../../shared/models'
import { ActivityPubActorType } from '../../shared/models/activitypub' 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 { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
import { VideoPlaylistPrivacy } from '../../shared/models/videos/playlist/video-playlist-privacy.model' import { VideoPlaylistPrivacy } from '../../shared/models/videos/playlist/video-playlist-privacy.model'
import { VideoPlaylistType } from '../../shared/models/videos/playlist/video-playlist-type.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, height: 480,
minWidth: 400 minWidth: 400
} }
const ACTOR_IMAGES_SIZE = { const ACTOR_IMAGES_SIZE: { [key in ActorImageType]: { width: number, height: number }[]} = {
AVATARS: { [ActorImageType.AVATAR]: [
{
width: 120, width: 120,
height: 120 height: 120
}, },
BANNERS: { {
width: 48,
height: 48
}
],
[ActorImageType.BANNER]: [
{
width: 1920, width: 1920,
height: 317 // 6/1 ratio height: 317 // 6/1 ratio
} }
]
} }
const EMBED_SIZE = { 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 onDisk?: boolean
} }
async function updateActorImageInstance (actor: MActorImages, type: ActorImageType, imageInfo: ImageInfo | null, t: Transaction) { async function updateActorImages (actor: MActorImages, type: ActorImageType, imagesInfo: ImageInfo[], t: Transaction) {
const oldImageModel = type === ActorImageType.AVATAR const avatarsOrBanners = type === ActorImageType.AVATAR
? actor.Avatar ? actor.Avatars
: actor.Banner : actor.Banners
if (imagesInfo.length === 0) {
await deleteActorImages(actor, type, t)
}
for (const imageInfo of imagesInfo) {
const oldImageModel = (avatarsOrBanners || []).find(i => i.width === imageInfo.width)
if (oldImageModel) { if (oldImageModel) {
// Don't update the avatar if the file URL did not change // Don't update the avatar if the file URL did not change
if (imageInfo?.fileUrl && oldImageModel.fileUrl === imageInfo.fileUrl) return actor if (imageInfo?.fileUrl && oldImageModel.fileUrl === imageInfo.fileUrl) {
continue
try { }
await oldImageModel.destroy({ transaction: t })
await safeDeleteActorImage(actor, oldImageModel, type, t)
setActorImage(actor, type, null)
} catch (err) {
logger.error('Cannot remove old actor image of actor %s.', actor.url, { err })
}
} }
if (imageInfo) {
const imageModel = await ActorImageModel.create({ const imageModel = await ActorImageModel.create({
filename: imageInfo.name, filename: imageInfo.name,
onDisk: imageInfo.onDisk ?? false, onDisk: imageInfo.onDisk ?? false,
fileUrl: imageInfo.fileUrl, fileUrl: imageInfo.fileUrl,
height: imageInfo.height, height: imageInfo.height,
width: imageInfo.width, width: imageInfo.width,
type type,
actorId: actor.id
}, { transaction: t }) }, { transaction: t })
setActorImage(actor, type, imageModel) addActorImage(actor, type, imageModel)
} }
return actor return actor
} }
async function deleteActorImageInstance (actor: MActorImages, type: ActorImageType, t: Transaction) { async function deleteActorImages (actor: MActorImages, type: ActorImageType, t: Transaction) {
try { try {
if (type === ActorImageType.AVATAR) { const association = buildAssociationName(type)
await actor.Avatar.destroy({ transaction: t })
actor.avatarId = null for (const image of actor[association]) {
actor.Avatar = null await image.destroy({ transaction: t })
} else {
await actor.Banner.destroy({ transaction: t })
actor.bannerId = null
actor.Banner = null
} }
actor[association] = []
} catch (err) { } catch (err) {
logger.error('Cannot remove old image of actor %s.', actor.url, { 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 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 { export {
ImageInfo, ImageInfo,
updateActorImageInstance, updateActorImages,
deleteActorImageInstance deleteActorImages
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function setActorImage (actorModel: MActorImages, type: ActorImageType, imageModel: MActorImage) { function addActorImage (actor: MActorImages, type: ActorImageType, imageModel: MActorImage) {
const id = imageModel const association = buildAssociationName(type)
? imageModel.id if (!actor[association]) actor[association] = []
: null
if (type === ActorImageType.AVATAR) { actor[association].push(imageModel)
actorModel.avatarId = id
actorModel.Avatar = imageModel
} else {
actorModel.bannerId = id
actorModel.Banner = imageModel
} }
return actorModel 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 { VideoChannelModel } from '@server/models/video/video-channel'
import { MAccount, MAccountDefault, MActor, MActorFullActor, MActorId, MActorImages, MChannel, MServer } from '@server/types/models' import { MAccount, MAccountDefault, MActor, MActorFullActor, MActorId, MActorImages, MChannel, MServer } from '@server/types/models'
import { ActivityPubActor, ActorImageType } from '@shared/models' import { ActivityPubActor, ActorImageType } from '@shared/models'
import { updateActorImageInstance } from '../image' import { updateActorImages } from '../image'
import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImageInfoFromObject } from './object-to-model-attributes' import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImagesInfoFromObject } from './object-to-model-attributes'
import { fetchActorFollowsCount } from './url-to-object' import { fetchActorFollowsCount } from './url-to-object'
export class APActorCreator { export class APActorCreator {
@ -27,11 +27,11 @@ export class APActorCreator {
return sequelizeTypescript.transaction(async t => { return sequelizeTypescript.transaction(async t => {
const server = await this.setServer(actorInstance, 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) 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) await this.tryToFixActorUrlIfNeeded(actorCreated, actorInstance, created, t)
if (actorCreated.type === 'Person' || actorCreated.type === 'Application') { // Account or PeerTube instance 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) { private async setImageIfNeeded (actor: MActor, type: ActorImageType, t: Transaction) {
const imageInfo = getImageInfoFromObject(this.actorObject, type) const imagesInfo = getImagesInfoFromObject(this.actorObject, type)
if (!imageInfo) return 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) { 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 { FilteredModelAttributes } from '@server/types'
import { getLowercaseExtension } from '@shared/core-utils' import { getLowercaseExtension } from '@shared/core-utils'
import { buildUUID } from '@shared/extra-utils' import { buildUUID } from '@shared/extra-utils'
import { ActivityPubActor, ActorImageType } from '@shared/models' import { ActivityIconObject, ActivityPubActor, ActorImageType } from '@shared/models'
function getActorAttributesFromObject ( function getActorAttributesFromObject (
actorObject: ActivityPubActor, actorObject: ActivityPubActor,
@ -30,20 +30,22 @@ function getActorAttributesFromObject (
} }
} }
function getImageInfoFromObject (actorObject: ActivityPubActor, type: ActorImageType) { function getImagesInfoFromObject (actorObject: ActivityPubActor, type: ActorImageType) {
const mimetypes = MIMETYPES.IMAGE const iconsOrImages = type === ActorImageType.AVATAR
const icon = type === ActorImageType.AVATAR ? actorObject.icons || actorObject.icon
? actorObject.icon
: actorObject.image : actorObject.image
if (!icon || icon.type !== 'Image' || !isActivityPubUrlValid(icon.url)) return undefined return normalizeIconOrImage(iconsOrImages).map(iconOrImage => {
const mimetypes = MIMETYPES.IMAGE
if (iconOrImage.type !== 'Image' || !isActivityPubUrlValid(iconOrImage.url)) return undefined
let extension: string let extension: string
if (icon.mediaType) { if (iconOrImage.mediaType) {
extension = mimetypes.MIMETYPE_EXT[icon.mediaType] extension = mimetypes.MIMETYPE_EXT[iconOrImage.mediaType]
} else { } else {
const tmp = getLowercaseExtension(icon.url) const tmp = getLowercaseExtension(iconOrImage.url)
if (mimetypes.EXT_MIMETYPE[tmp] !== undefined) extension = tmp if (mimetypes.EXT_MIMETYPE[tmp] !== undefined) extension = tmp
} }
@ -52,11 +54,12 @@ function getImageInfoFromObject (actorObject: ActivityPubActor, type: ActorImage
return { return {
name: buildUUID() + extension, name: buildUUID() + extension,
fileUrl: icon.url, fileUrl: iconOrImage.url,
height: icon.height, height: iconOrImage.height,
width: icon.width, width: iconOrImage.width,
type type
} }
})
} }
function getActorDisplayNameFromObject (actorObject: ActivityPubActor) { function getActorDisplayNameFromObject (actorObject: ActivityPubActor) {
@ -65,6 +68,15 @@ function getActorDisplayNameFromObject (actorObject: ActivityPubActor) {
export { export {
getActorAttributesFromObject, getActorAttributesFromObject,
getImageInfoFromObject, getImagesInfoFromObject,
getActorDisplayNameFromObject 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 { MAccount, MActor, MActorFull, MChannel } from '@server/types/models'
import { ActivityPubActor, ActorImageType } from '@shared/models' import { ActivityPubActor, ActorImageType } from '@shared/models'
import { getOrCreateAPOwner } from './get' import { getOrCreateAPOwner } from './get'
import { updateActorImageInstance } from './image' import { updateActorImages } from './image'
import { fetchActorFollowsCount } from './shared' import { fetchActorFollowsCount } from './shared'
import { getImageInfoFromObject } from './shared/object-to-model-attributes' import { getImagesInfoFromObject } from './shared/object-to-model-attributes'
export class APActorUpdater { export class APActorUpdater {
@ -29,8 +29,8 @@ export class APActorUpdater {
} }
async update () { async update () {
const avatarInfo = getImageInfoFromObject(this.actorObject, ActorImageType.AVATAR) const avatarsInfo = getImagesInfoFromObject(this.actorObject, ActorImageType.AVATAR)
const bannerInfo = getImageInfoFromObject(this.actorObject, ActorImageType.BANNER) const bannersInfo = getImagesInfoFromObject(this.actorObject, ActorImageType.BANNER)
try { try {
await this.updateActorInstance(this.actor, this.actorObject) await this.updateActorInstance(this.actor, this.actorObject)
@ -47,8 +47,8 @@ export class APActorUpdater {
} }
await runInReadCommittedTransaction(async t => { await runInReadCommittedTransaction(async t => {
await updateActorImageInstance(this.actor, ActorImageType.AVATAR, avatarInfo, t) await updateActorImages(this.actor, ActorImageType.BANNER, bannersInfo, t)
await updateActorImageInstance(this.actor, ActorImageType.BANNER, bannerInfo, t) await updateActorImages(this.actor, ActorImageType.AVATAR, avatarsInfo, t)
}) })
await runInReadCommittedTransaction(async 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 { join } from 'path'
import validator from 'validator' import validator from 'validator'
import { toCompleteUUID } from '@server/helpers/custom-validators/misc' import { toCompleteUUID } from '@server/helpers/custom-validators/misc'
import { ActorImageModel } from '@server/models/actor/actor-image'
import { root } from '@shared/core-utils' import { root } from '@shared/core-utils'
import { escapeHTML } from '@shared/core-utils/renderer' import { escapeHTML } from '@shared/core-utils/renderer'
import { sha256 } from '@shared/extra-utils' import { sha256 } from '@shared/extra-utils'
@ -16,7 +17,6 @@ import { mdToOneLinePlainText } from '../helpers/markdown'
import { CONFIG } from '../initializers/config' import { CONFIG } from '../initializers/config'
import { import {
ACCEPT_HEADERS, ACCEPT_HEADERS,
ACTOR_IMAGES_SIZE,
CUSTOM_HTML_TAG_COMMENTS, CUSTOM_HTML_TAG_COMMENTS,
EMBED_SIZE, EMBED_SIZE,
FILES_CONTENT_HASH, FILES_CONTENT_HASH,
@ -29,6 +29,7 @@ import { VideoModel } from '../models/video/video'
import { VideoChannelModel } from '../models/video/video-channel' import { VideoChannelModel } from '../models/video/video-channel'
import { VideoPlaylistModel } from '../models/video/video-playlist' import { VideoPlaylistModel } from '../models/video/video-playlist'
import { MAccountActor, MChannelActor } from '../types/models' import { MAccountActor, MChannelActor } from '../types/models'
import { getBiggestActorImage } from './actor-image'
import { ServerConfigManager } from './server-config-manager' import { ServerConfigManager } from './server-config-manager'
type Tags = { type Tags = {
@ -273,10 +274,11 @@ class ClientHtml {
const siteName = CONFIG.INSTANCE.NAME const siteName = CONFIG.INSTANCE.NAME
const title = entity.getDisplayName() const title = entity.getDisplayName()
const avatar = getBiggestActorImage(entity.Actor.Avatars)
const image = { const image = {
url: entity.Actor.getAvatarUrl(), url: ActorImageModel.getImageUrl(avatar),
width: ACTOR_IMAGES_SIZE.AVATARS.width, width: avatar?.width,
height: ACTOR_IMAGES_SIZE.AVATARS.height height: avatar?.height
} }
const ogType = 'website' const ogType = 'website'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -121,14 +121,20 @@ export class AccountVideoRateModel extends Model<Partial<AttributesOnly<AccountV
type?: string type?: string
accountId: number accountId: number
}) { }) {
const getQuery = (forCount: boolean) => {
const query: FindOptions = { const query: FindOptions = {
offset: options.start, offset: options.start,
limit: options.count, limit: options.count,
order: getSort(options.sort), order: getSort(options.sort),
where: { where: {
accountId: options.accountId accountId: options.accountId
}, }
include: [ }
if (options.type) query.where['type'] = options.type
if (forCount !== true) {
query.include = [
{ {
model: VideoModel, model: VideoModel,
required: true, required: true,
@ -141,9 +147,14 @@ export class AccountVideoRateModel extends Model<Partial<AttributesOnly<AccountV
} }
] ]
} }
if (options.type) query.where['type'] = options.type
return AccountVideoRateModel.findAndCountAll(query) return query
}
return Promise.all([
AccountVideoRateModel.count(getQuery(true)),
AccountVideoRateModel.findAll(getQuery(false))
]).then(([ total, data ]) => ({ total, data }))
} }
static listRemoteRateUrlsOfLocalVideos () { 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) { static cleanOldRatesOf (videoId: number, type: VideoRateType, beforeUpdatedAt: Date) {

View File

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

View File

@ -1,5 +1,5 @@
import { difference, values } from 'lodash' import { difference, values } from 'lodash'
import { IncludeOptions, Op, QueryTypes, Transaction, WhereOptions } from 'sequelize' import { Includeable, IncludeOptions, literal, Op, QueryTypes, Transaction, WhereOptions } from 'sequelize'
import { import {
AfterCreate, AfterCreate,
AfterDestroy, AfterDestroy,
@ -30,12 +30,12 @@ import {
MActorFollowFormattable, MActorFollowFormattable,
MActorFollowSubscriptions MActorFollowSubscriptions
} from '@server/types/models' } from '@server/types/models'
import { AttributesOnly } from '@shared/typescript-utils'
import { ActivityPubActorType } from '@shared/models' import { ActivityPubActorType } from '@shared/models'
import { AttributesOnly } from '@shared/typescript-utils'
import { FollowState } from '../../../shared/models/actors' import { FollowState } from '../../../shared/models/actors'
import { ActorFollow } from '../../../shared/models/actors/follow.model' import { ActorFollow } from '../../../shared/models/actors/follow.model'
import { logger } from '../../helpers/logger' 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 { AccountModel } from '../account/account'
import { ServerModel } from '../server/server' import { ServerModel } from '../server/server'
import { doesExist } from '../shared/query' import { doesExist } from '../shared/query'
@ -375,7 +375,12 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
Object.assign(followingWhere, { type: actorType }) Object.assign(followingWhere, { type: actorType })
} }
const query = { const getQuery = (forCount: boolean) => {
const actorModel = forCount
? ActorModel.unscoped()
: ActorModel
return {
distinct: true, distinct: true,
offset: start, offset: start,
limit: count, limit: count,
@ -383,7 +388,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
where: followWhere, where: followWhere,
include: [ include: [
{ {
model: ActorModel, model: actorModel,
required: true, required: true,
as: 'ActorFollower', as: 'ActorFollower',
where: { where: {
@ -391,7 +396,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
} }
}, },
{ {
model: ActorModel, model: actorModel,
as: 'ActorFollowing', as: 'ActorFollowing',
required: true, required: true,
where: followingWhere, where: followingWhere,
@ -404,14 +409,12 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
} }
] ]
} }
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: { static listFollowersForApi (options: {
@ -429,11 +432,17 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
const followerWhere: WhereOptions = {} const followerWhere: WhereOptions = {}
if (search) { if (search) {
Object.assign(followWhere, { const escapedSearch = ActorFollowModel.sequelize.escape('%' + search + '%')
[Op.or]: [
searchAttribute(search, '$ActorFollower.preferredUsername$'), Object.assign(followerWhere, {
searchAttribute(search, '$ActorFollower.Server.host$') 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,21 +450,27 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
Object.assign(followerWhere, { type: actorType }) Object.assign(followerWhere, { type: actorType })
} }
const query = { const getQuery = (forCount: boolean) => {
const actorModel = forCount
? ActorModel.unscoped()
: ActorModel
return {
distinct: true, distinct: true,
offset: start, offset: start,
limit: count, limit: count,
order: getFollowsSort(sort), order: getFollowsSort(sort),
where: followWhere, where: followWhere,
include: [ include: [
{ {
model: ActorModel, model: actorModel,
required: true, required: true,
as: 'ActorFollower', as: 'ActorFollower',
where: followerWhere where: followerWhere
}, },
{ {
model: ActorModel, model: actorModel,
as: 'ActorFollowing', as: 'ActorFollowing',
required: true, required: true,
where: { where: {
@ -466,14 +481,12 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
} }
] ]
} }
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: { static listSubscriptionsForApi (options: {
@ -497,24 +510,11 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
}) })
} }
const query = { const getQuery = (forCount: boolean) => {
attributes: [], let channelInclude: Includeable[] = []
distinct: true,
offset: start, if (forCount !== true) {
limit: count, channelInclude = [
order: getSort(sort),
where,
include: [
{
attributes: [ 'id' ],
model: ActorModel.unscoped(),
as: 'ActorFollowing',
required: true,
include: [
{
model: VideoChannelModel.unscoped(),
required: true,
include: [
{ {
attributes: { attributes: {
exclude: unusedActorAttributesForAPI exclude: unusedActorAttributesForAPI
@ -537,18 +537,41 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
} }
] ]
} }
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) return Promise.all([
.then(({ rows, count }) => { ActorFollowModel.count(getQuery(true)),
return { ActorFollowModel.findAll<MActorFollowSubscriptions>(getQuery(false))
data: rows.map(r => r.ActorFollowing.VideoChannel), ]).then(([ total, rows ]) => ({
total: count total,
} data: rows.map(r => r.ActorFollowing.VideoChannel)
}) }))
} }
static async keepUnfollowedInstance (hosts: string[]) { static async keepUnfollowedInstance (hosts: string[]) {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,2 +1,3 @@
export * from './model-builder'
export * from './query' export * from './query'
export * from './update' 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 { ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 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 { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user'
import { uuidToShort } from '@shared/extra-utils' import { uuidToShort } from '@shared/extra-utils'
import { UserNotification, UserNotificationType } from '@shared/models' import { UserNotification, UserNotificationType } from '@shared/models'
@ -7,207 +8,18 @@ import { AttributesOnly } from '@shared/typescript-utils'
import { isBooleanValid } from '../../helpers/custom-validators/misc' import { isBooleanValid } from '../../helpers/custom-validators/misc'
import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications' import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
import { AbuseModel } from '../abuse/abuse' import { AbuseModel } from '../abuse/abuse'
import { VideoAbuseModel } from '../abuse/video-abuse'
import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
import { AccountModel } from '../account/account' import { AccountModel } from '../account/account'
import { ActorModel } from '../actor/actor'
import { ActorFollowModel } from '../actor/actor-follow' import { ActorFollowModel } from '../actor/actor-follow'
import { ActorImageModel } from '../actor/actor-image'
import { ApplicationModel } from '../application/application' import { ApplicationModel } from '../application/application'
import { PluginModel } from '../server/plugin' import { PluginModel } from '../server/plugin'
import { ServerModel } from '../server/server' import { throwIfNotValid } from '../utils'
import { getSort, throwIfNotValid } from '../utils'
import { VideoModel } from '../video/video' import { VideoModel } from '../video/video'
import { VideoBlacklistModel } from '../video/video-blacklist' import { VideoBlacklistModel } from '../video/video-blacklist'
import { VideoChannelModel } from '../video/video-channel'
import { VideoCommentModel } from '../video/video-comment' import { VideoCommentModel } from '../video/video-comment'
import { VideoImportModel } from '../video/video-import' import { VideoImportModel } from '../video/video-import'
import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder'
import { UserModel } from './user' 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({ @Table({
tableName: 'userNotification', tableName: 'userNotification',
indexes: [ indexes: [
@ -342,7 +154,7 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
}, },
onDelete: 'cascade' onDelete: 'cascade'
}) })
Comment: VideoCommentModel VideoComment: VideoCommentModel
@ForeignKey(() => AbuseModel) @ForeignKey(() => AbuseModel)
@Column @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) { static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
const where = { userId } const where = { userId }
const query: FindOptions = { const query = {
userId,
unread,
offset: start, offset: start,
limit: count, limit: count,
order: getSort(sort), sort,
where where,
sequelize: this.sequelize
} }
if (unread !== undefined) query.where['read'] = !unread if (unread !== undefined) query.where['read'] = !unread
@ -445,8 +260,8 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
.then(count => count || 0), .then(count => count || 0),
count === 0 count === 0
? [] ? [] as UserNotificationModelForApi[]
: UserNotificationModel.scope(ScopeNames.WITH_ALL).findAll(query) : new UserNotificationListQueryBuilder(query).listNotifications()
]).then(([ total, data ]) => ({ total, data })) ]).then(([ total, data ]) => ({ total, data }))
} }
@ -524,25 +339,31 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
toFormattedJSON (this: UserNotificationModelForApi): UserNotification { toFormattedJSON (this: UserNotificationModelForApi): UserNotification {
const video = this.Video 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 : undefined
const videoImport = this.VideoImport const videoImport = this.VideoImport
? { ? {
id: this.VideoImport.id, 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, torrentName: this.VideoImport.torrentName,
magnetUri: this.VideoImport.magnetUri, magnetUri: this.VideoImport.magnetUri,
targetUrl: this.VideoImport.targetUrl targetUrl: this.VideoImport.targetUrl
} }
: undefined : undefined
const comment = this.Comment const comment = this.VideoComment
? { ? {
id: this.Comment.id, id: this.VideoComment.id,
threadId: this.Comment.getThreadId(), threadId: this.VideoComment.getThreadId(),
account: this.formatActor(this.Comment.Account), account: this.formatActor(this.VideoComment.Account),
video: this.formatVideo(this.Comment.Video) video: this.formatVideo(this.VideoComment.Video)
} }
: undefined : undefined
@ -570,8 +391,9 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
id: this.ActorFollow.ActorFollower.Account.id, id: this.ActorFollow.ActorFollower.Account.id,
displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(), displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
name: this.ActorFollow.ActorFollower.preferredUsername, 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: { following: {
type: actorFollowingType[this.ActorFollow.ActorFollowing.type], 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 { return {
id: video.id, id: video.id,
uuid: video.uuid, 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 const commentAbuse = abuse.VideoCommentAbuse?.VideoComment
? { ? {
threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(), threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
@ -637,9 +459,13 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
} }
: undefined : 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 { return {
id: abuse.id, id: abuse.id,
@ -651,19 +477,32 @@ export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNoti
} }
formatActor ( formatActor (
this: UserNotificationModelForApi,
accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
) { ) {
const avatar = accountOrChannel.Actor.Avatar
? { path: accountOrChannel.Actor.Avatar.getStaticPath() }
: undefined
return { return {
id: accountOrChannel.id, id: accountOrChannel.id,
displayName: accountOrChannel.getDisplayName(), displayName: accountOrChannel.getDisplayName(),
name: accountOrChannel.Actor.preferredUsername, name: accountOrChannel.Actor.preferredUsername,
host: accountOrChannel.Actor.getHost(), 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: [ include: [
{ {
model: ActorImageModel, model: ActorImageModel,
as: 'Banner', as: 'Banners',
required: false required: false
} }
] ]
@ -495,13 +495,10 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
where where
} }
return UserModel.findAndCountAll(query) return Promise.all([
.then(({ rows, count }) => { UserModel.unscoped().count(query),
return { UserModel.findAll(query)
data: rows, ]).then(([ total, data ]) => ({ total, data }))
total: count
}
})
} }
static listWithRight (right: UserRight): Promise<MUserDefault[]> { static listWithRight (right: UserRight): Promise<MUserDefault[]> {

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 { createSafeIn } from '@server/models/utils'
import { MUserAccountId } from '@server/types/models' import { MUserAccountId } from '@server/types/models'
import { ActorImageType } from '@shared/models'
import validator from 'validator' import validator from 'validator'
import { AbstractRunQuery } from './abstract-run-query' import { AbstractRunQuery } from './abstract-run-query'
import { VideoTableAttributes } from './video-table-attributes' import { VideoTableAttributes } from './video-table-attributes'
@ -42,8 +43,9 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
) )
this.addJoin( this.addJoin(
'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Actor->Avatar" ' + 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Actor->Avatars" ' +
'ON "VideoChannel->Actor"."avatarId" = "VideoChannel->Actor->Avatar"."id"' 'ON "VideoChannel->Actor"."id" = "VideoChannel->Actor->Avatars"."actorId" ' +
`AND "VideoChannel->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
) )
this.attributes = { this.attributes = {
@ -51,7 +53,7 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
...this.buildAttributesObject('VideoChannel', this.tables.getChannelAttributes()), ...this.buildAttributesObject('VideoChannel', this.tables.getChannelAttributes()),
...this.buildActorInclude('VideoChannel->Actor'), ...this.buildActorInclude('VideoChannel->Actor'),
...this.buildAvatarInclude('VideoChannel->Actor->Avatar'), ...this.buildAvatarInclude('VideoChannel->Actor->Avatars'),
...this.buildServerInclude('VideoChannel->Actor->Server') ...this.buildServerInclude('VideoChannel->Actor->Server')
} }
} }
@ -68,8 +70,9 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
) )
this.addJoin( this.addJoin(
'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Account->Actor->Avatar" ' + 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Account->Actor->Avatars" ' +
'ON "VideoChannel->Account->Actor"."avatarId" = "VideoChannel->Account->Actor->Avatar"."id"' 'ON "VideoChannel->Account"."actorId"= "VideoChannel->Account->Actor->Avatars"."actorId" ' +
`AND "VideoChannel->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
) )
this.attributes = { this.attributes = {
@ -77,7 +80,7 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
...this.buildAttributesObject('VideoChannel->Account', this.tables.getAccountAttributes()), ...this.buildAttributesObject('VideoChannel->Account', this.tables.getAccountAttributes()),
...this.buildActorInclude('VideoChannel->Account->Actor'), ...this.buildActorInclude('VideoChannel->Account->Actor'),
...this.buildAvatarInclude('VideoChannel->Account->Actor->Avatar'), ...this.buildAvatarInclude('VideoChannel->Account->Actor->Avatars'),
...this.buildServerInclude('VideoChannel->Account->Actor->Server') ...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 { TrackerModel } from '@server/models/server/tracker'
import { UserVideoHistoryModel } from '@server/models/user/user-video-history' import { UserVideoHistoryModel } from '@server/models/user/user-video-history'
import { VideoInclude } from '@shared/models' import { VideoInclude } from '@shared/models'
import { ScheduleVideoUpdateModel } from '../../schedule-video-update' import { ScheduleVideoUpdateModel } from '../../../schedule-video-update'
import { TagModel } from '../../tag' import { TagModel } from '../../../tag'
import { ThumbnailModel } from '../../thumbnail' import { ThumbnailModel } from '../../../thumbnail'
import { VideoModel } from '../../video' import { VideoModel } from '../../../video'
import { VideoBlacklistModel } from '../../video-blacklist' import { VideoBlacklistModel } from '../../../video-blacklist'
import { VideoChannelModel } from '../../video-channel' import { VideoChannelModel } from '../../../video-channel'
import { VideoFileModel } from '../../video-file' import { VideoFileModel } from '../../../video-file'
import { VideoLiveModel } from '../../video-live' import { VideoLiveModel } from '../../../video-live'
import { VideoStreamingPlaylistModel } from '../../video-streaming-playlist' import { VideoStreamingPlaylistModel } from '../../../video-streaming-playlist'
import { VideoTableAttributes } from './video-table-attributes' import { VideoTableAttributes } from './video-table-attributes'
type SQLRow = { [id: string]: string | number } type SQLRow = { [id: string]: string | number }
@ -34,6 +34,7 @@ export class VideoModelBuilder {
private videoFileMemo: { [ id: number ]: VideoFileModel } private videoFileMemo: { [ id: number ]: VideoFileModel }
private thumbnailsDone: Set<any> private thumbnailsDone: Set<any>
private actorImagesDone: Set<any>
private historyDone: Set<any> private historyDone: Set<any>
private blacklistDone: Set<any> private blacklistDone: Set<any>
private accountBlocklistDone: Set<any> private accountBlocklistDone: Set<any>
@ -69,11 +70,21 @@ export class VideoModelBuilder {
for (const row of rows) { for (const row of rows) {
this.buildVideoAndAccount(row) this.buildVideoAndAccount(row)
const videoModel = this.videosMemo[row.id] const videoModel = this.videosMemo[row.id as number]
this.setUserHistory(row, videoModel) this.setUserHistory(row, videoModel)
this.addThumbnail(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) { if (!rowsWebTorrentFiles) {
this.addWebTorrentFile(row, videoModel) this.addWebTorrentFile(row, videoModel)
} }
@ -113,6 +124,7 @@ export class VideoModelBuilder {
this.videoFileMemo = {} this.videoFileMemo = {}
this.thumbnailsDone = new Set() this.thumbnailsDone = new Set()
this.actorImagesDone = new Set()
this.historyDone = new Set() this.historyDone = new Set()
this.blacklistDone = new Set() this.blacklistDone = new Set()
this.liveDone = new Set() this.liveDone = new Set()
@ -195,13 +207,8 @@ export class VideoModelBuilder {
private buildActor (row: SQLRow, prefix: string) { private buildActor (row: SQLRow, prefix: string) {
const actorPrefix = `${prefix}.Actor` const actorPrefix = `${prefix}.Actor`
const avatarPrefix = `${actorPrefix}.Avatar`
const serverPrefix = `${actorPrefix}.Server` 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 const serverModel = row[`${serverPrefix}.id`] !== null
? new ServerModel(this.grab(row, this.tables.getServerAttributes(), serverPrefix), this.buildOpts) ? new ServerModel(this.grab(row, this.tables.getServerAttributes(), serverPrefix), this.buildOpts)
: null : null
@ -209,8 +216,8 @@ export class VideoModelBuilder {
if (serverModel) serverModel.BlockedBy = [] if (serverModel) serverModel.BlockedBy = []
const actorModel = new ActorModel(this.grab(row, this.tables.getActorAttributes(), actorPrefix), this.buildOpts) const actorModel = new ActorModel(this.grab(row, this.tables.getActorAttributes(), actorPrefix), this.buildOpts)
actorModel.Avatar = avatarModel
actorModel.Server = serverModel actorModel.Server = serverModel
actorModel.Avatars = []
return actorModel return actorModel
} }
@ -226,6 +233,18 @@ export class VideoModelBuilder {
this.historyDone.add(id) 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) { private addThumbnail (row: SQLRow, videoModel: VideoModel) {
const id = row['Thumbnails.id'] const id = row['Thumbnails.id']
if (!id || this.thumbnailsDone.has(id)) return if (!id || this.thumbnailsDone.has(id)) return

View File

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

View File

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

View File

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

View File

@ -155,13 +155,10 @@ export class VideoImportModel extends Model<Partial<AttributesOnly<VideoImportMo
where where
} }
return VideoImportModel.findAndCountAll<MVideoImportDefault>(query) return Promise.all([
.then(({ rows, count }) => { VideoImportModel.unscoped().count(query),
return { VideoImportModel.findAll<MVideoImportDefault>(query)
data: rows, ]).then(([ total, data ]) => ({ total, data }))
total: count
}
})
} }
getTargetIdentifier () { getTargetIdentifier () {

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