aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2019-08-01 16:54:24 +0200
committerChocobozzz <me@florianbigard.com>2019-08-01 16:54:24 +0200
commit65af03a241aa83ab7ba71278b6c99acd26428b8a (patch)
tree0cc04c31cdf631d11a915ae40389e8fa141f136b
parenta21e25ff641854c8b01664cb18655aa420620af6 (diff)
downloadPeerTube-65af03a241aa83ab7ba71278b6c99acd26428b8a.tar.gz
PeerTube-65af03a241aa83ab7ba71278b6c99acd26428b8a.tar.zst
PeerTube-65af03a241aa83ab7ba71278b6c99acd26428b8a.zip
Automatically update playlist thumbnails
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts35
-rw-r--r--client/src/app/shared/video-playlist/video-playlist.model.ts13
-rw-r--r--server/controllers/api/video-playlist.ts67
-rw-r--r--server/controllers/api/videos/import.ts4
-rw-r--r--server/controllers/api/videos/index.ts8
-rw-r--r--server/initializers/constants.ts2
-rw-r--r--server/initializers/migrations/0415-thumbnail-auto-generated.ts35
-rw-r--r--server/lib/activitypub/playlist.ts3
-rw-r--r--server/lib/thumbnail.ts26
-rw-r--r--server/models/video/thumbnail.ts6
-rw-r--r--server/models/video/video-playlist-element.ts18
-rw-r--r--server/models/video/video-playlist.ts5
-rw-r--r--server/tests/api/videos/index.ts1
-rw-r--r--server/tests/api/videos/video-playlist-thumbnails.ts262
-rw-r--r--server/tests/api/videos/video-playlists.ts1
15 files changed, 443 insertions, 43 deletions
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts
index 6434b9e50..6f307a058 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts
+++ b/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
63 63
64 if (oldPosition > insertAfter) insertAfter-- 64 if (oldPosition > insertAfter) insertAfter--
65 65
66 this.videoPlaylistService.reorderPlaylist(this.playlist.id, oldPosition, insertAfter)
67 .subscribe(
68 () => { /* nothing to do */ },
69
70 err => this.notifier.error(err.message)
71 )
72
73 const element = this.playlistElements[previousIndex] 66 const element = this.playlistElements[previousIndex]
74 67
75 this.playlistElements.splice(previousIndex, 1) 68 this.playlistElements.splice(previousIndex, 1)
76 this.playlistElements.splice(newIndex, 0, element) 69 this.playlistElements.splice(newIndex, 0, element)
77 70
78 this.reorderClientPositions() 71 this.videoPlaylistService.reorderPlaylist(this.playlist.id, oldPosition, insertAfter)
72 .subscribe(
73 () => {
74 this.reorderClientPositions()
75 },
76
77 err => this.notifier.error(err.message)
78 )
79 } 79 }
80 80
81 onElementRemoved (element: VideoPlaylistElement) { 81 onElementRemoved (element: VideoPlaylistElement) {
82 const oldFirst = this.findFirst()
83
82 this.playlistElements = this.playlistElements.filter(v => v.id !== element.id) 84 this.playlistElements = this.playlistElements.filter(v => v.id !== element.id)
83 this.reorderClientPositions() 85 this.reorderClientPositions(oldFirst)
84 } 86 }
85 87
86 onNearOfBottom () { 88 onNearOfBottom () {
@@ -110,12 +112,25 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
110 }) 112 })
111 } 113 }
112 114
113 private reorderClientPositions () { 115 private reorderClientPositions (first?: VideoPlaylistElement) {
116 if (this.playlistElements.length === 0) return
117
118 const oldFirst = first || this.findFirst()
114 let i = 1 119 let i = 1
115 120
116 for (const element of this.playlistElements) { 121 for (const element of this.playlistElements) {
117 element.position = i 122 element.position = i
118 i++ 123 i++
119 } 124 }
125
126 // Reload playlist thumbnail if the first element changed
127 const newFirst = this.findFirst()
128 if (oldFirst && newFirst && oldFirst.id !== newFirst.id) {
129 this.playlist.refreshThumbnail()
130 }
131 }
132
133 private findFirst () {
134 return this.playlistElements.find(e => e.position === 1)
120 } 135 }
121} 136}
diff --git a/client/src/app/shared/video-playlist/video-playlist.model.ts b/client/src/app/shared/video-playlist/video-playlist.model.ts
index 7e311aa54..6f27e7475 100644
--- a/client/src/app/shared/video-playlist/video-playlist.model.ts
+++ b/client/src/app/shared/video-playlist/video-playlist.model.ts
@@ -38,6 +38,9 @@ export class VideoPlaylist implements ServerVideoPlaylist {
38 videoChannelBy?: string 38 videoChannelBy?: string
39 videoChannelAvatarUrl?: string 39 videoChannelAvatarUrl?: string
40 40
41 private thumbnailVersion: number
42 private originThumbnailUrl: string
43
41 constructor (hash: ServerVideoPlaylist, translations: {}) { 44 constructor (hash: ServerVideoPlaylist, translations: {}) {
42 const absoluteAPIUrl = getAbsoluteAPIUrl() 45 const absoluteAPIUrl = getAbsoluteAPIUrl()
43 46
@@ -54,6 +57,7 @@ export class VideoPlaylist implements ServerVideoPlaylist {
54 57
55 if (this.thumbnailPath) { 58 if (this.thumbnailPath) {
56 this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath 59 this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath
60 this.originThumbnailUrl = this.thumbnailUrl
57 } else { 61 } else {
58 this.thumbnailUrl = window.location.origin + '/client/assets/images/default-playlist.jpg' 62 this.thumbnailUrl = window.location.origin + '/client/assets/images/default-playlist.jpg'
59 } 63 }
@@ -81,4 +85,13 @@ export class VideoPlaylist implements ServerVideoPlaylist {
81 this.displayName = peertubeTranslate(this.displayName, translations) 85 this.displayName = peertubeTranslate(this.displayName, translations)
82 } 86 }
83 } 87 }
88
89 refreshThumbnail () {
90 if (!this.originThumbnailUrl) return
91
92 if (!this.thumbnailVersion) this.thumbnailVersion = 0
93 this.thumbnailVersion++
94
95 this.thumbnailUrl = this.originThumbnailUrl + '?v' + this.thumbnailVersion
96 }
84} 97}
diff --git a/server/controllers/api/video-playlist.ts b/server/controllers/api/video-playlist.ts
index 540120cca..bd454f553 100644
--- a/server/controllers/api/video-playlist.ts
+++ b/server/controllers/api/video-playlist.ts
@@ -40,6 +40,7 @@ import { JobQueue } from '../../lib/job-queue'
40import { CONFIG } from '../../initializers/config' 40import { CONFIG } from '../../initializers/config'
41import { sequelizeTypescript } from '../../initializers/database' 41import { sequelizeTypescript } from '../../initializers/database'
42import { createPlaylistMiniatureFromExisting } from '../../lib/thumbnail' 42import { createPlaylistMiniatureFromExisting } from '../../lib/thumbnail'
43import { VideoModel } from '../../models/video/video'
43 44
44const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR }) 45const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR })
45 46
@@ -171,13 +172,16 @@ async function addVideoPlaylist (req: express.Request, res: express.Response) {
171 172
172 const thumbnailField = req.files['thumbnailfile'] 173 const thumbnailField = req.files['thumbnailfile']
173 const thumbnailModel = thumbnailField 174 const thumbnailModel = thumbnailField
174 ? await createPlaylistMiniatureFromExisting(thumbnailField[0].path, videoPlaylist) 175 ? await createPlaylistMiniatureFromExisting(thumbnailField[0].path, videoPlaylist, false)
175 : undefined 176 : undefined
176 177
177 const videoPlaylistCreated: VideoPlaylistModel = await sequelizeTypescript.transaction(async t => { 178 const videoPlaylistCreated: VideoPlaylistModel = await sequelizeTypescript.transaction(async t => {
178 const videoPlaylistCreated = await videoPlaylist.save({ transaction: t }) 179 const videoPlaylistCreated = await videoPlaylist.save({ transaction: t })
179 180
180 if (thumbnailModel) await videoPlaylistCreated.setAndSaveThumbnail(thumbnailModel, t) 181 if (thumbnailModel) {
182 thumbnailModel.automaticallyGenerated = false
183 await videoPlaylistCreated.setAndSaveThumbnail(thumbnailModel, t)
184 }
181 185
182 // We need more attributes for the federation 186 // We need more attributes for the federation
183 videoPlaylistCreated.OwnerAccount = await AccountModel.load(user.Account.id, t) 187 videoPlaylistCreated.OwnerAccount = await AccountModel.load(user.Account.id, t)
@@ -206,7 +210,7 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
206 210
207 const thumbnailField = req.files['thumbnailfile'] 211 const thumbnailField = req.files['thumbnailfile']
208 const thumbnailModel = thumbnailField 212 const thumbnailModel = thumbnailField
209 ? await createPlaylistMiniatureFromExisting(thumbnailField[0].path, videoPlaylistInstance) 213 ? await createPlaylistMiniatureFromExisting(thumbnailField[0].path, videoPlaylistInstance, false)
210 : undefined 214 : undefined
211 215
212 try { 216 try {
@@ -239,7 +243,10 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
239 243
240 const playlistUpdated = await videoPlaylistInstance.save(sequelizeOptions) 244 const playlistUpdated = await videoPlaylistInstance.save(sequelizeOptions)
241 245
242 if (thumbnailModel) await playlistUpdated.setAndSaveThumbnail(thumbnailModel, t) 246 if (thumbnailModel) {
247 thumbnailModel.automaticallyGenerated = false
248 await playlistUpdated.setAndSaveThumbnail(thumbnailModel, t)
249 }
243 250
244 const isNewPlaylist = wasPrivatePlaylist && playlistUpdated.privacy !== VideoPlaylistPrivacy.PRIVATE 251 const isNewPlaylist = wasPrivatePlaylist && playlistUpdated.privacy !== VideoPlaylistPrivacy.PRIVATE
245 252
@@ -301,23 +308,17 @@ async function addVideoInPlaylist (req: express.Request, res: express.Response)
301 videoPlaylist.changed('updatedAt', true) 308 videoPlaylist.changed('updatedAt', true)
302 await videoPlaylist.save({ transaction: t }) 309 await videoPlaylist.save({ transaction: t })
303 310
304 await sendUpdateVideoPlaylist(videoPlaylist, t)
305
306 return playlistElement 311 return playlistElement
307 }) 312 })
308 313
309 // If the user did not set a thumbnail, automatically take the video thumbnail 314 // If the user did not set a thumbnail, automatically take the video thumbnail
310 if (videoPlaylist.hasThumbnail() === false) { 315 if (videoPlaylist.hasThumbnail() === false || (videoPlaylist.hasGeneratedThumbnail() && playlistElement.position === 1)) {
311 logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url) 316 await generateThumbnailForPlaylist(videoPlaylist, video)
312
313 const inputPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getMiniature().filename)
314 const thumbnailModel = await createPlaylistMiniatureFromExisting(inputPath, videoPlaylist, true)
315
316 thumbnailModel.videoPlaylistId = videoPlaylist.id
317
318 await thumbnailModel.save()
319 } 317 }
320 318
319 sendUpdateVideoPlaylist(videoPlaylist, undefined)
320 .catch(err => logger.error('Cannot send video playlist update.', { err }))
321
321 logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position) 322 logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position)
322 323
323 return res.json({ 324 return res.json({
@@ -365,11 +366,17 @@ async function removeVideoFromPlaylist (req: express.Request, res: express.Respo
365 videoPlaylist.changed('updatedAt', true) 366 videoPlaylist.changed('updatedAt', true)
366 await videoPlaylist.save({ transaction: t }) 367 await videoPlaylist.save({ transaction: t })
367 368
368 await sendUpdateVideoPlaylist(videoPlaylist, t)
369
370 logger.info('Video playlist element %d of playlist %s deleted.', videoPlaylistElement.position, videoPlaylist.uuid) 369 logger.info('Video playlist element %d of playlist %s deleted.', videoPlaylistElement.position, videoPlaylist.uuid)
371 }) 370 })
372 371
372 // Do we need to regenerate the default thumbnail?
373 if (positionToDelete === 1 && videoPlaylist.hasGeneratedThumbnail()) {
374 await regeneratePlaylistThumbnail(videoPlaylist)
375 }
376
377 sendUpdateVideoPlaylist(videoPlaylist, undefined)
378 .catch(err => logger.error('Cannot send video playlist update.', { err }))
379
373 return res.type('json').status(204).end() 380 return res.type('json').status(204).end()
374} 381}
375 382
@@ -413,8 +420,13 @@ async function reorderVideosPlaylist (req: express.Request, res: express.Respons
413 await sendUpdateVideoPlaylist(videoPlaylist, t) 420 await sendUpdateVideoPlaylist(videoPlaylist, t)
414 }) 421 })
415 422
423 // The first element changed
424 if ((start === 1 || insertAfter === 0) && videoPlaylist.hasGeneratedThumbnail()) {
425 await regeneratePlaylistThumbnail(videoPlaylist)
426 }
427
416 logger.info( 428 logger.info(
417 'Reordered playlist %s (inserted after %d elements %d - %d).', 429 'Reordered playlist %s (inserted after position %d elements %d - %d).',
418 videoPlaylist.uuid, insertAfter, start, start + reorderLength - 1 430 videoPlaylist.uuid, insertAfter, start, start + reorderLength - 1
419 ) 431 )
420 432
@@ -440,3 +452,22 @@ async function getVideoPlaylistVideos (req: express.Request, res: express.Respon
440 } 452 }
441 return res.json(getFormattedObjects(resultList.data, resultList.total, options)) 453 return res.json(getFormattedObjects(resultList.data, resultList.total, options))
442} 454}
455
456async function regeneratePlaylistThumbnail (videoPlaylist: VideoPlaylistModel) {
457 await videoPlaylist.Thumbnail.destroy()
458 videoPlaylist.Thumbnail = null
459
460 const firstElement = await VideoPlaylistElementModel.loadFirstElementWithVideoThumbnail(videoPlaylist.id)
461 if (firstElement) await generateThumbnailForPlaylist(videoPlaylist, firstElement.Video)
462}
463
464async function generateThumbnailForPlaylist (videoPlaylist: VideoPlaylistModel, video: VideoModel) {
465 logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url)
466
467 const inputPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getMiniature().filename)
468 const thumbnailModel = await createPlaylistMiniatureFromExisting(inputPath, videoPlaylist, true, true)
469
470 thumbnailModel.videoPlaylistId = videoPlaylist.id
471
472 videoPlaylist.Thumbnail = await thumbnailModel.save()
473}
diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts
index 1f08fe20a..04c9b547b 100644
--- a/server/controllers/api/videos/import.ts
+++ b/server/controllers/api/videos/import.ts
@@ -207,7 +207,7 @@ async function processThumbnail (req: express.Request, video: VideoModel) {
207 if (thumbnailField) { 207 if (thumbnailField) {
208 const thumbnailPhysicalFile = thumbnailField[ 0 ] 208 const thumbnailPhysicalFile = thumbnailField[ 0 ]
209 209
210 return createVideoMiniatureFromExisting(thumbnailPhysicalFile.path, video, ThumbnailType.MINIATURE) 210 return createVideoMiniatureFromExisting(thumbnailPhysicalFile.path, video, ThumbnailType.MINIATURE, false)
211 } 211 }
212 212
213 return undefined 213 return undefined
@@ -218,7 +218,7 @@ async function processPreview (req: express.Request, video: VideoModel) {
218 if (previewField) { 218 if (previewField) {
219 const previewPhysicalFile = previewField[0] 219 const previewPhysicalFile = previewField[0]
220 220
221 return createVideoMiniatureFromExisting(previewPhysicalFile.path, video, ThumbnailType.PREVIEW) 221 return createVideoMiniatureFromExisting(previewPhysicalFile.path, video, ThumbnailType.PREVIEW, false)
222 } 222 }
223 223
224 return undefined 224 return undefined
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index 973bf1123..155ca4678 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -223,13 +223,13 @@ async function addVideo (req: express.Request, res: express.Response) {
223 // Process thumbnail or create it from the video 223 // Process thumbnail or create it from the video
224 const thumbnailField = req.files['thumbnailfile'] 224 const thumbnailField = req.files['thumbnailfile']
225 const thumbnailModel = thumbnailField 225 const thumbnailModel = thumbnailField
226 ? await createVideoMiniatureFromExisting(thumbnailField[0].path, video, ThumbnailType.MINIATURE) 226 ? await createVideoMiniatureFromExisting(thumbnailField[0].path, video, ThumbnailType.MINIATURE, false)
227 : await generateVideoMiniature(video, videoFile, ThumbnailType.MINIATURE) 227 : await generateVideoMiniature(video, videoFile, ThumbnailType.MINIATURE)
228 228
229 // Process preview or create it from the video 229 // Process preview or create it from the video
230 const previewField = req.files['previewfile'] 230 const previewField = req.files['previewfile']
231 const previewModel = previewField 231 const previewModel = previewField
232 ? await createVideoMiniatureFromExisting(previewField[0].path, video, ThumbnailType.PREVIEW) 232 ? await createVideoMiniatureFromExisting(previewField[0].path, video, ThumbnailType.PREVIEW, false)
233 : await generateVideoMiniature(video, videoFile, ThumbnailType.PREVIEW) 233 : await generateVideoMiniature(video, videoFile, ThumbnailType.PREVIEW)
234 234
235 // Create the torrent file 235 // Create the torrent file
@@ -329,11 +329,11 @@ async function updateVideo (req: express.Request, res: express.Response) {
329 329
330 // Process thumbnail or create it from the video 330 // Process thumbnail or create it from the video
331 const thumbnailModel = req.files && req.files['thumbnailfile'] 331 const thumbnailModel = req.files && req.files['thumbnailfile']
332 ? await createVideoMiniatureFromExisting(req.files['thumbnailfile'][0].path, videoInstance, ThumbnailType.MINIATURE) 332 ? await createVideoMiniatureFromExisting(req.files['thumbnailfile'][0].path, videoInstance, ThumbnailType.MINIATURE, false)
333 : undefined 333 : undefined
334 334
335 const previewModel = req.files && req.files['previewfile'] 335 const previewModel = req.files && req.files['previewfile']
336 ? await createVideoMiniatureFromExisting(req.files['previewfile'][0].path, videoInstance, ThumbnailType.PREVIEW) 336 ? await createVideoMiniatureFromExisting(req.files['previewfile'][0].path, videoInstance, ThumbnailType.PREVIEW, false)
337 : undefined 337 : undefined
338 338
339 try { 339 try {
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 8ab7c6bbd..b9d90b2bd 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -14,7 +14,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
14 14
15// --------------------------------------------------------------------------- 15// ---------------------------------------------------------------------------
16 16
17const LAST_MIGRATION_VERSION = 410 17const LAST_MIGRATION_VERSION = 415
18 18
19// --------------------------------------------------------------------------- 19// ---------------------------------------------------------------------------
20 20
diff --git a/server/initializers/migrations/0415-thumbnail-auto-generated.ts b/server/initializers/migrations/0415-thumbnail-auto-generated.ts
new file mode 100644
index 000000000..f822a4c05
--- /dev/null
+++ b/server/initializers/migrations/0415-thumbnail-auto-generated.ts
@@ -0,0 +1,35 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction,
5 queryInterface: Sequelize.QueryInterface,
6 sequelize: Sequelize.Sequelize,
7 db: any
8}): Promise<void> {
9 {
10 const data = {
11 type: Sequelize.BOOLEAN,
12 allowNull: true,
13 defaultValue: null
14 }
15
16 await utils.queryInterface.addColumn('thumbnail', 'automaticallyGenerated', data)
17 }
18
19 {
20 // Set auto generated to true for watch later playlists
21 const query = 'UPDATE thumbnail SET "automaticallyGenerated" = true WHERE "videoPlaylistId" IN ' +
22 '(SELECT id FROM "videoPlaylist" WHERE type = 2)'
23
24 await utils.sequelize.query(query)
25 }
26}
27
28function down (options) {
29 throw new Error('Not implemented.')
30}
31
32export {
33 up,
34 down
35}
diff --git a/server/lib/activitypub/playlist.ts b/server/lib/activitypub/playlist.ts
index 36a91faec..f569d881c 100644
--- a/server/lib/activitypub/playlist.ts
+++ b/server/lib/activitypub/playlist.ts
@@ -105,6 +105,9 @@ async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAc
105 } catch (err) { 105 } catch (err) {
106 logger.warn('Cannot generate thumbnail of %s.', playlistObject.id, { err }) 106 logger.warn('Cannot generate thumbnail of %s.', playlistObject.id, { err })
107 } 107 }
108 } else if (refreshedPlaylist.hasThumbnail()) {
109 await refreshedPlaylist.Thumbnail.destroy()
110 refreshedPlaylist.Thumbnail = null
108 } 111 }
109 112
110 return resetVideoPlaylistElements(accItems, refreshedPlaylist) 113 return resetVideoPlaylistElements(accItems, refreshedPlaylist)
diff --git a/server/lib/thumbnail.ts b/server/lib/thumbnail.ts
index 18bdcded4..a59773f5a 100644
--- a/server/lib/thumbnail.ts
+++ b/server/lib/thumbnail.ts
@@ -12,12 +12,18 @@ import { VideoPlaylistModel } from '../models/video/video-playlist'
12 12
13type ImageSize = { height: number, width: number } 13type ImageSize = { height: number, width: number }
14 14
15function createPlaylistMiniatureFromExisting (inputPath: string, playlist: VideoPlaylistModel, keepOriginal = false, size?: ImageSize) { 15function createPlaylistMiniatureFromExisting (
16 inputPath: string,
17 playlist: VideoPlaylistModel,
18 automaticallyGenerated: boolean,
19 keepOriginal = false,
20 size?: ImageSize
21) {
16 const { filename, outputPath, height, width, existingThumbnail } = buildMetadataFromPlaylist(playlist, size) 22 const { filename, outputPath, height, width, existingThumbnail } = buildMetadataFromPlaylist(playlist, size)
17 const type = ThumbnailType.MINIATURE 23 const type = ThumbnailType.MINIATURE
18 24
19 const thumbnailCreator = () => processImage(inputPath, outputPath, { width, height }, keepOriginal) 25 const thumbnailCreator = () => processImage(inputPath, outputPath, { width, height }, keepOriginal)
20 return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail }) 26 return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, automaticallyGenerated, existingThumbnail })
21} 27}
22 28
23function createPlaylistMiniatureFromUrl (fileUrl: string, playlist: VideoPlaylistModel, size?: ImageSize) { 29function createPlaylistMiniatureFromUrl (fileUrl: string, playlist: VideoPlaylistModel, size?: ImageSize) {
@@ -35,11 +41,17 @@ function createVideoMiniatureFromUrl (fileUrl: string, video: VideoModel, type:
35 return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl }) 41 return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl })
36} 42}
37 43
38function createVideoMiniatureFromExisting (inputPath: string, video: VideoModel, type: ThumbnailType, size?: ImageSize) { 44function createVideoMiniatureFromExisting (
45 inputPath: string,
46 video: VideoModel,
47 type: ThumbnailType,
48 automaticallyGenerated: boolean,
49 size?: ImageSize
50) {
39 const { filename, outputPath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size) 51 const { filename, outputPath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size)
40 const thumbnailCreator = () => processImage(inputPath, outputPath, { width, height }) 52 const thumbnailCreator = () => processImage(inputPath, outputPath, { width, height })
41 53
42 return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail }) 54 return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, automaticallyGenerated, existingThumbnail })
43} 55}
44 56
45function generateVideoMiniature (video: VideoModel, videoFile: VideoFileModel, type: ThumbnailType) { 57function generateVideoMiniature (video: VideoModel, videoFile: VideoFileModel, type: ThumbnailType) {
@@ -50,7 +62,7 @@ function generateVideoMiniature (video: VideoModel, videoFile: VideoFileModel, t
50 ? () => processImage(ASSETS_PATH.DEFAULT_AUDIO_BACKGROUND, outputPath, { width, height }, true) 62 ? () => processImage(ASSETS_PATH.DEFAULT_AUDIO_BACKGROUND, outputPath, { width, height }, true)
51 : () => generateImageFromVideoFile(input, basePath, filename, { height, width }) 63 : () => generateImageFromVideoFile(input, basePath, filename, { height, width })
52 64
53 return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail }) 65 return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, automaticallyGenerated: true, existingThumbnail })
54} 66}
55 67
56function createPlaceholderThumbnail (fileUrl: string, video: VideoModel, type: ThumbnailType, size: ImageSize) { 68function createPlaceholderThumbnail (fileUrl: string, video: VideoModel, type: ThumbnailType, size: ImageSize) {
@@ -134,10 +146,11 @@ async function createThumbnailFromFunction (parameters: {
134 height: number, 146 height: number,
135 width: number, 147 width: number,
136 type: ThumbnailType, 148 type: ThumbnailType,
149 automaticallyGenerated?: boolean,
137 fileUrl?: string, 150 fileUrl?: string,
138 existingThumbnail?: ThumbnailModel 151 existingThumbnail?: ThumbnailModel
139}) { 152}) {
140 const { thumbnailCreator, filename, width, height, type, existingThumbnail, fileUrl = null } = parameters 153 const { thumbnailCreator, filename, width, height, type, existingThumbnail, automaticallyGenerated = null, fileUrl = null } = parameters
141 154
142 const thumbnail = existingThumbnail ? existingThumbnail : new ThumbnailModel() 155 const thumbnail = existingThumbnail ? existingThumbnail : new ThumbnailModel()
143 156
@@ -146,6 +159,7 @@ async function createThumbnailFromFunction (parameters: {
146 thumbnail.width = width 159 thumbnail.width = width
147 thumbnail.type = type 160 thumbnail.type = type
148 thumbnail.fileUrl = fileUrl 161 thumbnail.fileUrl = fileUrl
162 thumbnail.automaticallyGenerated = automaticallyGenerated
149 163
150 await thumbnailCreator() 164 await thumbnailCreator()
151 165
diff --git a/server/models/video/thumbnail.ts b/server/models/video/thumbnail.ts
index 8faf0adba..b767a6874 100644
--- a/server/models/video/thumbnail.ts
+++ b/server/models/video/thumbnail.ts
@@ -44,6 +44,10 @@ export class ThumbnailModel extends Model<ThumbnailModel> {
44 @Column 44 @Column
45 fileUrl: string 45 fileUrl: string
46 46
47 @AllowNull(true)
48 @Column
49 automaticallyGenerated: boolean
50
47 @ForeignKey(() => VideoModel) 51 @ForeignKey(() => VideoModel)
48 @Column 52 @Column
49 videoId: number 53 videoId: number
@@ -88,7 +92,7 @@ export class ThumbnailModel extends Model<ThumbnailModel> {
88 } 92 }
89 93
90 @AfterDestroy 94 @AfterDestroy
91 static removeFilesAndSendDelete (instance: ThumbnailModel) { 95 static removeFiles (instance: ThumbnailModel) {
92 logger.info('Removing %s file %s.', ThumbnailModel.types[instance.type].label, instance.filename) 96 logger.info('Removing %s file %s.', ThumbnailModel.types[instance.type].label, instance.filename)
93 97
94 // Don't block the transaction 98 // Don't block the transaction
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
index bed6f8eaf..dd7653533 100644
--- a/server/models/video/video-playlist-element.ts
+++ b/server/models/video/video-playlist-element.ts
@@ -218,6 +218,24 @@ export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel>
218 }) 218 })
219 } 219 }
220 220
221 static loadFirstElementWithVideoThumbnail (videoPlaylistId: number) {
222 const query = {
223 order: getSort('position'),
224 where: {
225 videoPlaylistId
226 },
227 include: [
228 {
229 model: VideoModel.scope(VideoScopeNames.WITH_THUMBNAILS),
230 required: true
231 }
232 ]
233 }
234
235 return VideoPlaylistElementModel
236 .findOne(query)
237 }
238
221 static getNextPositionOf (videoPlaylistId: number, transaction?: Transaction) { 239 static getNextPositionOf (videoPlaylistId: number, transaction?: Transaction) {
222 const query: AggregateOptions<number> = { 240 const query: AggregateOptions<number> = {
223 where: { 241 where: {
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
index 61ff78bd2..c8e97c491 100644
--- a/server/models/video/video-playlist.ts
+++ b/server/models/video/video-playlist.ts
@@ -265,7 +265,6 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
265 VideoPlaylistElements: VideoPlaylistElementModel[] 265 VideoPlaylistElements: VideoPlaylistElementModel[]
266 266
267 @HasOne(() => ThumbnailModel, { 267 @HasOne(() => ThumbnailModel, {
268
269 foreignKey: { 268 foreignKey: {
270 name: 'videoPlaylistId', 269 name: 'videoPlaylistId',
271 allowNull: true 270 allowNull: true
@@ -434,6 +433,10 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
434 return !!this.Thumbnail 433 return !!this.Thumbnail
435 } 434 }
436 435
436 hasGeneratedThumbnail () {
437 return this.hasThumbnail() && this.Thumbnail.automaticallyGenerated === true
438 }
439
437 generateThumbnailName () { 440 generateThumbnailName () {
438 const extension = '.jpg' 441 const extension = '.jpg'
439 442
diff --git a/server/tests/api/videos/index.ts b/server/tests/api/videos/index.ts
index 93e1f3e98..72e6061bb 100644
--- a/server/tests/api/videos/index.ts
+++ b/server/tests/api/videos/index.ts
@@ -12,6 +12,7 @@ import './video-hls'
12import './video-imports' 12import './video-imports'
13import './video-nsfw' 13import './video-nsfw'
14import './video-playlists' 14import './video-playlists'
15import './video-playlist-thumbnails'
15import './video-privacy' 16import './video-privacy'
16import './video-schedule-update' 17import './video-schedule-update'
17import './video-transcoder' 18import './video-transcoder'
diff --git a/server/tests/api/videos/video-playlist-thumbnails.ts b/server/tests/api/videos/video-playlist-thumbnails.ts
new file mode 100644
index 000000000..73ab02c17
--- /dev/null
+++ b/server/tests/api/videos/video-playlist-thumbnails.ts
@@ -0,0 +1,262 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import {
6 addVideoInPlaylist,
7 cleanupTests,
8 createVideoPlaylist,
9 doubleFollow,
10 flushAndRunMultipleServers,
11 getVideoPlaylistsList, removeVideoFromPlaylist,
12 ServerInfo,
13 setAccessTokensToServers,
14 setDefaultVideoChannel,
15 testImage,
16 uploadVideoAndGetId,
17 waitJobs,
18 reorderVideosPlaylist
19} from '../../../../shared/extra-utils'
20import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
21
22const expect = chai.expect
23
24describe('Playlist thumbnail', function () {
25 let servers: ServerInfo[] = []
26
27 let playlistWithoutThumbnail: number
28 let playlistWithThumbnail: number
29
30 let withThumbnailE1: number
31 let withThumbnailE2: number
32 let withoutThumbnailE1: number
33 let withoutThumbnailE2: number
34
35 let video1: number
36 let video2: number
37
38 async function getPlaylistWithoutThumbnail (server: ServerInfo) {
39 const res = await getVideoPlaylistsList(server.url, 0, 10)
40
41 return res.body.data.find(p => p.displayName === 'playlist without thumbnail')
42 }
43
44 async function getPlaylistWithThumbnail (server: ServerInfo) {
45 const res = await getVideoPlaylistsList(server.url, 0, 10)
46
47 return res.body.data.find(p => p.displayName === 'playlist with thumbnail')
48 }
49
50 before(async function () {
51 this.timeout(120000)
52
53 servers = await flushAndRunMultipleServers(2, { transcoding: { enabled: false } })
54
55 // Get the access tokens
56 await setAccessTokensToServers(servers)
57 await setDefaultVideoChannel(servers)
58
59 // Server 1 and server 2 follow each other
60 await doubleFollow(servers[0], servers[1])
61
62 video1 = (await uploadVideoAndGetId({ server: servers[0], videoName: 'video 1' })).id
63 video2 = (await uploadVideoAndGetId({ server: servers[0], videoName: 'video 2' })).id
64
65 await waitJobs(servers)
66 })
67
68 it('Should automatically update the thumbnail when adding an element', async function () {
69 this.timeout(30000)
70
71 const res = await createVideoPlaylist({
72 url: servers[ 1 ].url,
73 token: servers[ 1 ].accessToken,
74 playlistAttrs: {
75 displayName: 'playlist without thumbnail',
76 privacy: VideoPlaylistPrivacy.PUBLIC,
77 videoChannelId: servers[ 1 ].videoChannel.id
78 }
79 })
80 playlistWithoutThumbnail = res.body.videoPlaylist.id
81
82 const res2 = await addVideoInPlaylist({
83 url: servers[ 1 ].url,
84 token: servers[ 1 ].accessToken,
85 playlistId: playlistWithoutThumbnail,
86 elementAttrs: { videoId: video1 }
87 })
88 withoutThumbnailE1 = res2.body.videoPlaylistElement.id
89
90 await waitJobs(servers)
91
92 for (const server of servers) {
93 const p = await getPlaylistWithoutThumbnail(server)
94 await testImage(server.url, 'thumbnail-playlist', p.thumbnailPath)
95 }
96 })
97
98 it('Should not update the thumbnail if we explicitly uploaded a thumbnail', async function () {
99 this.timeout(30000)
100
101 const res = await createVideoPlaylist({
102 url: servers[ 1 ].url,
103 token: servers[ 1 ].accessToken,
104 playlistAttrs: {
105 displayName: 'playlist with thumbnail',
106 privacy: VideoPlaylistPrivacy.PUBLIC,
107 videoChannelId: servers[ 1 ].videoChannel.id,
108 thumbnailfile: 'thumbnail.jpg'
109 }
110 })
111 playlistWithThumbnail = res.body.videoPlaylist.id
112
113 const res2 = await addVideoInPlaylist({
114 url: servers[ 1 ].url,
115 token: servers[ 1 ].accessToken,
116 playlistId: playlistWithThumbnail,
117 elementAttrs: { videoId: video1 }
118 })
119 withThumbnailE1 = res2.body.videoPlaylistElement.id
120
121 await waitJobs(servers)
122
123 for (const server of servers) {
124 const p = await getPlaylistWithThumbnail(server)
125 await testImage(server.url, 'thumbnail', p.thumbnailPath)
126 }
127 })
128
129 it('Should automatically update the thumbnail when moving the first element', async function () {
130 this.timeout(30000)
131
132 const res = await addVideoInPlaylist({
133 url: servers[ 1 ].url,
134 token: servers[ 1 ].accessToken,
135 playlistId: playlistWithoutThumbnail,
136 elementAttrs: { videoId: video2 }
137 })
138 withoutThumbnailE2 = res.body.videoPlaylistElement.id
139
140 await reorderVideosPlaylist({
141 url: servers[1].url,
142 token: servers[1].accessToken,
143 playlistId: playlistWithoutThumbnail,
144 elementAttrs: {
145 startPosition: 1,
146 insertAfterPosition: 2
147 }
148 })
149
150 await waitJobs(servers)
151
152 for (const server of servers) {
153 const p = await getPlaylistWithoutThumbnail(server)
154 await testImage(server.url, 'thumbnail-playlist', p.thumbnailPath)
155 }
156 })
157
158 it('Should not update the thumbnail when moving the first element if we explicitly uploaded a thumbnail', async function () {
159 this.timeout(30000)
160
161 const res = await addVideoInPlaylist({
162 url: servers[ 1 ].url,
163 token: servers[ 1 ].accessToken,
164 playlistId: playlistWithThumbnail,
165 elementAttrs: { videoId: video2 }
166 })
167 withThumbnailE2 = res.body.videoPlaylistElement.id
168
169 await reorderVideosPlaylist({
170 url: servers[1].url,
171 token: servers[1].accessToken,
172 playlistId: playlistWithThumbnail,
173 elementAttrs: {
174 startPosition: 1,
175 insertAfterPosition: 2
176 }
177 })
178
179 await waitJobs(servers)
180
181 for (const server of servers) {
182 const p = await getPlaylistWithThumbnail(server)
183 await testImage(server.url, 'thumbnail', p.thumbnailPath)
184 }
185 })
186
187 it('Should automatically update the thumbnail when deleting the first element', async function () {
188 this.timeout(30000)
189
190 await removeVideoFromPlaylist({
191 url: servers[ 1 ].url,
192 token: servers[ 1 ].accessToken,
193 playlistId: playlistWithoutThumbnail,
194 playlistElementId: withoutThumbnailE1
195 })
196
197 await waitJobs(servers)
198
199 for (const server of servers) {
200 const p = await getPlaylistWithoutThumbnail(server)
201 await testImage(server.url, 'thumbnail-playlist', p.thumbnailPath)
202 }
203 })
204
205 it('Should not update the thumbnail when deleting the first element if we explicitly uploaded a thumbnail', async function () {
206 this.timeout(30000)
207
208 await removeVideoFromPlaylist({
209 url: servers[ 1 ].url,
210 token: servers[ 1 ].accessToken,
211 playlistId: playlistWithThumbnail,
212 playlistElementId: withThumbnailE1
213 })
214
215 await waitJobs(servers)
216
217 for (const server of servers) {
218 const p = await getPlaylistWithThumbnail(server)
219 await testImage(server.url, 'thumbnail', p.thumbnailPath)
220 }
221 })
222
223 it('Should the thumbnail when we delete the last element', async function () {
224 this.timeout(30000)
225
226 await removeVideoFromPlaylist({
227 url: servers[ 1 ].url,
228 token: servers[ 1 ].accessToken,
229 playlistId: playlistWithoutThumbnail,
230 playlistElementId: withoutThumbnailE2
231 })
232
233 await waitJobs(servers)
234
235 for (const server of servers) {
236 const p = await getPlaylistWithoutThumbnail(server)
237 expect(p.thumbnailPath).to.be.null
238 }
239 })
240
241 it('Should not update the thumbnail when we delete the last element if we explicitly uploaded a thumbnail', async function () {
242 this.timeout(30000)
243
244 await removeVideoFromPlaylist({
245 url: servers[ 1 ].url,
246 token: servers[ 1 ].accessToken,
247 playlistId: playlistWithThumbnail,
248 playlistElementId: withThumbnailE2
249 })
250
251 await waitJobs(servers)
252
253 for (const server of servers) {
254 const p = await getPlaylistWithThumbnail(server)
255 await testImage(server.url, 'thumbnail', p.thumbnailPath)
256 }
257 })
258
259 after(async function () {
260 await cleanupTests(servers)
261 })
262})
diff --git a/server/tests/api/videos/video-playlists.ts b/server/tests/api/videos/video-playlists.ts
index 7d5e3914b..424b217fb 100644
--- a/server/tests/api/videos/video-playlists.ts
+++ b/server/tests/api/videos/video-playlists.ts
@@ -344,6 +344,7 @@ describe('Test video playlists', function () {
344 }) 344 })
345 345
346 describe('List playlists', function () { 346 describe('List playlists', function () {
347
347 it('Should correctly list the playlists', async function () { 348 it('Should correctly list the playlists', async function () {
348 this.timeout(30000) 349 this.timeout(30000)
349 350