Browse Source

Create a dedicated table to track video thumbnails

Chocobozzz 7 months ago
parent
commit
e8bafea35b
No account linked to committer's email address

+ 0
- 2
server.ts View File

@@ -255,8 +255,6 @@ async function startApplication () {
255 255
 
256 256
   // Make server listening
257 257
   server.listen(port, hostname, () => {
258
-    logger.debug('CONFIG', { CONFIG })
259
-
260 258
     logger.info('Server listening on %s:%d', hostname, port)
261 259
     logger.info('Web server: %s', WEBSERVER.URL)
262 260
   })

+ 25
- 22
server/controllers/api/video-playlist.ts View File

@@ -12,7 +12,7 @@ import {
12 12
 } from '../../middlewares'
13 13
 import { videoPlaylistsSortValidator } from '../../middlewares/validators'
14 14
 import { buildNSFWFilter, createReqFiles, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
15
-import { MIMETYPES, THUMBNAILS_SIZE, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constants'
15
+import { MIMETYPES, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constants'
16 16
 import { logger } from '../../helpers/logger'
17 17
 import { resetSequelizeInstance } from '../../helpers/database-utils'
18 18
 import { VideoPlaylistModel } from '../../models/video/video-playlist'
@@ -28,7 +28,6 @@ import {
28 28
 } from '../../middlewares/validators/videos/video-playlists'
29 29
 import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model'
30 30
 import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
31
-import { processImage } from '../../helpers/image-utils'
32 31
 import { join } from 'path'
33 32
 import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send'
34 33
 import { getVideoPlaylistActivityPubUrl, getVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url'
@@ -37,12 +36,12 @@ import { VideoModel } from '../../models/video/video'
37 36
 import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
38 37
 import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model'
39 38
 import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model'
40
-import { copy, pathExists } from 'fs-extra'
41 39
 import { AccountModel } from '../../models/account/account'
42 40
 import { VideoPlaylistReorder } from '../../../shared/models/videos/playlist/video-playlist-reorder.model'
43 41
 import { JobQueue } from '../../lib/job-queue'
44 42
 import { CONFIG } from '../../initializers/config'
45 43
 import { sequelizeTypescript } from '../../initializers/database'
44
+import { createPlaylistThumbnailFromExisting } from '../../lib/thumbnail'
46 45
 
47 46
 const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR })
48 47
 
@@ -174,14 +173,18 @@ async function addVideoPlaylist (req: express.Request, res: express.Response) {
174 173
   }
175 174
 
176 175
   const thumbnailField = req.files['thumbnailfile']
177
-  if (thumbnailField) {
178
-    const thumbnailPhysicalFile = thumbnailField[ 0 ]
179
-    await processImage(thumbnailPhysicalFile, join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName()), THUMBNAILS_SIZE)
180
-  }
176
+  const thumbnailModel = thumbnailField
177
+    ? await createPlaylistThumbnailFromExisting(thumbnailField[0].path, videoPlaylist)
178
+    : undefined
181 179
 
182 180
   const videoPlaylistCreated: VideoPlaylistModel = await sequelizeTypescript.transaction(async t => {
183 181
     const videoPlaylistCreated = await videoPlaylist.save({ transaction: t })
184 182
 
183
+    if (thumbnailModel) {
184
+      thumbnailModel.videoPlaylistId = videoPlaylistCreated.id
185
+      videoPlaylistCreated.setThumbnail(await thumbnailModel.save({ transaction: t }))
186
+    }
187
+
185 188
     // We need more attributes for the federation
186 189
     videoPlaylistCreated.OwnerAccount = await AccountModel.load(user.Account.id, t)
187 190
     await sendCreateVideoPlaylist(videoPlaylistCreated, t)
@@ -206,14 +209,9 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
206 209
   const wasPrivatePlaylist = videoPlaylistInstance.privacy === VideoPlaylistPrivacy.PRIVATE
207 210
 
208 211
   const thumbnailField = req.files['thumbnailfile']
209
-  if (thumbnailField) {
210
-    const thumbnailPhysicalFile = thumbnailField[ 0 ]
211
-    await processImage(
212
-      thumbnailPhysicalFile,
213
-      join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylistInstance.getThumbnailName()),
214
-      THUMBNAILS_SIZE
215
-    )
216
-  }
212
+  const thumbnailModel = thumbnailField
213
+    ? await createPlaylistThumbnailFromExisting(thumbnailField[0].path, videoPlaylistInstance)
214
+    : undefined
217 215
 
218 216
   try {
219 217
     await sequelizeTypescript.transaction(async t => {
@@ -241,6 +239,11 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
241 239
 
242 240
       const playlistUpdated = await videoPlaylistInstance.save(sequelizeOptions)
243 241
 
242
+      if (thumbnailModel) {
243
+        thumbnailModel.videoPlaylistId = playlistUpdated.id
244
+        playlistUpdated.setThumbnail(await thumbnailModel.save({ transaction: t }))
245
+      }
246
+
244 247
       const isNewPlaylist = wasPrivatePlaylist && playlistUpdated.privacy !== VideoPlaylistPrivacy.PRIVATE
245 248
 
246 249
       if (isNewPlaylist) {
@@ -307,15 +310,15 @@ async function addVideoInPlaylist (req: express.Request, res: express.Response)
307 310
   })
308 311
 
309 312
   // If the user did not set a thumbnail, automatically take the video thumbnail
310
-  if (playlistElement.position === 1) {
311
-    const playlistThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName())
313
+  if (playlistElement.position === 1 && videoPlaylist.hasThumbnail() === false) {
314
+    logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url)
312 315
 
313
-    if (await pathExists(playlistThumbnailPath) === false) {
314
-      logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url)
316
+    const inputPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnail().filename)
317
+    const thumbnailModel = await createPlaylistThumbnailFromExisting(inputPath, videoPlaylist, true)
315 318
 
316
-      const videoThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName())
317
-      await copy(videoThumbnailPath, playlistThumbnailPath)
318
-    }
319
+    thumbnailModel.videoPlaylistId = videoPlaylist.id
320
+
321
+    await thumbnailModel.save()
319 322
   }
320 323
 
321 324
   logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position)

+ 48
- 22
server/controllers/api/videos/import.ts View File

@@ -3,7 +3,7 @@ import * as magnetUtil from 'magnet-uri'
3 3
 import 'multer'
4 4
 import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger'
5 5
 import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares'
6
-import { MIMETYPES, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../../../initializers/constants'
6
+import { MIMETYPES } from '../../../initializers/constants'
7 7
 import { getYoutubeDLInfo, YoutubeDLInfo } from '../../../helpers/youtube-dl'
8 8
 import { createReqFiles } from '../../../helpers/express-utils'
9 9
 import { logger } from '../../../helpers/logger'
@@ -13,12 +13,10 @@ import { getVideoActivityPubUrl } from '../../../lib/activitypub'
13 13
 import { TagModel } from '../../../models/video/tag'
14 14
 import { VideoImportModel } from '../../../models/video/video-import'
15 15
 import { JobQueue } from '../../../lib/job-queue/job-queue'
16
-import { processImage } from '../../../helpers/image-utils'
17 16
 import { join } from 'path'
18 17
 import { isArray } from '../../../helpers/custom-validators/misc'
19 18
 import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
20 19
 import { VideoChannelModel } from '../../../models/video/video-channel'
21
-import { UserModel } from '../../../models/account/user'
22 20
 import * as Bluebird from 'bluebird'
23 21
 import * as parseTorrent from 'parse-torrent'
24 22
 import { getSecureTorrentName } from '../../../helpers/utils'
@@ -26,6 +24,9 @@ import { move, readFile } from 'fs-extra'
26 24
 import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
27 25
 import { CONFIG } from '../../../initializers/config'
28 26
 import { sequelizeTypescript } from '../../../initializers/database'
27
+import { createVideoThumbnailFromExisting } from '../../../lib/thumbnail'
28
+import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
29
+import { ThumbnailModel } from '../../../models/video/thumbnail'
29 30
 
30 31
 const auditLogger = auditLoggerFactory('video-imports')
31 32
 const videoImportsRouter = express.Router()
@@ -89,10 +90,10 @@ async function addTorrentImport (req: express.Request, res: express.Response, to
89 90
     videoName = isArray(parsed.name) ? parsed.name[ 0 ] : parsed.name as string
90 91
   }
91 92
 
92
-  const video = buildVideo(res.locals.videoChannel.id, body, { name: videoName }, user)
93
+  const video = buildVideo(res.locals.videoChannel.id, body, { name: videoName })
93 94
 
94
-  await processThumbnail(req, video)
95
-  await processPreview(req, video)
95
+  const thumbnailModel = await processThumbnail(req, video)
96
+  const previewModel = await processPreview(req, video)
96 97
 
97 98
   const tags = body.tags || undefined
98 99
   const videoImportAttributes = {
@@ -101,7 +102,14 @@ async function addTorrentImport (req: express.Request, res: express.Response, to
101 102
     state: VideoImportState.PENDING,
102 103
     userId: user.id
103 104
   }
104
-  const videoImport = await insertIntoDB(video, res.locals.videoChannel, tags, videoImportAttributes)
105
+  const videoImport = await insertIntoDB({
106
+    video,
107
+    thumbnailModel,
108
+    previewModel,
109
+    videoChannel: res.locals.videoChannel,
110
+    tags,
111
+    videoImportAttributes
112
+  })
105 113
 
106 114
   // Create job to import the video
107 115
   const payload = {
@@ -132,10 +140,10 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
132 140
     }).end()
133 141
   }
134 142
 
135
-  const video = buildVideo(res.locals.videoChannel.id, body, youtubeDLInfo, user)
143
+  const video = buildVideo(res.locals.videoChannel.id, body, youtubeDLInfo)
136 144
 
137
-  const downloadThumbnail = !await processThumbnail(req, video)
138
-  const downloadPreview = !await processPreview(req, video)
145
+  const thumbnailModel = await processThumbnail(req, video)
146
+  const previewModel = await processPreview(req, video)
139 147
 
140 148
   const tags = body.tags || youtubeDLInfo.tags
141 149
   const videoImportAttributes = {
@@ -143,15 +151,22 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
143 151
     state: VideoImportState.PENDING,
144 152
     userId: user.id
145 153
   }
146
-  const videoImport = await insertIntoDB(video, res.locals.videoChannel, tags, videoImportAttributes)
154
+  const videoImport = await insertIntoDB({
155
+    video: video,
156
+    thumbnailModel,
157
+    previewModel,
158
+    videoChannel: res.locals.videoChannel,
159
+    tags,
160
+    videoImportAttributes
161
+  })
147 162
 
148 163
   // Create job to import the video
149 164
   const payload = {
150 165
     type: 'youtube-dl' as 'youtube-dl',
151 166
     videoImportId: videoImport.id,
152 167
     thumbnailUrl: youtubeDLInfo.thumbnailUrl,
153
-    downloadThumbnail,
154
-    downloadPreview
168
+    downloadThumbnail: !thumbnailModel,
169
+    downloadPreview: !previewModel
155 170
   }
156 171
   await JobQueue.Instance.createJob({ type: 'video-import', payload })
157 172
 
@@ -160,7 +175,7 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
160 175
   return res.json(videoImport.toFormattedJSON()).end()
161 176
 }
162 177
 
163
-function buildVideo (channelId: number, body: VideoImportCreate, importData: YoutubeDLInfo, user: UserModel) {
178
+function buildVideo (channelId: number, body: VideoImportCreate, importData: YoutubeDLInfo) {
164 179
   const videoData = {
165 180
     name: body.name || importData.name || 'Unknown name',
166 181
     remote: false,
@@ -189,32 +204,34 @@ async function processThumbnail (req: express.Request, video: VideoModel) {
189 204
   const thumbnailField = req.files ? req.files['thumbnailfile'] : undefined
190 205
   if (thumbnailField) {
191 206
     const thumbnailPhysicalFile = thumbnailField[ 0 ]
192
-    await processImage(thumbnailPhysicalFile, join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName()), THUMBNAILS_SIZE)
193 207
 
194
-    return true
208
+    return createVideoThumbnailFromExisting(thumbnailPhysicalFile.path, video, ThumbnailType.THUMBNAIL)
195 209
   }
196 210
 
197
-  return false
211
+  return undefined
198 212
 }
199 213
 
200 214
 async function processPreview (req: express.Request, video: VideoModel) {
201 215
   const previewField = req.files ? req.files['previewfile'] : undefined
202 216
   if (previewField) {
203 217
     const previewPhysicalFile = previewField[0]
204
-    await processImage(previewPhysicalFile, join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName()), PREVIEWS_SIZE)
205 218
 
206
-    return true
219
+    return createVideoThumbnailFromExisting(previewPhysicalFile.path, video, ThumbnailType.PREVIEW)
207 220
   }
208 221
 
209
-  return false
222
+  return undefined
210 223
 }
211 224
 
212
-function insertIntoDB (
225
+function insertIntoDB (parameters: {
213 226
   video: VideoModel,
227
+  thumbnailModel: ThumbnailModel,
228
+  previewModel: ThumbnailModel,
214 229
   videoChannel: VideoChannelModel,
215 230
   tags: string[],
216 231
   videoImportAttributes: FilteredModelAttributes<VideoImportModel>
217
-): Bluebird<VideoImportModel> {
232
+}): Bluebird<VideoImportModel> {
233
+  let { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes } = parameters
234
+
218 235
   return sequelizeTypescript.transaction(async t => {
219 236
     const sequelizeOptions = { transaction: t }
220 237
 
@@ -222,6 +239,15 @@ function insertIntoDB (
222 239
     const videoCreated = await video.save(sequelizeOptions)
223 240
     videoCreated.VideoChannel = videoChannel
224 241
 
242
+    if (thumbnailModel) {
243
+      thumbnailModel.videoId = videoCreated.id
244
+      videoCreated.addThumbnail(await thumbnailModel.save({ transaction: t }))
245
+    }
246
+    if (previewModel) {
247
+      previewModel.videoId = videoCreated.id
248
+      videoCreated.addThumbnail(await previewModel.save({ transaction: t }))
249
+    }
250
+
225 251
     await autoBlacklistVideoIfNeeded(video, videoChannel.Account.User, t)
226 252
 
227 253
     // Set tags to the video

+ 31
- 31
server/controllers/api/videos/index.ts View File

@@ -2,20 +2,11 @@ import * as express from 'express'
2 2
 import { extname, join } from 'path'
3 3
 import { VideoCreate, VideoPrivacy, VideoState, VideoUpdate } from '../../../../shared'
4 4
 import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils'
5
-import { processImage } from '../../../helpers/image-utils'
6 5
 import { logger } from '../../../helpers/logger'
7 6
 import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
8 7
 import { getFormattedObjects, getServerActor } from '../../../helpers/utils'
9 8
 import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
10
-import {
11
-  MIMETYPES,
12
-  PREVIEWS_SIZE,
13
-  THUMBNAILS_SIZE,
14
-  VIDEO_CATEGORIES,
15
-  VIDEO_LANGUAGES,
16
-  VIDEO_LICENCES,
17
-  VIDEO_PRIVACIES
18
-} from '../../../initializers/constants'
9
+import { MIMETYPES, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers/constants'
19 10
 import {
20 11
   changeVideoChannelShare,
21 12
   federateVideoIfNeeded,
@@ -61,6 +52,8 @@ import { Notifier } from '../../../lib/notifier'
61 52
 import { sendView } from '../../../lib/activitypub/send/send-view'
62 53
 import { CONFIG } from '../../../initializers/config'
63 54
 import { sequelizeTypescript } from '../../../initializers/database'
55
+import { createVideoThumbnailFromExisting, generateVideoThumbnail } from '../../../lib/thumbnail'
56
+import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
64 57
 
65 58
 const auditLogger = auditLoggerFactory('videos')
66 59
 const videosRouter = express.Router()
@@ -220,21 +213,15 @@ async function addVideo (req: express.Request, res: express.Response) {
220 213
 
221 214
   // Process thumbnail or create it from the video
222 215
   const thumbnailField = req.files['thumbnailfile']
223
-  if (thumbnailField) {
224
-    const thumbnailPhysicalFile = thumbnailField[0]
225
-    await processImage(thumbnailPhysicalFile, join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName()), THUMBNAILS_SIZE)
226
-  } else {
227
-    await video.createThumbnail(videoFile)
228
-  }
216
+  const thumbnailModel = thumbnailField
217
+    ? await createVideoThumbnailFromExisting(thumbnailField[0].path, video, ThumbnailType.THUMBNAIL)
218
+    : await generateVideoThumbnail(video, videoFile, ThumbnailType.THUMBNAIL)
229 219
 
230 220
   // Process preview or create it from the video
231 221
   const previewField = req.files['previewfile']
232
-  if (previewField) {
233
-    const previewPhysicalFile = previewField[0]
234
-    await processImage(previewPhysicalFile, join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName()), PREVIEWS_SIZE)
235
-  } else {
236
-    await video.createPreview(videoFile)
237
-  }
222
+  const previewModel = previewField
223
+    ? await createVideoThumbnailFromExisting(previewField[0].path, video, ThumbnailType.PREVIEW)
224
+    : await generateVideoThumbnail(video, videoFile, ThumbnailType.PREVIEW)
238 225
 
239 226
   // Create the torrent file
240 227
   await video.createTorrentAndSetInfoHash(videoFile)
@@ -243,6 +230,13 @@ async function addVideo (req: express.Request, res: express.Response) {
243 230
     const sequelizeOptions = { transaction: t }
244 231
 
245 232
     const videoCreated = await video.save(sequelizeOptions)
233
+
234
+    thumbnailModel.videoId = videoCreated.id
235
+    previewModel.videoId = videoCreated.id
236
+
237
+    videoCreated.addThumbnail(await thumbnailModel.save({ transaction: t }))
238
+    videoCreated.addThumbnail(await previewModel.save({ transaction: t }))
239
+
246 240
     // Do not forget to add video channel information to the created video
247 241
     videoCreated.VideoChannel = res.locals.videoChannel
248 242
 
@@ -313,16 +307,13 @@ async function updateVideo (req: express.Request, res: express.Response) {
313 307
   const wasUnlistedVideo = videoInstance.privacy === VideoPrivacy.UNLISTED
314 308
 
315 309
   // Process thumbnail or create it from the video
316
-  if (req.files && req.files['thumbnailfile']) {
317
-    const thumbnailPhysicalFile = req.files['thumbnailfile'][0]
318
-    await processImage(thumbnailPhysicalFile, join(CONFIG.STORAGE.THUMBNAILS_DIR, videoInstance.getThumbnailName()), THUMBNAILS_SIZE)
319
-  }
310
+  const thumbnailModel = req.files && req.files['thumbnailfile']
311
+    ? await createVideoThumbnailFromExisting(req.files['thumbnailfile'][0].path, videoInstance, ThumbnailType.THUMBNAIL)
312
+    : undefined
320 313
 
321
-  // Process preview or create it from the video
322
-  if (req.files && req.files['previewfile']) {
323
-    const previewPhysicalFile = req.files['previewfile'][0]
324
-    await processImage(previewPhysicalFile, join(CONFIG.STORAGE.PREVIEWS_DIR, videoInstance.getPreviewName()), PREVIEWS_SIZE)
325
-  }
314
+  const previewModel = req.files && req.files['previewfile']
315
+    ? await createVideoThumbnailFromExisting(req.files['previewfile'][0].path, videoInstance, ThumbnailType.PREVIEW)
316
+    : undefined
326 317
 
327 318
   try {
328 319
     const videoInstanceUpdated = await sequelizeTypescript.transaction(async t => {
@@ -355,6 +346,15 @@ async function updateVideo (req: express.Request, res: express.Response) {
355 346
 
356 347
       const videoInstanceUpdated = await videoInstance.save(sequelizeOptions)
357 348
 
349
+      if (thumbnailModel) {
350
+        thumbnailModel.videoId = videoInstanceUpdated.id
351
+        videoInstanceUpdated.addThumbnail(await thumbnailModel.save({ transaction: t }))
352
+      }
353
+      if (previewModel) {
354
+        previewModel.videoId = videoInstanceUpdated.id
355
+        videoInstanceUpdated.addThumbnail(await previewModel.save({ transaction: t }))
356
+      }
357
+
358 358
       // Video tags update?
359 359
       if (videoInfoToUpdate.tags !== undefined) {
360 360
         const tagInstances = await TagModel.findOrCreateTags(videoInfoToUpdate.tags, t)

+ 0
- 1
server/controllers/api/videos/ownership.ts View File

@@ -17,7 +17,6 @@ import { VideoChannelModel } from '../../../models/video/video-channel'
17 17
 import { getFormattedObjects } from '../../../helpers/utils'
18 18
 import { changeVideoChannelShare } from '../../../lib/activitypub'
19 19
 import { sendUpdateVideo } from '../../../lib/activitypub/send'
20
-import { UserModel } from '../../../models/account/user'
21 20
 
22 21
 const ownershipVideoRouter = express.Router()
23 22
 

+ 1
- 1
server/controllers/static.ts View File

@@ -164,7 +164,7 @@ export {
164 164
 
165 165
 // ---------------------------------------------------------------------------
166 166
 
167
-async function getPreview (req: express.Request, res: express.Response, next: express.NextFunction) {
167
+async function getPreview (req: express.Request, res: express.Response) {
168 168
   const path = await VideosPreviewCache.Instance.getFilePath(req.params.uuid)
169 169
   if (!path) return res.sendStatus(404)
170 170
 

+ 3
- 2
server/helpers/image-utils.ts View File

@@ -6,7 +6,8 @@ import { logger } from './logger'
6 6
 async function processImage (
7 7
   physicalFile: { path: string },
8 8
   destination: string,
9
-  newSize: { width: number, height: number }
9
+  newSize: { width: number, height: number },
10
+  keepOriginal = false
10 11
 ) {
11 12
   if (physicalFile.path === destination) {
12 13
     throw new Error('Sharp needs an input path different that the output path.')
@@ -24,7 +25,7 @@ async function processImage (
24 25
     .resize(newSize.width, newSize.height)
25 26
     .toFile(destination)
26 27
 
27
-  await remove(physicalFile.path)
28
+  if (keepOriginal !== true) await remove(physicalFile.path)
28 29
 }
29 30
 
30 31
 // ---------------------------------------------------------------------------

+ 3
- 1
server/initializers/database.ts View File

@@ -36,6 +36,7 @@ import { UserNotificationSettingModel } from '../models/account/user-notificatio
36 36
 import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
37 37
 import { VideoPlaylistModel } from '../models/video/video-playlist'
38 38
 import { VideoPlaylistElementModel } from '../models/video/video-playlist-element'
39
+import { ThumbnailModel } from '../models/video/thumbnail'
39 40
 
40 41
 require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
41 42
 
@@ -105,7 +106,8 @@ async function initDatabaseModels (silent: boolean) {
105 106
     UserNotificationSettingModel,
106 107
     VideoStreamingPlaylistModel,
107 108
     VideoPlaylistModel,
108
-    VideoPlaylistElementModel
109
+    VideoPlaylistElementModel,
110
+    ThumbnailModel
109 111
   ])
110 112
 
111 113
   // Check extensions exist in the database

+ 11
- 14
server/lib/activitypub/playlist.ts View File

@@ -1,12 +1,12 @@
1 1
 import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
2 2
 import { crawlCollectionPage } from './crawl'
3
-import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY, THUMBNAILS_SIZE } from '../../initializers/constants'
3
+import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants'
4 4
 import { AccountModel } from '../../models/account/account'
5 5
 import { isArray } from '../../helpers/custom-validators/misc'
6 6
 import { getOrCreateActorAndServerAndModel } from './actor'
7 7
 import { logger } from '../../helpers/logger'
8 8
 import { VideoPlaylistModel } from '../../models/video/video-playlist'
9
-import { doRequest, downloadImage } from '../../helpers/requests'
9
+import { doRequest } from '../../helpers/requests'
10 10
 import { checkUrlsSameHost } from '../../helpers/activitypub'
11 11
 import * as Bluebird from 'bluebird'
12 12
 import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object'
@@ -16,9 +16,8 @@ import { VideoPlaylistElementModel } from '../../models/video/video-playlist-ele
16 16
 import { VideoModel } from '../../models/video/video'
17 17
 import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
18 18
 import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
19
-import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
20
-import { CONFIG } from '../../initializers/config'
21 19
 import { sequelizeTypescript } from '../../initializers/database'
20
+import { createPlaylistThumbnailFromUrl } from '../thumbnail'
22 21
 
23 22
 function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount: AccountModel, to: string[]) {
24 23
   const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPlaylistPrivacy.PUBLIC : VideoPlaylistPrivacy.UNLISTED
@@ -97,16 +96,20 @@ async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAc
97 96
     return Promise.resolve()
98 97
   })
99 98
 
100
-  // Empty playlists generally do not have a miniature, so skip this
101
-  if (accItems.length !== 0) {
99
+  const refreshedPlaylist = await VideoPlaylistModel.loadWithAccountAndChannel(playlist.id, null)
100
+
101
+  if (playlistObject.icon) {
102 102
     try {
103
-      await generateThumbnailFromUrl(playlist, playlistObject.icon)
103
+      const thumbnailModel = await createPlaylistThumbnailFromUrl(playlistObject.icon.url, refreshedPlaylist)
104
+      thumbnailModel.videoPlaylistId = refreshedPlaylist.id
105
+
106
+      refreshedPlaylist.setThumbnail(await thumbnailModel.save())
104 107
     } catch (err) {
105 108
       logger.warn('Cannot generate thumbnail of %s.', playlistObject.id, { err })
106 109
     }
107 110
   }
108 111
 
109
-  return resetVideoPlaylistElements(accItems, playlist)
112
+  return resetVideoPlaylistElements(accItems, refreshedPlaylist)
110 113
 }
111 114
 
112 115
 async function refreshVideoPlaylistIfNeeded (videoPlaylist: VideoPlaylistModel): Promise<VideoPlaylistModel> {
@@ -191,12 +194,6 @@ async function resetVideoPlaylistElements (elementUrls: string[], playlist: Vide
191 194
   return undefined
192 195
 }
193 196
 
194
-function generateThumbnailFromUrl (playlist: VideoPlaylistModel, icon: ActivityIconObject) {
195
-  const thumbnailName = playlist.getThumbnailName()
196
-
197
-  return downloadImage(icon.url, CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName, THUMBNAILS_SIZE)
198
-}
199
-
200 197
 async function fetchRemoteVideoPlaylist (playlistUrl: string): Promise<{ statusCode: number, playlistObject: PlaylistObject }> {
201 198
   const options = {
202 199
     uri: playlistUrl,

+ 68
- 24
server/lib/activitypub/videos.ts View File

@@ -3,11 +3,10 @@ import * as sequelize from 'sequelize'
3 3
 import * as magnetUtil from 'magnet-uri'
4 4
 import * as request from 'request'
5 5
 import {
6
-  ActivityIconObject,
7 6
   ActivityPlaylistSegmentHashesObject,
8 7
   ActivityPlaylistUrlObject,
9 8
   ActivityUrlObject,
10
-  ActivityVideoUrlObject,
9
+  ActivityVideoUrlObject, VideoCreate,
11 10
   VideoState
12 11
 } from '../../../shared/index'
13 12
 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
@@ -16,8 +15,15 @@ import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validat
16 15
 import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
17 16
 import { resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils'
18 17
 import { logger } from '../../helpers/logger'
19
-import { doRequest, downloadImage } from '../../helpers/requests'
20
-import { ACTIVITY_PUB, MIMETYPES, P2P_MEDIA_LOADER_PEER_VERSION, REMOTE_SCHEME, THUMBNAILS_SIZE } from '../../initializers/constants'
18
+import { doRequest } from '../../helpers/requests'
19
+import {
20
+  ACTIVITY_PUB,
21
+  MIMETYPES,
22
+  P2P_MEDIA_LOADER_PEER_VERSION,
23
+  PREVIEWS_SIZE,
24
+  REMOTE_SCHEME,
25
+  STATIC_PATHS
26
+} from '../../initializers/constants'
21 27
 import { ActorModel } from '../../models/activitypub/actor'
22 28
 import { TagModel } from '../../models/video/tag'
23 29
 import { VideoModel } from '../../models/video/video'
@@ -43,8 +49,11 @@ import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
43 49
 import { AccountVideoRateModel } from '../../models/account/account-video-rate'
44 50
 import { VideoShareModel } from '../../models/video/video-share'
45 51
 import { VideoCommentModel } from '../../models/video/video-comment'
46
-import { CONFIG } from '../../initializers/config'
47 52
 import { sequelizeTypescript } from '../../initializers/database'
53
+import { createPlaceholderThumbnail, createVideoThumbnailFromUrl } from '../thumbnail'
54
+import { ThumbnailModel } from '../../models/video/thumbnail'
55
+import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
56
+import { join } from 'path'
48 57
 
49 58
 async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
50 59
   // If the video is not private and is published, we federate it
@@ -100,18 +109,18 @@ async function fetchRemoteVideoDescription (video: VideoModel) {
100 109
 }
101 110
 
102 111
 function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) {
103
-  const host = video.VideoChannel.Account.Actor.Server.host
112
+  const url = buildRemoteBaseUrl(video, path)
104 113
 
105 114
   // We need to provide a callback, if no we could have an uncaught exception
106
-  return request.get(REMOTE_SCHEME.HTTP + '://' + host + path, err => {
115
+  return request.get(url, err => {
107 116
     if (err) reject(err)
108 117
   })
109 118
 }
110 119
 
111
-function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) {
112
-  const thumbnailName = video.getThumbnailName()
120
+function buildRemoteBaseUrl (video: VideoModel, path: string) {
121
+  const host = video.VideoChannel.Account.Actor.Server.host
113 122
 
114
-  return downloadImage(icon.url, CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName, THUMBNAILS_SIZE)
123
+  return REMOTE_SCHEME.HTTP + '://' + host + path
115 124
 }
116 125
 
117 126
 function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
@@ -236,6 +245,14 @@ async function updateVideoFromAP (options: {
236 245
   const wasUnlistedVideo = options.video.privacy === VideoPrivacy.UNLISTED
237 246
 
238 247
   try {
248
+    let thumbnailModel: ThumbnailModel
249
+
250
+    try {
251
+      thumbnailModel = await createVideoThumbnailFromUrl(options.videoObject.icon.url, options.video, ThumbnailType.THUMBNAIL)
252
+    } catch (err) {
253
+      logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
254
+    }
255
+
239 256
     await sequelizeTypescript.transaction(async t => {
240 257
       const sequelizeOptions = { transaction: t }
241 258
 
@@ -272,6 +289,17 @@ async function updateVideoFromAP (options: {
272 289
 
273 290
       await options.video.save(sequelizeOptions)
274 291
 
292
+      if (thumbnailModel) {
293
+        thumbnailModel.videoId = options.video.id
294
+        options.video.addThumbnail(await thumbnailModel.save({ transaction: t }))
295
+      }
296
+
297
+      // FIXME: use icon URL instead
298
+      const previewUrl = buildRemoteBaseUrl(options.video, join(STATIC_PATHS.PREVIEWS, options.video.getPreview().filename))
299
+      const previewModel = createPlaceholderThumbnail(previewUrl, options.video, ThumbnailType.PREVIEW, PREVIEWS_SIZE)
300
+
301
+      options.video.addThumbnail(await previewModel.save({ transaction: t }))
302
+
275 303
       {
276 304
         const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
277 305
         const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
@@ -347,12 +375,6 @@ async function updateVideoFromAP (options: {
347 375
     logger.debug('Cannot update the remote video.', { err })
348 376
     throw err
349 377
   }
350
-
351
-  try {
352
-    await generateThumbnailFromUrl(options.video, options.videoObject.icon)
353
-  } catch (err) {
354
-    logger.warn('Cannot generate thumbnail of %s.', options.videoObject.id, { err })
355
-  }
356 378
 }
357 379
 
358 380
 async function refreshVideoIfNeeded (options: {
@@ -412,7 +434,6 @@ export {
412 434
   getOrCreateVideoAndAccountAndChannel,
413 435
   fetchRemoteVideoStaticFile,
414 436
   fetchRemoteVideoDescription,
415
-  generateThumbnailFromUrl,
416 437
   getOrCreateVideoChannelFromVideoObject
417 438
 }
418 439
 
@@ -440,13 +461,34 @@ function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistS
440 461
 async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
441 462
   logger.debug('Adding remote video %s.', videoObject.id)
442 463
 
464
+  const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
465
+  const video = VideoModel.build(videoData)
466
+
467
+  const promiseThumbnail = createVideoThumbnailFromUrl(videoObject.icon.url, video, ThumbnailType.THUMBNAIL)
468
+
469
+  let thumbnailModel: ThumbnailModel
470
+  if (waitThumbnail === true) {
471
+    thumbnailModel = await promiseThumbnail
472
+  }
473
+
443 474
   const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
444 475
     const sequelizeOptions = { transaction: t }
445 476
 
446
-    const videoData = await videoActivityObjectToDBAttributes(channelActor.VideoChannel, videoObject, videoObject.to)
447
-    const video = VideoModel.build(videoData)
448
-
449 477
     const videoCreated = await video.save(sequelizeOptions)
478
+    videoCreated.VideoChannel = channelActor.VideoChannel
479
+
480
+    if (thumbnailModel) {
481
+      thumbnailModel.videoId = videoCreated.id
482
+
483
+      videoCreated.addThumbnail(await thumbnailModel.save({ transaction: t }))
484
+    }
485
+
486
+    // FIXME: use icon URL instead
487
+    const previewUrl = buildRemoteBaseUrl(videoCreated, join(STATIC_PATHS.PREVIEWS, video.generatePreviewName()))
488
+    const previewModel = createPlaceholderThumbnail(previewUrl, video, ThumbnailType.PREVIEW, PREVIEWS_SIZE)
489
+    previewModel.videoId = videoCreated.id
490
+
491
+    videoCreated.addThumbnail(await previewModel.save({ transaction: t }))
450 492
 
451 493
     // Process files
452 494
     const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
@@ -476,14 +518,16 @@ async function createVideo (videoObject: VideoTorrentObject, channelActor: Actor
476 518
 
477 519
     logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
478 520
 
479
-    videoCreated.VideoChannel = channelActor.VideoChannel
480 521
     return videoCreated
481 522
   })
482 523
 
483
-  const p = generateThumbnailFromUrl(videoCreated, videoObject.icon)
484
-    .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err }))
524
+  if (waitThumbnail === false) {
525
+    promiseThumbnail.then(thumbnailModel => {
526
+      thumbnailModel = videoCreated.id
485 527
 
486
-  if (waitThumbnail === true) await p
528
+      return thumbnailModel.save()
529
+    })
530
+  }
487 531
 
488 532
   return videoCreated
489 533
 }

+ 10
- 22
server/lib/files-cache/abstract-video-static-file-cache.ts View File

@@ -1,41 +1,29 @@
1
-import * as AsyncLRU from 'async-lru'
2 1
 import { createWriteStream, remove } from 'fs-extra'
3 2
 import { logger } from '../../helpers/logger'
4 3
 import { VideoModel } from '../../models/video/video'
5 4
 import { fetchRemoteVideoStaticFile } from '../activitypub'
5
+import * as memoizee from 'memoizee'
6 6
 
7 7
 export abstract class AbstractVideoStaticFileCache <T> {
8 8
 
9
-  protected lru
9
+  getFilePath: (params: T) => Promise<string>
10 10
 
11
-  abstract getFilePath (params: T): Promise<string>
11
+  abstract getFilePathImpl (params: T): Promise<string>
12 12
 
13 13
   // Load and save the remote file, then return the local path from filesystem
14 14
   protected abstract loadRemoteFile (key: string): Promise<string>
15 15
 
16 16
   init (max: number, maxAge: number) {
17
-    this.lru = new AsyncLRU({
18
-      max,
17
+    this.getFilePath = memoizee(this.getFilePathImpl, {
19 18
       maxAge,
20
-      load: (key, cb) => {
21
-        this.loadRemoteFile(key)
22
-          .then(res => cb(null, res))
23
-          .catch(err => cb(err))
19
+      max,
20
+      promise: true,
21
+      dispose: (value: string) => {
22
+        remove(value)
23
+          .then(() => logger.debug('%s evicted from %s', value, this.constructor.name))
24
+          .catch(err => logger.error('Cannot remove %s from cache %s.', value, this.constructor.name, { err }))
24 25
       }
25 26
     })
26
-
27
-    this.lru.on('evict', (obj: { key: string, value: string }) => {
28
-      remove(obj.value)
29
-        .then(() => logger.debug('%s evicted from %s', obj.value, this.constructor.name))
30
-    })
31
-  }
32
-
33
-  protected loadFromLRU (key: string) {
34
-    return new Promise<string>((res, rej) => {
35
-      this.lru.get(key, (err, value) => {
36
-        err ? rej(err) : res(value)
37
-      })
38
-    })
39 27
   }
40 28
 
41 29
   protected saveRemoteVideoFileAndReturnPath (video: VideoModel, remoteStaticPath: string, destPath: string) {

+ 3
- 2
server/lib/files-cache/videos-caption-cache.ts View File

@@ -20,14 +20,14 @@ class VideosCaptionCache extends AbstractVideoStaticFileCache <GetPathParam> {
20 20
     return this.instance || (this.instance = new this())
21 21
   }
22 22
 
23
-  async getFilePath (params: GetPathParam) {
23
+  async getFilePathImpl (params: GetPathParam) {
24 24
     const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(params.videoId, params.language)
25 25
     if (!videoCaption) return undefined
26 26
 
27 27
     if (videoCaption.isOwned()) return join(CONFIG.STORAGE.CAPTIONS_DIR, videoCaption.getCaptionName())
28 28
 
29 29
     const key = params.videoId + VideosCaptionCache.KEY_DELIMITER + params.language
30
-    return this.loadFromLRU(key)
30
+    return this.loadRemoteFile(key)
31 31
   }
32 32
 
33 33
   protected async loadRemoteFile (key: string) {
@@ -42,6 +42,7 @@ class VideosCaptionCache extends AbstractVideoStaticFileCache <GetPathParam> {
42 42
     const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
43 43
     if (!video) return undefined
44 44
 
45
+    // FIXME: use URL
45 46
     const remoteStaticPath = videoCaption.getCaptionStaticPath()
46 47
     const destPath = join(FILES_CACHE.VIDEO_CAPTIONS.DIRECTORY, videoCaption.getCaptionName())
47 48
 

+ 6
- 5
server/lib/files-cache/videos-preview-cache.ts View File

@@ -16,13 +16,13 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
16 16
     return this.instance || (this.instance = new this())
17 17
   }
18 18
 
19
-  async getFilePath (videoUUID: string) {
19
+  async getFilePathImpl (videoUUID: string) {
20 20
     const video = await VideoModel.loadByUUIDWithFile(videoUUID)
21 21
     if (!video) return undefined
22 22
 
23
-    if (video.isOwned()) return join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName())
23
+    if (video.isOwned()) return join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreview().filename)
24 24
 
25
-    return this.loadFromLRU(videoUUID)
25
+    return this.loadRemoteFile(videoUUID)
26 26
   }
27 27
 
28 28
   protected async loadRemoteFile (key: string) {
@@ -31,8 +31,9 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
31 31
 
32 32
     if (video.isOwned()) throw new Error('Cannot load remote preview of owned video.')
33 33
 
34
-    const remoteStaticPath = join(STATIC_PATHS.PREVIEWS, video.getPreviewName())
35
-    const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, video.getPreviewName())
34
+    // FIXME: use URL
35
+    const remoteStaticPath = join(STATIC_PATHS.PREVIEWS, video.getPreview().filename)
36
+    const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, video.getPreview().filename)
36 37
 
37 38
     return this.saveRemoteVideoFileAndReturnPath(video, remoteStaticPath, destPath)
38 39
   }

+ 23
- 18
server/lib/job-queue/handlers/video-import.ts View File

@@ -6,8 +6,7 @@ import { VideoImportState } from '../../../../shared/models/videos'
6 6
 import { getDurationFromVideoFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils'
7 7
 import { extname, join } from 'path'
8 8
 import { VideoFileModel } from '../../../models/video/video-file'
9
-import { PREVIEWS_SIZE, THUMBNAILS_SIZE, VIDEO_IMPORT_TIMEOUT } from '../../../initializers/constants'
10
-import { downloadImage } from '../../../helpers/requests'
9
+import { VIDEO_IMPORT_TIMEOUT } from '../../../initializers/constants'
11 10
 import { VideoState } from '../../../../shared'
12 11
 import { JobQueue } from '../index'
13 12
 import { federateVideoIfNeeded } from '../../activitypub'
@@ -18,6 +17,9 @@ import { move, remove, stat } from 'fs-extra'
18 17
 import { Notifier } from '../../notifier'
19 18
 import { CONFIG } from '../../../initializers/config'
20 19
 import { sequelizeTypescript } from '../../../initializers/database'
20
+import { ThumbnailModel } from '../../../models/video/thumbnail'
21
+import { createVideoThumbnailFromUrl, generateVideoThumbnail } from '../../thumbnail'
22
+import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
21 23
 
22 24
 type VideoImportYoutubeDLPayload = {
23 25
   type: 'youtube-dl'
@@ -146,25 +148,19 @@ async function processFile (downloader: () => Promise<string>, videoImport: Vide
146 148
     tempVideoPath = null // This path is not used anymore
147 149
 
148 150
     // Process thumbnail
149
-    if (options.downloadThumbnail) {
150
-      if (options.thumbnailUrl) {
151
-        await downloadImage(options.thumbnailUrl, CONFIG.STORAGE.THUMBNAILS_DIR, videoImport.Video.getThumbnailName(), THUMBNAILS_SIZE)
152
-      } else {
153
-        await videoImport.Video.createThumbnail(videoFile)
154
-      }
155
-    } else if (options.generateThumbnail) {
156
-      await videoImport.Video.createThumbnail(videoFile)
151
+    let thumbnailModel: ThumbnailModel
152
+    if (options.downloadThumbnail && options.thumbnailUrl) {
153
+      thumbnailModel = await createVideoThumbnailFromUrl(options.thumbnailUrl, videoImport.Video, ThumbnailType.THUMBNAIL)
154
+    } else if (options.generateThumbnail || options.downloadThumbnail) {
155
+      thumbnailModel = await generateVideoThumbnail(videoImport.Video, videoFile, ThumbnailType.THUMBNAIL)
157 156
     }
158 157
 
159 158
     // Process preview
160
-    if (options.downloadPreview) {
161
-      if (options.thumbnailUrl) {
162
-        await downloadImage(options.thumbnailUrl, CONFIG.STORAGE.PREVIEWS_DIR, videoImport.Video.getPreviewName(), PREVIEWS_SIZE)
163
-      } else {
164
-        await videoImport.Video.createPreview(videoFile)
165
-      }
166
-    } else if (options.generatePreview) {
167
-      await videoImport.Video.createPreview(videoFile)
159
+    let previewModel: ThumbnailModel
160
+    if (options.downloadPreview && options.thumbnailUrl) {
161
+      previewModel = await createVideoThumbnailFromUrl(options.thumbnailUrl, videoImport.Video, ThumbnailType.PREVIEW)
162
+    } else if (options.generatePreview || options.downloadPreview) {
163
+      previewModel = await generateVideoThumbnail(videoImport.Video, videoFile, ThumbnailType.PREVIEW)
168 164
     }
169 165
 
170 166
     // Create torrent
@@ -184,6 +180,15 @@ async function processFile (downloader: () => Promise<string>, videoImport: Vide
184 180
       video.state = CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED
185 181
       await video.save({ transaction: t })
186 182
 
183
+      if (thumbnailModel) {
184
+        thumbnailModel.videoId = video.id
185
+        video.addThumbnail(await thumbnailModel.save({ transaction: t }))
186
+      }
187
+      if (previewModel) {
188
+        previewModel.videoId = video.id
189
+        video.addThumbnail(await previewModel.save({ transaction: t }))
190
+      }
191
+
187 192
       // Now we can federate the video (reload from database, we need more attributes)
188 193
       const videoForFederation = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
189 194
       await federateVideoIfNeeded(videoForFederation, true, t)

+ 151
- 0
server/lib/thumbnail.ts View File

@@ -0,0 +1,151 @@
1
+import { VideoFileModel } from '../models/video/video-file'
2
+import { generateImageFromVideoFile } from '../helpers/ffmpeg-utils'
3
+import { CONFIG } from '../initializers/config'
4
+import { PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants'
5
+import { VideoModel } from '../models/video/video'
6
+import { ThumbnailModel } from '../models/video/thumbnail'
7
+import { ThumbnailType } from '../../shared/models/videos/thumbnail.type'
8
+import { processImage } from '../helpers/image-utils'
9
+import { join } from 'path'
10
+import { downloadImage } from '../helpers/requests'
11
+import { VideoPlaylistModel } from '../models/video/video-playlist'
12
+
13
+type ImageSize = { height: number, width: number }
14
+
15
+function createPlaylistThumbnailFromExisting (inputPath: string, playlist: VideoPlaylistModel, keepOriginal = false, size?: ImageSize) {
16
+  const { filename, outputPath, height, width, existingThumbnail } = buildMetadataFromPlaylist(playlist, size)
17
+  const type = ThumbnailType.THUMBNAIL
18
+
19
+  const thumbnailCreator = () => processImage({ path: inputPath }, outputPath, { width, height }, keepOriginal)
20
+  return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail })
21
+}
22
+
23
+function createPlaylistThumbnailFromUrl (url: string, playlist: VideoPlaylistModel, size?: ImageSize) {
24
+  const { filename, basePath, height, width, existingThumbnail } = buildMetadataFromPlaylist(playlist, size)
25
+  const type = ThumbnailType.THUMBNAIL
26
+
27
+  const thumbnailCreator = () => downloadImage(url, basePath, filename, { width, height })
28
+  return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, url })
29
+}
30
+
31
+function createVideoThumbnailFromUrl (url: string, video: VideoModel, type: ThumbnailType, size?: ImageSize) {
32
+  const { filename, basePath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size)
33
+  const thumbnailCreator = () => downloadImage(url, basePath, filename, { width, height })
34
+
35
+  return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, url })
36
+}
37
+
38
+function createVideoThumbnailFromExisting (inputPath: string, video: VideoModel, type: ThumbnailType, size?: ImageSize) {
39
+  const { filename, outputPath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size)
40
+  const thumbnailCreator = () => processImage({ path: inputPath }, outputPath, { width, height })
41
+
42
+  return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail })
43
+}
44
+
45
+function generateVideoThumbnail (video: VideoModel, videoFile: VideoFileModel, type: ThumbnailType) {
46
+  const input = video.getVideoFilePath(videoFile)
47
+
48
+  const { filename, basePath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type)
49
+  const thumbnailCreator = () => generateImageFromVideoFile(input, basePath, filename, { height, width })
50
+
51
+  return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail })
52
+}
53
+
54
+function createPlaceholderThumbnail (url: string, video: VideoModel, type: ThumbnailType, size: ImageSize) {
55
+  const { filename, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size)
56
+
57
+  const thumbnail = existingThumbnail ? existingThumbnail : new ThumbnailModel()
58
+
59
+  thumbnail.filename = filename
60
+  thumbnail.height = height
61
+  thumbnail.width = width
62
+  thumbnail.type = type
63
+  thumbnail.url = url
64
+
65
+  return thumbnail
66
+}
67
+
68
+// ---------------------------------------------------------------------------
69
+
70
+export {
71
+  generateVideoThumbnail,
72
+  createVideoThumbnailFromUrl,
73
+  createVideoThumbnailFromExisting,
74
+  createPlaceholderThumbnail,
75
+  createPlaylistThumbnailFromUrl,
76
+  createPlaylistThumbnailFromExisting
77
+}
78
+
79
+function buildMetadataFromPlaylist (playlist: VideoPlaylistModel, size: ImageSize) {
80
+  const filename = playlist.generateThumbnailName()
81
+  const basePath = CONFIG.STORAGE.THUMBNAILS_DIR
82
+
83
+  return {
84
+    filename,
85
+    basePath,
86
+    existingThumbnail: playlist.Thumbnail,
87
+    outputPath: join(basePath, filename),
88
+    height: size ? size.height : THUMBNAILS_SIZE.height,
89
+    width: size ? size.width : THUMBNAILS_SIZE.width
90
+  }
91
+}
92
+
93
+function buildMetadataFromVideo (video: VideoModel, type: ThumbnailType, size?: ImageSize) {
94
+  const existingThumbnail = Array.isArray(video.Thumbnails)
95
+    ? video.Thumbnails.find(t => t.type === type)
96
+    : undefined
97
+
98
+  if (type === ThumbnailType.THUMBNAIL) {
99
+    const filename = video.generateThumbnailName()
100
+    const basePath = CONFIG.STORAGE.THUMBNAILS_DIR
101
+
102
+    return {
103
+      filename,
104
+      basePath,
105
+      existingThumbnail,
106
+      outputPath: join(basePath, filename),
107
+      height: size ? size.height : THUMBNAILS_SIZE.height,
108
+      width: size ? size.width : THUMBNAILS_SIZE.width
109
+    }
110
+  }
111
+
112
+  if (type === ThumbnailType.PREVIEW) {
113
+    const filename = video.generatePreviewName()
114
+    const basePath = CONFIG.STORAGE.PREVIEWS_DIR
115
+
116
+    return {
117
+      filename,
118
+      basePath,
119
+      existingThumbnail,
120
+      outputPath: join(basePath, filename),
121
+      height: size ? size.height : PREVIEWS_SIZE.height,
122
+      width: size ? size.width : PREVIEWS_SIZE.width
123
+    }
124
+  }
125
+
126
+  return undefined
127
+}
128
+
129
+async function createThumbnailFromFunction (parameters: {
130
+  thumbnailCreator: () => Promise<any>,
131
+  filename: string,
132
+  height: number,
133
+  width: number,
134
+  type: ThumbnailType,
135
+  url?: string,
136
+  existingThumbnail?: ThumbnailModel
137
+}) {
138
+  const { thumbnailCreator, filename, width, height, type, existingThumbnail, url = null } = parameters
139
+
140
+  const thumbnail = existingThumbnail ? existingThumbnail : new ThumbnailModel()
141
+
142
+  thumbnail.filename = filename
143
+  thumbnail.height = height
144
+  thumbnail.width = width
145
+  thumbnail.type = type
146
+  thumbnail.url = url
147
+
148
+  await thumbnailCreator()
149
+
150
+  return thumbnail
151
+}

+ 116
- 0
server/models/video/thumbnail.ts View File

@@ -0,0 +1,116 @@
1
+import { join } from 'path'
2
+import { AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
3
+import { STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
4
+import { logger } from '../../helpers/logger'
5
+import { remove } from 'fs-extra'
6
+import { CONFIG } from '../../initializers/config'
7
+import { VideoModel } from './video'
8
+import { VideoPlaylistModel } from './video-playlist'
9
+import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
10
+
11
+@Table({
12
+  tableName: 'thumbnail',
13
+  indexes: [
14
+    {
15
+      fields: [ 'videoId' ]
16
+    },
17
+    {
18
+      fields: [ 'videoPlaylistId' ],
19
+      unique: true
20
+    }
21
+  ]
22
+})
23
+export class ThumbnailModel extends Model<ThumbnailModel> {
24
+
25
+  @AllowNull(false)
26
+  @Column
27
+  filename: string
28
+
29
+  @AllowNull(true)
30
+  @Default(null)
31
+  @Column
32
+  height: number
33
+
34
+  @AllowNull(true)
35
+  @Default(null)
36
+  @Column
37
+  width: number
38
+
39
+  @AllowNull(false)
40
+  @Column
41
+  type: ThumbnailType
42
+
43
+  @AllowNull(true)
44
+  @Column
45
+  url: string
46
+
47
+  @ForeignKey(() => VideoModel)
48
+  @Column
49
+  videoId: number
50
+
51
+  @BelongsTo(() => VideoModel, {
52
+    foreignKey: {
53
+      allowNull: true
54
+    },
55
+    onDelete: 'CASCADE'
56
+  })
57
+  Video: VideoModel
58
+
59
+  @ForeignKey(() => VideoPlaylistModel)
60
+  @Column
61
+  videoPlaylistId: number
62
+
63
+  @BelongsTo(() => VideoPlaylistModel, {
64
+    foreignKey: {
65
+      allowNull: true
66
+    },
67
+    onDelete: 'CASCADE'
68
+  })
69
+  VideoPlaylist: VideoPlaylistModel
70
+
71
+  @CreatedAt
72
+  createdAt: Date
73
+
74
+  @UpdatedAt
75
+  updatedAt: Date
76
+
77
+  private static types: { [ id in ThumbnailType ]: { label: string, directory: string, staticPath: string } } = {
78
+    [ThumbnailType.THUMBNAIL]: {
79
+      label: 'thumbnail',
80
+      directory: CONFIG.STORAGE.THUMBNAILS_DIR,
81
+      staticPath: STATIC_PATHS.THUMBNAILS
82
+    },
83
+    [ThumbnailType.PREVIEW]: {
84
+      label: 'preview',
85
+      directory: CONFIG.STORAGE.PREVIEWS_DIR,
86
+      staticPath: STATIC_PATHS.PREVIEWS
87
+    }
88
+  }
89
+
90
+  @AfterDestroy
91
+  static removeFilesAndSendDelete (instance: ThumbnailModel) {
92
+    logger.info('Removing %s file %s.', ThumbnailModel.types[instance.type].label, instance.filename)
93
+
94
+    // Don't block the transaction
95
+    instance.removeThumbnail()
96
+            .catch(err => logger.error('Cannot remove thumbnail file %s.', instance.filename, err))
97
+  }
98
+
99
+  static generateDefaultPreviewName (videoUUID: string) {
100
+    return videoUUID + '.jpg'
101
+  }
102
+
103
+  getUrl () {
104
+    if (this.url) return this.url
105
+
106
+    const staticPath = ThumbnailModel.types[this.type].staticPath
107
+    return WEBSERVER.URL + staticPath + this.filename
108
+  }
109
+
110
+  removeThumbnail () {
111
+    const directory = ThumbnailModel.types[this.type].directory
112
+    const thumbnailPath = join(directory, this.filename)
113
+
114
+    return remove(thumbnailPath)
115
+  }
116
+}

+ 4
- 4
server/models/video/video-format-utils.ts View File

@@ -7,7 +7,7 @@ import {
7 7
   ActivityUrlObject,
8 8
   VideoTorrentObject
9 9
 } from '../../../shared/models/activitypub/objects'
10
-import { MIMETYPES, THUMBNAILS_SIZE, WEBSERVER } from '../../initializers/constants'
10
+import { MIMETYPES, WEBSERVER } from '../../initializers/constants'
11 11
 import { VideoCaptionModel } from './video-caption'
12 12
 import {
13 13
   getVideoCommentsActivityPubUrl,
@@ -326,10 +326,10 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
326 326
     subtitleLanguage,
327 327
     icon: {
328 328
       type: 'Image',
329
-      url: video.getThumbnailUrl(baseUrlHttp),
329
+      url: video.getThumbnail().getUrl(),
330 330
       mediaType: 'image/jpeg',
331
-      width: THUMBNAILS_SIZE.width,
332
-      height: THUMBNAILS_SIZE.height
331
+      width: video.getThumbnail().width,
332
+      height: video.getThumbnail().height
333 333
     },
334 334
     url,
335 335
     likes: getVideoLikesActivityPubUrl(video),

+ 56
- 29
server/models/video/video-playlist.ts View File

@@ -1,6 +1,5 @@
1 1
 import {
2 2
   AllowNull,
3
-  BeforeDestroy,
4 3
   BelongsTo,
5 4
   Column,
6 5
   CreatedAt,
@@ -8,6 +7,7 @@ import {
8 7
   Default,
9 8
   ForeignKey,
10 9
   HasMany,
10
+  HasOne,
11 11
   Is,
12 12
   IsUUID,
13 13
   Model,
@@ -40,16 +40,16 @@ import { join } from 'path'
40 40
 import { VideoPlaylistElementModel } from './video-playlist-element'
41 41
 import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
42 42
 import { activityPubCollectionPagination } from '../../helpers/activitypub'
43
-import { remove } from 'fs-extra'
44
-import { logger } from '../../helpers/logger'
45 43
 import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
46
-import { CONFIG } from '../../initializers/config'
44
+import { ThumbnailModel } from './thumbnail'
45
+import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
47 46
 
48 47
 enum ScopeNames {
49 48
   AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
50 49
   WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH',
51 50
   WITH_ACCOUNT_AND_CHANNEL_SUMMARY = 'WITH_ACCOUNT_AND_CHANNEL_SUMMARY',
52 51
   WITH_ACCOUNT = 'WITH_ACCOUNT',
52
+  WITH_THUMBNAIL = 'WITH_THUMBNAIL',
53 53
   WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL'
54 54
 }
55 55
 
@@ -62,6 +62,14 @@ type AvailableForListOptions = {
62 62
 }
63 63
 
64 64
 @Scopes({
65
+  [ ScopeNames.WITH_THUMBNAIL ]: {
66
+    include: [
67
+      {
68
+        model: () => ThumbnailModel,
69
+        required: false
70
+      }
71
+    ]
72
+  },
65 73
   [ ScopeNames.WITH_VIDEOS_LENGTH ]: {
66 74
     attributes: {
67 75
       include: [
@@ -256,12 +264,15 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
256 264
   })
257 265
   VideoPlaylistElements: VideoPlaylistElementModel[]
258 266
 
259
-  @BeforeDestroy
260
-  static async removeFiles (instance: VideoPlaylistModel) {
261
-    logger.info('Removing files of video playlist %s.', instance.url)
262
-
263
-    return instance.removeThumbnail()
264
-  }
267
+  @HasOne(() => ThumbnailModel, {
268
+    foreignKey: {
269
+      name: 'videoPlaylistId',
270
+      allowNull: true
271
+    },
272
+    onDelete: 'CASCADE',
273
+    hooks: true
274
+  })
275
+  Thumbnail: ThumbnailModel
265 276
 
266 277
   static listForApi (options: {
267 278
     followerActorId: number
@@ -292,7 +303,8 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
292 303
           } as AvailableForListOptions
293 304
         ]
294 305
       } as any, // FIXME: typings
295
-      ScopeNames.WITH_VIDEOS_LENGTH
306
+      ScopeNames.WITH_VIDEOS_LENGTH,
307
+      ScopeNames.WITH_THUMBNAIL
296 308
     ]
297 309
 
298 310
     return VideoPlaylistModel
@@ -365,7 +377,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
365 377
     }
366 378
 
367 379
     return VideoPlaylistModel
368
-      .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH ])
380
+      .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
369 381
       .findOne(query)
370 382
   }
371 383
 
@@ -378,7 +390,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
378 390
     }
379 391
 
380 392
     return VideoPlaylistModel
381
-      .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH ])
393
+      .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
382 394
       .findOne(query)
383 395
   }
384 396
 
@@ -389,7 +401,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
389 401
       }
390 402
     }
391 403
 
392
-    return VideoPlaylistModel.scope(ScopeNames.WITH_ACCOUNT).findOne(query)
404
+    return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query)
393 405
   }
394 406
 
395 407
   static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
@@ -411,24 +423,34 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
411 423
     return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query)
412 424
   }
413 425
 
414
-  getThumbnailName () {
426
+  setThumbnail (thumbnail: ThumbnailModel) {
427
+    this.Thumbnail = thumbnail
428
+  }
429
+
430
+  getThumbnail () {
431
+    return this.Thumbnail
432
+  }
433
+
434
+  hasThumbnail () {
435
+    return !!this.Thumbnail
436
+  }
437
+
438
+  generateThumbnailName () {
415 439
     const extension = '.jpg'
416 440
 
417 441
     return 'playlist-' + this.uuid + extension
418 442
   }
419 443
 
420 444
   getThumbnailUrl () {
421
-    return WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
445
+    if (!this.hasThumbnail()) return null
446
+
447
+    return WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.getThumbnail().filename
422 448
   }
423 449
 
424 450
   getThumbnailStaticPath () {
425
-    return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
426
-  }
451
+    if (!this.hasThumbnail()) return null
427 452
 
428
-  removeThumbnail () {
429
-    const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
430
-    return remove(thumbnailPath)
431
-      .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err }))
453
+    return join(STATIC_PATHS.THUMBNAILS, this.getThumbnail().filename)
432 454
   }
433 455
 
434 456
   setAsRefreshed () {
@@ -482,6 +504,17 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
482 504
       return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t)
483 505
     }
484 506
 
507
+    let icon: ActivityIconObject
508
+    if (this.hasThumbnail()) {
509
+      icon = {
510
+        type: 'Image' as 'Image',
511
+        url: this.getThumbnailUrl(),
512
+        mediaType: 'image/jpeg' as 'image/jpeg',
513
+        width: THUMBNAILS_SIZE.width,
514
+        height: THUMBNAILS_SIZE.height
515
+      }
516
+    }
517
+
485 518
     return activityPubCollectionPagination(this.url, handler, page)
486 519
       .then(o => {
487 520
         return Object.assign(o, {
@@ -492,13 +525,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
492 525
           published: this.createdAt.toISOString(),
493 526
           updated: this.updatedAt.toISOString(),
494 527
           attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],
495
-          icon: {
496
-            type: 'Image' as 'Image',
497
-            url: this.getThumbnailUrl(),
498
-            mediaType: 'image/jpeg' as 'image/jpeg',
499
-            width: THUMBNAILS_SIZE.width,
500
-            height: THUMBNAILS_SIZE.height
501
-          }
528
+          icon
502 529
         })
503 530
       })
504 531
   }

+ 90
- 65
server/models/video/video.ts View File

@@ -107,6 +107,8 @@ import { VideoImportModel } from './video-import'
107 107
 import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
108 108
 import { VideoPlaylistElementModel } from './video-playlist-element'
109 109
 import { CONFIG } from '../../initializers/config'
110
+import { ThumbnailModel } from './thumbnail'
111
+import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
110 112
 
111 113
 // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
112 114
 const indexes: Sequelize.DefineIndexesOptions[] = [
@@ -181,7 +183,8 @@ export enum ScopeNames {
181 183
   WITH_BLACKLISTED = 'WITH_BLACKLISTED',
182 184
   WITH_USER_HISTORY = 'WITH_USER_HISTORY',
183 185
   WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
184
-  WITH_USER_ID = 'WITH_USER_ID'
186
+  WITH_USER_ID = 'WITH_USER_ID',
187
+  WITH_THUMBNAILS = 'WITH_THUMBNAILS'
185 188
 }
186 189
 
187 190
 type ForAPIOptions = {
@@ -473,6 +476,14 @@ type AvailableForListIDsOptions = {
473 476
 
474 477
     return query
475 478
   },
479
+  [ ScopeNames.WITH_THUMBNAILS ]: {
480
+    include: [
481
+      {
482
+        model: () => ThumbnailModel,
483
+        required: false
484
+      }
485
+    ]
486
+  },
476 487
   [ ScopeNames.WITH_USER_ID ]: {
477 488
     include: [
478 489
       {
@@ -771,6 +782,16 @@ export class VideoModel extends Model<VideoModel> {
771 782
   })
772 783
   Tags: TagModel[]
773 784
 
785
+  @HasMany(() => ThumbnailModel, {
786
+    foreignKey: {
787
+      name: 'videoId',
788
+      allowNull: true
789
+    },
790
+    hooks: true,
791
+    onDelete: 'cascade'
792
+  })
793
+  Thumbnails: ThumbnailModel[]
794
+
774 795
   @HasMany(() => VideoPlaylistElementModel, {
775 796
     foreignKey: {
776 797
       name: 'videoId',
@@ -920,15 +941,11 @@ export class VideoModel extends Model<VideoModel> {
920 941
 
921 942
     logger.info('Removing files of video %s.', instance.url)
922 943
 
923
-    tasks.push(instance.removeThumbnail())
924
-
925 944
     if (instance.isOwned()) {
926 945
       if (!Array.isArray(instance.VideoFiles)) {
927 946
         instance.VideoFiles = await instance.$get('VideoFiles') as VideoFileModel[]
928 947
       }
929 948
 
930
-      tasks.push(instance.removePreview())
931
-
932 949
       // Remove physical files and torrents
933 950
       instance.VideoFiles.forEach(file => {
934 951
         tasks.push(instance.removeFile(file))
@@ -955,7 +972,11 @@ export class VideoModel extends Model<VideoModel> {
955 972
       }
956 973
     }
957 974
 
958
-    return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ]).findAll(query)
975
+    return VideoModel.scope([
976
+      ScopeNames.WITH_FILES,
977
+      ScopeNames.WITH_STREAMING_PLAYLISTS,
978
+      ScopeNames.WITH_THUMBNAILS
979
+    ]).findAll(query)
959 980
   }
960 981
 
961 982
   static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
@@ -1048,7 +1069,7 @@ export class VideoModel extends Model<VideoModel> {
1048 1069
 
1049 1070
     return Bluebird.all([
1050 1071
       // FIXME: typing issue
1051
-      VideoModel.findAll(query as any),
1072
+      VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findAll(query as any),
1052 1073
       VideoModel.sequelize.query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT })
1053 1074
     ]).then(([ rows, totals ]) => {
1054 1075
       // totals: totalVideos + totalVideoShares
@@ -1102,12 +1123,14 @@ export class VideoModel extends Model<VideoModel> {
1102 1123
       })
1103 1124
     }
1104 1125
 
1105
-    return VideoModel.findAndCountAll(query).then(({ rows, count }) => {
1106
-      return {
1107
-        data: rows,
1108
-        total: count
1109
-      }
1110
-    })
1126
+    return VideoModel.scope(ScopeNames.WITH_THUMBNAILS)
1127
+                     .findAndCountAll(query)
1128
+                     .then(({ rows, count }) => {
1129
+                       return {
1130
+                         data: rows,
1131
+                         total: count
1132
+                       }
1133
+                     })
1111 1134
   }
1112 1135
 
1113 1136
   static async listForApi (options: {
@@ -1296,7 +1319,7 @@ export class VideoModel extends Model<VideoModel> {
1296 1319
       transaction: t
1297 1320
     }
1298 1321
 
1299
-    return VideoModel.findOne(options)
1322
+    return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1300 1323
   }
1301 1324
 
1302 1325
   static loadWithRights (id: number | string, t?: Sequelize.Transaction) {
@@ -1306,7 +1329,11 @@ export class VideoModel extends Model<VideoModel> {
1306 1329
       transaction: t
1307 1330
     }
1308 1331
 
1309
-    return VideoModel.scope([ ScopeNames.WITH_BLACKLISTED, ScopeNames.WITH_USER_ID ]).findOne(options)
1332
+    return VideoModel.scope([
1333
+      ScopeNames.WITH_BLACKLISTED,
1334
+      ScopeNames.WITH_USER_ID,
1335
+      ScopeNames.WITH_THUMBNAILS
1336
+    ]).findOne(options)
1310 1337
   }
1311 1338
 
1312 1339
   static loadOnlyId (id: number | string, t?: Sequelize.Transaction) {
@@ -1318,12 +1345,15 @@ export class VideoModel extends Model<VideoModel> {
1318 1345
       transaction: t
1319 1346
     }
1320 1347
 
1321
-    return VideoModel.findOne(options)
1348
+    return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1322 1349
   }
1323 1350
 
1324 1351
   static loadWithFiles (id: number, t?: Sequelize.Transaction, logging?: boolean) {
1325
-    return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ])
1326
-                     .findByPk(id, { transaction: t, logging })
1352
+    return VideoModel.scope([
1353
+      ScopeNames.WITH_FILES,
1354
+      ScopeNames.WITH_STREAMING_PLAYLISTS,
1355
+      ScopeNames.WITH_THUMBNAILS
1356
+    ]).findByPk(id, { transaction: t, logging })
1327 1357
   }
1328 1358
 
1329 1359
   static loadByUUIDWithFile (uuid: string) {
@@ -1333,7 +1363,7 @@ export class VideoModel extends Model<VideoModel> {
1333 1363
       }
1334 1364
     }
1335 1365
 
1336
-    return VideoModel.findOne(options)
1366
+    return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1337 1367
   }
1338 1368
 
1339 1369
   static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
@@ -1344,7 +1374,7 @@ export class VideoModel extends Model<VideoModel> {
1344 1374
       transaction
1345 1375
     }
1346 1376
 
1347
-    return VideoModel.findOne(query)
1377
+    return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query)
1348 1378
   }
1349 1379
 
1350 1380
   static loadByUrlAndPopulateAccount (url: string, transaction?: Sequelize.Transaction) {
@@ -1358,7 +1388,8 @@ export class VideoModel extends Model<VideoModel> {
1358 1388
     return VideoModel.scope([
1359 1389
       ScopeNames.WITH_ACCOUNT_DETAILS,
1360 1390
       ScopeNames.WITH_FILES,
1361
-      ScopeNames.WITH_STREAMING_PLAYLISTS
1391
+      ScopeNames.WITH_STREAMING_PLAYLISTS,
1392
+      ScopeNames.WITH_THUMBNAILS
1362 1393
     ]).findOne(query)
1363 1394
   }
1364 1395
 
@@ -1377,7 +1408,8 @@ export class VideoModel extends Model<VideoModel> {
1377 1408
       ScopeNames.WITH_ACCOUNT_DETAILS,
1378 1409
       ScopeNames.WITH_SCHEDULED_UPDATE,
1379 1410
       ScopeNames.WITH_FILES,
1380
-      ScopeNames.WITH_STREAMING_PLAYLISTS
1411
+      ScopeNames.WITH_STREAMING_PLAYLISTS,
1412
+      ScopeNames.WITH_THUMBNAILS
1381 1413
     ]
1382 1414
 
1383 1415
     if (userId) {
@@ -1403,6 +1435,7 @@ export class VideoModel extends Model<VideoModel> {
1403 1435
       ScopeNames.WITH_BLACKLISTED,
1404 1436
       ScopeNames.WITH_ACCOUNT_DETAILS,
1405 1437
       ScopeNames.WITH_SCHEDULED_UPDATE,
1438
+      ScopeNames.WITH_THUMBNAILS,
1406 1439
       { method: [ ScopeNames.WITH_FILES, true ] } as any, // FIXME: typings
1407 1440
       { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] } as any // FIXME: typings
1408 1441
     ]
@@ -1555,7 +1588,7 @@ export class VideoModel extends Model<VideoModel> {
1555 1588
     }
1556 1589
 
1557 1590
     // FIXME: typing
1558
-    const apiScope: any[] = []
1591
+    const apiScope: any[] = [ ScopeNames.WITH_THUMBNAILS ]
1559 1592
 
1560 1593
     if (options.user) {
1561 1594
       apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] })
@@ -1611,18 +1644,37 @@ export class VideoModel extends Model<VideoModel> {
1611 1644
     return maxBy(this.VideoFiles, file => file.resolution)
1612 1645
   }
1613 1646
 
1647
+  addThumbnail (thumbnail: ThumbnailModel) {
1648
+    if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1649
+
1650
+    // Already have this thumbnail, skip
1651
+    if (this.Thumbnails.find(t => t.id === thumbnail.id)) return
1652
+
1653
+    this.Thumbnails.push(thumbnail)
1654
+  }
1655
+
1614 1656
   getVideoFilename (videoFile: VideoFileModel) {
1615 1657
     return this.uuid + '-' + videoFile.resolution + videoFile.extname
1616 1658
   }
1617 1659
 
1618
-  getThumbnailName () {
1619
-    const extension = '.jpg'
1620
-    return this.uuid + extension
1660
+  generateThumbnailName () {
1661
+    return this.uuid + '.jpg'
1621 1662
   }
1622 1663
 
1623
-  getPreviewName () {
1624
-    const extension = '.jpg'
1625
-    return this.uuid + extension
1664
+  getThumbnail () {
1665
+    if (Array.isArray(this.Thumbnails) === false) return undefined
1666
+
1667
+    return this.Thumbnails.find(t => t.type === ThumbnailType.THUMBNAIL)
1668
+  }
1669
+
1670
+  generatePreviewName () {
1671
+    return this.uuid + '.jpg'
1672
+  }
1673
+
1674
+  getPreview () {
1675
+    if (Array.isArray(this.Thumbnails) === false) return undefined
1676
+
1677
+    return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1626 1678
   }
1627 1679
 
1628 1680
   getTorrentFileName (videoFile: VideoFileModel) {
@@ -1634,24 +1686,6 @@ export class VideoModel extends Model<VideoModel> {
1634 1686
     return this.remote === false
1635 1687
   }
1636 1688
 
1637
-  createPreview (videoFile: VideoFileModel) {
1638
-    return generateImageFromVideoFile(
1639
-      this.getVideoFilePath(videoFile),
1640
-      CONFIG.STORAGE.PREVIEWS_DIR,
1641
-      this.getPreviewName(),
1642
-      PREVIEWS_SIZE
1643
-    )
1644
-  }
1645
-
1646
-  createThumbnail (videoFile: VideoFileModel) {
1647
-    return generateImageFromVideoFile(
1648
-      this.getVideoFilePath(videoFile),
1649
-      CONFIG.STORAGE.THUMBNAILS_DIR,
1650
-      this.getThumbnailName(),
1651
-      THUMBNAILS_SIZE
1652
-    )
1653
-  }
1654
-
1655 1689
   getTorrentFilePath (videoFile: VideoFileModel) {
1656 1690
     return join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
1657 1691
   }
@@ -1692,11 +1726,18 @@ export class VideoModel extends Model<VideoModel> {
1692 1726
   }
1693 1727
 
1694 1728
   getThumbnailStaticPath () {
1695
-    return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
1729
+    const thumbnail = this.getThumbnail()
1730
+    if (!thumbnail) return null
1731
+
1732
+    return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
1696 1733
   }
1697 1734
 
1698 1735
   getPreviewStaticPath () {
1699
-    return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
1736
+    const preview = this.getPreview()
1737
+    if (!preview) return null
1738
+
1739
+    // We use a local cache, so specify our cache endpoint instead of potential remote URL
1740
+    return join(STATIC_PATHS.PREVIEWS, preview.filename)
1700 1741
   }
1701 1742
 
1702 1743
   toFormattedJSON (options?: VideoFormattingJSONOptions): Video {
@@ -1732,18 +1773,6 @@ export class VideoModel extends Model<VideoModel> {
1732 1773
     return `/api/${API_VERSION}/videos/${this.uuid}/description`
1733 1774
   }
1734 1775
 
1735
-  removeThumbnail () {
1736
-    const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
1737
-    return remove(thumbnailPath)
1738
-      .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err }))
1739
-  }
1740
-
1741
-  removePreview () {
1742
-    const previewPath = join(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
1743
-    return remove(previewPath)
1744
-      .catch(err => logger.warn('Cannot delete preview %s.', previewPath, { err }))
1745
-  }
1746
-
1747 1776
   removeFile (videoFile: VideoFileModel, isRedundancy = false) {
1748 1777
     const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR
1749 1778
 
@@ -1816,10 +1845,6 @@ export class VideoModel extends Model<VideoModel> {
1816 1845
     return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1817 1846
   }
1818 1847
 
1819
-  getThumbnailUrl (baseUrlHttp: string) {
1820
-    return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
1821
-  }
1822
-
1823 1848
   getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1824 1849
     return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
1825 1850
   }

+ 1
- 1
shared/models/activitypub/objects/playlist-object.ts View File

@@ -11,7 +11,7 @@ export interface PlaylistObject {
11 11
   totalItems: number
12 12
   attributedTo: string[]
13 13
 
14
-  icon: ActivityIconObject
14
+  icon?: ActivityIconObject
15 15
 
16 16
   published: string
17 17
   updated: string

+ 4
- 0
shared/models/videos/thumbnail.type.ts View File

@@ -0,0 +1,4 @@
1
+export enum ThumbnailType {
2
+  THUMBNAIL = 1,
3
+  PREVIEW = 2
4
+}

Loading…
Cancel
Save