Forráskód Böngészése

Automatically update playlist thumbnails

Chocobozzz 4 éve
szülő
commit
65af03a241

+ 25 - 10
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts

@@ -63,24 +63,26 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
 
     if (oldPosition > insertAfter) insertAfter--
 
-    this.videoPlaylistService.reorderPlaylist(this.playlist.id, oldPosition, insertAfter)
-      .subscribe(
-        () => { /* nothing to do */ },
-
-        err => this.notifier.error(err.message)
-      )
-
     const element = this.playlistElements[previousIndex]
 
     this.playlistElements.splice(previousIndex, 1)
     this.playlistElements.splice(newIndex, 0, element)
 
-    this.reorderClientPositions()
+    this.videoPlaylistService.reorderPlaylist(this.playlist.id, oldPosition, insertAfter)
+      .subscribe(
+        () => {
+          this.reorderClientPositions()
+        },
+
+        err => this.notifier.error(err.message)
+      )
   }
 
   onElementRemoved (element: VideoPlaylistElement) {
+    const oldFirst = this.findFirst()
+
     this.playlistElements = this.playlistElements.filter(v => v.id !== element.id)
-    this.reorderClientPositions()
+    this.reorderClientPositions(oldFirst)
   }
 
   onNearOfBottom () {
@@ -110,12 +112,25 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
       })
   }
 
-  private reorderClientPositions () {
+  private reorderClientPositions (first?: VideoPlaylistElement) {
+    if (this.playlistElements.length === 0) return
+
+    const oldFirst = first || this.findFirst()
     let i = 1
 
     for (const element of this.playlistElements) {
       element.position = i
       i++
     }
+
+    // Reload playlist thumbnail if the first element changed
+    const newFirst = this.findFirst()
+    if (oldFirst && newFirst && oldFirst.id !== newFirst.id) {
+      this.playlist.refreshThumbnail()
+    }
+  }
+
+  private findFirst () {
+    return this.playlistElements.find(e => e.position === 1)
   }
 }

+ 13 - 0
client/src/app/shared/video-playlist/video-playlist.model.ts

@@ -38,6 +38,9 @@ export class VideoPlaylist implements ServerVideoPlaylist {
   videoChannelBy?: string
   videoChannelAvatarUrl?: string
 
+  private thumbnailVersion: number
+  private originThumbnailUrl: string
+
   constructor (hash: ServerVideoPlaylist, translations: {}) {
     const absoluteAPIUrl = getAbsoluteAPIUrl()
 
@@ -54,6 +57,7 @@ export class VideoPlaylist implements ServerVideoPlaylist {
 
     if (this.thumbnailPath) {
       this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath
+      this.originThumbnailUrl = this.thumbnailUrl
     } else {
       this.thumbnailUrl = window.location.origin + '/client/assets/images/default-playlist.jpg'
     }
@@ -81,4 +85,13 @@ export class VideoPlaylist implements ServerVideoPlaylist {
       this.displayName = peertubeTranslate(this.displayName, translations)
     }
   }
+
+  refreshThumbnail () {
+    if (!this.originThumbnailUrl) return
+
+    if (!this.thumbnailVersion) this.thumbnailVersion = 0
+    this.thumbnailVersion++
+
+    this.thumbnailUrl = this.originThumbnailUrl + '?v' + this.thumbnailVersion
+  }
 }

+ 49 - 18
server/controllers/api/video-playlist.ts

@@ -40,6 +40,7 @@ import { JobQueue } from '../../lib/job-queue'
 import { CONFIG } from '../../initializers/config'
 import { sequelizeTypescript } from '../../initializers/database'
 import { createPlaylistMiniatureFromExisting } from '../../lib/thumbnail'
+import { VideoModel } from '../../models/video/video'
 
 const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR })
 
@@ -171,13 +172,16 @@ async function addVideoPlaylist (req: express.Request, res: express.Response) {
 
   const thumbnailField = req.files['thumbnailfile']
   const thumbnailModel = thumbnailField
-    ? await createPlaylistMiniatureFromExisting(thumbnailField[0].path, videoPlaylist)
+    ? await createPlaylistMiniatureFromExisting(thumbnailField[0].path, videoPlaylist, false)
     : undefined
 
   const videoPlaylistCreated: VideoPlaylistModel = await sequelizeTypescript.transaction(async t => {
     const videoPlaylistCreated = await videoPlaylist.save({ transaction: t })
 
-    if (thumbnailModel) await videoPlaylistCreated.setAndSaveThumbnail(thumbnailModel, t)
+    if (thumbnailModel) {
+      thumbnailModel.automaticallyGenerated = false
+      await videoPlaylistCreated.setAndSaveThumbnail(thumbnailModel, t)
+    }
 
     // We need more attributes for the federation
     videoPlaylistCreated.OwnerAccount = await AccountModel.load(user.Account.id, t)
@@ -206,7 +210,7 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
 
   const thumbnailField = req.files['thumbnailfile']
   const thumbnailModel = thumbnailField
-    ? await createPlaylistMiniatureFromExisting(thumbnailField[0].path, videoPlaylistInstance)
+    ? await createPlaylistMiniatureFromExisting(thumbnailField[0].path, videoPlaylistInstance, false)
     : undefined
 
   try {
@@ -239,7 +243,10 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
 
       const playlistUpdated = await videoPlaylistInstance.save(sequelizeOptions)
 
-      if (thumbnailModel) await playlistUpdated.setAndSaveThumbnail(thumbnailModel, t)
+      if (thumbnailModel) {
+        thumbnailModel.automaticallyGenerated = false
+        await playlistUpdated.setAndSaveThumbnail(thumbnailModel, t)
+      }
 
       const isNewPlaylist = wasPrivatePlaylist && playlistUpdated.privacy !== VideoPlaylistPrivacy.PRIVATE
 
@@ -301,23 +308,17 @@ async function addVideoInPlaylist (req: express.Request, res: express.Response)
     videoPlaylist.changed('updatedAt', true)
     await videoPlaylist.save({ transaction: t })
 
-    await sendUpdateVideoPlaylist(videoPlaylist, t)
-
     return playlistElement
   })
 
   // If the user did not set a thumbnail, automatically take the video thumbnail
-  if (videoPlaylist.hasThumbnail() === false) {
-    logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url)
-
-    const inputPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getMiniature().filename)
-    const thumbnailModel = await createPlaylistMiniatureFromExisting(inputPath, videoPlaylist, true)
-
-    thumbnailModel.videoPlaylistId = videoPlaylist.id
-
-    await thumbnailModel.save()
+  if (videoPlaylist.hasThumbnail() === false || (videoPlaylist.hasGeneratedThumbnail() && playlistElement.position === 1)) {
+    await generateThumbnailForPlaylist(videoPlaylist, video)
   }
 
+  sendUpdateVideoPlaylist(videoPlaylist, undefined)
+    .catch(err => logger.error('Cannot send video playlist update.', { err }))
+
   logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position)
 
   return res.json({
@@ -365,11 +366,17 @@ async function removeVideoFromPlaylist (req: express.Request, res: express.Respo
     videoPlaylist.changed('updatedAt', true)
     await videoPlaylist.save({ transaction: t })
 
-    await sendUpdateVideoPlaylist(videoPlaylist, t)
-
     logger.info('Video playlist element %d of playlist %s deleted.', videoPlaylistElement.position, videoPlaylist.uuid)
   })
 
+  // Do we need to regenerate the default thumbnail?
+  if (positionToDelete === 1 && videoPlaylist.hasGeneratedThumbnail()) {
+    await regeneratePlaylistThumbnail(videoPlaylist)
+  }
+
+  sendUpdateVideoPlaylist(videoPlaylist, undefined)
+    .catch(err => logger.error('Cannot send video playlist update.', { err }))
+
   return res.type('json').status(204).end()
 }
 
@@ -413,8 +420,13 @@ async function reorderVideosPlaylist (req: express.Request, res: express.Respons
     await sendUpdateVideoPlaylist(videoPlaylist, t)
   })
 
+  // The first element changed
+  if ((start === 1 || insertAfter === 0) && videoPlaylist.hasGeneratedThumbnail()) {
+    await regeneratePlaylistThumbnail(videoPlaylist)
+  }
+
   logger.info(
-    'Reordered playlist %s (inserted after %d elements %d - %d).',
+    'Reordered playlist %s (inserted after position %d elements %d - %d).',
     videoPlaylist.uuid, insertAfter, start, start + reorderLength - 1
   )
 
@@ -440,3 +452,22 @@ async function getVideoPlaylistVideos (req: express.Request, res: express.Respon
   }
   return res.json(getFormattedObjects(resultList.data, resultList.total, options))
 }
+
+async function regeneratePlaylistThumbnail (videoPlaylist: VideoPlaylistModel) {
+  await videoPlaylist.Thumbnail.destroy()
+  videoPlaylist.Thumbnail = null
+
+  const firstElement = await VideoPlaylistElementModel.loadFirstElementWithVideoThumbnail(videoPlaylist.id)
+  if (firstElement) await generateThumbnailForPlaylist(videoPlaylist, firstElement.Video)
+}
+
+async function generateThumbnailForPlaylist (videoPlaylist: VideoPlaylistModel, video: VideoModel) {
+  logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url)
+
+  const inputPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getMiniature().filename)
+  const thumbnailModel = await createPlaylistMiniatureFromExisting(inputPath, videoPlaylist, true, true)
+
+  thumbnailModel.videoPlaylistId = videoPlaylist.id
+
+  videoPlaylist.Thumbnail = await thumbnailModel.save()
+}

+ 2 - 2
server/controllers/api/videos/import.ts

@@ -207,7 +207,7 @@ async function processThumbnail (req: express.Request, video: VideoModel) {
   if (thumbnailField) {
     const thumbnailPhysicalFile = thumbnailField[ 0 ]
 
-    return createVideoMiniatureFromExisting(thumbnailPhysicalFile.path, video, ThumbnailType.MINIATURE)
+    return createVideoMiniatureFromExisting(thumbnailPhysicalFile.path, video, ThumbnailType.MINIATURE, false)
   }
 
   return undefined
@@ -218,7 +218,7 @@ async function processPreview (req: express.Request, video: VideoModel) {
   if (previewField) {
     const previewPhysicalFile = previewField[0]
 
-    return createVideoMiniatureFromExisting(previewPhysicalFile.path, video, ThumbnailType.PREVIEW)
+    return createVideoMiniatureFromExisting(previewPhysicalFile.path, video, ThumbnailType.PREVIEW, false)
   }
 
   return undefined

+ 4 - 4
server/controllers/api/videos/index.ts

@@ -223,13 +223,13 @@ async function addVideo (req: express.Request, res: express.Response) {
   // Process thumbnail or create it from the video
   const thumbnailField = req.files['thumbnailfile']
   const thumbnailModel = thumbnailField
-    ? await createVideoMiniatureFromExisting(thumbnailField[0].path, video, ThumbnailType.MINIATURE)
+    ? await createVideoMiniatureFromExisting(thumbnailField[0].path, video, ThumbnailType.MINIATURE, false)
     : await generateVideoMiniature(video, videoFile, ThumbnailType.MINIATURE)
 
   // Process preview or create it from the video
   const previewField = req.files['previewfile']
   const previewModel = previewField
-    ? await createVideoMiniatureFromExisting(previewField[0].path, video, ThumbnailType.PREVIEW)
+    ? await createVideoMiniatureFromExisting(previewField[0].path, video, ThumbnailType.PREVIEW, false)
     : await generateVideoMiniature(video, videoFile, ThumbnailType.PREVIEW)
 
   // Create the torrent file
@@ -329,11 +329,11 @@ async function updateVideo (req: express.Request, res: express.Response) {
 
   // Process thumbnail or create it from the video
   const thumbnailModel = req.files && req.files['thumbnailfile']
-    ? await createVideoMiniatureFromExisting(req.files['thumbnailfile'][0].path, videoInstance, ThumbnailType.MINIATURE)
+    ? await createVideoMiniatureFromExisting(req.files['thumbnailfile'][0].path, videoInstance, ThumbnailType.MINIATURE, false)
     : undefined
 
   const previewModel = req.files && req.files['previewfile']
-    ? await createVideoMiniatureFromExisting(req.files['previewfile'][0].path, videoInstance, ThumbnailType.PREVIEW)
+    ? await createVideoMiniatureFromExisting(req.files['previewfile'][0].path, videoInstance, ThumbnailType.PREVIEW, false)
     : undefined
 
   try {

+ 1 - 1
server/initializers/constants.ts

@@ -14,7 +14,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
 
 // ---------------------------------------------------------------------------
 
-const LAST_MIGRATION_VERSION = 410
+const LAST_MIGRATION_VERSION = 415
 
 // ---------------------------------------------------------------------------
 

+ 35 - 0
server/initializers/migrations/0415-thumbnail-auto-generated.ts

@@ -0,0 +1,35 @@
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+  transaction: Sequelize.Transaction,
+  queryInterface: Sequelize.QueryInterface,
+  sequelize: Sequelize.Sequelize,
+  db: any
+}): Promise<void> {
+  {
+    const data = {
+      type: Sequelize.BOOLEAN,
+      allowNull: true,
+      defaultValue: null
+    }
+
+    await utils.queryInterface.addColumn('thumbnail', 'automaticallyGenerated', data)
+  }
+
+  {
+    // Set auto generated to true for watch later playlists
+    const query = 'UPDATE thumbnail SET "automaticallyGenerated" = true WHERE "videoPlaylistId" IN ' +
+      '(SELECT id FROM "videoPlaylist" WHERE type = 2)'
+
+    await utils.sequelize.query(query)
+  }
+}
+
+function down (options) {
+  throw new Error('Not implemented.')
+}
+
+export {
+  up,
+  down
+}

+ 3 - 0
server/lib/activitypub/playlist.ts

@@ -105,6 +105,9 @@ async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAc
     } catch (err) {
       logger.warn('Cannot generate thumbnail of %s.', playlistObject.id, { err })
     }
+  } else if (refreshedPlaylist.hasThumbnail()) {
+    await refreshedPlaylist.Thumbnail.destroy()
+    refreshedPlaylist.Thumbnail = null
   }
 
   return resetVideoPlaylistElements(accItems, refreshedPlaylist)

+ 20 - 6
server/lib/thumbnail.ts

@@ -12,12 +12,18 @@ import { VideoPlaylistModel } from '../models/video/video-playlist'
 
 type ImageSize = { height: number, width: number }
 
-function createPlaylistMiniatureFromExisting (inputPath: string, playlist: VideoPlaylistModel, keepOriginal = false, size?: ImageSize) {
+function createPlaylistMiniatureFromExisting (
+  inputPath: string,
+  playlist: VideoPlaylistModel,
+  automaticallyGenerated: boolean,
+  keepOriginal = false,
+  size?: ImageSize
+) {
   const { filename, outputPath, height, width, existingThumbnail } = buildMetadataFromPlaylist(playlist, size)
   const type = ThumbnailType.MINIATURE
 
   const thumbnailCreator = () => processImage(inputPath, outputPath, { width, height }, keepOriginal)
-  return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail })
+  return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, automaticallyGenerated, existingThumbnail })
 }
 
 function createPlaylistMiniatureFromUrl (fileUrl: string, playlist: VideoPlaylistModel, size?: ImageSize) {
@@ -35,11 +41,17 @@ function createVideoMiniatureFromUrl (fileUrl: string, video: VideoModel, type:
   return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl })
 }
 
-function createVideoMiniatureFromExisting (inputPath: string, video: VideoModel, type: ThumbnailType, size?: ImageSize) {
+function createVideoMiniatureFromExisting (
+  inputPath: string,
+  video: VideoModel,
+  type: ThumbnailType,
+  automaticallyGenerated: boolean,
+  size?: ImageSize
+) {
   const { filename, outputPath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size)
   const thumbnailCreator = () => processImage(inputPath, outputPath, { width, height })
 
-  return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail })
+  return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, automaticallyGenerated, existingThumbnail })
 }
 
 function generateVideoMiniature (video: VideoModel, videoFile: VideoFileModel, type: ThumbnailType) {
@@ -50,7 +62,7 @@ function generateVideoMiniature (video: VideoModel, videoFile: VideoFileModel, t
     ? () => processImage(ASSETS_PATH.DEFAULT_AUDIO_BACKGROUND, outputPath, { width, height }, true)
     : () => generateImageFromVideoFile(input, basePath, filename, { height, width })
 
-  return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail })
+  return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, automaticallyGenerated: true, existingThumbnail })
 }
 
 function createPlaceholderThumbnail (fileUrl: string, video: VideoModel, type: ThumbnailType, size: ImageSize) {
@@ -134,10 +146,11 @@ async function createThumbnailFromFunction (parameters: {
   height: number,
   width: number,
   type: ThumbnailType,
+  automaticallyGenerated?: boolean,
   fileUrl?: string,
   existingThumbnail?: ThumbnailModel
 }) {
-  const { thumbnailCreator, filename, width, height, type, existingThumbnail, fileUrl = null } = parameters
+  const { thumbnailCreator, filename, width, height, type, existingThumbnail, automaticallyGenerated = null, fileUrl = null } = parameters
 
   const thumbnail = existingThumbnail ? existingThumbnail : new ThumbnailModel()
 
@@ -146,6 +159,7 @@ async function createThumbnailFromFunction (parameters: {
   thumbnail.width = width
   thumbnail.type = type
   thumbnail.fileUrl = fileUrl
+  thumbnail.automaticallyGenerated = automaticallyGenerated
 
   await thumbnailCreator()
 

+ 5 - 1
server/models/video/thumbnail.ts

@@ -44,6 +44,10 @@ export class ThumbnailModel extends Model<ThumbnailModel> {
   @Column
   fileUrl: string
 
+  @AllowNull(true)
+  @Column
+  automaticallyGenerated: boolean
+
   @ForeignKey(() => VideoModel)
   @Column
   videoId: number
@@ -88,7 +92,7 @@ export class ThumbnailModel extends Model<ThumbnailModel> {
   }
 
   @AfterDestroy
-  static removeFilesAndSendDelete (instance: ThumbnailModel) {
+  static removeFiles (instance: ThumbnailModel) {
     logger.info('Removing %s file %s.', ThumbnailModel.types[instance.type].label, instance.filename)
 
     // Don't block the transaction

+ 18 - 0
server/models/video/video-playlist-element.ts

@@ -218,6 +218,24 @@ export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel>
       })
   }
 
+  static loadFirstElementWithVideoThumbnail (videoPlaylistId: number) {
+    const query = {
+      order: getSort('position'),
+      where: {
+        videoPlaylistId
+      },
+      include: [
+        {
+          model: VideoModel.scope(VideoScopeNames.WITH_THUMBNAILS),
+          required: true
+        }
+      ]
+    }
+
+    return VideoPlaylistElementModel
+      .findOne(query)
+  }
+
   static getNextPositionOf (videoPlaylistId: number, transaction?: Transaction) {
     const query: AggregateOptions<number> = {
       where: {

+ 4 - 1
server/models/video/video-playlist.ts

@@ -265,7 +265,6 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
   VideoPlaylistElements: VideoPlaylistElementModel[]
 
   @HasOne(() => ThumbnailModel, {
-
     foreignKey: {
       name: 'videoPlaylistId',
       allowNull: true
@@ -434,6 +433,10 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
     return !!this.Thumbnail
   }
 
+  hasGeneratedThumbnail () {
+    return this.hasThumbnail() && this.Thumbnail.automaticallyGenerated === true
+  }
+
   generateThumbnailName () {
     const extension = '.jpg'
 

+ 1 - 0
server/tests/api/videos/index.ts

@@ -12,6 +12,7 @@ import './video-hls'
 import './video-imports'
 import './video-nsfw'
 import './video-playlists'
+import './video-playlist-thumbnails'
 import './video-privacy'
 import './video-schedule-update'
 import './video-transcoder'

+ 262 - 0
server/tests/api/videos/video-playlist-thumbnails.ts

@@ -0,0 +1,262 @@
+/* tslint:disable:no-unused-expression */
+
+import * as chai from 'chai'
+import 'mocha'
+import {
+  addVideoInPlaylist,
+  cleanupTests,
+  createVideoPlaylist,
+  doubleFollow,
+  flushAndRunMultipleServers,
+  getVideoPlaylistsList, removeVideoFromPlaylist,
+  ServerInfo,
+  setAccessTokensToServers,
+  setDefaultVideoChannel,
+  testImage,
+  uploadVideoAndGetId,
+  waitJobs,
+  reorderVideosPlaylist
+} from '../../../../shared/extra-utils'
+import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
+
+const expect = chai.expect
+
+describe('Playlist thumbnail', function () {
+  let servers: ServerInfo[] = []
+
+  let playlistWithoutThumbnail: number
+  let playlistWithThumbnail: number
+
+  let withThumbnailE1: number
+  let withThumbnailE2: number
+  let withoutThumbnailE1: number
+  let withoutThumbnailE2: number
+
+  let video1: number
+  let video2: number
+
+  async function getPlaylistWithoutThumbnail (server: ServerInfo) {
+    const res = await getVideoPlaylistsList(server.url, 0, 10)
+
+    return res.body.data.find(p => p.displayName === 'playlist without thumbnail')
+  }
+
+  async function getPlaylistWithThumbnail (server: ServerInfo) {
+    const res = await getVideoPlaylistsList(server.url, 0, 10)
+
+    return res.body.data.find(p => p.displayName === 'playlist with thumbnail')
+  }
+
+  before(async function () {
+    this.timeout(120000)
+
+    servers = await flushAndRunMultipleServers(2, { transcoding: { enabled: false } })
+
+    // Get the access tokens
+    await setAccessTokensToServers(servers)
+    await setDefaultVideoChannel(servers)
+
+    // Server 1 and server 2 follow each other
+    await doubleFollow(servers[0], servers[1])
+
+    video1 = (await uploadVideoAndGetId({ server: servers[0], videoName: 'video 1' })).id
+    video2 = (await uploadVideoAndGetId({ server: servers[0], videoName: 'video 2' })).id
+
+    await waitJobs(servers)
+  })
+
+  it('Should automatically update the thumbnail when adding an element', async function () {
+    this.timeout(30000)
+
+    const res = await createVideoPlaylist({
+      url: servers[ 1 ].url,
+      token: servers[ 1 ].accessToken,
+      playlistAttrs: {
+        displayName: 'playlist without thumbnail',
+        privacy: VideoPlaylistPrivacy.PUBLIC,
+        videoChannelId: servers[ 1 ].videoChannel.id
+      }
+    })
+    playlistWithoutThumbnail = res.body.videoPlaylist.id
+
+    const res2 = await addVideoInPlaylist({
+      url: servers[ 1 ].url,
+      token: servers[ 1 ].accessToken,
+      playlistId: playlistWithoutThumbnail,
+      elementAttrs: { videoId: video1 }
+    })
+    withoutThumbnailE1 = res2.body.videoPlaylistElement.id
+
+    await waitJobs(servers)
+
+    for (const server of servers) {
+      const p = await getPlaylistWithoutThumbnail(server)
+      await testImage(server.url, 'thumbnail-playlist', p.thumbnailPath)
+    }
+  })
+
+  it('Should not update the thumbnail if we explicitly uploaded a thumbnail', async function () {
+    this.timeout(30000)
+
+    const res = await createVideoPlaylist({
+      url: servers[ 1 ].url,
+      token: servers[ 1 ].accessToken,
+      playlistAttrs: {
+        displayName: 'playlist with thumbnail',
+        privacy: VideoPlaylistPrivacy.PUBLIC,
+        videoChannelId: servers[ 1 ].videoChannel.id,
+        thumbnailfile: 'thumbnail.jpg'
+      }
+    })
+    playlistWithThumbnail = res.body.videoPlaylist.id
+
+    const res2 = await addVideoInPlaylist({
+      url: servers[ 1 ].url,
+      token: servers[ 1 ].accessToken,
+      playlistId: playlistWithThumbnail,
+      elementAttrs: { videoId: video1 }
+    })
+    withThumbnailE1 = res2.body.videoPlaylistElement.id
+
+    await waitJobs(servers)
+
+    for (const server of servers) {
+      const p = await getPlaylistWithThumbnail(server)
+      await testImage(server.url, 'thumbnail', p.thumbnailPath)
+    }
+  })
+
+  it('Should automatically update the thumbnail when moving the first element', async function () {
+    this.timeout(30000)
+
+    const res = await addVideoInPlaylist({
+      url: servers[ 1 ].url,
+      token: servers[ 1 ].accessToken,
+      playlistId: playlistWithoutThumbnail,
+      elementAttrs: { videoId: video2 }
+    })
+    withoutThumbnailE2 = res.body.videoPlaylistElement.id
+
+    await reorderVideosPlaylist({
+      url: servers[1].url,
+      token: servers[1].accessToken,
+      playlistId: playlistWithoutThumbnail,
+      elementAttrs: {
+        startPosition: 1,
+        insertAfterPosition: 2
+      }
+    })
+
+    await waitJobs(servers)
+
+    for (const server of servers) {
+      const p = await getPlaylistWithoutThumbnail(server)
+      await testImage(server.url, 'thumbnail-playlist', p.thumbnailPath)
+    }
+  })
+
+  it('Should not update the thumbnail when moving the first element if we explicitly uploaded a thumbnail', async function () {
+    this.timeout(30000)
+
+    const res = await addVideoInPlaylist({
+      url: servers[ 1 ].url,
+      token: servers[ 1 ].accessToken,
+      playlistId: playlistWithThumbnail,
+      elementAttrs: { videoId: video2 }
+    })
+    withThumbnailE2 = res.body.videoPlaylistElement.id
+
+    await reorderVideosPlaylist({
+      url: servers[1].url,
+      token: servers[1].accessToken,
+      playlistId: playlistWithThumbnail,
+      elementAttrs: {
+        startPosition: 1,
+        insertAfterPosition: 2
+      }
+    })
+
+    await waitJobs(servers)
+
+    for (const server of servers) {
+      const p = await getPlaylistWithThumbnail(server)
+      await testImage(server.url, 'thumbnail', p.thumbnailPath)
+    }
+  })
+
+  it('Should automatically update the thumbnail when deleting the first element', async function () {
+    this.timeout(30000)
+
+    await removeVideoFromPlaylist({
+      url: servers[ 1 ].url,
+      token: servers[ 1 ].accessToken,
+      playlistId: playlistWithoutThumbnail,
+      playlistElementId: withoutThumbnailE1
+    })
+
+    await waitJobs(servers)
+
+    for (const server of servers) {
+      const p = await getPlaylistWithoutThumbnail(server)
+      await testImage(server.url, 'thumbnail-playlist', p.thumbnailPath)
+    }
+  })
+
+  it('Should not update the thumbnail when deleting the first element if we explicitly uploaded a thumbnail', async function () {
+    this.timeout(30000)
+
+    await removeVideoFromPlaylist({
+      url: servers[ 1 ].url,
+      token: servers[ 1 ].accessToken,
+      playlistId: playlistWithThumbnail,
+      playlistElementId: withThumbnailE1
+    })
+
+    await waitJobs(servers)
+
+    for (const server of servers) {
+      const p = await getPlaylistWithThumbnail(server)
+      await testImage(server.url, 'thumbnail', p.thumbnailPath)
+    }
+  })
+
+  it('Should the thumbnail when we delete the last element', async function () {
+    this.timeout(30000)
+
+    await removeVideoFromPlaylist({
+      url: servers[ 1 ].url,
+      token: servers[ 1 ].accessToken,
+      playlistId: playlistWithoutThumbnail,
+      playlistElementId: withoutThumbnailE2
+    })
+
+    await waitJobs(servers)
+
+    for (const server of servers) {
+      const p = await getPlaylistWithoutThumbnail(server)
+      expect(p.thumbnailPath).to.be.null
+    }
+  })
+
+  it('Should not update the thumbnail when we delete the last element if we explicitly uploaded a thumbnail', async function () {
+    this.timeout(30000)
+
+    await removeVideoFromPlaylist({
+      url: servers[ 1 ].url,
+      token: servers[ 1 ].accessToken,
+      playlistId: playlistWithThumbnail,
+      playlistElementId: withThumbnailE2
+    })
+
+    await waitJobs(servers)
+
+    for (const server of servers) {
+      const p = await getPlaylistWithThumbnail(server)
+      await testImage(server.url, 'thumbnail', p.thumbnailPath)
+    }
+  })
+
+  after(async function () {
+    await cleanupTests(servers)
+  })
+})

+ 1 - 0
server/tests/api/videos/video-playlists.ts

@@ -344,6 +344,7 @@ describe('Test video playlists', function () {
   })
 
   describe('List playlists', function () {
+
     it('Should correctly list the playlists', async function () {
       this.timeout(30000)