aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--server.ts2
-rw-r--r--server/controllers/api/video-playlist.ts47
-rw-r--r--server/controllers/api/videos/import.ts70
-rw-r--r--server/controllers/api/videos/index.ts62
-rw-r--r--server/controllers/api/videos/ownership.ts1
-rw-r--r--server/controllers/static.ts2
-rw-r--r--server/helpers/image-utils.ts5
-rw-r--r--server/initializers/database.ts4
-rw-r--r--server/lib/activitypub/playlist.ts25
-rw-r--r--server/lib/activitypub/videos.ts92
-rw-r--r--server/lib/files-cache/abstract-video-static-file-cache.ts32
-rw-r--r--server/lib/files-cache/videos-caption-cache.ts5
-rw-r--r--server/lib/files-cache/videos-preview-cache.ts11
-rw-r--r--server/lib/job-queue/handlers/video-import.ts41
-rw-r--r--server/lib/thumbnail.ts151
-rw-r--r--server/models/video/thumbnail.ts116
-rw-r--r--server/models/video/video-format-utils.ts8
-rw-r--r--server/models/video/video-playlist.ts85
-rw-r--r--server/models/video/video.ts155
-rw-r--r--shared/models/activitypub/objects/playlist-object.ts2
-rw-r--r--shared/models/videos/thumbnail.type.ts4
21 files changed, 654 insertions, 266 deletions
diff --git a/server.ts b/server.ts
index 3884dd13c..aa4382ee7 100644
--- a/server.ts
+++ b/server.ts
@@ -255,8 +255,6 @@ async function startApplication () {
255 255
256 // Make server listening 256 // Make server listening
257 server.listen(port, hostname, () => { 257 server.listen(port, hostname, () => {
258 logger.debug('CONFIG', { CONFIG })
259
260 logger.info('Server listening on %s:%d', hostname, port) 258 logger.info('Server listening on %s:%d', hostname, port)
261 logger.info('Web server: %s', WEBSERVER.URL) 259 logger.info('Web server: %s', WEBSERVER.URL)
262 }) 260 })
diff --git a/server/controllers/api/video-playlist.ts b/server/controllers/api/video-playlist.ts
index 71c244a60..99325aa9d 100644
--- a/server/controllers/api/video-playlist.ts
+++ b/server/controllers/api/video-playlist.ts
@@ -12,7 +12,7 @@ import {
12} from '../../middlewares' 12} from '../../middlewares'
13import { videoPlaylistsSortValidator } from '../../middlewares/validators' 13import { videoPlaylistsSortValidator } from '../../middlewares/validators'
14import { buildNSFWFilter, createReqFiles, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' 14import { buildNSFWFilter, createReqFiles, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
15import { MIMETYPES, THUMBNAILS_SIZE, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constants' 15import { MIMETYPES, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constants'
16import { logger } from '../../helpers/logger' 16import { logger } from '../../helpers/logger'
17import { resetSequelizeInstance } from '../../helpers/database-utils' 17import { resetSequelizeInstance } from '../../helpers/database-utils'
18import { VideoPlaylistModel } from '../../models/video/video-playlist' 18import { VideoPlaylistModel } from '../../models/video/video-playlist'
@@ -28,7 +28,6 @@ import {
28} from '../../middlewares/validators/videos/video-playlists' 28} from '../../middlewares/validators/videos/video-playlists'
29import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model' 29import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model'
30import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model' 30import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
31import { processImage } from '../../helpers/image-utils'
32import { join } from 'path' 31import { join } from 'path'
33import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send' 32import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send'
34import { getVideoPlaylistActivityPubUrl, getVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url' 33import { getVideoPlaylistActivityPubUrl, getVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url'
@@ -37,12 +36,12 @@ import { VideoModel } from '../../models/video/video'
37import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element' 36import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
38import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model' 37import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model'
39import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model' 38import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model'
40import { copy, pathExists } from 'fs-extra'
41import { AccountModel } from '../../models/account/account' 39import { AccountModel } from '../../models/account/account'
42import { VideoPlaylistReorder } from '../../../shared/models/videos/playlist/video-playlist-reorder.model' 40import { VideoPlaylistReorder } from '../../../shared/models/videos/playlist/video-playlist-reorder.model'
43import { JobQueue } from '../../lib/job-queue' 41import { JobQueue } from '../../lib/job-queue'
44import { CONFIG } from '../../initializers/config' 42import { CONFIG } from '../../initializers/config'
45import { sequelizeTypescript } from '../../initializers/database' 43import { sequelizeTypescript } from '../../initializers/database'
44import { createPlaylistThumbnailFromExisting } from '../../lib/thumbnail'
46 45
47const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR }) 46const 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 const thumbnailField = req.files['thumbnailfile'] 175 const thumbnailField = req.files['thumbnailfile']
177 if (thumbnailField) { 176 const thumbnailModel = thumbnailField
178 const thumbnailPhysicalFile = thumbnailField[ 0 ] 177 ? await createPlaylistThumbnailFromExisting(thumbnailField[0].path, videoPlaylist)
179 await processImage(thumbnailPhysicalFile, join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName()), THUMBNAILS_SIZE) 178 : undefined
180 }
181 179
182 const videoPlaylistCreated: VideoPlaylistModel = await sequelizeTypescript.transaction(async t => { 180 const videoPlaylistCreated: VideoPlaylistModel = await sequelizeTypescript.transaction(async t => {
183 const videoPlaylistCreated = await videoPlaylist.save({ transaction: t }) 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 // We need more attributes for the federation 188 // We need more attributes for the federation
186 videoPlaylistCreated.OwnerAccount = await AccountModel.load(user.Account.id, t) 189 videoPlaylistCreated.OwnerAccount = await AccountModel.load(user.Account.id, t)
187 await sendCreateVideoPlaylist(videoPlaylistCreated, t) 190 await sendCreateVideoPlaylist(videoPlaylistCreated, t)
@@ -206,14 +209,9 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
206 const wasPrivatePlaylist = videoPlaylistInstance.privacy === VideoPlaylistPrivacy.PRIVATE 209 const wasPrivatePlaylist = videoPlaylistInstance.privacy === VideoPlaylistPrivacy.PRIVATE
207 210
208 const thumbnailField = req.files['thumbnailfile'] 211 const thumbnailField = req.files['thumbnailfile']
209 if (thumbnailField) { 212 const thumbnailModel = thumbnailField
210 const thumbnailPhysicalFile = thumbnailField[ 0 ] 213 ? await createPlaylistThumbnailFromExisting(thumbnailField[0].path, videoPlaylistInstance)
211 await processImage( 214 : undefined
212 thumbnailPhysicalFile,
213 join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylistInstance.getThumbnailName()),
214 THUMBNAILS_SIZE
215 )
216 }
217 215
218 try { 216 try {
219 await sequelizeTypescript.transaction(async t => { 217 await sequelizeTypescript.transaction(async t => {
@@ -241,6 +239,11 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
241 239
242 const playlistUpdated = await videoPlaylistInstance.save(sequelizeOptions) 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 const isNewPlaylist = wasPrivatePlaylist && playlistUpdated.privacy !== VideoPlaylistPrivacy.PRIVATE 247 const isNewPlaylist = wasPrivatePlaylist && playlistUpdated.privacy !== VideoPlaylistPrivacy.PRIVATE
245 248
246 if (isNewPlaylist) { 249 if (isNewPlaylist) {
@@ -307,15 +310,15 @@ async function addVideoInPlaylist (req: express.Request, res: express.Response)
307 }) 310 })
308 311
309 // If the user did not set a thumbnail, automatically take the video thumbnail 312 // If the user did not set a thumbnail, automatically take the video thumbnail
310 if (playlistElement.position === 1) { 313 if (playlistElement.position === 1 && videoPlaylist.hasThumbnail() === false) {
311 const playlistThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoPlaylist.getThumbnailName()) 314 logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url)
312 315
313 if (await pathExists(playlistThumbnailPath) === false) { 316 const inputPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnail().filename)
314 logger.info('Generating default thumbnail to playlist %s.', videoPlaylist.url) 317 const thumbnailModel = await createPlaylistThumbnailFromExisting(inputPath, videoPlaylist, true)
315 318
316 const videoThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName()) 319 thumbnailModel.videoPlaylistId = videoPlaylist.id
317 await copy(videoThumbnailPath, playlistThumbnailPath) 320
318 } 321 await thumbnailModel.save()
319 } 322 }
320 323
321 logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position) 324 logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position)
diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts
index a72b8c72e..f9a24a0c2 100644
--- a/server/controllers/api/videos/import.ts
+++ b/server/controllers/api/videos/import.ts
@@ -3,7 +3,7 @@ import * as magnetUtil from 'magnet-uri'
3import 'multer' 3import 'multer'
4import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger' 4import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger'
5import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares' 5import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares'
6import { MIMETYPES, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../../../initializers/constants' 6import { MIMETYPES } from '../../../initializers/constants'
7import { getYoutubeDLInfo, YoutubeDLInfo } from '../../../helpers/youtube-dl' 7import { getYoutubeDLInfo, YoutubeDLInfo } from '../../../helpers/youtube-dl'
8import { createReqFiles } from '../../../helpers/express-utils' 8import { createReqFiles } from '../../../helpers/express-utils'
9import { logger } from '../../../helpers/logger' 9import { logger } from '../../../helpers/logger'
@@ -13,12 +13,10 @@ import { getVideoActivityPubUrl } from '../../../lib/activitypub'
13import { TagModel } from '../../../models/video/tag' 13import { TagModel } from '../../../models/video/tag'
14import { VideoImportModel } from '../../../models/video/video-import' 14import { VideoImportModel } from '../../../models/video/video-import'
15import { JobQueue } from '../../../lib/job-queue/job-queue' 15import { JobQueue } from '../../../lib/job-queue/job-queue'
16import { processImage } from '../../../helpers/image-utils'
17import { join } from 'path' 16import { join } from 'path'
18import { isArray } from '../../../helpers/custom-validators/misc' 17import { isArray } from '../../../helpers/custom-validators/misc'
19import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model' 18import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
20import { VideoChannelModel } from '../../../models/video/video-channel' 19import { VideoChannelModel } from '../../../models/video/video-channel'
21import { UserModel } from '../../../models/account/user'
22import * as Bluebird from 'bluebird' 20import * as Bluebird from 'bluebird'
23import * as parseTorrent from 'parse-torrent' 21import * as parseTorrent from 'parse-torrent'
24import { getSecureTorrentName } from '../../../helpers/utils' 22import { getSecureTorrentName } from '../../../helpers/utils'
@@ -26,6 +24,9 @@ import { move, readFile } from 'fs-extra'
26import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' 24import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
27import { CONFIG } from '../../../initializers/config' 25import { CONFIG } from '../../../initializers/config'
28import { sequelizeTypescript } from '../../../initializers/database' 26import { sequelizeTypescript } from '../../../initializers/database'
27import { createVideoThumbnailFromExisting } from '../../../lib/thumbnail'
28import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
29import { ThumbnailModel } from '../../../models/video/thumbnail'
29 30
30const auditLogger = auditLoggerFactory('video-imports') 31const auditLogger = auditLoggerFactory('video-imports')
31const videoImportsRouter = express.Router() 32const videoImportsRouter = express.Router()
@@ -89,10 +90,10 @@ async function addTorrentImport (req: express.Request, res: express.Response, to
89 videoName = isArray(parsed.name) ? parsed.name[ 0 ] : parsed.name as string 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 const thumbnailModel = await processThumbnail(req, video)
95 await processPreview(req, video) 96 const previewModel = await processPreview(req, video)
96 97
97 const tags = body.tags || undefined 98 const tags = body.tags || undefined
98 const videoImportAttributes = { 99 const videoImportAttributes = {
@@ -101,7 +102,14 @@ async function addTorrentImport (req: express.Request, res: express.Response, to
101 state: VideoImportState.PENDING, 102 state: VideoImportState.PENDING,
102 userId: user.id 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 // Create job to import the video 114 // Create job to import the video
107 const payload = { 115 const payload = {
@@ -132,10 +140,10 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
132 }).end() 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) 145 const thumbnailModel = await processThumbnail(req, video)
138 const downloadPreview = !await processPreview(req, video) 146 const previewModel = await processPreview(req, video)
139 147
140 const tags = body.tags || youtubeDLInfo.tags 148 const tags = body.tags || youtubeDLInfo.tags
141 const videoImportAttributes = { 149 const videoImportAttributes = {
@@ -143,15 +151,22 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
143 state: VideoImportState.PENDING, 151 state: VideoImportState.PENDING,
144 userId: user.id 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 // Create job to import the video 163 // Create job to import the video
149 const payload = { 164 const payload = {
150 type: 'youtube-dl' as 'youtube-dl', 165 type: 'youtube-dl' as 'youtube-dl',
151 videoImportId: videoImport.id, 166 videoImportId: videoImport.id,
152 thumbnailUrl: youtubeDLInfo.thumbnailUrl, 167 thumbnailUrl: youtubeDLInfo.thumbnailUrl,
153 downloadThumbnail, 168 downloadThumbnail: !thumbnailModel,
154 downloadPreview 169 downloadPreview: !previewModel
155 } 170 }
156 await JobQueue.Instance.createJob({ type: 'video-import', payload }) 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 return res.json(videoImport.toFormattedJSON()).end() 175 return res.json(videoImport.toFormattedJSON()).end()
161} 176}
162 177
163function buildVideo (channelId: number, body: VideoImportCreate, importData: YoutubeDLInfo, user: UserModel) { 178function buildVideo (channelId: number, body: VideoImportCreate, importData: YoutubeDLInfo) {
164 const videoData = { 179 const videoData = {
165 name: body.name || importData.name || 'Unknown name', 180 name: body.name || importData.name || 'Unknown name',
166 remote: false, 181 remote: false,
@@ -189,32 +204,34 @@ async function processThumbnail (req: express.Request, video: VideoModel) {
189 const thumbnailField = req.files ? req.files['thumbnailfile'] : undefined 204 const thumbnailField = req.files ? req.files['thumbnailfile'] : undefined
190 if (thumbnailField) { 205 if (thumbnailField) {
191 const thumbnailPhysicalFile = thumbnailField[ 0 ] 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
200async function processPreview (req: express.Request, video: VideoModel) { 214async function processPreview (req: express.Request, video: VideoModel) {
201 const previewField = req.files ? req.files['previewfile'] : undefined 215 const previewField = req.files ? req.files['previewfile'] : undefined
202 if (previewField) { 216 if (previewField) {
203 const previewPhysicalFile = previewField[0] 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
212function insertIntoDB ( 225function insertIntoDB (parameters: {
213 video: VideoModel, 226 video: VideoModel,
227 thumbnailModel: ThumbnailModel,
228 previewModel: ThumbnailModel,
214 videoChannel: VideoChannelModel, 229 videoChannel: VideoChannelModel,
215 tags: string[], 230 tags: string[],
216 videoImportAttributes: FilteredModelAttributes<VideoImportModel> 231 videoImportAttributes: FilteredModelAttributes<VideoImportModel>
217): Bluebird<VideoImportModel> { 232}): Bluebird<VideoImportModel> {
233 let { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes } = parameters
234
218 return sequelizeTypescript.transaction(async t => { 235 return sequelizeTypescript.transaction(async t => {
219 const sequelizeOptions = { transaction: t } 236 const sequelizeOptions = { transaction: t }
220 237
@@ -222,6 +239,15 @@ function insertIntoDB (
222 const videoCreated = await video.save(sequelizeOptions) 239 const videoCreated = await video.save(sequelizeOptions)
223 videoCreated.VideoChannel = videoChannel 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 await autoBlacklistVideoIfNeeded(video, videoChannel.Account.User, t) 251 await autoBlacklistVideoIfNeeded(video, videoChannel.Account.User, t)
226 252
227 // Set tags to the video 253 // Set tags to the video
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index d6f513254..24721a17f 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -2,20 +2,11 @@ import * as express from 'express'
2import { extname, join } from 'path' 2import { extname, join } from 'path'
3import { VideoCreate, VideoPrivacy, VideoState, VideoUpdate } from '../../../../shared' 3import { VideoCreate, VideoPrivacy, VideoState, VideoUpdate } from '../../../../shared'
4import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils' 4import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils'
5import { processImage } from '../../../helpers/image-utils'
6import { logger } from '../../../helpers/logger' 5import { logger } from '../../../helpers/logger'
7import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 6import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
8import { getFormattedObjects, getServerActor } from '../../../helpers/utils' 7import { getFormattedObjects, getServerActor } from '../../../helpers/utils'
9import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' 8import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
10import { 9import { MIMETYPES, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers/constants'
11 MIMETYPES,
12 PREVIEWS_SIZE,
13 THUMBNAILS_SIZE,
14 VIDEO_CATEGORIES,
15 VIDEO_LANGUAGES,
16 VIDEO_LICENCES,
17 VIDEO_PRIVACIES
18} from '../../../initializers/constants'
19import { 10import {
20 changeVideoChannelShare, 11 changeVideoChannelShare,
21 federateVideoIfNeeded, 12 federateVideoIfNeeded,
@@ -61,6 +52,8 @@ import { Notifier } from '../../../lib/notifier'
61import { sendView } from '../../../lib/activitypub/send/send-view' 52import { sendView } from '../../../lib/activitypub/send/send-view'
62import { CONFIG } from '../../../initializers/config' 53import { CONFIG } from '../../../initializers/config'
63import { sequelizeTypescript } from '../../../initializers/database' 54import { sequelizeTypescript } from '../../../initializers/database'
55import { createVideoThumbnailFromExisting, generateVideoThumbnail } from '../../../lib/thumbnail'
56import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
64 57
65const auditLogger = auditLoggerFactory('videos') 58const auditLogger = auditLoggerFactory('videos')
66const videosRouter = express.Router() 59const videosRouter = express.Router()
@@ -220,21 +213,15 @@ async function addVideo (req: express.Request, res: express.Response) {
220 213
221 // Process thumbnail or create it from the video 214 // Process thumbnail or create it from the video
222 const thumbnailField = req.files['thumbnailfile'] 215 const thumbnailField = req.files['thumbnailfile']
223 if (thumbnailField) { 216 const thumbnailModel = thumbnailField
224 const thumbnailPhysicalFile = thumbnailField[0] 217 ? await createVideoThumbnailFromExisting(thumbnailField[0].path, video, ThumbnailType.THUMBNAIL)
225 await processImage(thumbnailPhysicalFile, join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName()), THUMBNAILS_SIZE) 218 : await generateVideoThumbnail(video, videoFile, ThumbnailType.THUMBNAIL)
226 } else {
227 await video.createThumbnail(videoFile)
228 }
229 219
230 // Process preview or create it from the video 220 // Process preview or create it from the video
231 const previewField = req.files['previewfile'] 221 const previewField = req.files['previewfile']
232 if (previewField) { 222 const previewModel = previewField
233 const previewPhysicalFile = previewField[0] 223 ? await createVideoThumbnailFromExisting(previewField[0].path, video, ThumbnailType.PREVIEW)
234 await processImage(previewPhysicalFile, join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName()), PREVIEWS_SIZE) 224 : await generateVideoThumbnail(video, videoFile, ThumbnailType.PREVIEW)
235 } else {
236 await video.createPreview(videoFile)
237 }
238 225
239 // Create the torrent file 226 // Create the torrent file
240 await video.createTorrentAndSetInfoHash(videoFile) 227 await video.createTorrentAndSetInfoHash(videoFile)
@@ -243,6 +230,13 @@ async function addVideo (req: express.Request, res: express.Response) {
243 const sequelizeOptions = { transaction: t } 230 const sequelizeOptions = { transaction: t }
244 231
245 const videoCreated = await video.save(sequelizeOptions) 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 // Do not forget to add video channel information to the created video 240 // Do not forget to add video channel information to the created video
247 videoCreated.VideoChannel = res.locals.videoChannel 241 videoCreated.VideoChannel = res.locals.videoChannel
248 242
@@ -313,16 +307,13 @@ async function updateVideo (req: express.Request, res: express.Response) {
313 const wasUnlistedVideo = videoInstance.privacy === VideoPrivacy.UNLISTED 307 const wasUnlistedVideo = videoInstance.privacy === VideoPrivacy.UNLISTED
314 308
315 // Process thumbnail or create it from the video 309 // Process thumbnail or create it from the video
316 if (req.files && req.files['thumbnailfile']) { 310 const thumbnailModel = req.files && req.files['thumbnailfile']
317 const thumbnailPhysicalFile = req.files['thumbnailfile'][0] 311 ? await createVideoThumbnailFromExisting(req.files['thumbnailfile'][0].path, videoInstance, ThumbnailType.THUMBNAIL)
318 await processImage(thumbnailPhysicalFile, join(CONFIG.STORAGE.THUMBNAILS_DIR, videoInstance.getThumbnailName()), THUMBNAILS_SIZE) 312 : undefined
319 }
320 313
321 // Process preview or create it from the video 314 const previewModel = req.files && req.files['previewfile']
322 if (req.files && req.files['previewfile']) { 315 ? await createVideoThumbnailFromExisting(req.files['previewfile'][0].path, videoInstance, ThumbnailType.PREVIEW)
323 const previewPhysicalFile = req.files['previewfile'][0] 316 : undefined
324 await processImage(previewPhysicalFile, join(CONFIG.STORAGE.PREVIEWS_DIR, videoInstance.getPreviewName()), PREVIEWS_SIZE)
325 }
326 317
327 try { 318 try {
328 const videoInstanceUpdated = await sequelizeTypescript.transaction(async t => { 319 const videoInstanceUpdated = await sequelizeTypescript.transaction(async t => {
@@ -355,6 +346,15 @@ async function updateVideo (req: express.Request, res: express.Response) {
355 346
356 const videoInstanceUpdated = await videoInstance.save(sequelizeOptions) 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 // Video tags update? 358 // Video tags update?
359 if (videoInfoToUpdate.tags !== undefined) { 359 if (videoInfoToUpdate.tags !== undefined) {
360 const tagInstances = await TagModel.findOrCreateTags(videoInfoToUpdate.tags, t) 360 const tagInstances = await TagModel.findOrCreateTags(videoInfoToUpdate.tags, t)
diff --git a/server/controllers/api/videos/ownership.ts b/server/controllers/api/videos/ownership.ts
index fc73856c9..bc247c4ee 100644
--- a/server/controllers/api/videos/ownership.ts
+++ b/server/controllers/api/videos/ownership.ts
@@ -17,7 +17,6 @@ import { VideoChannelModel } from '../../../models/video/video-channel'
17import { getFormattedObjects } from '../../../helpers/utils' 17import { getFormattedObjects } from '../../../helpers/utils'
18import { changeVideoChannelShare } from '../../../lib/activitypub' 18import { changeVideoChannelShare } from '../../../lib/activitypub'
19import { sendUpdateVideo } from '../../../lib/activitypub/send' 19import { sendUpdateVideo } from '../../../lib/activitypub/send'
20import { UserModel } from '../../../models/account/user'
21 20
22const ownershipVideoRouter = express.Router() 21const ownershipVideoRouter = express.Router()
23 22
diff --git a/server/controllers/static.ts b/server/controllers/static.ts
index f6bb88725..d75b95f52 100644
--- a/server/controllers/static.ts
+++ b/server/controllers/static.ts
@@ -164,7 +164,7 @@ export {
164 164
165// --------------------------------------------------------------------------- 165// ---------------------------------------------------------------------------
166 166
167async function getPreview (req: express.Request, res: express.Response, next: express.NextFunction) { 167async function getPreview (req: express.Request, res: express.Response) {
168 const path = await VideosPreviewCache.Instance.getFilePath(req.params.uuid) 168 const path = await VideosPreviewCache.Instance.getFilePath(req.params.uuid)
169 if (!path) return res.sendStatus(404) 169 if (!path) return res.sendStatus(404)
170 170
diff --git a/server/helpers/image-utils.ts b/server/helpers/image-utils.ts
index e43ea3f1d..eeaef0f5d 100644
--- a/server/helpers/image-utils.ts
+++ b/server/helpers/image-utils.ts
@@ -6,7 +6,8 @@ import { logger } from './logger'
6async function processImage ( 6async function processImage (
7 physicalFile: { path: string }, 7 physicalFile: { path: string },
8 destination: string, 8 destination: string,
9 newSize: { width: number, height: number } 9 newSize: { width: number, height: number },
10 keepOriginal = false
10) { 11) {
11 if (physicalFile.path === destination) { 12 if (physicalFile.path === destination) {
12 throw new Error('Sharp needs an input path different that the output path.') 13 throw new Error('Sharp needs an input path different that the output path.')
@@ -24,7 +25,7 @@ async function processImage (
24 .resize(newSize.width, newSize.height) 25 .resize(newSize.width, newSize.height)
25 .toFile(destination) 26 .toFile(destination)
26 27
27 await remove(physicalFile.path) 28 if (keepOriginal !== true) await remove(physicalFile.path)
28} 29}
29 30
30// --------------------------------------------------------------------------- 31// ---------------------------------------------------------------------------
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index 872a56220..8f237eb23 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -36,6 +36,7 @@ import { UserNotificationSettingModel } from '../models/account/user-notificatio
36import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' 36import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
37import { VideoPlaylistModel } from '../models/video/video-playlist' 37import { VideoPlaylistModel } from '../models/video/video-playlist'
38import { VideoPlaylistElementModel } from '../models/video/video-playlist-element' 38import { VideoPlaylistElementModel } from '../models/video/video-playlist-element'
39import { ThumbnailModel } from '../models/video/thumbnail'
39 40
40require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 41require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
41 42
@@ -105,7 +106,8 @@ async function initDatabaseModels (silent: boolean) {
105 UserNotificationSettingModel, 106 UserNotificationSettingModel,
106 VideoStreamingPlaylistModel, 107 VideoStreamingPlaylistModel,
107 VideoPlaylistModel, 108 VideoPlaylistModel,
108 VideoPlaylistElementModel 109 VideoPlaylistElementModel,
110 ThumbnailModel
109 ]) 111 ])
110 112
111 // Check extensions exist in the database 113 // Check extensions exist in the database
diff --git a/server/lib/activitypub/playlist.ts b/server/lib/activitypub/playlist.ts
index f312409bc..341e469f3 100644
--- a/server/lib/activitypub/playlist.ts
+++ b/server/lib/activitypub/playlist.ts
@@ -1,12 +1,12 @@
1import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object' 1import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
2import { crawlCollectionPage } from './crawl' 2import { crawlCollectionPage } from './crawl'
3import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY, THUMBNAILS_SIZE } from '../../initializers/constants' 3import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants'
4import { AccountModel } from '../../models/account/account' 4import { AccountModel } from '../../models/account/account'
5import { isArray } from '../../helpers/custom-validators/misc' 5import { isArray } from '../../helpers/custom-validators/misc'
6import { getOrCreateActorAndServerAndModel } from './actor' 6import { getOrCreateActorAndServerAndModel } from './actor'
7import { logger } from '../../helpers/logger' 7import { logger } from '../../helpers/logger'
8import { VideoPlaylistModel } from '../../models/video/video-playlist' 8import { VideoPlaylistModel } from '../../models/video/video-playlist'
9import { doRequest, downloadImage } from '../../helpers/requests' 9import { doRequest } from '../../helpers/requests'
10import { checkUrlsSameHost } from '../../helpers/activitypub' 10import { checkUrlsSameHost } from '../../helpers/activitypub'
11import * as Bluebird from 'bluebird' 11import * as Bluebird from 'bluebird'
12import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object' 12import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object'
@@ -16,9 +16,8 @@ import { VideoPlaylistElementModel } from '../../models/video/video-playlist-ele
16import { VideoModel } from '../../models/video/video' 16import { VideoModel } from '../../models/video/video'
17import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model' 17import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
18import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model' 18import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
19import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
20import { CONFIG } from '../../initializers/config'
21import { sequelizeTypescript } from '../../initializers/database' 19import { sequelizeTypescript } from '../../initializers/database'
20import { createPlaylistThumbnailFromUrl } from '../thumbnail'
22 21
23function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount: AccountModel, to: string[]) { 22function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount: AccountModel, to: string[]) {
24 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPlaylistPrivacy.PUBLIC : VideoPlaylistPrivacy.UNLISTED 23 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPlaylistPrivacy.PUBLIC : VideoPlaylistPrivacy.UNLISTED
@@ -97,16 +96,20 @@ async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAc
97 return Promise.resolve() 96 return Promise.resolve()
98 }) 97 })
99 98
100 // Empty playlists generally do not have a miniature, so skip this 99 const refreshedPlaylist = await VideoPlaylistModel.loadWithAccountAndChannel(playlist.id, null)
101 if (accItems.length !== 0) { 100
101 if (playlistObject.icon) {
102 try { 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 } catch (err) { 107 } catch (err) {
105 logger.warn('Cannot generate thumbnail of %s.', playlistObject.id, { err }) 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
112async function refreshVideoPlaylistIfNeeded (videoPlaylist: VideoPlaylistModel): Promise<VideoPlaylistModel> { 115async function refreshVideoPlaylistIfNeeded (videoPlaylist: VideoPlaylistModel): Promise<VideoPlaylistModel> {
@@ -191,12 +194,6 @@ async function resetVideoPlaylistElements (elementUrls: string[], playlist: Vide
191 return undefined 194 return undefined
192} 195}
193 196
194function 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
200async function fetchRemoteVideoPlaylist (playlistUrl: string): Promise<{ statusCode: number, playlistObject: PlaylistObject }> { 197async function fetchRemoteVideoPlaylist (playlistUrl: string): Promise<{ statusCode: number, playlistObject: PlaylistObject }> {
201 const options = { 198 const options = {
202 uri: playlistUrl, 199 uri: playlistUrl,
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts
index b9252e363..16c37a55f 100644
--- a/server/lib/activitypub/videos.ts
+++ b/server/lib/activitypub/videos.ts
@@ -3,11 +3,10 @@ import * as sequelize from 'sequelize'
3import * as magnetUtil from 'magnet-uri' 3import * as magnetUtil from 'magnet-uri'
4import * as request from 'request' 4import * as request from 'request'
5import { 5import {
6 ActivityIconObject,
7 ActivityPlaylistSegmentHashesObject, 6 ActivityPlaylistSegmentHashesObject,
8 ActivityPlaylistUrlObject, 7 ActivityPlaylistUrlObject,
9 ActivityUrlObject, 8 ActivityUrlObject,
10 ActivityVideoUrlObject, 9 ActivityVideoUrlObject, VideoCreate,
11 VideoState 10 VideoState
12} from '../../../shared/index' 11} from '../../../shared/index'
13import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' 12import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
@@ -16,8 +15,15 @@ import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validat
16import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos' 15import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
17import { resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils' 16import { resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils'
18import { logger } from '../../helpers/logger' 17import { logger } from '../../helpers/logger'
19import { doRequest, downloadImage } from '../../helpers/requests' 18import { doRequest } from '../../helpers/requests'
20import { ACTIVITY_PUB, MIMETYPES, P2P_MEDIA_LOADER_PEER_VERSION, REMOTE_SCHEME, THUMBNAILS_SIZE } from '../../initializers/constants' 19import {
20 ACTIVITY_PUB,
21 MIMETYPES,
22 P2P_MEDIA_LOADER_PEER_VERSION,
23 PREVIEWS_SIZE,
24 REMOTE_SCHEME,
25 STATIC_PATHS
26} from '../../initializers/constants'
21import { ActorModel } from '../../models/activitypub/actor' 27import { ActorModel } from '../../models/activitypub/actor'
22import { TagModel } from '../../models/video/tag' 28import { TagModel } from '../../models/video/tag'
23import { VideoModel } from '../../models/video/video' 29import { VideoModel } from '../../models/video/video'
@@ -43,8 +49,11 @@ import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
43import { AccountVideoRateModel } from '../../models/account/account-video-rate' 49import { AccountVideoRateModel } from '../../models/account/account-video-rate'
44import { VideoShareModel } from '../../models/video/video-share' 50import { VideoShareModel } from '../../models/video/video-share'
45import { VideoCommentModel } from '../../models/video/video-comment' 51import { VideoCommentModel } from '../../models/video/video-comment'
46import { CONFIG } from '../../initializers/config'
47import { sequelizeTypescript } from '../../initializers/database' 52import { sequelizeTypescript } from '../../initializers/database'
53import { createPlaceholderThumbnail, createVideoThumbnailFromUrl } from '../thumbnail'
54import { ThumbnailModel } from '../../models/video/thumbnail'
55import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
56import { join } from 'path'
48 57
49async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) { 58async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
50 // If the video is not private and is published, we federate it 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
102function fetchRemoteVideoStaticFile (video: VideoModel, path: string, reject: Function) { 111function 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 // We need to provide a callback, if no we could have an uncaught exception 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 if (err) reject(err) 116 if (err) reject(err)
108 }) 117 })
109} 118}
110 119
111function generateThumbnailFromUrl (video: VideoModel, icon: ActivityIconObject) { 120function buildRemoteBaseUrl (video: VideoModel, path: string) {
112 const thumbnailName = video.getThumbnailName() 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
117function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) { 126function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
@@ -236,6 +245,14 @@ async function updateVideoFromAP (options: {
236 const wasUnlistedVideo = options.video.privacy === VideoPrivacy.UNLISTED 245 const wasUnlistedVideo = options.video.privacy === VideoPrivacy.UNLISTED
237 246
238 try { 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 await sequelizeTypescript.transaction(async t => { 256 await sequelizeTypescript.transaction(async t => {
240 const sequelizeOptions = { transaction: t } 257 const sequelizeOptions = { transaction: t }
241 258
@@ -272,6 +289,17 @@ async function updateVideoFromAP (options: {
272 289
273 await options.video.save(sequelizeOptions) 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 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject) 304 const videoFileAttributes = videoFileActivityUrlToDBAttributes(options.video, options.videoObject)
277 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a)) 305 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
@@ -347,12 +375,6 @@ async function updateVideoFromAP (options: {
347 logger.debug('Cannot update the remote video.', { err }) 375 logger.debug('Cannot update the remote video.', { err })
348 throw err 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
358async function refreshVideoIfNeeded (options: { 380async function refreshVideoIfNeeded (options: {
@@ -412,7 +434,6 @@ export {
412 getOrCreateVideoAndAccountAndChannel, 434 getOrCreateVideoAndAccountAndChannel,
413 fetchRemoteVideoStaticFile, 435 fetchRemoteVideoStaticFile,
414 fetchRemoteVideoDescription, 436 fetchRemoteVideoDescription,
415 generateThumbnailFromUrl,
416 getOrCreateVideoChannelFromVideoObject 437 getOrCreateVideoChannelFromVideoObject
417} 438}
418 439
@@ -440,13 +461,34 @@ function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistS
440async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) { 461async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
441 logger.debug('Adding remote video %s.', videoObject.id) 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 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => { 474 const videoCreated: VideoModel = await sequelizeTypescript.transaction(async t => {
444 const sequelizeOptions = { transaction: t } 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 const videoCreated = await video.save(sequelizeOptions) 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 // Process files 493 // Process files
452 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject) 494 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject)
@@ -476,14 +518,16 @@ async function createVideo (videoObject: VideoTorrentObject, channelActor: Actor
476 518
477 logger.info('Remote video with uuid %s inserted.', videoObject.uuid) 519 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
478 520
479 videoCreated.VideoChannel = channelActor.VideoChannel
480 return videoCreated 521 return videoCreated
481 }) 522 })
482 523
483 const p = generateThumbnailFromUrl(videoCreated, videoObject.icon) 524 if (waitThumbnail === false) {
484 .catch(err => logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err })) 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 return videoCreated 532 return videoCreated
489} 533}
diff --git a/server/lib/files-cache/abstract-video-static-file-cache.ts b/server/lib/files-cache/abstract-video-static-file-cache.ts
index 7512f2b9d..61837e0f8 100644
--- a/server/lib/files-cache/abstract-video-static-file-cache.ts
+++ b/server/lib/files-cache/abstract-video-static-file-cache.ts
@@ -1,41 +1,29 @@
1import * as AsyncLRU from 'async-lru'
2import { createWriteStream, remove } from 'fs-extra' 1import { createWriteStream, remove } from 'fs-extra'
3import { logger } from '../../helpers/logger' 2import { logger } from '../../helpers/logger'
4import { VideoModel } from '../../models/video/video' 3import { VideoModel } from '../../models/video/video'
5import { fetchRemoteVideoStaticFile } from '../activitypub' 4import { fetchRemoteVideoStaticFile } from '../activitypub'
5import * as memoizee from 'memoizee'
6 6
7export abstract class AbstractVideoStaticFileCache <T> { 7export 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 // Load and save the remote file, then return the local path from filesystem 13 // Load and save the remote file, then return the local path from filesystem
14 protected abstract loadRemoteFile (key: string): Promise<string> 14 protected abstract loadRemoteFile (key: string): Promise<string>
15 15
16 init (max: number, maxAge: number) { 16 init (max: number, maxAge: number) {
17 this.lru = new AsyncLRU({ 17 this.getFilePath = memoizee(this.getFilePathImpl, {
18 max,
19 maxAge, 18 maxAge,
20 load: (key, cb) => { 19 max,
21 this.loadRemoteFile(key) 20 promise: true,
22 .then(res => cb(null, res)) 21 dispose: (value: string) => {
23 .catch(err => cb(err)) 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 protected saveRemoteVideoFileAndReturnPath (video: VideoModel, remoteStaticPath: string, destPath: string) { 29 protected saveRemoteVideoFileAndReturnPath (video: VideoModel, remoteStaticPath: string, destPath: string) {
diff --git a/server/lib/files-cache/videos-caption-cache.ts b/server/lib/files-cache/videos-caption-cache.ts
index 0926f4009..d4a0a3345 100644
--- a/server/lib/files-cache/videos-caption-cache.ts
+++ b/server/lib/files-cache/videos-caption-cache.ts
@@ -20,14 +20,14 @@ class VideosCaptionCache extends AbstractVideoStaticFileCache <GetPathParam> {
20 return this.instance || (this.instance = new this()) 20 return this.instance || (this.instance = new this())
21 } 21 }
22 22
23 async getFilePath (params: GetPathParam) { 23 async getFilePathImpl (params: GetPathParam) {
24 const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(params.videoId, params.language) 24 const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(params.videoId, params.language)
25 if (!videoCaption) return undefined 25 if (!videoCaption) return undefined
26 26
27 if (videoCaption.isOwned()) return join(CONFIG.STORAGE.CAPTIONS_DIR, videoCaption.getCaptionName()) 27 if (videoCaption.isOwned()) return join(CONFIG.STORAGE.CAPTIONS_DIR, videoCaption.getCaptionName())
28 28
29 const key = params.videoId + VideosCaptionCache.KEY_DELIMITER + params.language 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 protected async loadRemoteFile (key: string) { 33 protected async loadRemoteFile (key: string) {
@@ -42,6 +42,7 @@ class VideosCaptionCache extends AbstractVideoStaticFileCache <GetPathParam> {
42 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId) 42 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
43 if (!video) return undefined 43 if (!video) return undefined
44 44
45 // FIXME: use URL
45 const remoteStaticPath = videoCaption.getCaptionStaticPath() 46 const remoteStaticPath = videoCaption.getCaptionStaticPath()
46 const destPath = join(FILES_CACHE.VIDEO_CAPTIONS.DIRECTORY, videoCaption.getCaptionName()) 47 const destPath = join(FILES_CACHE.VIDEO_CAPTIONS.DIRECTORY, videoCaption.getCaptionName())
47 48
diff --git a/server/lib/files-cache/videos-preview-cache.ts b/server/lib/files-cache/videos-preview-cache.ts
index 6575e1c83..fc0d92c78 100644
--- a/server/lib/files-cache/videos-preview-cache.ts
+++ b/server/lib/files-cache/videos-preview-cache.ts
@@ -16,13 +16,13 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
16 return this.instance || (this.instance = new this()) 16 return this.instance || (this.instance = new this())
17 } 17 }
18 18
19 async getFilePath (videoUUID: string) { 19 async getFilePathImpl (videoUUID: string) {
20 const video = await VideoModel.loadByUUIDWithFile(videoUUID) 20 const video = await VideoModel.loadByUUIDWithFile(videoUUID)
21 if (!video) return undefined 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 protected async loadRemoteFile (key: string) { 28 protected async loadRemoteFile (key: string) {
@@ -31,8 +31,9 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
31 31
32 if (video.isOwned()) throw new Error('Cannot load remote preview of owned video.') 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()) 34 // FIXME: use URL
35 const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, video.getPreviewName()) 35 const remoteStaticPath = join(STATIC_PATHS.PREVIEWS, video.getPreview().filename)
36 const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, video.getPreview().filename)
36 37
37 return this.saveRemoteVideoFileAndReturnPath(video, remoteStaticPath, destPath) 38 return this.saveRemoteVideoFileAndReturnPath(video, remoteStaticPath, destPath)
38 } 39 }
diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts
index 8e8aa1597..3fa0dd65d 100644
--- a/server/lib/job-queue/handlers/video-import.ts
+++ b/server/lib/job-queue/handlers/video-import.ts
@@ -6,8 +6,7 @@ import { VideoImportState } from '../../../../shared/models/videos'
6import { getDurationFromVideoFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils' 6import { getDurationFromVideoFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils'
7import { extname, join } from 'path' 7import { extname, join } from 'path'
8import { VideoFileModel } from '../../../models/video/video-file' 8import { VideoFileModel } from '../../../models/video/video-file'
9import { PREVIEWS_SIZE, THUMBNAILS_SIZE, VIDEO_IMPORT_TIMEOUT } from '../../../initializers/constants' 9import { VIDEO_IMPORT_TIMEOUT } from '../../../initializers/constants'
10import { downloadImage } from '../../../helpers/requests'
11import { VideoState } from '../../../../shared' 10import { VideoState } from '../../../../shared'
12import { JobQueue } from '../index' 11import { JobQueue } from '../index'
13import { federateVideoIfNeeded } from '../../activitypub' 12import { federateVideoIfNeeded } from '../../activitypub'
@@ -18,6 +17,9 @@ import { move, remove, stat } from 'fs-extra'
18import { Notifier } from '../../notifier' 17import { Notifier } from '../../notifier'
19import { CONFIG } from '../../../initializers/config' 18import { CONFIG } from '../../../initializers/config'
20import { sequelizeTypescript } from '../../../initializers/database' 19import { sequelizeTypescript } from '../../../initializers/database'
20import { ThumbnailModel } from '../../../models/video/thumbnail'
21import { createVideoThumbnailFromUrl, generateVideoThumbnail } from '../../thumbnail'
22import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
21 23
22type VideoImportYoutubeDLPayload = { 24type VideoImportYoutubeDLPayload = {
23 type: 'youtube-dl' 25 type: 'youtube-dl'
@@ -146,25 +148,19 @@ async function processFile (downloader: () => Promise<string>, videoImport: Vide
146 tempVideoPath = null // This path is not used anymore 148 tempVideoPath = null // This path is not used anymore
147 149
148 // Process thumbnail 150 // Process thumbnail
149 if (options.downloadThumbnail) { 151 let thumbnailModel: ThumbnailModel
150 if (options.thumbnailUrl) { 152 if (options.downloadThumbnail && options.thumbnailUrl) {
151 await downloadImage(options.thumbnailUrl, CONFIG.STORAGE.THUMBNAILS_DIR, videoImport.Video.getThumbnailName(), THUMBNAILS_SIZE) 153 thumbnailModel = await createVideoThumbnailFromUrl(options.thumbnailUrl, videoImport.Video, ThumbnailType.THUMBNAIL)
152 } else { 154 } else if (options.generateThumbnail || options.downloadThumbnail) {
153 await videoImport.Video.createThumbnail(videoFile) 155 thumbnailModel = await generateVideoThumbnail(videoImport.Video, videoFile, ThumbnailType.THUMBNAIL)
154 }
155 } else if (options.generateThumbnail) {
156 await videoImport.Video.createThumbnail(videoFile)
157 } 156 }
158 157
159 // Process preview 158 // Process preview
160 if (options.downloadPreview) { 159 let previewModel: ThumbnailModel
161 if (options.thumbnailUrl) { 160 if (options.downloadPreview && options.thumbnailUrl) {
162 await downloadImage(options.thumbnailUrl, CONFIG.STORAGE.PREVIEWS_DIR, videoImport.Video.getPreviewName(), PREVIEWS_SIZE) 161 previewModel = await createVideoThumbnailFromUrl(options.thumbnailUrl, videoImport.Video, ThumbnailType.PREVIEW)
163 } else { 162 } else if (options.generatePreview || options.downloadPreview) {
164 await videoImport.Video.createPreview(videoFile) 163 previewModel = await generateVideoThumbnail(videoImport.Video, videoFile, ThumbnailType.PREVIEW)
165 }
166 } else if (options.generatePreview) {
167 await videoImport.Video.createPreview(videoFile)
168 } 164 }
169 165
170 // Create torrent 166 // Create torrent
@@ -184,6 +180,15 @@ async function processFile (downloader: () => Promise<string>, videoImport: Vide
184 video.state = CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED 180 video.state = CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED
185 await video.save({ transaction: t }) 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 // Now we can federate the video (reload from database, we need more attributes) 192 // Now we can federate the video (reload from database, we need more attributes)
188 const videoForFederation = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t) 193 const videoForFederation = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
189 await federateVideoIfNeeded(videoForFederation, true, t) 194 await federateVideoIfNeeded(videoForFederation, true, t)
diff --git a/server/lib/thumbnail.ts b/server/lib/thumbnail.ts
new file mode 100644
index 000000000..344c28566
--- /dev/null
+++ b/server/lib/thumbnail.ts
@@ -0,0 +1,151 @@
1import { VideoFileModel } from '../models/video/video-file'
2import { generateImageFromVideoFile } from '../helpers/ffmpeg-utils'
3import { CONFIG } from '../initializers/config'
4import { PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants'
5import { VideoModel } from '../models/video/video'
6import { ThumbnailModel } from '../models/video/thumbnail'
7import { ThumbnailType } from '../../shared/models/videos/thumbnail.type'
8import { processImage } from '../helpers/image-utils'
9import { join } from 'path'
10import { downloadImage } from '../helpers/requests'
11import { VideoPlaylistModel } from '../models/video/video-playlist'
12
13type ImageSize = { height: number, width: number }
14
15function 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
23function 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
31function 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
38function 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
45function 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
54function 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
70export {
71 generateVideoThumbnail,
72 createVideoThumbnailFromUrl,
73 createVideoThumbnailFromExisting,
74 createPlaceholderThumbnail,
75 createPlaylistThumbnailFromUrl,
76 createPlaylistThumbnailFromExisting
77}
78
79function 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
93function 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
129async 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}
diff --git a/server/models/video/thumbnail.ts b/server/models/video/thumbnail.ts
new file mode 100644
index 000000000..baa5533ac
--- /dev/null
+++ b/server/models/video/thumbnail.ts
@@ -0,0 +1,116 @@
1import { join } from 'path'
2import { AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
4import { logger } from '../../helpers/logger'
5import { remove } from 'fs-extra'
6import { CONFIG } from '../../initializers/config'
7import { VideoModel } from './video'
8import { VideoPlaylistModel } from './video-playlist'
9import { 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})
23export 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}
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts
index 64771b1ff..89992a5a8 100644
--- a/server/models/video/video-format-utils.ts
+++ b/server/models/video/video-format-utils.ts
@@ -7,7 +7,7 @@ import {
7 ActivityUrlObject, 7 ActivityUrlObject,
8 VideoTorrentObject 8 VideoTorrentObject
9} from '../../../shared/models/activitypub/objects' 9} from '../../../shared/models/activitypub/objects'
10import { MIMETYPES, THUMBNAILS_SIZE, WEBSERVER } from '../../initializers/constants' 10import { MIMETYPES, WEBSERVER } from '../../initializers/constants'
11import { VideoCaptionModel } from './video-caption' 11import { VideoCaptionModel } from './video-caption'
12import { 12import {
13 getVideoCommentsActivityPubUrl, 13 getVideoCommentsActivityPubUrl,
@@ -326,10 +326,10 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
326 subtitleLanguage, 326 subtitleLanguage,
327 icon: { 327 icon: {
328 type: 'Image', 328 type: 'Image',
329 url: video.getThumbnailUrl(baseUrlHttp), 329 url: video.getThumbnail().getUrl(),
330 mediaType: 'image/jpeg', 330 mediaType: 'image/jpeg',
331 width: THUMBNAILS_SIZE.width, 331 width: video.getThumbnail().width,
332 height: THUMBNAILS_SIZE.height 332 height: video.getThumbnail().height
333 }, 333 },
334 url, 334 url,
335 likes: getVideoLikesActivityPubUrl(video), 335 likes: getVideoLikesActivityPubUrl(video),
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
index 0725b752a..073609c24 100644
--- a/server/models/video/video-playlist.ts
+++ b/server/models/video/video-playlist.ts
@@ -1,6 +1,5 @@
1import { 1import {
2 AllowNull, 2 AllowNull,
3 BeforeDestroy,
4 BelongsTo, 3 BelongsTo,
5 Column, 4 Column,
6 CreatedAt, 5 CreatedAt,
@@ -8,6 +7,7 @@ import {
8 Default, 7 Default,
9 ForeignKey, 8 ForeignKey,
10 HasMany, 9 HasMany,
10 HasOne,
11 Is, 11 Is,
12 IsUUID, 12 IsUUID,
13 Model, 13 Model,
@@ -40,16 +40,16 @@ import { join } from 'path'
40import { VideoPlaylistElementModel } from './video-playlist-element' 40import { VideoPlaylistElementModel } from './video-playlist-element'
41import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object' 41import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
42import { activityPubCollectionPagination } from '../../helpers/activitypub' 42import { activityPubCollectionPagination } from '../../helpers/activitypub'
43import { remove } from 'fs-extra'
44import { logger } from '../../helpers/logger'
45import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model' 43import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
46import { CONFIG } from '../../initializers/config' 44import { ThumbnailModel } from './thumbnail'
45import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
47 46
48enum ScopeNames { 47enum ScopeNames {
49 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', 48 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
50 WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH', 49 WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH',
51 WITH_ACCOUNT_AND_CHANNEL_SUMMARY = 'WITH_ACCOUNT_AND_CHANNEL_SUMMARY', 50 WITH_ACCOUNT_AND_CHANNEL_SUMMARY = 'WITH_ACCOUNT_AND_CHANNEL_SUMMARY',
52 WITH_ACCOUNT = 'WITH_ACCOUNT', 51 WITH_ACCOUNT = 'WITH_ACCOUNT',
52 WITH_THUMBNAIL = 'WITH_THUMBNAIL',
53 WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL' 53 WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL'
54} 54}
55 55
@@ -62,6 +62,14 @@ type AvailableForListOptions = {
62} 62}
63 63
64@Scopes({ 64@Scopes({
65 [ ScopeNames.WITH_THUMBNAIL ]: {
66 include: [
67 {
68 model: () => ThumbnailModel,
69 required: false
70 }
71 ]
72 },
65 [ ScopeNames.WITH_VIDEOS_LENGTH ]: { 73 [ ScopeNames.WITH_VIDEOS_LENGTH ]: {
66 attributes: { 74 attributes: {
67 include: [ 75 include: [
@@ -256,12 +264,15 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
256 }) 264 })
257 VideoPlaylistElements: VideoPlaylistElementModel[] 265 VideoPlaylistElements: VideoPlaylistElementModel[]
258 266
259 @BeforeDestroy 267 @HasOne(() => ThumbnailModel, {
260 static async removeFiles (instance: VideoPlaylistModel) { 268 foreignKey: {
261 logger.info('Removing files of video playlist %s.', instance.url) 269 name: 'videoPlaylistId',
262 270 allowNull: true
263 return instance.removeThumbnail() 271 },
264 } 272 onDelete: 'CASCADE',
273 hooks: true
274 })
275 Thumbnail: ThumbnailModel
265 276
266 static listForApi (options: { 277 static listForApi (options: {
267 followerActorId: number 278 followerActorId: number
@@ -292,7 +303,8 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
292 } as AvailableForListOptions 303 } as AvailableForListOptions
293 ] 304 ]
294 } as any, // FIXME: typings 305 } as any, // FIXME: typings
295 ScopeNames.WITH_VIDEOS_LENGTH 306 ScopeNames.WITH_VIDEOS_LENGTH,
307 ScopeNames.WITH_THUMBNAIL
296 ] 308 ]
297 309
298 return VideoPlaylistModel 310 return VideoPlaylistModel
@@ -365,7 +377,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
365 } 377 }
366 378
367 return VideoPlaylistModel 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 .findOne(query) 381 .findOne(query)
370 } 382 }
371 383
@@ -378,7 +390,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
378 } 390 }
379 391
380 return VideoPlaylistModel 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 .findOne(query) 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 static getPrivacyLabel (privacy: VideoPlaylistPrivacy) { 407 static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
@@ -411,24 +423,34 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
411 return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query) 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 const extension = '.jpg' 439 const extension = '.jpg'
416 440
417 return 'playlist-' + this.uuid + extension 441 return 'playlist-' + this.uuid + extension
418 } 442 }
419 443
420 getThumbnailUrl () { 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 getThumbnailStaticPath () { 450 getThumbnailStaticPath () {
425 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName()) 451 if (!this.hasThumbnail()) return null
426 }
427 452
428 removeThumbnail () { 453 return join(STATIC_PATHS.THUMBNAILS, this.getThumbnail().filename)
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 }))
432 } 454 }
433 455
434 setAsRefreshed () { 456 setAsRefreshed () {
@@ -482,6 +504,17 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
482 return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t) 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 return activityPubCollectionPagination(this.url, handler, page) 518 return activityPubCollectionPagination(this.url, handler, page)
486 .then(o => { 519 .then(o => {
487 return Object.assign(o, { 520 return Object.assign(o, {
@@ -492,13 +525,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
492 published: this.createdAt.toISOString(), 525 published: this.createdAt.toISOString(),
493 updated: this.updatedAt.toISOString(), 526 updated: this.updatedAt.toISOString(),
494 attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [], 527 attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],
495 icon: { 528 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 }
502 }) 529 })
503 }) 530 })
504 } 531 }
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 38447797e..9840d17fd 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -107,6 +107,8 @@ import { VideoImportModel } from './video-import'
107import { VideoStreamingPlaylistModel } from './video-streaming-playlist' 107import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
108import { VideoPlaylistElementModel } from './video-playlist-element' 108import { VideoPlaylistElementModel } from './video-playlist-element'
109import { CONFIG } from '../../initializers/config' 109import { CONFIG } from '../../initializers/config'
110import { ThumbnailModel } from './thumbnail'
111import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
110 112
111// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation 113// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
112const indexes: Sequelize.DefineIndexesOptions[] = [ 114const indexes: Sequelize.DefineIndexesOptions[] = [
@@ -181,7 +183,8 @@ export enum ScopeNames {
181 WITH_BLACKLISTED = 'WITH_BLACKLISTED', 183 WITH_BLACKLISTED = 'WITH_BLACKLISTED',
182 WITH_USER_HISTORY = 'WITH_USER_HISTORY', 184 WITH_USER_HISTORY = 'WITH_USER_HISTORY',
183 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS', 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
187type ForAPIOptions = { 190type ForAPIOptions = {
@@ -473,6 +476,14 @@ type AvailableForListIDsOptions = {
473 476
474 return query 477 return query
475 }, 478 },
479 [ ScopeNames.WITH_THUMBNAILS ]: {
480 include: [
481 {
482 model: () => ThumbnailModel,
483 required: false
484 }
485 ]
486 },
476 [ ScopeNames.WITH_USER_ID ]: { 487 [ ScopeNames.WITH_USER_ID ]: {
477 include: [ 488 include: [
478 { 489 {
@@ -771,6 +782,16 @@ export class VideoModel extends Model<VideoModel> {
771 }) 782 })
772 Tags: TagModel[] 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 @HasMany(() => VideoPlaylistElementModel, { 795 @HasMany(() => VideoPlaylistElementModel, {
775 foreignKey: { 796 foreignKey: {
776 name: 'videoId', 797 name: 'videoId',
@@ -920,15 +941,11 @@ export class VideoModel extends Model<VideoModel> {
920 941
921 logger.info('Removing files of video %s.', instance.url) 942 logger.info('Removing files of video %s.', instance.url)
922 943
923 tasks.push(instance.removeThumbnail())
924
925 if (instance.isOwned()) { 944 if (instance.isOwned()) {
926 if (!Array.isArray(instance.VideoFiles)) { 945 if (!Array.isArray(instance.VideoFiles)) {
927 instance.VideoFiles = await instance.$get('VideoFiles') as VideoFileModel[] 946 instance.VideoFiles = await instance.$get('VideoFiles') as VideoFileModel[]
928 } 947 }
929 948
930 tasks.push(instance.removePreview())
931
932 // Remove physical files and torrents 949 // Remove physical files and torrents
933 instance.VideoFiles.forEach(file => { 950 instance.VideoFiles.forEach(file => {
934 tasks.push(instance.removeFile(file)) 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 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) { 982 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
@@ -1048,7 +1069,7 @@ export class VideoModel extends Model<VideoModel> {
1048 1069
1049 return Bluebird.all([ 1070 return Bluebird.all([
1050 // FIXME: typing issue 1071 // FIXME: typing issue
1051 VideoModel.findAll(query as any), 1072 VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findAll(query as any),
1052 VideoModel.sequelize.query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT }) 1073 VideoModel.sequelize.query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT })
1053 ]).then(([ rows, totals ]) => { 1074 ]).then(([ rows, totals ]) => {
1054 // totals: totalVideos + totalVideoShares 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 }) => { 1126 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS)
1106 return { 1127 .findAndCountAll(query)
1107 data: rows, 1128 .then(({ rows, count }) => {
1108 total: count 1129 return {
1109 } 1130 data: rows,
1110 }) 1131 total: count
1132 }
1133 })
1111 } 1134 }
1112 1135
1113 static async listForApi (options: { 1136 static async listForApi (options: {
@@ -1296,7 +1319,7 @@ export class VideoModel extends Model<VideoModel> {
1296 transaction: t 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 static loadWithRights (id: number | string, t?: Sequelize.Transaction) { 1325 static loadWithRights (id: number | string, t?: Sequelize.Transaction) {
@@ -1306,7 +1329,11 @@ export class VideoModel extends Model<VideoModel> {
1306 transaction: t 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 static loadOnlyId (id: number | string, t?: Sequelize.Transaction) { 1339 static loadOnlyId (id: number | string, t?: Sequelize.Transaction) {
@@ -1318,12 +1345,15 @@ export class VideoModel extends Model<VideoModel> {
1318 transaction: t 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 static loadWithFiles (id: number, t?: Sequelize.Transaction, logging?: boolean) { 1351 static loadWithFiles (id: number, t?: Sequelize.Transaction, logging?: boolean) {
1325 return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ]) 1352 return VideoModel.scope([
1326 .findByPk(id, { transaction: t, logging }) 1353 ScopeNames.WITH_FILES,
1354 ScopeNames.WITH_STREAMING_PLAYLISTS,
1355 ScopeNames.WITH_THUMBNAILS
1356 ]).findByPk(id, { transaction: t, logging })
1327 } 1357 }
1328 1358
1329 static loadByUUIDWithFile (uuid: string) { 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 static loadByUrl (url: string, transaction?: Sequelize.Transaction) { 1369 static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
@@ -1344,7 +1374,7 @@ export class VideoModel extends Model<VideoModel> {
1344 transaction 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 static loadByUrlAndPopulateAccount (url: string, transaction?: Sequelize.Transaction) { 1380 static loadByUrlAndPopulateAccount (url: string, transaction?: Sequelize.Transaction) {
@@ -1358,7 +1388,8 @@ export class VideoModel extends Model<VideoModel> {
1358 return VideoModel.scope([ 1388 return VideoModel.scope([
1359 ScopeNames.WITH_ACCOUNT_DETAILS, 1389 ScopeNames.WITH_ACCOUNT_DETAILS,
1360 ScopeNames.WITH_FILES, 1390 ScopeNames.WITH_FILES,
1361 ScopeNames.WITH_STREAMING_PLAYLISTS 1391 ScopeNames.WITH_STREAMING_PLAYLISTS,
1392 ScopeNames.WITH_THUMBNAILS
1362 ]).findOne(query) 1393 ]).findOne(query)
1363 } 1394 }
1364 1395
@@ -1377,7 +1408,8 @@ export class VideoModel extends Model<VideoModel> {
1377 ScopeNames.WITH_ACCOUNT_DETAILS, 1408 ScopeNames.WITH_ACCOUNT_DETAILS,
1378 ScopeNames.WITH_SCHEDULED_UPDATE, 1409 ScopeNames.WITH_SCHEDULED_UPDATE,
1379 ScopeNames.WITH_FILES, 1410 ScopeNames.WITH_FILES,
1380 ScopeNames.WITH_STREAMING_PLAYLISTS 1411 ScopeNames.WITH_STREAMING_PLAYLISTS,
1412 ScopeNames.WITH_THUMBNAILS
1381 ] 1413 ]
1382 1414
1383 if (userId) { 1415 if (userId) {
@@ -1403,6 +1435,7 @@ export class VideoModel extends Model<VideoModel> {
1403 ScopeNames.WITH_BLACKLISTED, 1435 ScopeNames.WITH_BLACKLISTED,
1404 ScopeNames.WITH_ACCOUNT_DETAILS, 1436 ScopeNames.WITH_ACCOUNT_DETAILS,
1405 ScopeNames.WITH_SCHEDULED_UPDATE, 1437 ScopeNames.WITH_SCHEDULED_UPDATE,
1438 ScopeNames.WITH_THUMBNAILS,
1406 { method: [ ScopeNames.WITH_FILES, true ] } as any, // FIXME: typings 1439 { method: [ ScopeNames.WITH_FILES, true ] } as any, // FIXME: typings
1407 { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] } as any // FIXME: typings 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 // FIXME: typing 1590 // FIXME: typing
1558 const apiScope: any[] = [] 1591 const apiScope: any[] = [ ScopeNames.WITH_THUMBNAILS ]
1559 1592
1560 if (options.user) { 1593 if (options.user) {
1561 apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] }) 1594 apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] })
@@ -1611,18 +1644,37 @@ export class VideoModel extends Model<VideoModel> {
1611 return maxBy(this.VideoFiles, file => file.resolution) 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 getVideoFilename (videoFile: VideoFileModel) { 1656 getVideoFilename (videoFile: VideoFileModel) {
1615 return this.uuid + '-' + videoFile.resolution + videoFile.extname 1657 return this.uuid + '-' + videoFile.resolution + videoFile.extname
1616 } 1658 }
1617 1659
1618 getThumbnailName () { 1660 generateThumbnailName () {
1619 const extension = '.jpg' 1661 return this.uuid + '.jpg'
1620 return this.uuid + extension
1621 } 1662 }
1622 1663
1623 getPreviewName () { 1664 getThumbnail () {
1624 const extension = '.jpg' 1665 if (Array.isArray(this.Thumbnails) === false) return undefined
1625 return this.uuid + extension 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 getTorrentFileName (videoFile: VideoFileModel) { 1680 getTorrentFileName (videoFile: VideoFileModel) {
@@ -1634,24 +1686,6 @@ export class VideoModel extends Model<VideoModel> {
1634 return this.remote === false 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 getTorrentFilePath (videoFile: VideoFileModel) { 1689 getTorrentFilePath (videoFile: VideoFileModel) {
1656 return join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile)) 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 getThumbnailStaticPath () { 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 getPreviewStaticPath () { 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 toFormattedJSON (options?: VideoFormattingJSONOptions): Video { 1743 toFormattedJSON (options?: VideoFormattingJSONOptions): Video {
@@ -1732,18 +1773,6 @@ export class VideoModel extends Model<VideoModel> {
1732 return `/api/${API_VERSION}/videos/${this.uuid}/description` 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 removeFile (videoFile: VideoFileModel, isRedundancy = false) { 1776 removeFile (videoFile: VideoFileModel, isRedundancy = false) {
1748 const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR 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 return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ] 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 getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) { 1848 getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1824 return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile) 1849 return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
1825 } 1850 }
diff --git a/shared/models/activitypub/objects/playlist-object.ts b/shared/models/activitypub/objects/playlist-object.ts
index c11a23a69..b561d8efd 100644
--- a/shared/models/activitypub/objects/playlist-object.ts
+++ b/shared/models/activitypub/objects/playlist-object.ts
@@ -11,7 +11,7 @@ export interface PlaylistObject {
11 totalItems: number 11 totalItems: number
12 attributedTo: string[] 12 attributedTo: string[]
13 13
14 icon: ActivityIconObject 14 icon?: ActivityIconObject
15 15
16 published: string 16 published: string
17 updated: string 17 updated: string
diff --git a/shared/models/videos/thumbnail.type.ts b/shared/models/videos/thumbnail.type.ts
new file mode 100644
index 000000000..317b4db43
--- /dev/null
+++ b/shared/models/videos/thumbnail.type.ts
@@ -0,0 +1,4 @@
1export enum ThumbnailType {
2 THUMBNAIL = 1,
3 PREVIEW = 2
4}