Browse Source

Generate small versions of banners too

Chocobozzz 1 month ago
parent
commit
11521f231f
31 changed files with 178 additions and 339 deletions
  1. 7 6
      client/src/app/+about/about-instance/about-instance.component.ts
  2. 14 13
      client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.ts
  3. 17 32
      client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts
  4. 2 2
      client/src/app/helpers/utils/channel.ts
  5. 4 3
      client/src/app/shared/shared-custom-markup/peertube-custom-tags/instance-banner-markup.component.ts
  6. 3 2
      client/src/app/shared/shared-instance/instance-banner.component.ts
  7. 2 1
      client/src/app/shared/shared-main/video-channel/video-channel.model.ts
  8. 4 41
      client/src/assets/player/shared/common/utils.ts
  9. 0 101
      client/src/assets/player/utils.ts
  10. 20 0
      packages/core-utils/src/common/array.ts
  11. 0 0
      packages/tests/fixtures/banner-resized-1920.jpg
  12. BIN
      packages/tests/fixtures/banner-resized-600.jpg
  13. BIN
      packages/tests/fixtures/banner-user-import-resized-1920.jpg
  14. BIN
      packages/tests/fixtures/banner-user-import-resized-600.jpg
  15. 22 15
      packages/tests/src/api/server/config.ts
  16. 1 1
      packages/tests/src/api/users/user-export.ts
  17. 3 1
      packages/tests/src/api/users/user-import.ts
  18. 19 15
      packages/tests/src/api/videos/video-channels.ts
  19. 2 2
      packages/tests/src/shared/checks.ts
  20. 3 3
      packages/tests/src/shared/import-export.ts
  21. 6 7
      server/core/controllers/feeds/shared/common-feed-utils.ts
  22. 7 8
      server/core/controllers/feeds/video-podcast-feeds.ts
  23. 7 3
      server/core/initializers/constants.ts
  24. 11 29
      server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts
  25. 0 14
      server/core/lib/actor-image.ts
  26. 6 7
      server/core/lib/html/shared/actor-html.ts
  27. 4 1
      server/core/lib/user-import-export/exporters/actor-exporter.ts
  28. 7 21
      server/core/models/actor/actor.ts
  29. 2 3
      server/core/models/user/user-notification.ts
  30. 3 5
      server/core/models/video/video.ts
  31. 2 3
      server/scripts/migrations/peertube-4.2.ts

+ 7 - 6
client/src/app/+about/about-instance/about-instance.component.ts

@@ -1,16 +1,17 @@
-import { ViewportScroller, NgIf, NgFor } from '@angular/common'
+import { NgFor, NgIf, ViewportScroller } from '@angular/common'
 import { AfterViewChecked, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
 import { ActivatedRoute, RouterLink } from '@angular/router'
 import { Notifier, ServerService } from '@app/core'
+import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
+import { maxBy } from '@peertube/peertube-core-utils'
 import { HTMLServerConfig, ServerStats } from '@peertube/peertube-models'
 import { copyToClipboard } from '@root-helpers/utils'
+import { CustomMarkupContainerComponent } from '../../shared/shared-custom-markup/custom-markup-container.component'
+import { InstanceFeaturesTableComponent } from '../../shared/shared-instance/instance-features-table.component'
+import { PluginSelectorDirective } from '../../shared/shared-main/plugins/plugin-selector.directive'
 import { ResolverData } from './about-instance.resolver'
 import { ContactAdminModalComponent } from './contact-admin-modal.component'
 import { InstanceStatisticsComponent } from './instance-statistics.component'
-import { InstanceFeaturesTableComponent } from '../../shared/shared-instance/instance-features-table.component'
-import { PluginSelectorDirective } from '../../shared/shared-main/plugins/plugin-selector.directive'
-import { CustomMarkupContainerComponent } from '../../shared/shared-custom-markup/custom-markup-container.component'
-import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
 
 @Component({
   selector: 'my-about-instance',
@@ -82,7 +83,7 @@ export class AboutInstanceComponent implements OnInit, AfterViewChecked {
     this.shortDescription = about.instance.shortDescription
 
     this.instanceBannerUrl = about.instance.banners.length !== 0
-      ? about.instance.banners[0].path
+      ? maxBy(about.instance.banners, 'width').path
       : undefined
 
     this.serverConfig = this.serverService.getHTMLConfig()

+ 14 - 13
client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.ts

@@ -1,22 +1,23 @@
-import { SelectOptionsItem } from 'src/types/select-options-item.model'
+import { NgClass, NgIf } from '@angular/common'
+import { HttpErrorResponse } from '@angular/common/http'
 import { Component, Input, OnInit } from '@angular/core'
 import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { RouterLink } from '@angular/router'
 import { Notifier, ServerService } from '@app/core'
-import { HttpErrorResponse } from '@angular/common/http'
 import { genericUploadErrorHandler } from '@app/helpers'
+import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
+import { InstanceService } from '@app/shared/shared-main/instance/instance.service'
+import { maxBy } from '@peertube/peertube-core-utils'
 import { ActorImage, HTMLServerConfig } from '@peertube/peertube-models'
-import { HelpComponent } from '../../../shared/shared-main/misc/help.component'
-import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
+import { SelectOptionsItem } from 'src/types/select-options-item.model'
+import { ActorAvatarEditComponent } from '../../../shared/shared-actor-image-edit/actor-avatar-edit.component'
+import { ActorBannerEditComponent } from '../../../shared/shared-actor-image-edit/actor-banner-edit.component'
+import { CustomMarkupHelpComponent } from '../../../shared/shared-custom-markup/custom-markup-help.component'
+import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
 import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
-import { RouterLink } from '@angular/router'
 import { SelectCheckboxComponent } from '../../../shared/shared-forms/select/select-checkbox.component'
-import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
-import { CustomMarkupHelpComponent } from '../../../shared/shared-custom-markup/custom-markup-help.component'
-import { NgClass, NgIf } from '@angular/common'
-import { ActorBannerEditComponent } from '../../../shared/shared-actor-image-edit/actor-banner-edit.component'
-import { ActorAvatarEditComponent } from '../../../shared/shared-actor-image-edit/actor-avatar-edit.component'
-import { InstanceService } from '@app/shared/shared-main/instance/instance.service'
-import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
+import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
+import { HelpComponent } from '../../../shared/shared-main/misc/help.component'
 
 @Component({
   selector: 'my-edit-instance-information',
@@ -127,7 +128,7 @@ export class EditInstanceInformationComponent implements OnInit {
   }
 
   private updateActorImages () {
-    this.instanceBannerUrl = this.serverConfig.instance.banners?.[0]?.path
+    this.instanceBannerUrl = maxBy(this.serverConfig.instance.banners, 'width')?.path
     this.instanceAvatars = this.serverConfig.instance.avatars
   }
 

+ 17 - 32
client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts

@@ -1,23 +1,23 @@
-import { ChartData, ChartOptions, TooltipItem, TooltipModel } from 'chart.js'
-import { max, maxBy, min, minBy } from 'lodash-es'
-import { Subject, first, map, switchMap } from 'rxjs'
+import { NgFor, NgIf } from '@angular/common'
 import { Component } from '@angular/core'
-import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, ScreenService } from '@app/core'
+import { RouterLink } from '@angular/router'
+import { AuthService, ComponentPagination, ConfirmService, Notifier, ScreenService, hasMoreItems } from '@app/core'
 import { formatICU } from '@app/helpers'
-import { NumberFormatterPipe } from '../../shared/shared-main/angular/number-formatter.pipe'
+import { VideoChannel } from '@app/shared/shared-main/video-channel/video-channel.model'
+import { VideoChannelService } from '@app/shared/shared-main/video-channel/video-channel.service'
+import { maxBy, minBy } from '@peertube/peertube-core-utils'
+import { ChartData, ChartOptions, TooltipItem, TooltipModel } from 'chart.js'
 import { ChartModule } from 'primeng/chart'
+import { Subject, first, map, switchMap } from 'rxjs'
+import { ActorAvatarComponent } from '../../shared/shared-actor-image/actor-avatar.component'
+import { AdvancedInputFilterComponent } from '../../shared/shared-forms/advanced-input-filter.component'
+import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
 import { DeferLoadingDirective } from '../../shared/shared-main/angular/defer-loading.directive'
+import { InfiniteScrollerDirective } from '../../shared/shared-main/angular/infinite-scroller.directive'
+import { NumberFormatterPipe } from '../../shared/shared-main/angular/number-formatter.pipe'
 import { DeleteButtonComponent } from '../../shared/shared-main/buttons/delete-button.component'
 import { EditButtonComponent } from '../../shared/shared-main/buttons/edit-button.component'
-import { ActorAvatarComponent } from '../../shared/shared-actor-image/actor-avatar.component'
-import { InfiniteScrollerDirective } from '../../shared/shared-main/angular/infinite-scroller.directive'
-import { AdvancedInputFilterComponent } from '../../shared/shared-forms/advanced-input-filter.component'
 import { ChannelsSetupMessageComponent } from '../../shared/shared-main/misc/channels-setup-message.component'
-import { RouterLink } from '@angular/router'
-import { NgIf, NgFor } from '@angular/common'
-import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
-import { VideoChannel } from '@app/shared/shared-main/video-channel/video-channel.model'
-import { VideoChannelService } from '@app/shared/shared-main/video-channel/video-channel.service'
 
 @Component({
   templateUrl: './my-video-channels.component.html',
@@ -156,23 +156,8 @@ export class MyVideoChannelsComponent {
   }
 
   private buildChartOptions () {
-    // chart options that depend on chart data:
-    // we don't want to skew values and have min at 0, so we define what the floor/ceiling is here
-    const videoChannelsMinimumDailyViews = min(
-      // compute local minimum daily views for each channel, by their "views" attribute
-      this.videoChannels.map(v => minBy(
-        v.viewsPerDay,
-        day => day.views
-      ).views) // the object returned is a ViewPerDate, so we still need to get the views attribute
-    )
-
-    const videoChannelsMaximumDailyViews = max(
-      // compute local maximum daily views for each channel, by their "views" attribute
-      this.videoChannels.map(v => maxBy(
-        v.viewsPerDay,
-        day => day.views
-      ).views) // the object returned is a ViewPerDate, so we still need to get the views attribute
-    )
+    const channelsMinimumDailyViews = Math.min(...this.videoChannels.map(v => minBy(v.viewsPerDay, 'views').views))
+    const channelsMaximumDailyViews = Math.max(...this.videoChannels.map(v => maxBy(v.viewsPerDay, 'views').views))
 
     this.chartOptions = {
       plugins: {
@@ -199,8 +184,8 @@ export class MyVideoChannelsComponent {
         },
         y: {
           display: false,
-          min: Math.max(0, videoChannelsMinimumDailyViews - (3 * videoChannelsMaximumDailyViews / 100)),
-          max: Math.max(1, videoChannelsMaximumDailyViews)
+          min: Math.max(0, channelsMinimumDailyViews - (3 * channelsMaximumDailyViews / 100)),
+          max: Math.max(1, channelsMaximumDailyViews)
         }
       },
       layout: {

+ 2 - 2
client/src/app/helpers/utils/channel.ts

@@ -1,7 +1,7 @@
-import { minBy } from 'lodash-es'
+import { minBy } from '@peertube/peertube-core-utils'
+import { VideoChannel } from '@peertube/peertube-models'
 import { first, map } from 'rxjs/operators'
 import { SelectChannelItem } from 'src/types/select-options-item.model'
-import { VideoChannel } from '@peertube/peertube-models'
 import { AuthService } from '../../core/auth'
 
 function listUserChannelsForSelect (authService: AuthService) {

+ 4 - 3
client/src/app/shared/shared-custom-markup/peertube-custom-tags/instance-banner-markup.component.ts

@@ -1,7 +1,8 @@
+import { NgClass, NgIf } from '@angular/common'
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'
-import { CustomMarkupComponent } from './shared'
 import { ServerService } from '@app/core'
-import { NgIf, NgClass } from '@angular/common'
+import { maxBy } from '@peertube/peertube-core-utils'
+import { CustomMarkupComponent } from './shared'
 
 /*
  * Markup component that creates the img HTML element containing the instance banner
@@ -28,7 +29,7 @@ export class InstanceBannerMarkupComponent implements OnInit, CustomMarkupCompon
   ngOnInit () {
     const { instance } = this.server.getHTMLConfig()
 
-    this.instanceBannerUrl = instance.banners?.[0]?.path
+    this.instanceBannerUrl = maxBy(instance.banners, 'width')?.path
     this.cd.markForCheck()
   }
 }

+ 3 - 2
client/src/app/shared/shared-instance/instance-banner.component.ts

@@ -1,6 +1,7 @@
+import { NgClass, NgIf } from '@angular/common'
 import { Component, Input, OnInit, booleanAttribute } from '@angular/core'
 import { ServerService } from '@app/core'
-import { NgIf, NgClass } from '@angular/common'
+import { maxBy } from '@peertube/peertube-core-utils'
 
 @Component({
   selector: 'my-instance-banner',
@@ -20,6 +21,6 @@ export class InstanceBannerComponent implements OnInit {
   ngOnInit () {
     const { instance } = this.server.getHTMLConfig()
 
-    this.instanceBannerUrl = instance.banners?.[0]?.path
+    this.instanceBannerUrl = maxBy(instance.banners, 'width')?.path
   }
 }

+ 2 - 1
client/src/app/shared/shared-main/video-channel/video-channel.model.ts

@@ -1,6 +1,7 @@
 import { getAbsoluteAPIUrl } from '@app/helpers'
 import { Account as ServerAccount, ActorImage, VideoChannel as ServerVideoChannel, ViewsPerDate } from '@peertube/peertube-models'
 import { Actor } from '../account/actor.model'
+import { maxBy } from '@peertube/peertube-core-utils'
 
 export class VideoChannel extends Actor implements ServerVideoChannel {
   displayName: string
@@ -35,7 +36,7 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
       return ''
     }
 
-    const banner = channel.banners[0]
+    const banner = maxBy(channel.banners, 'width')
     if (!banner) return ''
 
     if (banner.url) return banner.url

+ 4 - 41
client/src/assets/player/shared/common/utils.ts

@@ -1,6 +1,4 @@
-import { VideoFile } from '@peertube/peertube-models'
-
-function toTitleCase (str: string) {
+export function toTitleCase (str: string) {
   return str.charAt(0).toUpperCase() + str.slice(1)
 }
 
@@ -10,36 +8,14 @@ const dictionaryBytes = [
   { max: 1073741824, type: 'MB', decimals: 0 },
   { max: 1.0995116e12, type: 'GB', decimals: 1 }
 ]
-function bytes (value: number) {
+export function bytes (value: number) {
   const format = dictionaryBytes.find(d => value < d.max) || dictionaryBytes[dictionaryBytes.length - 1]
   const calc = (value / (format.max / 1024)).toFixed(format.decimals)
 
   return [ calc, format.type ]
 }
 
-function videoFileMaxByResolution (files: VideoFile[]) {
-  let max = files[0]
-
-  for (let i = 1; i < files.length; i++) {
-    const file = files[i]
-    if (max.resolution.id < file.resolution.id) max = file
-  }
-
-  return max
-}
-
-function videoFileMinByResolution (files: VideoFile[]) {
-  let min = files[0]
-
-  for (let i = 1; i < files.length; i++) {
-    const file = files[i]
-    if (min.resolution.id > file.resolution.id) min = file
-  }
-
-  return min
-}
-
-function getRtcConfig () {
+export function getRtcConfig () {
   return {
     iceServers: [
       {
@@ -52,19 +28,6 @@ function getRtcConfig () {
   }
 }
 
-function isSameOrigin (current: string, target: string) {
+export function isSameOrigin (current: string, target: string) {
   return new URL(current).origin === new URL(target).origin
 }
-
-// ---------------------------------------------------------------------------
-
-export {
-  getRtcConfig,
-  toTitleCase,
-
-  videoFileMaxByResolution,
-  videoFileMinByResolution,
-  bytes,
-
-  isSameOrigin
-}

+ 0 - 101
client/src/assets/player/utils.ts

@@ -1,101 +0,0 @@
-import { HTMLServerConfig, Video, VideoFile } from '@peertube/peertube-models'
-
-function toTitleCase (str: string) {
-  return str.charAt(0).toUpperCase() + str.slice(1)
-}
-
-function isWebRTCDisabled () {
-  return !!((window as any).RTCPeerConnection || (window as any).mozRTCPeerConnection || (window as any).webkitRTCPeerConnection) === false
-}
-
-function isP2PEnabled (video: Video, config: HTMLServerConfig, userP2PEnabled: boolean) {
-  if (video.isLocal && config.tracker.enabled === false) return false
-  if (isWebRTCDisabled()) return false
-
-  return userP2PEnabled
-}
-
-function isIOS () {
-  if (/iPad|iPhone|iPod/.test(navigator.platform)) {
-    return true
-  }
-
-  // Detect iPad Desktop mode
-  return !!(navigator.maxTouchPoints &&
-      navigator.maxTouchPoints > 2 &&
-      navigator.platform.includes('MacIntel'))
-}
-
-function isSafari () {
-  return /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
-}
-
-// https://github.com/danrevah/ngx-pipes/blob/master/src/pipes/math/bytes.ts
-// Don't import all Angular stuff, just copy the code with shame
-const dictionaryBytes: { max: number, type: string }[] = [
-  { max: 1024, type: 'B' },
-  { max: 1048576, type: 'KB' },
-  { max: 1073741824, type: 'MB' },
-  { max: 1.0995116e12, type: 'GB' }
-]
-function bytes (value: number) {
-  const format = dictionaryBytes.find(d => value < d.max) || dictionaryBytes[dictionaryBytes.length - 1]
-  const calc = Math.floor(value / (format.max / 1024)).toString()
-
-  return [ calc, format.type ]
-}
-
-function isMobile () {
-  return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
-}
-
-function videoFileMaxByResolution (files: VideoFile[]) {
-  let max = files[0]
-
-  for (let i = 1; i < files.length; i++) {
-    const file = files[i]
-    if (max.resolution.id < file.resolution.id) max = file
-  }
-
-  return max
-}
-
-function videoFileMinByResolution (files: VideoFile[]) {
-  let min = files[0]
-
-  for (let i = 1; i < files.length; i++) {
-    const file = files[i]
-    if (min.resolution.id > file.resolution.id) min = file
-  }
-
-  return min
-}
-
-function getRtcConfig () {
-  return {
-    iceServers: [
-      {
-        urls: 'stun:stun.stunprotocol.org'
-      },
-      {
-        urls: 'stun:stun.framasoft.org'
-      }
-    ]
-  }
-}
-
-// ---------------------------------------------------------------------------
-
-export {
-  getRtcConfig,
-  toTitleCase,
-  isWebRTCDisabled,
-  isP2PEnabled,
-
-  videoFileMaxByResolution,
-  videoFileMinByResolution,
-  isMobile,
-  bytes,
-  isIOS,
-  isSafari
-}

+ 20 - 0
packages/core-utils/src/common/array.ts

@@ -43,3 +43,23 @@ export function sortBy (obj: any[], key1: string, key2?: string) {
     return 1
   })
 }
+
+export function maxBy <T> (arr: T[], property: keyof T) {
+  let result: T
+
+  for (const obj of arr) {
+    if (!result || result[property] < obj[property]) result = obj
+  }
+
+  return result
+}
+
+export function minBy <T> (arr: T[], property: keyof T) {
+  let result: T
+
+  for (const obj of arr) {
+    if (!result || result[property] > obj[property]) result = obj
+  }
+
+  return result
+}

+ 0 - 0
packages/tests/fixtures/banner-resized.jpg → packages/tests/fixtures/banner-resized-1920.jpg


BIN
packages/tests/fixtures/banner-resized-600.jpg


BIN
packages/tests/fixtures/banner-user-import-resized-1920.jpg


BIN
packages/tests/fixtures/banner-user-import-resized-600.jpg


+ 22 - 15
packages/tests/src/api/server/config.ts

@@ -1,19 +1,19 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
-import { expect } from 'chai'
-import { parallelTests } from '@peertube/peertube-node-utils'
 import { ActorImageType, CustomConfig, HttpStatusCode } from '@peertube/peertube-models'
+import { parallelTests } from '@peertube/peertube-node-utils'
 import {
+  PeerTubeServer,
   cleanupTests,
   createSingleServer,
   killallServers,
   makeActivityPubGetRequest,
   makeGetRequest,
   makeRawRequest,
-  PeerTubeServer,
   setAccessTokensToServers
 } from '@peertube/peertube-server-commands'
-import { testFileExistsOrNot, testImage, testAvatarSize } from '@tests/shared/checks.js'
+import { testAvatarSize, testFileExistsOnFSOrNot, testImage } from '@tests/shared/checks.js'
+import { expect } from 'chai'
 import { basename } from 'path'
 
 function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
@@ -703,18 +703,21 @@ describe('Test config', function () {
     }
 
     describe('Banner', function () {
-      let bannerPath: string
+      const bannerPaths: string[] = []
 
       it('Should update instance banner', async function () {
         await server.config.updateInstanceImage({ type: ActorImageType.BANNER, fixture: 'banner.jpg' })
 
         const { banners } = await checkAndGetServerImages()
 
-        expect(banners).to.have.lengthOf(1)
+        expect(banners).to.have.lengthOf(2)
 
-        bannerPath = banners[0].path
-        await testImage(server.url, 'banner-resized', bannerPath)
-        await testFileExistsOrNot(server, 'avatars', basename(bannerPath), true)
+        for (const banner of banners) {
+          await testImage(server.url, `banner-resized-${banner.width}`, banner.path)
+          await testFileExistsOnFSOrNot(server, 'avatars', basename(banner.path), true)
+
+          bannerPaths.push(banner.path)
+        }
       })
 
       it('Should re-update an existing instance banner', async function () {
@@ -727,12 +730,14 @@ describe('Test config', function () {
         const { banners } = await checkAndGetServerImages()
         expect(banners).to.have.lengthOf(0)
 
-        await testFileExistsOrNot(server, 'avatars', basename(bannerPath), false)
+        for (const bannerPath of bannerPaths) {
+          await testFileExistsOnFSOrNot(server, 'avatars', basename(bannerPath), false)
+        }
       })
     })
 
     describe('Avatar', function () {
-      let avatarPath: string
+      const avatarPaths: string[] = []
 
       it('Should update instance avatar', async function () {
         for (const extension of [ '.png', '.gif' ]) {
@@ -744,10 +749,10 @@ describe('Test config', function () {
 
           for (const avatar of avatars) {
             await testAvatarSize({ url: server.url, avatar, imageName: `avatar-resized-${avatar.width}x${avatar.width}` })
-          }
+            await testFileExistsOnFSOrNot(server, 'avatars', basename(avatar.path), true)
 
-          avatarPath = avatars[0].path
-          await testFileExistsOrNot(server, 'avatars', basename(avatarPath), true)
+            avatarPaths.push(avatar.path)
+          }
         }
       })
 
@@ -768,7 +773,9 @@ describe('Test config', function () {
         const { avatars } = await checkAndGetServerImages()
         expect(avatars).to.have.lengthOf(0)
 
-        await testFileExistsOrNot(server, 'avatars', basename(avatarPath), false)
+        for (const avatarPath of avatarPaths) {
+          await testFileExistsOnFSOrNot(server, 'avatars', basename(avatarPath), false)
+        }
       })
 
       it('Should not have the avatars anymore in the AP representation of the instance', async function () {

+ 1 - 1
packages/tests/src/api/users/user-export.ts

@@ -444,7 +444,7 @@ function runTest (withObjectStorage: boolean) {
         expect(secondaryChannel.support).to.equal('noah support')
 
         expect(secondaryChannel.avatars).to.have.lengthOf(4)
-        expect(secondaryChannel.banners).to.have.lengthOf(1)
+        expect(secondaryChannel.banners).to.have.lengthOf(2)
 
         const urls = [ ...secondaryChannel.avatars, ...secondaryChannel.banners ].map(a => a.url)
         for (const url of urls) {

+ 3 - 1
packages/tests/src/api/users/user-import.ts

@@ -198,7 +198,9 @@ function runTest (withObjectStorage: boolean) {
     expect(importedSecond.description).to.equal('noah description')
     expect(importedSecond.support).to.equal('noah support')
 
-    await testImage(remoteServer.url, 'banner-resized', importedSecond.banners[0].path)
+    for (const banner of importedSecond.banners) {
+      await testImage(remoteServer.url, `banner-user-import-resized-${banner.width}`, banner.path)
+    }
 
     for (const avatar of importedSecond.avatars) {
       await testImage(remoteServer.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatar.path, '.png')

+ 19 - 15
packages/tests/src/api/videos/video-channels.ts

@@ -1,22 +1,22 @@
 /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 
-import { expect } from 'chai'
-import { basename } from 'path'
-import { ACTOR_IMAGES_SIZE } from '@peertube/peertube-server/core/initializers/constants.js'
-import { testFileExistsOrNot, testImage } from '@tests/shared/checks.js'
-import { SQLCommand } from '@tests/shared/sql-command.js'
 import { wait } from '@peertube/peertube-core-utils'
 import { ActorImageType, User, VideoChannel } from '@peertube/peertube-models'
 import {
+  PeerTubeServer,
   cleanupTests,
   createMultipleServers,
   doubleFollow,
-  PeerTubeServer,
   setAccessTokensToServers,
   setDefaultAccountAvatar,
   setDefaultVideoChannel,
   waitJobs
 } from '@peertube/peertube-server-commands'
+import { ACTOR_IMAGES_SIZE } from '@peertube/peertube-server/core/initializers/constants.js'
+import { testFileExistsOnFSOrNot, testImage } from '@tests/shared/checks.js'
+import { SQLCommand } from '@tests/shared/sql-command.js'
+import { expect } from 'chai'
+import { basename } from 'path'
 
 async function findChannel (server: PeerTubeServer, channelId: number) {
   const body = await server.channels.list({ sort: '-name' })
@@ -294,7 +294,7 @@ describe('Test video channels', function () {
       for (const avatar of videoChannel.avatars) {
         avatarPaths[server.port] = avatar.path
         await testImage(server.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatarPaths[server.port], '.png')
-        await testFileExistsOrNot(server, 'avatars', basename(avatarPaths[server.port]), true)
+        await testFileExistsOnFSOrNot(server, 'avatars', basename(avatarPaths[server.port]), true)
 
         const row = await sqlCommands[i].getActorImage(basename(avatarPaths[server.port]))
 
@@ -320,14 +320,18 @@ describe('Test video channels', function () {
       const server = servers[i]
 
       const videoChannel = await server.channels.get({ channelName: 'second_video_channel@' + servers[0].host })
+      const expectedSizes = ACTOR_IMAGES_SIZE[ActorImageType.BANNER]
 
-      bannerPaths[server.port] = videoChannel.banners[0].path
-      await testImage(server.url, 'banner-resized', bannerPaths[server.port])
-      await testFileExistsOrNot(server, 'avatars', basename(bannerPaths[server.port]), true)
+      expect(videoChannel.banners.length).to.equal(expectedSizes.length, 'Expected banners to be generated in all sizes')
 
-      const row = await sqlCommands[i].getActorImage(basename(bannerPaths[server.port]))
-      expect(row.height).to.equal(ACTOR_IMAGES_SIZE[ActorImageType.BANNER][0].height)
-      expect(row.width).to.equal(ACTOR_IMAGES_SIZE[ActorImageType.BANNER][0].width)
+      for (const banner of videoChannel.banners) {
+        bannerPaths[server.port] = banner.path
+        await testImage(server.url, `banner-resized-${banner.width}`, bannerPaths[server.port])
+        await testFileExistsOnFSOrNot(server, 'avatars', basename(bannerPaths[server.port]), true)
+
+        const row = await sqlCommands[i].getActorImage(basename(bannerPaths[server.port]))
+        expect(expectedSizes.some(({ height, width }) => row.height === height && row.width === width)).to.equal(true)
+      }
     }
   })
 
@@ -357,7 +361,7 @@ describe('Test video channels', function () {
 
     for (const server of servers) {
       const videoChannel = await findChannel(server, secondVideoChannelId)
-      await testFileExistsOrNot(server, 'avatars', basename(avatarPaths[server.port]), false)
+      await testFileExistsOnFSOrNot(server, 'avatars', basename(avatarPaths[server.port]), false)
 
       expect(videoChannel.avatars).to.be.empty
     }
@@ -372,7 +376,7 @@ describe('Test video channels', function () {
 
     for (const server of servers) {
       const videoChannel = await findChannel(server, secondVideoChannelId)
-      await testFileExistsOrNot(server, 'avatars', basename(bannerPaths[server.port]), false)
+      await testFileExistsOnFSOrNot(server, 'avatars', basename(bannerPaths[server.port]), false)
 
       expect(videoChannel.banners).to.be.empty
     }

+ 2 - 2
packages/tests/src/shared/checks.ts

@@ -114,7 +114,7 @@ async function testImage (url: string, imageName: string, imageHTTPPath: string,
   }
 }
 
-async function testFileExistsOrNot (server: PeerTubeServer, directory: string, filePath: string, exist: boolean) {
+async function testFileExistsOnFSOrNot (server: PeerTubeServer, directory: string, filePath: string, exist: boolean) {
   const base = server.servers.buildDirectory(directory)
 
   expect(await pathExists(join(base, filePath))).to.equal(exist)
@@ -182,7 +182,7 @@ export {
   testAvatarSize,
   testImage,
   expectLogDoesNotContain,
-  testFileExistsOrNot,
+  testFileExistsOnFSOrNot,
   expectStartWith,
   expectNotStartWith,
   expectEndWith,

+ 3 - 3
packages/tests/src/shared/import-export.ts

@@ -27,7 +27,7 @@ import { resolve } from 'path'
 import { MockSmtpServer } from './mock-servers/mock-email.js'
 import { getAllNotificationsSettings } from './notifications.js'
 import { getFilenameFromUrl } from '@peertube/peertube-node-utils'
-import { testFileExistsOrNot } from './checks.js'
+import { testFileExistsOnFSOrNot } from './checks.js'
 
 type ExportOutbox = ActivityPubOrderedCollection<ActivityCreate<VideoObject | VideoCommentObject>>
 
@@ -101,10 +101,10 @@ export async function checkExportFileExists (options: {
       return makeRawRequest({ url: redirectedUrl, expectedStatus: HttpStatusCode.OK_200 })
     }
 
-    return testFileExistsOrNot(server, 'tmp-persistent', filename, true)
+    return testFileExistsOnFSOrNot(server, 'tmp-persistent', filename, true)
   }
 
-  await testFileExistsOrNot(server, 'tmp-persistent', filename, false)
+  await testFileExistsOnFSOrNot(server, 'tmp-persistent', filename, false)
 
   if (withObjectStorage) {
     await makeRawRequest({ url: redirectedUrl, expectedStatus: HttpStatusCode.NOT_FOUND_404 })

+ 6 - 7
server/core/controllers/feeds/shared/common-feed-utils.ts

@@ -1,14 +1,13 @@
-import express from 'express'
 import { Feed } from '@peertube/feed'
 import { CustomTag, CustomXMLNS, Person } from '@peertube/feed/lib/typings/index.js'
+import { maxBy, pick } from '@peertube/peertube-core-utils'
+import { ActorImageType } from '@peertube/peertube-models'
 import { mdToOneLinePlainText } from '@server/helpers/markdown.js'
 import { CONFIG } from '@server/initializers/config.js'
 import { WEBSERVER } from '@server/initializers/constants.js'
-import { getBiggestActorImage } from '@server/lib/actor-image.js'
 import { UserModel } from '@server/models/user/user.js'
 import { MAccountDefault, MChannelBannerAccountDefault, MUser, MVideoFullLight } from '@server/types/models/index.js'
-import { pick } from '@peertube/peertube-core-utils'
-import { ActorImageType } from '@peertube/peertube-models'
+import express from 'express'
 
 export function initFeed (parameters: {
   name: string
@@ -105,12 +104,12 @@ export async function buildFeedMetadata (options: {
     accountLink = videoChannel.Account.getClientUrl()
 
     if (videoChannel.Actor.hasImage(ActorImageType.AVATAR)) {
-      const videoChannelAvatar = getBiggestActorImage(videoChannel.Actor.Avatars)
+      const videoChannelAvatar = maxBy(videoChannel.Actor.Avatars, 'width')
       imageUrl = WEBSERVER.URL + videoChannelAvatar.getStaticPath()
     }
 
     if (videoChannel.Account.Actor.hasImage(ActorImageType.AVATAR)) {
-      const accountAvatar = getBiggestActorImage(videoChannel.Account.Actor.Avatars)
+      const accountAvatar = maxBy(videoChannel.Account.Actor.Avatars, 'width')
       accountImageUrl = WEBSERVER.URL + accountAvatar.getStaticPath()
     }
 
@@ -123,7 +122,7 @@ export async function buildFeedMetadata (options: {
     accountLink = link
 
     if (account.Actor.hasImage(ActorImageType.AVATAR)) {
-      const accountAvatar = getBiggestActorImage(account.Actor.Avatars)
+      const accountAvatar = maxBy(account.Actor.Avatars, 'width')
       imageUrl = WEBSERVER.URL + accountAvatar?.getStaticPath()
       accountImageUrl = imageUrl
     }

+ 7 - 8
server/core/controllers/feeds/video-podcast-feeds.ts

@@ -1,21 +1,20 @@
-import express from 'express'
-import { extname } from 'path'
 import { Feed } from '@peertube/feed'
 import { CustomTag, CustomXMLNS, LiveItemStatus } from '@peertube/feed/lib/typings/index.js'
-import { getBiggestActorImage } from '@server/lib/actor-image.js'
+import { maxBy, sortObjectComparator } from '@peertube/peertube-core-utils'
+import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@peertube/peertube-models'
 import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
 import { Hooks } from '@server/lib/plugins/hooks.js'
+import { getVideoFileMimeType } from '@server/lib/video-file.js'
 import { buildPodcastGroupsCache, cacheRouteFactory, videoFeedsPodcastSetCacheKey } from '@server/middlewares/index.js'
 import { MVideo, MVideoCaptionVideo, MVideoFullLight } from '@server/types/models/index.js'
-import { sortObjectComparator } from '@peertube/peertube-core-utils'
-import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@peertube/peertube-models'
+import express from 'express'
+import { extname } from 'path'
 import { buildNSFWFilter } from '../../helpers/express-utils.js'
 import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants.js'
 import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares/index.js'
-import { VideoModel } from '../../models/video/video.js'
 import { VideoCaptionModel } from '../../models/video/video-caption.js'
+import { VideoModel } from '../../models/video/video.js'
 import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed } from './shared/index.js'
-import { getVideoFileMimeType } from '@server/lib/video-file.js'
 
 const videoPodcastFeedsRouter = express.Router()
 
@@ -151,7 +150,7 @@ async function generatePodcastItem (options: {
   let personImage: string
 
   if (account.Actor.hasImage(ActorImageType.AVATAR)) {
-    const avatar = getBiggestActorImage(account.Actor.Avatars)
+    const avatar = maxBy(account.Actor.Avatars, 'width')
     personImage = WEBSERVER.URL + avatar.getStaticPath()
   }
 

+ 7 - 3
server/core/initializers/constants.ts

@@ -898,7 +898,7 @@ const PREVIEWS_SIZE = {
   minWidth: 400
 }
 const ACTOR_IMAGES_SIZE: { [key in ActorImageType_Type]: { width: number, height: number }[] } = {
-  [ActorImageType.AVATAR]: [
+  [ActorImageType.AVATAR]: [ // 1/1 ratio
     {
       width: 1500,
       height: 1500
@@ -916,10 +916,14 @@ const ACTOR_IMAGES_SIZE: { [key in ActorImageType_Type]: { width: number, height
       height: 48
     }
   ],
-  [ActorImageType.BANNER]: [
+  [ActorImageType.BANNER]: [ // 6/1 ratio
     {
       width: 1920,
-      height: 317 // 6/1 ratio
+      height: 317
+    },
+    {
+      width: 600,
+      height: 100
     }
   ]
 }

+ 11 - 29
server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts

@@ -1,4 +1,4 @@
-import { arrayify } from '@peertube/peertube-core-utils'
+import { arrayify, maxBy, minBy } from '@peertube/peertube-core-utils'
 import {
   ActivityHashTagObject,
   ActivityMagnetUrlObject,
@@ -24,13 +24,11 @@ import { VideoFileModel } from '@server/models/video/video-file.js'
 import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist.js'
 import { FilteredModelAttributes } from '@server/types/index.js'
 import { MChannelId, MStreamingPlaylistVideo, MVideo, MVideoId, isStreamingPlaylist } from '@server/types/models/index.js'
-import maxBy from 'lodash-es/maxBy.js'
-import minBy from 'lodash-es/minBy.js'
 import { decode as magnetUriDecode } from 'magnet-uri'
 import { basename, extname } from 'path'
 import { getDurationFromActivityStream } from '../../activity.js'
 
-function getThumbnailFromIcons (videoObject: VideoObject) {
+export function getThumbnailFromIcons (videoObject: VideoObject) {
   let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth)
   // Fallback if there are not valid icons
   if (validIcons.length === 0) validIcons = videoObject.icon
@@ -38,19 +36,19 @@ function getThumbnailFromIcons (videoObject: VideoObject) {
   return minBy(validIcons, 'width')
 }
 
-function getPreviewFromIcons (videoObject: VideoObject) {
+export function getPreviewFromIcons (videoObject: VideoObject) {
   const validIcons = videoObject.icon.filter(i => i.width > PREVIEWS_SIZE.minWidth)
 
   return maxBy(validIcons, 'width')
 }
 
-function getTagsFromObject (videoObject: VideoObject) {
+export function getTagsFromObject (videoObject: VideoObject) {
   return videoObject.tag
     .filter(isAPHashTagObject)
     .map(t => t.name)
 }
 
-function getFileAttributesFromUrl (
+export function getFileAttributesFromUrl (
   videoOrPlaylist: MVideo | MStreamingPlaylistVideo,
   urls: (ActivityTagObject | ActivityUrlObject)[]
 ) {
@@ -117,7 +115,7 @@ function getFileAttributesFromUrl (
   return attributes
 }
 
-function getStreamingPlaylistAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
+export function getStreamingPlaylistAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
   const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[]
   if (playlistUrls.length === 0) return []
 
@@ -154,7 +152,7 @@ function getStreamingPlaylistAttributesFromObject (video: MVideoId, videoObject:
   return attributes
 }
 
-function getLiveAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
+export function getLiveAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
   return {
     saveReplay: videoObject.liveSaveReplay,
     permanentLive: videoObject.permanentLive,
@@ -163,7 +161,7 @@ function getLiveAttributesFromObject (video: MVideoId, videoObject: VideoObject)
   }
 }
 
-function getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
+export function getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
   return videoObject.subtitleLanguage.map(c => ({
     videoId: video.id,
     filename: VideoCaptionModel.generateCaptionName(c.identifier),
@@ -172,7 +170,7 @@ function getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObje
   }))
 }
 
-function getStoryboardAttributeFromObject (video: MVideoId, videoObject: VideoObject) {
+export function getStoryboardAttributeFromObject (video: MVideoId, videoObject: VideoObject) {
   if (!isArray(videoObject.preview)) return undefined
 
   const storyboard = videoObject.preview.find(p => p.rel.includes('storyboard'))
@@ -192,7 +190,7 @@ function getStoryboardAttributeFromObject (video: MVideoId, videoObject: VideoOb
   }
 }
 
-function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) {
+export function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) {
   const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
     ? VideoPrivacy.PUBLIC
     : VideoPrivacy.UNLISTED
@@ -247,23 +245,7 @@ function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: Vi
 }
 
 // ---------------------------------------------------------------------------
-
-export {
-  getThumbnailFromIcons,
-  getPreviewFromIcons,
-
-  getTagsFromObject,
-
-  getFileAttributesFromUrl,
-  getStreamingPlaylistAttributesFromObject,
-
-  getLiveAttributesFromObject,
-  getCaptionAttributesFromObject,
-  getStoryboardAttributeFromObject,
-
-  getVideoAttributesFromObject
-}
-
+// Private
 // ---------------------------------------------------------------------------
 
 function isAPVideoUrlObject (url: any): url is ActivityVideoUrlObject {

+ 0 - 14
server/core/lib/actor-image.ts

@@ -1,14 +0,0 @@
-import maxBy from 'lodash-es/maxBy.js'
-
-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
-}

+ 6 - 7
server/core/lib/html/shared/actor-html.ts

@@ -1,14 +1,13 @@
-import { escapeHTML } from '@peertube/peertube-core-utils'
+import { escapeHTML, maxBy } from '@peertube/peertube-core-utils'
 import { HttpStatusCode } from '@peertube/peertube-models'
-import express from 'express'
-import { CONFIG } from '../../../initializers/config.js'
 import { AccountModel } from '@server/models/account/account.js'
+import { ActorImageModel } from '@server/models/actor/actor-image.js'
 import { VideoChannelModel } from '@server/models/video/video-channel.js'
 import { MAccountHost, MChannelHost } from '@server/types/models/index.js'
-import { getBiggestActorImage } from '@server/lib/actor-image.js'
-import { ActorImageModel } from '@server/models/actor/actor-image.js'
-import { TagsHtml } from './tags-html.js'
+import express from 'express'
+import { CONFIG } from '../../../initializers/config.js'
 import { PageHtml } from './page-html.js'
+import { TagsHtml } from './tags-html.js'
 
 export class ActorHtml {
 
@@ -60,7 +59,7 @@ export class ActorHtml {
     const siteName = CONFIG.INSTANCE.NAME
     const title = entity.getDisplayName()
 
-    const avatar = getBiggestActorImage(entity.Actor.Avatars)
+    const avatar = maxBy(entity.Actor.Avatars, 'width')
     const image = {
       url: ActorImageModel.getImageUrl(avatar),
       width: avatar?.width,

+ 4 - 1
server/core/lib/user-import-export/exporters/actor-exporter.ts

@@ -13,7 +13,10 @@ export abstract class ActorExporter <T> extends AbstractUserExporter<T> {
 
       name: actor.preferredUsername,
 
-      avatars: this.exportActorImageJSON(actor.Avatars),
+      avatars: actor.hasImage(ActorImageType.AVATAR)
+        ? this.exportActorImageJSON(actor.Avatars)
+        : [],
+
       banners: actor.hasImage(ActorImageType.BANNER)
         ? this.exportActorImageJSON(actor.Banners)
         : []

+ 7 - 21
server/core/models/actor/actor.ts

@@ -1,12 +1,10 @@
-import { forceNumber } from '@peertube/peertube-core-utils'
+import { forceNumber, maxBy } from '@peertube/peertube-core-utils'
 import { ActivityIconObject, ActorImageType, ActorImageType_Type, type ActivityPubActorType } from '@peertube/peertube-models'
-import { getLowercaseExtension } from '@peertube/peertube-node-utils'
 import { AttributesOnly } from '@peertube/peertube-typescript-utils'
 import { activityPubContextify } from '@server/helpers/activity-pub-utils.js'
 import { getContextFilter } from '@server/lib/activitypub/context.js'
-import { getBiggestActorImage } from '@server/lib/actor-image.js'
 import { ModelCache } from '@server/models/shared/model-cache.js'
-import { col, fn, literal, Op, QueryTypes, Transaction, where } from 'sequelize'
+import { Op, QueryTypes, Transaction, col, fn, literal, where } from 'sequelize'
 import {
   AllowNull,
   BelongsTo,
@@ -33,16 +31,14 @@ import { isActivityPubUrlValid } from '../../helpers/custom-validators/activityp
 import {
   ACTIVITY_PUB,
   ACTIVITY_PUB_ACTOR_TYPES,
-  CONSTRAINTS_FIELDS,
-  MIMETYPES,
-  SERVER_ACTOR_NAME,
+  CONSTRAINTS_FIELDS, SERVER_ACTOR_NAME,
   WEBSERVER
 } from '../../initializers/constants.js'
 import {
   MActor,
-  MActorAccountChannelId,
   MActorAPAccount,
   MActorAPChannel,
+  MActorAccountChannelId,
   MActorFollowersUrl,
   MActorFormattable,
   MActorFull,
@@ -61,7 +57,6 @@ import { VideoChannelModel } from '../video/video-channel.js'
 import { VideoModel } from '../video/video.js'
 import { ActorFollowModel } from './actor-follow.js'
 import { ActorImageModel } from './actor-image.js'
-import maxBy from 'lodash-es/maxBy.js'
 
 enum ScopeNames {
   FULL = 'FULL'
@@ -562,24 +557,15 @@ export class ActorModel extends SequelizeModel<ActorModel> {
   }
 
   toActivityPubObject (this: MActorAPChannel | MActorAPAccount, name: string) {
-    let icon: ActivityIconObject[]
-    let image: ActivityIconObject
+    let icon: ActivityIconObject[] // Avatars
+    let image: ActivityIconObject[] // Banners
 
     if (this.hasImage(ActorImageType.AVATAR)) {
       icon = this.Avatars.map(a => a.toActivityPubObject())
     }
 
     if (this.hasImage(ActorImageType.BANNER)) {
-      const banner = getBiggestActorImage((this as MActorAPChannel).Banners)
-      const extension = getLowercaseExtension(banner.filename)
-
-      image = {
-        type: 'Image',
-        mediaType: MIMETYPES.IMAGE.EXT_MIMETYPE[extension],
-        height: banner.height,
-        width: banner.width,
-        url: ActorImageModel.getImageUrl(banner)
-      }
+      image = (this as MActorAPChannel).Banners.map(b => b.toActivityPubObject())
     }
 
     const json = {

+ 2 - 3
server/core/models/user/user-notification.ts

@@ -1,7 +1,6 @@
-import { forceNumber } from '@peertube/peertube-core-utils'
+import { forceNumber, maxBy } from '@peertube/peertube-core-utils'
 import { UserNotification, type UserNotificationType_Type } from '@peertube/peertube-models'
 import { uuidToShort } from '@peertube/peertube-node-utils'
-import { getBiggestActorImage } from '@server/lib/actor-image.js'
 import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user/index.js'
 import { ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
 import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Table, UpdatedAt } from 'sequelize-typescript'
@@ -518,7 +517,7 @@ export class UserNotificationModel extends SequelizeModel<UserNotificationModel>
     if (!avatars || avatars.length === 0) return { avatar: undefined, avatars: [] }
 
     return {
-      avatar: this.formatAvatar(getBiggestActorImage(avatars)),
+      avatar: this.formatAvatar(maxBy(avatars, 'width')),
 
       avatars: avatars.map(a => this.formatAvatar(a))
     }

+ 3 - 5
server/core/models/video/video.ts

@@ -1,4 +1,4 @@
-import { buildVideoEmbedPath, buildVideoWatchPath, pick, wait } from '@peertube/peertube-core-utils'
+import { buildVideoEmbedPath, buildVideoWatchPath, maxBy, minBy, pick, wait } from '@peertube/peertube-core-utils'
 import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream } from '@peertube/peertube-ffmpeg'
 import {
   FileStorage,
@@ -38,8 +38,6 @@ import { ModelCache } from '@server/models/shared/model-cache.js'
 import { MVideoSource } from '@server/types/models/video/video-source.js'
 import Bluebird from 'bluebird'
 import { remove } from 'fs-extra/esm'
-import maxBy from 'lodash-es/maxBy.js'
-import minBy from 'lodash-es/minBy.js'
 import { FindOptions, IncludeOptions, Includeable, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
 import {
   AfterCreate,
@@ -1711,9 +1709,9 @@ export class VideoModel extends SequelizeModel<VideoModel> {
     return this.VideoChannel.Account.Actor.Server?.isBlocked() || this.VideoChannel.Account.isBlocked()
   }
 
-  getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) {
+  getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], property: 'resolution') => MVideoFile) {
     const files = this.getAllFiles()
-    const file = fun(files, file => file.resolution)
+    const file = fun(files, 'resolution')
     if (!file) return undefined
 
     if (file.videoId) {

+ 2 - 3
server/scripts/migrations/peertube-4.2.ts

@@ -1,3 +1,4 @@
+import { maxBy, minBy } from '@peertube/peertube-core-utils'
 import { ActorImageType } from '@peertube/peertube-models'
 import { buildUUID, getLowercaseExtension } from '@peertube/peertube-node-utils'
 import { getImageSize, processImage } from '@server/helpers/image-utils.js'
@@ -6,13 +7,11 @@ import { ACTOR_IMAGES_SIZE } from '@server/initializers/constants.js'
 import { initDatabaseModels } from '@server/initializers/database.js'
 import { updateActorImages } from '@server/lib/activitypub/actors/index.js'
 import { sendUpdateActor } from '@server/lib/activitypub/send/index.js'
-import { getBiggestActorImage } from '@server/lib/actor-image.js'
 import { JobQueue } from '@server/lib/job-queue/index.js'
 import { AccountModel } from '@server/models/account/account.js'
 import { ActorModel } from '@server/models/actor/actor.js'
 import { VideoChannelModel } from '@server/models/video/video-channel.js'
 import { MAccountDefault, MActorDefault, MChannelDefault } from '@server/types/models/index.js'
-import minBy from 'lodash-es/minBy.js'
 import { join } from 'path'
 
 run()
@@ -100,7 +99,7 @@ async function generateSmallerAvatarIfNeeded (accountOrChannel: MAccountDefault
 }
 
 async function generateSmallerAvatar (actor: MActorDefault) {
-  const bigAvatar = getBiggestActorImage(actor.Avatars)
+  const bigAvatar = maxBy(actor.Avatars, 'width')
 
   const imageSize = minBy(ACTOR_IMAGES_SIZE[ActorImageType.AVATAR], 'width')
   const sourceFilename = bigAvatar.filename