aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/controllers/activitypub/client.ts30
-rw-r--r--server/controllers/api/accounts.ts7
-rw-r--r--server/controllers/api/config.ts13
-rw-r--r--server/controllers/api/server/stats.ts7
-rw-r--r--server/controllers/api/users/index.ts21
-rw-r--r--server/controllers/api/users/me.ts158
-rw-r--r--server/controllers/api/users/my-subscriptions.ts170
-rw-r--r--server/controllers/api/video-channel.ts6
-rw-r--r--server/controllers/api/videos/abuse.ts2
-rw-r--r--server/controllers/api/videos/blacklist.ts17
-rw-r--r--server/controllers/api/videos/import.ts1
-rw-r--r--server/controllers/api/videos/index.ts35
-rw-r--r--server/controllers/static.ts9
-rw-r--r--server/controllers/tracker.ts25
-rw-r--r--server/helpers/activitypub.ts10
-rw-r--r--server/helpers/audit-logger.ts3
-rw-r--r--server/helpers/core-utils.ts8
-rw-r--r--server/helpers/custom-validators/activitypub/activity.ts99
-rw-r--r--server/helpers/custom-validators/activitypub/actor.ts25
-rw-r--r--server/helpers/custom-validators/activitypub/announce.ts13
-rw-r--r--server/helpers/custom-validators/activitypub/cache-file.ts28
-rw-r--r--server/helpers/custom-validators/activitypub/flag.ts14
-rw-r--r--server/helpers/custom-validators/activitypub/misc.ts24
-rw-r--r--server/helpers/custom-validators/activitypub/rate.ts15
-rw-r--r--server/helpers/custom-validators/activitypub/undo.ts20
-rw-r--r--server/helpers/custom-validators/activitypub/video-comments.ts11
-rw-r--r--server/helpers/custom-validators/activitypub/videos.ts29
-rw-r--r--server/helpers/custom-validators/activitypub/view.ts10
-rw-r--r--server/helpers/custom-validators/misc.ts5
-rw-r--r--server/helpers/custom-validators/videos.ts4
-rw-r--r--server/helpers/ffmpeg-utils.ts38
-rw-r--r--server/helpers/requests.ts2
-rw-r--r--server/helpers/utils.ts1
-rw-r--r--server/helpers/video.ts4
-rw-r--r--server/initializers/checker-before-init.ts2
-rw-r--r--server/initializers/constants.ts24
-rw-r--r--server/initializers/database.ts4
-rw-r--r--server/initializers/installer.ts5
-rw-r--r--server/initializers/migrations/0320-blacklist-unfederate.ts27
-rw-r--r--server/initializers/migrations/0325-video-abuse-fields.ts37
-rw-r--r--server/initializers/migrations/0330-video-streaming-playlist.ts51
-rw-r--r--server/initializers/migrations/0335-video-downloading-enabled.ts27
-rw-r--r--server/initializers/migrations/0340-add-originally-published-at.ts (renamed from server/initializers/migrations/0295-add-originally-published-at.ts)11
-rw-r--r--server/lib/activitypub/actor.ts130
-rw-r--r--server/lib/activitypub/cache-file.ts23
-rw-r--r--server/lib/activitypub/process/process-accept.ts1
-rw-r--r--server/lib/activitypub/process/process-create.ts118
-rw-r--r--server/lib/activitypub/process/process-dislike.ts52
-rw-r--r--server/lib/activitypub/process/process-flag.ts49
-rw-r--r--server/lib/activitypub/process/process-follow.ts3
-rw-r--r--server/lib/activitypub/process/process-like.ts3
-rw-r--r--server/lib/activitypub/process/process-undo.ts8
-rw-r--r--server/lib/activitypub/process/process-view.ts35
-rw-r--r--server/lib/activitypub/process/process.ts14
-rw-r--r--server/lib/activitypub/send/send-create.ts76
-rw-r--r--server/lib/activitypub/send/send-dislike.ts41
-rw-r--r--server/lib/activitypub/send/send-flag.ts39
-rw-r--r--server/lib/activitypub/send/send-undo.ts15
-rw-r--r--server/lib/activitypub/send/send-update.ts2
-rw-r--r--server/lib/activitypub/send/send-view.ts40
-rw-r--r--server/lib/activitypub/share.ts20
-rw-r--r--server/lib/activitypub/url.ts7
-rw-r--r--server/lib/activitypub/video-rates.ts9
-rw-r--r--server/lib/activitypub/videos.ts107
-rw-r--r--server/lib/client-html.ts6
-rw-r--r--server/lib/emailer.ts4
-rw-r--r--server/lib/hls.ts164
-rw-r--r--server/lib/job-queue/handlers/activitypub-refresher.ts25
-rw-r--r--server/lib/job-queue/handlers/video-file.ts69
-rw-r--r--server/lib/schedulers/videos-redundancy-scheduler.ts189
-rw-r--r--server/lib/video-transcoding.ts52
-rw-r--r--server/middlewares/csp.ts2
-rw-r--r--server/middlewares/validators/redundancy.ts33
-rw-r--r--server/middlewares/validators/users.ts2
-rw-r--r--server/middlewares/validators/videos/video-blacklist.ts15
-rw-r--r--server/middlewares/validators/videos/videos.ts11
-rw-r--r--server/models/account/account.ts4
-rw-r--r--server/models/account/user-notification.ts171
-rw-r--r--server/models/redundancy/video-redundancy.ts141
-rw-r--r--server/models/video/video-abuse.ts19
-rw-r--r--server/models/video/video-blacklist.ts21
-rw-r--r--server/models/video/video-channel.ts4
-rw-r--r--server/models/video/video-file.ts26
-rw-r--r--server/models/video/video-format-utils.ts63
-rw-r--r--server/models/video/video-streaming-playlist.ts158
-rw-r--r--server/models/video/video.ts183
-rw-r--r--server/tests/api/check-params/accounts.ts2
-rw-r--r--server/tests/api/check-params/config.ts3
-rw-r--r--server/tests/api/check-params/contact-form.ts4
-rw-r--r--server/tests/api/check-params/users.ts18
-rw-r--r--server/tests/api/check-params/video-abuses.ts6
-rw-r--r--server/tests/api/check-params/video-blacklist.ts118
-rw-r--r--server/tests/api/check-params/video-imports.ts1
-rw-r--r--server/tests/api/check-params/videos.ts2
-rw-r--r--server/tests/api/redundancy/redundancy.ts216
-rw-r--r--server/tests/api/server/config.ts6
-rw-r--r--server/tests/api/server/follows.ts1
-rw-r--r--server/tests/api/server/handle-down.ts1
-rw-r--r--server/tests/api/server/redundancy.ts479
-rw-r--r--server/tests/api/server/reverse-proxy.ts2
-rw-r--r--server/tests/api/server/stats.ts4
-rw-r--r--server/tests/api/users/user-notifications.ts36
-rw-r--r--server/tests/api/users/users.ts16
-rw-r--r--server/tests/api/videos/index.ts2
-rw-r--r--server/tests/api/videos/multiple-servers.ts12
-rw-r--r--server/tests/api/videos/single-server.ts3
-rw-r--r--server/tests/api/videos/video-blacklist-management.ts192
-rw-r--r--server/tests/api/videos/video-blacklist.ts299
-rw-r--r--server/tests/api/videos/video-hls.ts139
-rw-r--r--server/tests/cli/update-host.ts11
-rw-r--r--server/tools/peertube-import-videos.ts15
-rw-r--r--server/tools/peertube-upload.ts2
112 files changed, 3046 insertions, 1750 deletions
diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts
index 1a4e28dc8..31c0a5fbd 100644
--- a/server/controllers/activitypub/client.ts
+++ b/server/controllers/activitypub/client.ts
@@ -3,22 +3,18 @@ import * as express from 'express'
3import { VideoPrivacy, VideoRateType } from '../../../shared/models/videos' 3import { VideoPrivacy, VideoRateType } from '../../../shared/models/videos'
4import { activityPubCollectionPagination, activityPubContextify } from '../../helpers/activitypub' 4import { activityPubCollectionPagination, activityPubContextify } from '../../helpers/activitypub'
5import { CONFIG, ROUTE_CACHE_LIFETIME } from '../../initializers' 5import { CONFIG, ROUTE_CACHE_LIFETIME } from '../../initializers'
6import { buildAnnounceWithVideoAudience, buildDislikeActivity, buildLikeActivity } from '../../lib/activitypub/send' 6import { buildAnnounceWithVideoAudience, buildLikeActivity } from '../../lib/activitypub/send'
7import { audiencify, getAudience } from '../../lib/activitypub/audience' 7import { audiencify, getAudience } from '../../lib/activitypub/audience'
8import { buildCreateActivity } from '../../lib/activitypub/send/send-create' 8import { buildCreateActivity } from '../../lib/activitypub/send/send-create'
9import { 9import {
10 asyncMiddleware, 10 asyncMiddleware,
11 videosShareValidator,
12 executeIfActivityPub, 11 executeIfActivityPub,
13 localAccountValidator, 12 localAccountValidator,
14 localVideoChannelValidator, 13 localVideoChannelValidator,
15 videosCustomGetValidator 14 videosCustomGetValidator,
15 videosShareValidator
16} from '../../middlewares' 16} from '../../middlewares'
17import { 17import { getAccountVideoRateValidator, videoCommentGetValidator, videosGetValidator } from '../../middlewares/validators'
18 getAccountVideoRateValidator,
19 videoCommentGetValidator,
20 videosGetValidator
21} from '../../middlewares/validators'
22import { AccountModel } from '../../models/account/account' 18import { AccountModel } from '../../models/account/account'
23import { ActorModel } from '../../models/activitypub/actor' 19import { ActorModel } from '../../models/activitypub/actor'
24import { ActorFollowModel } from '../../models/activitypub/actor-follow' 20import { ActorFollowModel } from '../../models/activitypub/actor-follow'
@@ -37,9 +33,10 @@ import {
37 getVideoSharesActivityPubUrl 33 getVideoSharesActivityPubUrl
38} from '../../lib/activitypub' 34} from '../../lib/activitypub'
39import { VideoCaptionModel } from '../../models/video/video-caption' 35import { VideoCaptionModel } from '../../models/video/video-caption'
40import { videoRedundancyGetValidator } from '../../middlewares/validators/redundancy' 36import { videoFileRedundancyGetValidator, videoPlaylistRedundancyGetValidator } from '../../middlewares/validators/redundancy'
41import { getServerActor } from '../../helpers/utils' 37import { getServerActor } from '../../helpers/utils'
42import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' 38import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
39import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike'
43 40
44const activityPubClientRouter = express.Router() 41const activityPubClientRouter = express.Router()
45 42
@@ -66,11 +63,11 @@ activityPubClientRouter.get('/accounts?/:name/dislikes/:videoId',
66 63
67activityPubClientRouter.get('/videos/watch/:id', 64activityPubClientRouter.get('/videos/watch/:id',
68 executeIfActivityPub(asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS))), 65 executeIfActivityPub(asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS))),
69 executeIfActivityPub(asyncMiddleware(videosGetValidator)), 66 executeIfActivityPub(asyncMiddleware(videosCustomGetValidator('only-video-with-rights'))),
70 executeIfActivityPub(asyncMiddleware(videoController)) 67 executeIfActivityPub(asyncMiddleware(videoController))
71) 68)
72activityPubClientRouter.get('/videos/watch/:id/activity', 69activityPubClientRouter.get('/videos/watch/:id/activity',
73 executeIfActivityPub(asyncMiddleware(videosGetValidator)), 70 executeIfActivityPub(asyncMiddleware(videosCustomGetValidator('only-video-with-rights'))),
74 executeIfActivityPub(asyncMiddleware(videoController)) 71 executeIfActivityPub(asyncMiddleware(videoController))
75) 72)
76activityPubClientRouter.get('/videos/watch/:id/announces', 73activityPubClientRouter.get('/videos/watch/:id/announces',
@@ -116,7 +113,11 @@ activityPubClientRouter.get('/video-channels/:name/following',
116) 113)
117 114
118activityPubClientRouter.get('/redundancy/videos/:videoId/:resolution([0-9]+)(-:fps([0-9]+))?', 115activityPubClientRouter.get('/redundancy/videos/:videoId/:resolution([0-9]+)(-:fps([0-9]+))?',
119 executeIfActivityPub(asyncMiddleware(videoRedundancyGetValidator)), 116 executeIfActivityPub(asyncMiddleware(videoFileRedundancyGetValidator)),
117 executeIfActivityPub(asyncMiddleware(videoRedundancyController))
118)
119activityPubClientRouter.get('/redundancy/video-playlists/:streamingPlaylistType/:videoId',
120 executeIfActivityPub(asyncMiddleware(videoPlaylistRedundancyGetValidator)),
120 executeIfActivityPub(asyncMiddleware(videoRedundancyController)) 121 executeIfActivityPub(asyncMiddleware(videoRedundancyController))
121) 122)
122 123
@@ -156,14 +157,15 @@ function getAccountVideoRate (rateType: VideoRateType) {
156 const url = getRateUrl(rateType, byActor, accountVideoRate.Video) 157 const url = getRateUrl(rateType, byActor, accountVideoRate.Video)
157 const APObject = rateType === 'like' 158 const APObject = rateType === 'like'
158 ? buildLikeActivity(url, byActor, accountVideoRate.Video) 159 ? buildLikeActivity(url, byActor, accountVideoRate.Video)
159 : buildCreateActivity(url, byActor, buildDislikeActivity(url, byActor, accountVideoRate.Video)) 160 : buildDislikeActivity(url, byActor, accountVideoRate.Video)
160 161
161 return activityPubResponse(activityPubContextify(APObject), res) 162 return activityPubResponse(activityPubContextify(APObject), res)
162 } 163 }
163} 164}
164 165
165async function videoController (req: express.Request, res: express.Response) { 166async function videoController (req: express.Request, res: express.Response) {
166 const video: VideoModel = res.locals.video 167 // We need more attributes
168 const video: VideoModel = await VideoModel.loadForGetAPI(res.locals.video.id)
167 169
168 if (video.url.startsWith(CONFIG.WEBSERVER.URL) === false) return res.redirect(video.url) 170 if (video.url.startsWith(CONFIG.WEBSERVER.URL) === false) return res.redirect(video.url)
169 171
diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts
index a69a83acf..8c0237203 100644
--- a/server/controllers/api/accounts.ts
+++ b/server/controllers/api/accounts.ts
@@ -14,6 +14,8 @@ import { AccountModel } from '../../models/account/account'
14import { VideoModel } from '../../models/video/video' 14import { VideoModel } from '../../models/video/video'
15import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' 15import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
16import { VideoChannelModel } from '../../models/video/video-channel' 16import { VideoChannelModel } from '../../models/video/video-channel'
17import { JobQueue } from '../../lib/job-queue'
18import { logger } from '../../helpers/logger'
17 19
18const accountsRouter = express.Router() 20const accountsRouter = express.Router()
19 21
@@ -57,6 +59,11 @@ export {
57function getAccount (req: express.Request, res: express.Response, next: express.NextFunction) { 59function getAccount (req: express.Request, res: express.Response, next: express.NextFunction) {
58 const account: AccountModel = res.locals.account 60 const account: AccountModel = res.locals.account
59 61
62 if (account.isOutdated()) {
63 JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'actor', url: account.Actor.url } })
64 .catch(err => logger.error('Cannot create AP refresher job for actor %s.', account.Actor.url, { err }))
65 }
66
60 return res.json(account.toFormattedJSON()) 67 return res.json(account.toFormattedJSON())
61} 68}
62 69
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index dd06a0597..1f3341bc0 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -1,5 +1,5 @@
1import * as express from 'express' 1import * as express from 'express'
2import { omit, snakeCase } from 'lodash' 2import { snakeCase } from 'lodash'
3import { ServerConfig, UserRight } from '../../../shared' 3import { ServerConfig, UserRight } from '../../../shared'
4import { About } from '../../../shared/models/server/about.model' 4import { About } from '../../../shared/models/server/about.model'
5import { CustomConfig } from '../../../shared/models/server/custom-config.model' 5import { CustomConfig } from '../../../shared/models/server/custom-config.model'
@@ -78,6 +78,9 @@ async function getConfig (req: express.Request, res: express.Response) {
78 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION 78 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
79 }, 79 },
80 transcoding: { 80 transcoding: {
81 hls: {
82 enabled: CONFIG.TRANSCODING.HLS.ENABLED
83 },
81 enabledResolutions 84 enabledResolutions
82 }, 85 },
83 import: { 86 import: {
@@ -120,6 +123,11 @@ async function getConfig (req: express.Request, res: express.Response) {
120 user: { 123 user: {
121 videoQuota: CONFIG.USER.VIDEO_QUOTA, 124 videoQuota: CONFIG.USER.VIDEO_QUOTA,
122 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY 125 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
126 },
127 trending: {
128 videos: {
129 intervalDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
130 }
123 } 131 }
124 } 132 }
125 133
@@ -241,6 +249,9 @@ function customConfig (): CustomConfig {
241 '480p': CONFIG.TRANSCODING.RESOLUTIONS[ '480p' ], 249 '480p': CONFIG.TRANSCODING.RESOLUTIONS[ '480p' ],
242 '720p': CONFIG.TRANSCODING.RESOLUTIONS[ '720p' ], 250 '720p': CONFIG.TRANSCODING.RESOLUTIONS[ '720p' ],
243 '1080p': CONFIG.TRANSCODING.RESOLUTIONS[ '1080p' ] 251 '1080p': CONFIG.TRANSCODING.RESOLUTIONS[ '1080p' ]
252 },
253 hls: {
254 enabled: CONFIG.TRANSCODING.HLS.ENABLED
244 } 255 }
245 }, 256 },
246 import: { 257 import: {
diff --git a/server/controllers/api/server/stats.ts b/server/controllers/api/server/stats.ts
index 85803f69e..89ffd1717 100644
--- a/server/controllers/api/server/stats.ts
+++ b/server/controllers/api/server/stats.ts
@@ -8,6 +8,7 @@ import { VideoCommentModel } from '../../../models/video/video-comment'
8import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy' 8import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
9import { CONFIG, ROUTE_CACHE_LIFETIME } from '../../../initializers/constants' 9import { CONFIG, ROUTE_CACHE_LIFETIME } from '../../../initializers/constants'
10import { cacheRoute } from '../../../middlewares/cache' 10import { cacheRoute } from '../../../middlewares/cache'
11import { VideoFileModel } from '../../../models/video/video-file'
11 12
12const statsRouter = express.Router() 13const statsRouter = express.Router()
13 14
@@ -16,11 +17,12 @@ statsRouter.get('/stats',
16 asyncMiddleware(getStats) 17 asyncMiddleware(getStats)
17) 18)
18 19
19async function getStats (req: express.Request, res: express.Response, next: express.NextFunction) { 20async function getStats (req: express.Request, res: express.Response) {
20 const { totalLocalVideos, totalLocalVideoViews, totalVideos } = await VideoModel.getStats() 21 const { totalLocalVideos, totalLocalVideoViews, totalVideos } = await VideoModel.getStats()
21 const { totalLocalVideoComments, totalVideoComments } = await VideoCommentModel.getStats() 22 const { totalLocalVideoComments, totalVideoComments } = await VideoCommentModel.getStats()
22 const { totalUsers } = await UserModel.getStats() 23 const { totalUsers } = await UserModel.getStats()
23 const { totalInstanceFollowers, totalInstanceFollowing } = await ActorFollowModel.getStats() 24 const { totalInstanceFollowers, totalInstanceFollowing } = await ActorFollowModel.getStats()
25 const { totalLocalVideoFilesSize } = await VideoFileModel.getStats()
24 26
25 const videosRedundancyStats = await Promise.all( 27 const videosRedundancyStats = await Promise.all(
26 CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.map(r => { 28 CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.map(r => {
@@ -32,8 +34,9 @@ async function getStats (req: express.Request, res: express.Response, next: expr
32 const data: ServerStats = { 34 const data: ServerStats = {
33 totalLocalVideos, 35 totalLocalVideos,
34 totalLocalVideoViews, 36 totalLocalVideoViews,
35 totalVideos, 37 totalLocalVideoFilesSize,
36 totalLocalVideoComments, 38 totalLocalVideoComments,
39 totalVideos,
37 totalVideoComments, 40 totalVideoComments,
38 totalUsers, 41 totalUsers,
39 totalInstanceFollowers, 42 totalInstanceFollowers,
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts
index 9e6a019f6..e3533a7f6 100644
--- a/server/controllers/api/users/index.ts
+++ b/server/controllers/api/users/index.ts
@@ -41,6 +41,7 @@ import { myBlocklistRouter } from './my-blocklist'
41import { myVideosHistoryRouter } from './my-history' 41import { myVideosHistoryRouter } from './my-history'
42import { myNotificationsRouter } from './my-notifications' 42import { myNotificationsRouter } from './my-notifications'
43import { Notifier } from '../../../lib/notifier' 43import { Notifier } from '../../../lib/notifier'
44import { mySubscriptionsRouter } from './my-subscriptions'
44 45
45const auditLogger = auditLoggerFactory('users') 46const auditLogger = auditLoggerFactory('users')
46 47
@@ -58,6 +59,7 @@ const askSendEmailLimiter = new RateLimit({
58 59
59const usersRouter = express.Router() 60const usersRouter = express.Router()
60usersRouter.use('/', myNotificationsRouter) 61usersRouter.use('/', myNotificationsRouter)
62usersRouter.use('/', mySubscriptionsRouter)
61usersRouter.use('/', myBlocklistRouter) 63usersRouter.use('/', myBlocklistRouter)
62usersRouter.use('/', myVideosHistoryRouter) 64usersRouter.use('/', myVideosHistoryRouter)
63usersRouter.use('/', meRouter) 65usersRouter.use('/', meRouter)
@@ -227,7 +229,7 @@ async function unblockUser (req: express.Request, res: express.Response, next: e
227 return res.status(204).end() 229 return res.status(204).end()
228} 230}
229 231
230async function blockUser (req: express.Request, res: express.Response, next: express.NextFunction) { 232async function blockUser (req: express.Request, res: express.Response) {
231 const user: UserModel = res.locals.user 233 const user: UserModel = res.locals.user
232 const reason = req.body.reason 234 const reason = req.body.reason
233 235
@@ -236,23 +238,23 @@ async function blockUser (req: express.Request, res: express.Response, next: exp
236 return res.status(204).end() 238 return res.status(204).end()
237} 239}
238 240
239function getUser (req: express.Request, res: express.Response, next: express.NextFunction) { 241function getUser (req: express.Request, res: express.Response) {
240 return res.json((res.locals.user as UserModel).toFormattedJSON()) 242 return res.json((res.locals.user as UserModel).toFormattedJSON())
241} 243}
242 244
243async function autocompleteUsers (req: express.Request, res: express.Response, next: express.NextFunction) { 245async function autocompleteUsers (req: express.Request, res: express.Response) {
244 const resultList = await UserModel.autoComplete(req.query.search as string) 246 const resultList = await UserModel.autoComplete(req.query.search as string)
245 247
246 return res.json(resultList) 248 return res.json(resultList)
247} 249}
248 250
249async function listUsers (req: express.Request, res: express.Response, next: express.NextFunction) { 251async function listUsers (req: express.Request, res: express.Response) {
250 const resultList = await UserModel.listForApi(req.query.start, req.query.count, req.query.sort, req.query.search) 252 const resultList = await UserModel.listForApi(req.query.start, req.query.count, req.query.sort, req.query.search)
251 253
252 return res.json(getFormattedObjects(resultList.data, resultList.total)) 254 return res.json(getFormattedObjects(resultList.data, resultList.total))
253} 255}
254 256
255async function removeUser (req: express.Request, res: express.Response, next: express.NextFunction) { 257async function removeUser (req: express.Request, res: express.Response) {
256 const user: UserModel = res.locals.user 258 const user: UserModel = res.locals.user
257 259
258 await user.destroy() 260 await user.destroy()
@@ -262,12 +264,13 @@ async function removeUser (req: express.Request, res: express.Response, next: ex
262 return res.sendStatus(204) 264 return res.sendStatus(204)
263} 265}
264 266
265async function updateUser (req: express.Request, res: express.Response, next: express.NextFunction) { 267async function updateUser (req: express.Request, res: express.Response) {
266 const body: UserUpdate = req.body 268 const body: UserUpdate = req.body
267 const userToUpdate = res.locals.user as UserModel 269 const userToUpdate = res.locals.user as UserModel
268 const oldUserAuditView = new UserAuditView(userToUpdate.toFormattedJSON()) 270 const oldUserAuditView = new UserAuditView(userToUpdate.toFormattedJSON())
269 const roleChanged = body.role !== undefined && body.role !== userToUpdate.role 271 const roleChanged = body.role !== undefined && body.role !== userToUpdate.role
270 272
273 if (body.password !== undefined) userToUpdate.password = body.password
271 if (body.email !== undefined) userToUpdate.email = body.email 274 if (body.email !== undefined) userToUpdate.email = body.email
272 if (body.emailVerified !== undefined) userToUpdate.emailVerified = body.emailVerified 275 if (body.emailVerified !== undefined) userToUpdate.emailVerified = body.emailVerified
273 if (body.videoQuota !== undefined) userToUpdate.videoQuota = body.videoQuota 276 if (body.videoQuota !== undefined) userToUpdate.videoQuota = body.videoQuota
@@ -277,11 +280,11 @@ async function updateUser (req: express.Request, res: express.Response, next: ex
277 const user = await userToUpdate.save() 280 const user = await userToUpdate.save()
278 281
279 // Destroy user token to refresh rights 282 // Destroy user token to refresh rights
280 if (roleChanged) await deleteUserToken(userToUpdate.id) 283 if (roleChanged || body.password !== undefined) await deleteUserToken(userToUpdate.id)
281 284
282 auditLogger.update(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()), oldUserAuditView) 285 auditLogger.update(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()), oldUserAuditView)
283 286
284 // Don't need to send this update to followers, these attributes are not propagated 287 // Don't need to send this update to followers, these attributes are not federated
285 288
286 return res.sendStatus(204) 289 return res.sendStatus(204)
287} 290}
@@ -291,7 +294,7 @@ async function askResetUserPassword (req: express.Request, res: express.Response
291 294
292 const verificationString = await Redis.Instance.setResetPasswordVerificationString(user.id) 295 const verificationString = await Redis.Instance.setResetPasswordVerificationString(user.id)
293 const url = CONFIG.WEBSERVER.URL + '/reset-password?userId=' + user.id + '&verificationString=' + verificationString 296 const url = CONFIG.WEBSERVER.URL + '/reset-password?userId=' + user.id + '&verificationString=' + verificationString
294 await Emailer.Instance.addForgetPasswordEmailJob(user.email, url) 297 await Emailer.Instance.addPasswordResetEmailJob(user.email, url)
295 298
296 return res.status(204).end() 299 return res.status(204).end()
297} 300}
diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts
index 8a3208160..d5e154869 100644
--- a/server/controllers/api/users/me.ts
+++ b/server/controllers/api/users/me.ts
@@ -8,36 +8,23 @@ import {
8 asyncMiddleware, 8 asyncMiddleware,
9 asyncRetryTransactionMiddleware, 9 asyncRetryTransactionMiddleware,
10 authenticate, 10 authenticate,
11 commonVideosFiltersValidator,
12 paginationValidator, 11 paginationValidator,
13 setDefaultPagination, 12 setDefaultPagination,
14 setDefaultSort, 13 setDefaultSort,
15 userSubscriptionAddValidator,
16 userSubscriptionGetValidator,
17 usersUpdateMeValidator, 14 usersUpdateMeValidator,
18 usersVideoRatingValidator 15 usersVideoRatingValidator
19} from '../../../middlewares' 16} from '../../../middlewares'
20import { 17import { deleteMeValidator, videoImportsSortValidator, videosSortValidator } from '../../../middlewares/validators'
21 areSubscriptionsExistValidator,
22 deleteMeValidator,
23 userSubscriptionsSortValidator,
24 videoImportsSortValidator,
25 videosSortValidator
26} from '../../../middlewares/validators'
27import { AccountVideoRateModel } from '../../../models/account/account-video-rate' 18import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
28import { UserModel } from '../../../models/account/user' 19import { UserModel } from '../../../models/account/user'
29import { VideoModel } from '../../../models/video/video' 20import { VideoModel } from '../../../models/video/video'
30import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type' 21import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type'
31import { buildNSFWFilter, createReqFiles } from '../../../helpers/express-utils' 22import { createReqFiles } from '../../../helpers/express-utils'
32import { UserVideoQuota } from '../../../../shared/models/users/user-video-quota.model' 23import { UserVideoQuota } from '../../../../shared/models/users/user-video-quota.model'
33import { updateAvatarValidator } from '../../../middlewares/validators/avatar' 24import { updateAvatarValidator } from '../../../middlewares/validators/avatar'
34import { updateActorAvatarFile } from '../../../lib/avatar' 25import { updateActorAvatarFile } from '../../../lib/avatar'
35import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger' 26import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger'
36import { VideoImportModel } from '../../../models/video/video-import' 27import { VideoImportModel } from '../../../models/video/video-import'
37import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
38import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
39import { JobQueue } from '../../../lib/job-queue'
40import { logger } from '../../../helpers/logger'
41import { AccountModel } from '../../../models/account/account' 28import { AccountModel } from '../../../models/account/account'
42 29
43const auditLogger = auditLoggerFactory('users-me') 30const auditLogger = auditLoggerFactory('users-me')
@@ -98,51 +85,6 @@ meRouter.post('/me/avatar/pick',
98 asyncRetryTransactionMiddleware(updateMyAvatar) 85 asyncRetryTransactionMiddleware(updateMyAvatar)
99) 86)
100 87
101// ##### Subscriptions part #####
102
103meRouter.get('/me/subscriptions/videos',
104 authenticate,
105 paginationValidator,
106 videosSortValidator,
107 setDefaultSort,
108 setDefaultPagination,
109 commonVideosFiltersValidator,
110 asyncMiddleware(getUserSubscriptionVideos)
111)
112
113meRouter.get('/me/subscriptions/exist',
114 authenticate,
115 areSubscriptionsExistValidator,
116 asyncMiddleware(areSubscriptionsExist)
117)
118
119meRouter.get('/me/subscriptions',
120 authenticate,
121 paginationValidator,
122 userSubscriptionsSortValidator,
123 setDefaultSort,
124 setDefaultPagination,
125 asyncMiddleware(getUserSubscriptions)
126)
127
128meRouter.post('/me/subscriptions',
129 authenticate,
130 userSubscriptionAddValidator,
131 asyncMiddleware(addUserSubscription)
132)
133
134meRouter.get('/me/subscriptions/:uri',
135 authenticate,
136 userSubscriptionGetValidator,
137 getUserSubscription
138)
139
140meRouter.delete('/me/subscriptions/:uri',
141 authenticate,
142 userSubscriptionGetValidator,
143 asyncRetryTransactionMiddleware(deleteUserSubscription)
144)
145
146// --------------------------------------------------------------------------- 88// ---------------------------------------------------------------------------
147 89
148export { 90export {
@@ -151,100 +93,6 @@ export {
151 93
152// --------------------------------------------------------------------------- 94// ---------------------------------------------------------------------------
153 95
154async function areSubscriptionsExist (req: express.Request, res: express.Response) {
155 const uris = req.query.uris as string[]
156 const user = res.locals.oauth.token.User as UserModel
157
158 const handles = uris.map(u => {
159 let [ name, host ] = u.split('@')
160 if (host === CONFIG.WEBSERVER.HOST) host = null
161
162 return { name, host, uri: u }
163 })
164
165 const results = await ActorFollowModel.listSubscribedIn(user.Account.Actor.id, handles)
166
167 const existObject: { [id: string ]: boolean } = {}
168 for (const handle of handles) {
169 const obj = results.find(r => {
170 const server = r.ActorFollowing.Server
171
172 return r.ActorFollowing.preferredUsername === handle.name &&
173 (
174 (!server && !handle.host) ||
175 (server.host === handle.host)
176 )
177 })
178
179 existObject[handle.uri] = obj !== undefined
180 }
181
182 return res.json(existObject)
183}
184
185async function addUserSubscription (req: express.Request, res: express.Response) {
186 const user = res.locals.oauth.token.User as UserModel
187 const [ name, host ] = req.body.uri.split('@')
188
189 const payload = {
190 name,
191 host,
192 followerActorId: user.Account.Actor.id
193 }
194
195 JobQueue.Instance.createJob({ type: 'activitypub-follow', payload })
196 .catch(err => logger.error('Cannot create follow job for subscription %s.', req.body.uri, err))
197
198 return res.status(204).end()
199}
200
201function getUserSubscription (req: express.Request, res: express.Response) {
202 const subscription: ActorFollowModel = res.locals.subscription
203
204 return res.json(subscription.ActorFollowing.VideoChannel.toFormattedJSON())
205}
206
207async function deleteUserSubscription (req: express.Request, res: express.Response) {
208 const subscription: ActorFollowModel = res.locals.subscription
209
210 await sequelizeTypescript.transaction(async t => {
211 return subscription.destroy({ transaction: t })
212 })
213
214 return res.type('json').status(204).end()
215}
216
217async function getUserSubscriptions (req: express.Request, res: express.Response) {
218 const user = res.locals.oauth.token.User as UserModel
219 const actorId = user.Account.Actor.id
220
221 const resultList = await ActorFollowModel.listSubscriptionsForApi(actorId, req.query.start, req.query.count, req.query.sort)
222
223 return res.json(getFormattedObjects(resultList.data, resultList.total))
224}
225
226async function getUserSubscriptionVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
227 const user = res.locals.oauth.token.User as UserModel
228 const resultList = await VideoModel.listForApi({
229 start: req.query.start,
230 count: req.query.count,
231 sort: req.query.sort,
232 includeLocalVideos: false,
233 categoryOneOf: req.query.categoryOneOf,
234 licenceOneOf: req.query.licenceOneOf,
235 languageOneOf: req.query.languageOneOf,
236 tagsOneOf: req.query.tagsOneOf,
237 tagsAllOf: req.query.tagsAllOf,
238 nsfw: buildNSFWFilter(res, req.query.nsfw),
239 filter: req.query.filter as VideoFilter,
240 withFiles: false,
241 followerActorId: user.Account.Actor.id,
242 user
243 })
244
245 return res.json(getFormattedObjects(resultList.data, resultList.total))
246}
247
248async function getUserVideos (req: express.Request, res: express.Response, next: express.NextFunction) { 96async function getUserVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
249 const user = res.locals.oauth.token.User as UserModel 97 const user = res.locals.oauth.token.User as UserModel
250 const resultList = await VideoModel.listUserVideosForApi( 98 const resultList = await VideoModel.listUserVideosForApi(
@@ -319,7 +167,7 @@ async function deleteMe (req: express.Request, res: express.Response) {
319 return res.sendStatus(204) 167 return res.sendStatus(204)
320} 168}
321 169
322async function updateMe (req: express.Request, res: express.Response, next: express.NextFunction) { 170async function updateMe (req: express.Request, res: express.Response) {
323 const body: UserUpdateMe = req.body 171 const body: UserUpdateMe = req.body
324 172
325 const user: UserModel = res.locals.oauth.token.user 173 const user: UserModel = res.locals.oauth.token.user
diff --git a/server/controllers/api/users/my-subscriptions.ts b/server/controllers/api/users/my-subscriptions.ts
new file mode 100644
index 000000000..accca6d52
--- /dev/null
+++ b/server/controllers/api/users/my-subscriptions.ts
@@ -0,0 +1,170 @@
1import * as express from 'express'
2import 'multer'
3import { getFormattedObjects } from '../../../helpers/utils'
4import { CONFIG, sequelizeTypescript } from '../../../initializers'
5import {
6 asyncMiddleware,
7 asyncRetryTransactionMiddleware,
8 authenticate,
9 commonVideosFiltersValidator,
10 paginationValidator,
11 setDefaultPagination,
12 setDefaultSort,
13 userSubscriptionAddValidator,
14 userSubscriptionGetValidator
15} from '../../../middlewares'
16import { areSubscriptionsExistValidator, userSubscriptionsSortValidator, videosSortValidator } from '../../../middlewares/validators'
17import { UserModel } from '../../../models/account/user'
18import { VideoModel } from '../../../models/video/video'
19import { buildNSFWFilter } from '../../../helpers/express-utils'
20import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
21import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
22import { JobQueue } from '../../../lib/job-queue'
23import { logger } from '../../../helpers/logger'
24
25const mySubscriptionsRouter = express.Router()
26
27mySubscriptionsRouter.get('/me/subscriptions/videos',
28 authenticate,
29 paginationValidator,
30 videosSortValidator,
31 setDefaultSort,
32 setDefaultPagination,
33 commonVideosFiltersValidator,
34 asyncMiddleware(getUserSubscriptionVideos)
35)
36
37mySubscriptionsRouter.get('/me/subscriptions/exist',
38 authenticate,
39 areSubscriptionsExistValidator,
40 asyncMiddleware(areSubscriptionsExist)
41)
42
43mySubscriptionsRouter.get('/me/subscriptions',
44 authenticate,
45 paginationValidator,
46 userSubscriptionsSortValidator,
47 setDefaultSort,
48 setDefaultPagination,
49 asyncMiddleware(getUserSubscriptions)
50)
51
52mySubscriptionsRouter.post('/me/subscriptions',
53 authenticate,
54 userSubscriptionAddValidator,
55 asyncMiddleware(addUserSubscription)
56)
57
58mySubscriptionsRouter.get('/me/subscriptions/:uri',
59 authenticate,
60 userSubscriptionGetValidator,
61 getUserSubscription
62)
63
64mySubscriptionsRouter.delete('/me/subscriptions/:uri',
65 authenticate,
66 userSubscriptionGetValidator,
67 asyncRetryTransactionMiddleware(deleteUserSubscription)
68)
69
70// ---------------------------------------------------------------------------
71
72export {
73 mySubscriptionsRouter
74}
75
76// ---------------------------------------------------------------------------
77
78async function areSubscriptionsExist (req: express.Request, res: express.Response) {
79 const uris = req.query.uris as string[]
80 const user = res.locals.oauth.token.User as UserModel
81
82 const handles = uris.map(u => {
83 let [ name, host ] = u.split('@')
84 if (host === CONFIG.WEBSERVER.HOST) host = null
85
86 return { name, host, uri: u }
87 })
88
89 const results = await ActorFollowModel.listSubscribedIn(user.Account.Actor.id, handles)
90
91 const existObject: { [id: string ]: boolean } = {}
92 for (const handle of handles) {
93 const obj = results.find(r => {
94 const server = r.ActorFollowing.Server
95
96 return r.ActorFollowing.preferredUsername === handle.name &&
97 (
98 (!server && !handle.host) ||
99 (server.host === handle.host)
100 )
101 })
102
103 existObject[handle.uri] = obj !== undefined
104 }
105
106 return res.json(existObject)
107}
108
109async function addUserSubscription (req: express.Request, res: express.Response) {
110 const user = res.locals.oauth.token.User as UserModel
111 const [ name, host ] = req.body.uri.split('@')
112
113 const payload = {
114 name,
115 host,
116 followerActorId: user.Account.Actor.id
117 }
118
119 JobQueue.Instance.createJob({ type: 'activitypub-follow', payload })
120 .catch(err => logger.error('Cannot create follow job for subscription %s.', req.body.uri, err))
121
122 return res.status(204).end()
123}
124
125function getUserSubscription (req: express.Request, res: express.Response) {
126 const subscription: ActorFollowModel = res.locals.subscription
127
128 return res.json(subscription.ActorFollowing.VideoChannel.toFormattedJSON())
129}
130
131async function deleteUserSubscription (req: express.Request, res: express.Response) {
132 const subscription: ActorFollowModel = res.locals.subscription
133
134 await sequelizeTypescript.transaction(async t => {
135 return subscription.destroy({ transaction: t })
136 })
137
138 return res.type('json').status(204).end()
139}
140
141async function getUserSubscriptions (req: express.Request, res: express.Response) {
142 const user = res.locals.oauth.token.User as UserModel
143 const actorId = user.Account.Actor.id
144
145 const resultList = await ActorFollowModel.listSubscriptionsForApi(actorId, req.query.start, req.query.count, req.query.sort)
146
147 return res.json(getFormattedObjects(resultList.data, resultList.total))
148}
149
150async function getUserSubscriptionVideos (req: express.Request, res: express.Response, next: express.NextFunction) {
151 const user = res.locals.oauth.token.User as UserModel
152 const resultList = await VideoModel.listForApi({
153 start: req.query.start,
154 count: req.query.count,
155 sort: req.query.sort,
156 includeLocalVideos: false,
157 categoryOneOf: req.query.categoryOneOf,
158 licenceOneOf: req.query.licenceOneOf,
159 languageOneOf: req.query.languageOneOf,
160 tagsOneOf: req.query.tagsOneOf,
161 tagsAllOf: req.query.tagsAllOf,
162 nsfw: buildNSFWFilter(res, req.query.nsfw),
163 filter: req.query.filter as VideoFilter,
164 withFiles: false,
165 followerActorId: user.Account.Actor.id,
166 user
167 })
168
169 return res.json(getFormattedObjects(resultList.data, resultList.total))
170}
diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts
index 3d6a6af7f..db7602139 100644
--- a/server/controllers/api/video-channel.ts
+++ b/server/controllers/api/video-channel.ts
@@ -30,6 +30,7 @@ import { updateActorAvatarFile } from '../../lib/avatar'
30import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../helpers/audit-logger' 30import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../helpers/audit-logger'
31import { resetSequelizeInstance } from '../../helpers/database-utils' 31import { resetSequelizeInstance } from '../../helpers/database-utils'
32import { UserModel } from '../../models/account/user' 32import { UserModel } from '../../models/account/user'
33import { JobQueue } from '../../lib/job-queue'
33 34
34const auditLogger = auditLoggerFactory('channels') 35const auditLogger = auditLoggerFactory('channels')
35const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR }) 36const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR })
@@ -197,6 +198,11 @@ async function removeVideoChannel (req: express.Request, res: express.Response)
197async function getVideoChannel (req: express.Request, res: express.Response, next: express.NextFunction) { 198async function getVideoChannel (req: express.Request, res: express.Response, next: express.NextFunction) {
198 const videoChannelWithVideos = await VideoChannelModel.loadAndPopulateAccountAndVideos(res.locals.videoChannel.id) 199 const videoChannelWithVideos = await VideoChannelModel.loadAndPopulateAccountAndVideos(res.locals.videoChannel.id)
199 200
201 if (videoChannelWithVideos.isOutdated()) {
202 JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'actor', url: videoChannelWithVideos.Actor.url } })
203 .catch(err => logger.error('Cannot create AP refresher job for actor %s.', videoChannelWithVideos.Actor.url, { err }))
204 }
205
200 return res.json(videoChannelWithVideos.toFormattedJSON()) 206 return res.json(videoChannelWithVideos.toFormattedJSON())
201} 207}
202 208
diff --git a/server/controllers/api/videos/abuse.ts b/server/controllers/api/videos/abuse.ts
index fe0a95cd5..32f9c4793 100644
--- a/server/controllers/api/videos/abuse.ts
+++ b/server/controllers/api/videos/abuse.ts
@@ -3,7 +3,6 @@ import { UserRight, VideoAbuseCreate, VideoAbuseState } from '../../../../shared
3import { logger } from '../../../helpers/logger' 3import { logger } from '../../../helpers/logger'
4import { getFormattedObjects } from '../../../helpers/utils' 4import { getFormattedObjects } from '../../../helpers/utils'
5import { sequelizeTypescript } from '../../../initializers' 5import { sequelizeTypescript } from '../../../initializers'
6import { sendVideoAbuse } from '../../../lib/activitypub/send'
7import { 6import {
8 asyncMiddleware, 7 asyncMiddleware,
9 asyncRetryTransactionMiddleware, 8 asyncRetryTransactionMiddleware,
@@ -23,6 +22,7 @@ import { VideoAbuseModel } from '../../../models/video/video-abuse'
23import { auditLoggerFactory, VideoAbuseAuditView } from '../../../helpers/audit-logger' 22import { auditLoggerFactory, VideoAbuseAuditView } from '../../../helpers/audit-logger'
24import { UserModel } from '../../../models/account/user' 23import { UserModel } from '../../../models/account/user'
25import { Notifier } from '../../../lib/notifier' 24import { Notifier } from '../../../lib/notifier'
25import { sendVideoAbuse } from '../../../lib/activitypub/send/send-flag'
26 26
27const auditLogger = auditLoggerFactory('abuse') 27const auditLogger = auditLoggerFactory('abuse')
28const abuseVideoRouter = express.Router() 28const abuseVideoRouter = express.Router()
diff --git a/server/controllers/api/videos/blacklist.ts b/server/controllers/api/videos/blacklist.ts
index 9ef08812b..43b0516e7 100644
--- a/server/controllers/api/videos/blacklist.ts
+++ b/server/controllers/api/videos/blacklist.ts
@@ -18,6 +18,8 @@ import { VideoBlacklistModel } from '../../../models/video/video-blacklist'
18import { sequelizeTypescript } from '../../../initializers' 18import { sequelizeTypescript } from '../../../initializers'
19import { Notifier } from '../../../lib/notifier' 19import { Notifier } from '../../../lib/notifier'
20import { VideoModel } from '../../../models/video/video' 20import { VideoModel } from '../../../models/video/video'
21import { sendCreateVideo, sendDeleteVideo, sendUpdateVideo } from '../../../lib/activitypub/send'
22import { federateVideoIfNeeded } from '../../../lib/activitypub'
21 23
22const blacklistRouter = express.Router() 24const blacklistRouter = express.Router()
23 25
@@ -66,12 +68,17 @@ async function addVideoToBlacklist (req: express.Request, res: express.Response)
66 68
67 const toCreate = { 69 const toCreate = {
68 videoId: videoInstance.id, 70 videoId: videoInstance.id,
71 unfederated: body.unfederate === true,
69 reason: body.reason 72 reason: body.reason
70 } 73 }
71 74
72 const blacklist = await VideoBlacklistModel.create(toCreate) 75 const blacklist = await VideoBlacklistModel.create(toCreate)
73 blacklist.Video = videoInstance 76 blacklist.Video = videoInstance
74 77
78 if (body.unfederate === true) {
79 await sendDeleteVideo(videoInstance, undefined)
80 }
81
75 Notifier.Instance.notifyOnVideoBlacklist(blacklist) 82 Notifier.Instance.notifyOnVideoBlacklist(blacklist)
76 83
77 logger.info('Video %s blacklisted.', res.locals.video.uuid) 84 logger.info('Video %s blacklisted.', res.locals.video.uuid)
@@ -101,8 +108,14 @@ async function removeVideoFromBlacklistController (req: express.Request, res: ex
101 const videoBlacklist = res.locals.videoBlacklist as VideoBlacklistModel 108 const videoBlacklist = res.locals.videoBlacklist as VideoBlacklistModel
102 const video: VideoModel = res.locals.video 109 const video: VideoModel = res.locals.video
103 110
104 await sequelizeTypescript.transaction(t => { 111 await sequelizeTypescript.transaction(async t => {
105 return videoBlacklist.destroy({ transaction: t }) 112 const unfederated = videoBlacklist.unfederated
113 await videoBlacklist.destroy({ transaction: t })
114
115 // Re federate the video
116 if (unfederated === true) {
117 await federateVideoIfNeeded(video, true, t)
118 }
106 }) 119 })
107 120
108 Notifier.Instance.notifyOnVideoUnblacklist(video) 121 Notifier.Instance.notifyOnVideoUnblacklist(video)
diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts
index 98366cd82..7053d5253 100644
--- a/server/controllers/api/videos/import.ts
+++ b/server/controllers/api/videos/import.ts
@@ -164,6 +164,7 @@ function buildVideo (channelId: number, body: VideoImportCreate, importData: You
164 licence: body.licence || importData.licence, 164 licence: body.licence || importData.licence,
165 language: body.language || undefined, 165 language: body.language || undefined,
166 commentsEnabled: body.commentsEnabled || true, 166 commentsEnabled: body.commentsEnabled || true,
167 downloadEnabled: body.downloadEnabled || true,
167 waitTranscoding: body.waitTranscoding || false, 168 waitTranscoding: body.waitTranscoding || false,
168 state: VideoState.TO_IMPORT, 169 state: VideoState.TO_IMPORT,
169 nsfw: body.nsfw || importData.nsfw || false, 170 nsfw: body.nsfw || importData.nsfw || false,
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index b26dcabe1..6ac13e6a4 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -23,7 +23,6 @@ import {
23 fetchRemoteVideoDescription, 23 fetchRemoteVideoDescription,
24 getVideoActivityPubUrl 24 getVideoActivityPubUrl
25} from '../../../lib/activitypub' 25} from '../../../lib/activitypub'
26import { sendCreateView } from '../../../lib/activitypub/send'
27import { JobQueue } from '../../../lib/job-queue' 26import { JobQueue } from '../../../lib/job-queue'
28import { Redis } from '../../../lib/redis' 27import { Redis } from '../../../lib/redis'
29import { 28import {
@@ -37,6 +36,7 @@ import {
37 setDefaultPagination, 36 setDefaultPagination,
38 setDefaultSort, 37 setDefaultSort,
39 videosAddValidator, 38 videosAddValidator,
39 videosCustomGetValidator,
40 videosGetValidator, 40 videosGetValidator,
41 videosRemoveValidator, 41 videosRemoveValidator,
42 videosSortValidator, 42 videosSortValidator,
@@ -59,6 +59,7 @@ import { resetSequelizeInstance } from '../../../helpers/database-utils'
59import { move } from 'fs-extra' 59import { move } from 'fs-extra'
60import { watchingRouter } from './watching' 60import { watchingRouter } from './watching'
61import { Notifier } from '../../../lib/notifier' 61import { Notifier } from '../../../lib/notifier'
62import { sendView } from '../../../lib/activitypub/send/send-view'
62 63
63const auditLogger = auditLoggerFactory('videos') 64const auditLogger = auditLoggerFactory('videos')
64const videosRouter = express.Router() 65const videosRouter = express.Router()
@@ -123,9 +124,9 @@ videosRouter.get('/:id/description',
123) 124)
124videosRouter.get('/:id', 125videosRouter.get('/:id',
125 optionalAuthenticate, 126 optionalAuthenticate,
126 asyncMiddleware(videosGetValidator), 127 asyncMiddleware(videosCustomGetValidator('only-video-with-rights')),
127 asyncMiddleware(checkVideoFollowConstraints), 128 asyncMiddleware(checkVideoFollowConstraints),
128 getVideo 129 asyncMiddleware(getVideo)
129) 130)
130videosRouter.post('/:id/views', 131videosRouter.post('/:id/views',
131 asyncMiddleware(videosGetValidator), 132 asyncMiddleware(videosGetValidator),
@@ -181,6 +182,7 @@ async function addVideo (req: express.Request, res: express.Response) {
181 licence: videoInfo.licence, 182 licence: videoInfo.licence,
182 language: videoInfo.language, 183 language: videoInfo.language,
183 commentsEnabled: videoInfo.commentsEnabled || false, 184 commentsEnabled: videoInfo.commentsEnabled || false,
185 downloadEnabled: videoInfo.downloadEnabled || true,
184 waitTranscoding: videoInfo.waitTranscoding || false, 186 waitTranscoding: videoInfo.waitTranscoding || false,
185 state: CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED, 187 state: CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED,
186 nsfw: videoInfo.nsfw || false, 188 nsfw: videoInfo.nsfw || false,
@@ -326,8 +328,9 @@ async function updateVideo (req: express.Request, res: express.Response) {
326 if (videoInfoToUpdate.support !== undefined) videoInstance.set('support', videoInfoToUpdate.support) 328 if (videoInfoToUpdate.support !== undefined) videoInstance.set('support', videoInfoToUpdate.support)
327 if (videoInfoToUpdate.description !== undefined) videoInstance.set('description', videoInfoToUpdate.description) 329 if (videoInfoToUpdate.description !== undefined) videoInstance.set('description', videoInfoToUpdate.description)
328 if (videoInfoToUpdate.commentsEnabled !== undefined) videoInstance.set('commentsEnabled', videoInfoToUpdate.commentsEnabled) 330 if (videoInfoToUpdate.commentsEnabled !== undefined) videoInstance.set('commentsEnabled', videoInfoToUpdate.commentsEnabled)
329 if (videoInfoToUpdate.originallyPublishedAt !== undefined && 331 if (videoInfoToUpdate.downloadEnabled !== undefined) videoInstance.set('downloadEnabled', videoInfoToUpdate.downloadEnabled)
330 videoInfoToUpdate.originallyPublishedAt !== null) { 332
333 if (videoInfoToUpdate.originallyPublishedAt !== undefined && videoInfoToUpdate.originallyPublishedAt !== null) {
331 videoInstance.set('originallyPublishedAt', videoInfoToUpdate.originallyPublishedAt) 334 videoInstance.set('originallyPublishedAt', videoInfoToUpdate.originallyPublishedAt)
332 } 335 }
333 336
@@ -370,7 +373,11 @@ async function updateVideo (req: express.Request, res: express.Response) {
370 } 373 }
371 374
372 const isNewVideo = wasPrivateVideo && videoInstanceUpdated.privacy !== VideoPrivacy.PRIVATE 375 const isNewVideo = wasPrivateVideo && videoInstanceUpdated.privacy !== VideoPrivacy.PRIVATE
373 await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo, t) 376
377 // Don't send update if the video was unfederated
378 if (!videoInstanceUpdated.VideoBlacklist || videoInstanceUpdated.VideoBlacklist.unfederated === false) {
379 await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo, t)
380 }
374 381
375 auditLogger.update( 382 auditLogger.update(
376 getAuditIdFromRes(res), 383 getAuditIdFromRes(res),
@@ -397,15 +404,17 @@ async function updateVideo (req: express.Request, res: express.Response) {
397 return res.type('json').status(204).end() 404 return res.type('json').status(204).end()
398} 405}
399 406
400function getVideo (req: express.Request, res: express.Response) { 407async function getVideo (req: express.Request, res: express.Response) {
401 const videoInstance = res.locals.video 408 // We need more attributes
409 const userId: number = res.locals.oauth ? res.locals.oauth.token.User.id : null
410 const video: VideoModel = await VideoModel.loadForGetAPI(res.locals.video.id, undefined, userId)
402 411
403 if (videoInstance.isOutdated()) { 412 if (video.isOutdated()) {
404 JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', videoUrl: videoInstance.url } }) 413 JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', url: video.url } })
405 .catch(err => logger.error('Cannot create AP refresher job for video %s.', videoInstance.url, { err })) 414 .catch(err => logger.error('Cannot create AP refresher job for video %s.', video.url, { err }))
406 } 415 }
407 416
408 return res.json(videoInstance.toFormattedDetailsJSON()) 417 return res.json(video.toFormattedDetailsJSON())
409} 418}
410 419
411async function viewVideo (req: express.Request, res: express.Response) { 420async function viewVideo (req: express.Request, res: express.Response) {
@@ -424,7 +433,7 @@ async function viewVideo (req: express.Request, res: express.Response) {
424 ]) 433 ])
425 434
426 const serverActor = await getServerActor() 435 const serverActor = await getServerActor()
427 await sendCreateView(serverActor, videoInstance, undefined) 436 await sendView(serverActor, videoInstance, undefined)
428 437
429 return res.status(204).end() 438 return res.status(204).end()
430} 439}
diff --git a/server/controllers/static.ts b/server/controllers/static.ts
index 4fd58f70c..b21f9da00 100644
--- a/server/controllers/static.ts
+++ b/server/controllers/static.ts
@@ -1,6 +1,6 @@
1import * as cors from 'cors' 1import * as cors from 'cors'
2import * as express from 'express' 2import * as express from 'express'
3import { CONFIG, ROUTE_CACHE_LIFETIME, STATIC_DOWNLOAD_PATHS, STATIC_MAX_AGE, STATIC_PATHS } from '../initializers' 3import { CONFIG, HLS_PLAYLIST_DIRECTORY, ROUTE_CACHE_LIFETIME, STATIC_DOWNLOAD_PATHS, STATIC_MAX_AGE, STATIC_PATHS } from '../initializers'
4import { VideosPreviewCache } from '../lib/cache' 4import { VideosPreviewCache } from '../lib/cache'
5import { cacheRoute } from '../middlewares/cache' 5import { cacheRoute } from '../middlewares/cache'
6import { asyncMiddleware, videosGetValidator } from '../middlewares' 6import { asyncMiddleware, videosGetValidator } from '../middlewares'
@@ -51,6 +51,13 @@ staticRouter.use(
51 asyncMiddleware(downloadVideoFile) 51 asyncMiddleware(downloadVideoFile)
52) 52)
53 53
54// HLS
55staticRouter.use(
56 STATIC_PATHS.PLAYLISTS.HLS,
57 cors(),
58 express.static(HLS_PLAYLIST_DIRECTORY, { fallthrough: false }) // 404 if the file does not exist
59)
60
54// Thumbnails path for express 61// Thumbnails path for express
55const thumbnailsPhysicalPath = CONFIG.STORAGE.THUMBNAILS_DIR 62const thumbnailsPhysicalPath = CONFIG.STORAGE.THUMBNAILS_DIR
56staticRouter.use( 63staticRouter.use(
diff --git a/server/controllers/tracker.ts b/server/controllers/tracker.ts
index 1deb8c402..8b77d9de7 100644
--- a/server/controllers/tracker.ts
+++ b/server/controllers/tracker.ts
@@ -7,6 +7,7 @@ import { Server as WebSocketServer } from 'ws'
7import { CONFIG, TRACKER_RATE_LIMITS } from '../initializers/constants' 7import { CONFIG, TRACKER_RATE_LIMITS } from '../initializers/constants'
8import { VideoFileModel } from '../models/video/video-file' 8import { VideoFileModel } from '../models/video/video-file'
9import { parse } from 'url' 9import { parse } from 'url'
10import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
10 11
11const TrackerServer = bitTorrentTracker.Server 12const TrackerServer = bitTorrentTracker.Server
12 13
@@ -21,7 +22,7 @@ const trackerServer = new TrackerServer({
21 udp: false, 22 udp: false,
22 ws: false, 23 ws: false,
23 dht: false, 24 dht: false,
24 filter: function (infoHash, params, cb) { 25 filter: async function (infoHash, params, cb) {
25 let ip: string 26 let ip: string
26 27
27 if (params.type === 'ws') { 28 if (params.type === 'ws') {
@@ -32,19 +33,25 @@ const trackerServer = new TrackerServer({
32 33
33 const key = ip + '-' + infoHash 34 const key = ip + '-' + infoHash
34 35
35 peersIps[ip] = peersIps[ip] ? peersIps[ip] + 1 : 1 36 peersIps[ ip ] = peersIps[ ip ] ? peersIps[ ip ] + 1 : 1
36 peersIpInfoHash[key] = peersIpInfoHash[key] ? peersIpInfoHash[key] + 1 : 1 37 peersIpInfoHash[ key ] = peersIpInfoHash[ key ] ? peersIpInfoHash[ key ] + 1 : 1
37 38
38 if (peersIpInfoHash[key] > TRACKER_RATE_LIMITS.ANNOUNCES_PER_IP_PER_INFOHASH) { 39 if (peersIpInfoHash[ key ] > TRACKER_RATE_LIMITS.ANNOUNCES_PER_IP_PER_INFOHASH) {
39 return cb(new Error(`Too many requests (${peersIpInfoHash[ key ]} of ip ${ip} for torrent ${infoHash}`)) 40 return cb(new Error(`Too many requests (${peersIpInfoHash[ key ]} of ip ${ip} for torrent ${infoHash}`))
40 } 41 }
41 42
42 VideoFileModel.isInfohashExists(infoHash) 43 try {
43 .then(exists => { 44 const videoFileExists = await VideoFileModel.doesInfohashExist(infoHash)
44 if (exists === false) return cb(new Error(`Unknown infoHash ${infoHash}`)) 45 if (videoFileExists === true) return cb()
45 46
46 return cb() 47 const playlistExists = await VideoStreamingPlaylistModel.doesInfohashExist(infoHash)
47 }) 48 if (playlistExists === true) return cb()
49
50 return cb(new Error(`Unknown infoHash ${infoHash}`))
51 } catch (err) {
52 logger.error('Error in tracker filter.', { err })
53 return cb(err)
54 }
48 } 55 }
49}) 56})
50 57
diff --git a/server/helpers/activitypub.ts b/server/helpers/activitypub.ts
index 79b76fa0b..62d78373e 100644
--- a/server/helpers/activitypub.ts
+++ b/server/helpers/activitypub.ts
@@ -15,7 +15,7 @@ function activityPubContextify <T> (data: T) {
15 'https://w3id.org/security/v1', 15 'https://w3id.org/security/v1',
16 { 16 {
17 RsaSignature2017: 'https://w3id.org/security#RsaSignature2017', 17 RsaSignature2017: 'https://w3id.org/security#RsaSignature2017',
18 pt: 'https://joinpeertube.org/ns', 18 pt: 'https://joinpeertube.org/ns#',
19 sc: 'http://schema.org#', 19 sc: 'http://schema.org#',
20 Hashtag: 'as:Hashtag', 20 Hashtag: 'as:Hashtag',
21 uuid: 'sc:identifier', 21 uuid: 'sc:identifier',
@@ -29,10 +29,12 @@ function activityPubContextify <T> (data: T) {
29 size: 'sc:Number', 29 size: 'sc:Number',
30 fps: 'sc:Number', 30 fps: 'sc:Number',
31 commentsEnabled: 'sc:Boolean', 31 commentsEnabled: 'sc:Boolean',
32 downloadEnabled: 'sc:Boolean',
32 waitTranscoding: 'sc:Boolean', 33 waitTranscoding: 'sc:Boolean',
33 expires: 'sc:expires', 34 expires: 'sc:expires',
34 support: 'sc:Text', 35 support: 'sc:Text',
35 CacheFile: 'pt:CacheFile' 36 CacheFile: 'pt:CacheFile',
37 Infohash: 'pt:Infohash'
36 }, 38 },
37 { 39 {
38 likes: { 40 likes: {
@@ -106,7 +108,7 @@ function buildSignedActivity (byActor: ActorModel, data: Object) {
106 return signJsonLDObject(byActor, activity) as Promise<Activity> 108 return signJsonLDObject(byActor, activity) as Promise<Activity>
107} 109}
108 110
109function getAPUrl (activity: string | { id: string }) { 111function getAPId (activity: string | { id: string }) {
110 if (typeof activity === 'string') return activity 112 if (typeof activity === 'string') return activity
111 113
112 return activity.id 114 return activity.id
@@ -123,7 +125,7 @@ function checkUrlsSameHost (url1: string, url2: string) {
123 125
124export { 126export {
125 checkUrlsSameHost, 127 checkUrlsSameHost,
126 getAPUrl, 128 getAPId,
127 activityPubContextify, 129 activityPubContextify,
128 activityPubCollectionPagination, 130 activityPubCollectionPagination,
129 buildSignedActivity 131 buildSignedActivity
diff --git a/server/helpers/audit-logger.ts b/server/helpers/audit-logger.ts
index 00311fce1..a121f0b8a 100644
--- a/server/helpers/audit-logger.ts
+++ b/server/helpers/audit-logger.ts
@@ -117,7 +117,8 @@ const videoKeysToKeep = [
117 'channel-uuid', 117 'channel-uuid',
118 'channel-name', 118 'channel-name',
119 'support', 119 'support',
120 'commentsEnabled' 120 'commentsEnabled',
121 'downloadEnabled'
121] 122]
122class VideoAuditView extends EntityAuditView { 123class VideoAuditView extends EntityAuditView {
123 constructor (private video: VideoDetails) { 124 constructor (private video: VideoDetails) {
diff --git a/server/helpers/core-utils.ts b/server/helpers/core-utils.ts
index 3fb824e36..f38b82d97 100644
--- a/server/helpers/core-utils.ts
+++ b/server/helpers/core-utils.ts
@@ -193,10 +193,14 @@ function peertubeTruncate (str: string, maxLength: number) {
193 return truncate(str, options) 193 return truncate(str, options)
194} 194}
195 195
196function sha256 (str: string, encoding: HexBase64Latin1Encoding = 'hex') { 196function sha256 (str: string | Buffer, encoding: HexBase64Latin1Encoding = 'hex') {
197 return createHash('sha256').update(str).digest(encoding) 197 return createHash('sha256').update(str).digest(encoding)
198} 198}
199 199
200function sha1 (str: string | Buffer, encoding: HexBase64Latin1Encoding = 'hex') {
201 return createHash('sha1').update(str).digest(encoding)
202}
203
200function promisify0<A> (func: (cb: (err: any, result: A) => void) => void): () => Promise<A> { 204function promisify0<A> (func: (cb: (err: any, result: A) => void) => void): () => Promise<A> {
201 return function promisified (): Promise<A> { 205 return function promisified (): Promise<A> {
202 return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => { 206 return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
@@ -262,7 +266,9 @@ export {
262 sanitizeHost, 266 sanitizeHost,
263 buildPath, 267 buildPath,
264 peertubeTruncate, 268 peertubeTruncate,
269
265 sha256, 270 sha256,
271 sha1,
266 272
267 promisify0, 273 promisify0,
268 promisify1, 274 promisify1,
diff --git a/server/helpers/custom-validators/activitypub/activity.ts b/server/helpers/custom-validators/activitypub/activity.ts
index 2562ead9b..b24590d9d 100644
--- a/server/helpers/custom-validators/activitypub/activity.ts
+++ b/server/helpers/custom-validators/activitypub/activity.ts
@@ -1,26 +1,14 @@
1import * as validator from 'validator' 1import * as validator from 'validator'
2import { Activity, ActivityType } from '../../../../shared/models/activitypub' 2import { Activity, ActivityType } from '../../../../shared/models/activitypub'
3import { 3import { sanitizeAndCheckActorObject } from './actor'
4 isActorAcceptActivityValid, 4import { isActivityPubUrlValid, isBaseActivityValid, isObjectValid } from './misc'
5 isActorDeleteActivityValid, 5import { isDislikeActivityValid } from './rate'
6 isActorFollowActivityValid, 6import { sanitizeAndCheckVideoCommentObject } from './video-comments'
7 isActorRejectActivityValid, 7import { sanitizeAndCheckVideoTorrentObject } from './videos'
8 isActorUpdateActivityValid
9} from './actor'
10import { isAnnounceActivityValid } from './announce'
11import { isActivityPubUrlValid } from './misc'
12import { isDislikeActivityValid, isLikeActivityValid } from './rate'
13import { isUndoActivityValid } from './undo'
14import { isVideoCommentCreateActivityValid, isVideoCommentDeleteActivityValid } from './video-comments'
15import {
16 isVideoFlagValid,
17 isVideoTorrentDeleteActivityValid,
18 sanitizeAndCheckVideoTorrentCreateActivity,
19 sanitizeAndCheckVideoTorrentUpdateActivity
20} from './videos'
21import { isViewActivityValid } from './view' 8import { isViewActivityValid } from './view'
22import { exists } from '../misc' 9import { exists } from '../misc'
23import { isCacheFileCreateActivityValid, isCacheFileUpdateActivityValid } from './cache-file' 10import { isCacheFileObjectValid } from './cache-file'
11import { isFlagActivityValid } from './flag'
24 12
25function isRootActivityValid (activity: any) { 13function isRootActivityValid (activity: any) {
26 return Array.isArray(activity['@context']) && ( 14 return Array.isArray(activity['@context']) && (
@@ -46,7 +34,10 @@ const activityCheckers: { [ P in ActivityType ]: (activity: Activity) => boolean
46 Reject: checkRejectActivity, 34 Reject: checkRejectActivity,
47 Announce: checkAnnounceActivity, 35 Announce: checkAnnounceActivity,
48 Undo: checkUndoActivity, 36 Undo: checkUndoActivity,
49 Like: checkLikeActivity 37 Like: checkLikeActivity,
38 View: checkViewActivity,
39 Flag: checkFlagActivity,
40 Dislike: checkDislikeActivity
50} 41}
51 42
52function isActivityValid (activity: any) { 43function isActivityValid (activity: any) {
@@ -66,47 +57,79 @@ export {
66 57
67// --------------------------------------------------------------------------- 58// ---------------------------------------------------------------------------
68 59
60function checkViewActivity (activity: any) {
61 return isBaseActivityValid(activity, 'View') &&
62 isViewActivityValid(activity)
63}
64
65function checkFlagActivity (activity: any) {
66 return isBaseActivityValid(activity, 'Flag') &&
67 isFlagActivityValid(activity)
68}
69
70function checkDislikeActivity (activity: any) {
71 return isBaseActivityValid(activity, 'Dislike') &&
72 isDislikeActivityValid(activity)
73}
74
69function checkCreateActivity (activity: any) { 75function checkCreateActivity (activity: any) {
70 return isViewActivityValid(activity) || 76 return isBaseActivityValid(activity, 'Create') &&
71 isDislikeActivityValid(activity) || 77 (
72 sanitizeAndCheckVideoTorrentCreateActivity(activity) || 78 isViewActivityValid(activity.object) ||
73 isVideoFlagValid(activity) || 79 isDislikeActivityValid(activity.object) ||
74 isVideoCommentCreateActivityValid(activity) || 80 isFlagActivityValid(activity.object) ||
75 isCacheFileCreateActivityValid(activity) 81
82 isCacheFileObjectValid(activity.object) ||
83 sanitizeAndCheckVideoCommentObject(activity.object) ||
84 sanitizeAndCheckVideoTorrentObject(activity.object)
85 )
76} 86}
77 87
78function checkUpdateActivity (activity: any) { 88function checkUpdateActivity (activity: any) {
79 return isCacheFileUpdateActivityValid(activity) || 89 return isBaseActivityValid(activity, 'Update') &&
80 sanitizeAndCheckVideoTorrentUpdateActivity(activity) || 90 (
81 isActorUpdateActivityValid(activity) 91 isCacheFileObjectValid(activity.object) ||
92 sanitizeAndCheckVideoTorrentObject(activity.object) ||
93 sanitizeAndCheckActorObject(activity.object)
94 )
82} 95}
83 96
84function checkDeleteActivity (activity: any) { 97function checkDeleteActivity (activity: any) {
85 return isVideoTorrentDeleteActivityValid(activity) || 98 // We don't really check objects
86 isActorDeleteActivityValid(activity) || 99 return isBaseActivityValid(activity, 'Delete') &&
87 isVideoCommentDeleteActivityValid(activity) 100 isObjectValid(activity.object)
88} 101}
89 102
90function checkFollowActivity (activity: any) { 103function checkFollowActivity (activity: any) {
91 return isActorFollowActivityValid(activity) 104 return isBaseActivityValid(activity, 'Follow') &&
105 isObjectValid(activity.object)
92} 106}
93 107
94function checkAcceptActivity (activity: any) { 108function checkAcceptActivity (activity: any) {
95 return isActorAcceptActivityValid(activity) 109 return isBaseActivityValid(activity, 'Accept')
96} 110}
97 111
98function checkRejectActivity (activity: any) { 112function checkRejectActivity (activity: any) {
99 return isActorRejectActivityValid(activity) 113 return isBaseActivityValid(activity, 'Reject')
100} 114}
101 115
102function checkAnnounceActivity (activity: any) { 116function checkAnnounceActivity (activity: any) {
103 return isAnnounceActivityValid(activity) 117 return isBaseActivityValid(activity, 'Announce') &&
118 isObjectValid(activity.object)
104} 119}
105 120
106function checkUndoActivity (activity: any) { 121function checkUndoActivity (activity: any) {
107 return isUndoActivityValid(activity) 122 return isBaseActivityValid(activity, 'Undo') &&
123 (
124 checkFollowActivity(activity.object) ||
125 checkLikeActivity(activity.object) ||
126 checkDislikeActivity(activity.object) ||
127 checkAnnounceActivity(activity.object) ||
128 checkCreateActivity(activity.object)
129 )
108} 130}
109 131
110function checkLikeActivity (activity: any) { 132function checkLikeActivity (activity: any) {
111 return isLikeActivityValid(activity) 133 return isBaseActivityValid(activity, 'Like') &&
134 isObjectValid(activity.object)
112} 135}
diff --git a/server/helpers/custom-validators/activitypub/actor.ts b/server/helpers/custom-validators/activitypub/actor.ts
index 070632a20..c05f60f14 100644
--- a/server/helpers/custom-validators/activitypub/actor.ts
+++ b/server/helpers/custom-validators/activitypub/actor.ts
@@ -73,24 +73,10 @@ function isActorDeleteActivityValid (activity: any) {
73 return isBaseActivityValid(activity, 'Delete') 73 return isBaseActivityValid(activity, 'Delete')
74} 74}
75 75
76function isActorFollowActivityValid (activity: any) { 76function sanitizeAndCheckActorObject (object: any) {
77 return isBaseActivityValid(activity, 'Follow') && 77 normalizeActor(object)
78 isActivityPubUrlValid(activity.object)
79}
80
81function isActorAcceptActivityValid (activity: any) {
82 return isBaseActivityValid(activity, 'Accept')
83}
84
85function isActorRejectActivityValid (activity: any) {
86 return isBaseActivityValid(activity, 'Reject')
87}
88
89function isActorUpdateActivityValid (activity: any) {
90 normalizeActor(activity.object)
91 78
92 return isBaseActivityValid(activity, 'Update') && 79 return isActorObjectValid(object)
93 isActorObjectValid(activity.object)
94} 80}
95 81
96function normalizeActor (actor: any) { 82function normalizeActor (actor: any) {
@@ -139,10 +125,7 @@ export {
139 isActorObjectValid, 125 isActorObjectValid,
140 isActorFollowingCountValid, 126 isActorFollowingCountValid,
141 isActorFollowersCountValid, 127 isActorFollowersCountValid,
142 isActorFollowActivityValid,
143 isActorAcceptActivityValid,
144 isActorRejectActivityValid,
145 isActorDeleteActivityValid, 128 isActorDeleteActivityValid,
146 isActorUpdateActivityValid, 129 sanitizeAndCheckActorObject,
147 isValidActorHandle 130 isValidActorHandle
148} 131}
diff --git a/server/helpers/custom-validators/activitypub/announce.ts b/server/helpers/custom-validators/activitypub/announce.ts
deleted file mode 100644
index 0519c6026..000000000
--- a/server/helpers/custom-validators/activitypub/announce.ts
+++ /dev/null
@@ -1,13 +0,0 @@
1import { isActivityPubUrlValid, isBaseActivityValid } from './misc'
2
3function isAnnounceActivityValid (activity: any) {
4 return isBaseActivityValid(activity, 'Announce') &&
5 (
6 isActivityPubUrlValid(activity.object) ||
7 (activity.object && isActivityPubUrlValid(activity.object.id))
8 )
9}
10
11export {
12 isAnnounceActivityValid
13}
diff --git a/server/helpers/custom-validators/activitypub/cache-file.ts b/server/helpers/custom-validators/activitypub/cache-file.ts
index bd70934c8..21d5c53ca 100644
--- a/server/helpers/custom-validators/activitypub/cache-file.ts
+++ b/server/helpers/custom-validators/activitypub/cache-file.ts
@@ -1,28 +1,26 @@
1import { isActivityPubUrlValid, isBaseActivityValid } from './misc' 1import { isActivityPubUrlValid } from './misc'
2import { isRemoteVideoUrlValid } from './videos' 2import { isRemoteVideoUrlValid } from './videos'
3import { isDateValid, exists } from '../misc' 3import { exists, isDateValid } from '../misc'
4import { CacheFileObject } from '../../../../shared/models/activitypub/objects' 4import { CacheFileObject } from '../../../../shared/models/activitypub/objects'
5 5
6function isCacheFileCreateActivityValid (activity: any) {
7 return isBaseActivityValid(activity, 'Create') &&
8 isCacheFileObjectValid(activity.object)
9}
10
11function isCacheFileUpdateActivityValid (activity: any) {
12 return isBaseActivityValid(activity, 'Update') &&
13 isCacheFileObjectValid(activity.object)
14}
15
16function isCacheFileObjectValid (object: CacheFileObject) { 6function isCacheFileObjectValid (object: CacheFileObject) {
17 return exists(object) && 7 return exists(object) &&
18 object.type === 'CacheFile' && 8 object.type === 'CacheFile' &&
19 isDateValid(object.expires) && 9 isDateValid(object.expires) &&
20 isActivityPubUrlValid(object.object) && 10 isActivityPubUrlValid(object.object) &&
21 isRemoteVideoUrlValid(object.url) 11 (isRemoteVideoUrlValid(object.url) || isPlaylistRedundancyUrlValid(object.url))
22} 12}
23 13
14// ---------------------------------------------------------------------------
15
24export { 16export {
25 isCacheFileUpdateActivityValid,
26 isCacheFileCreateActivityValid,
27 isCacheFileObjectValid 17 isCacheFileObjectValid
28} 18}
19
20// ---------------------------------------------------------------------------
21
22function isPlaylistRedundancyUrlValid (url: any) {
23 return url.type === 'Link' &&
24 (url.mediaType || url.mimeType) === 'application/x-mpegURL' &&
25 isActivityPubUrlValid(url.href)
26}
diff --git a/server/helpers/custom-validators/activitypub/flag.ts b/server/helpers/custom-validators/activitypub/flag.ts
new file mode 100644
index 000000000..6452e297c
--- /dev/null
+++ b/server/helpers/custom-validators/activitypub/flag.ts
@@ -0,0 +1,14 @@
1import { isActivityPubUrlValid } from './misc'
2import { isVideoAbuseReasonValid } from '../video-abuses'
3
4function isFlagActivityValid (activity: any) {
5 return activity.type === 'Flag' &&
6 isVideoAbuseReasonValid(activity.content) &&
7 isActivityPubUrlValid(activity.object)
8}
9
10// ---------------------------------------------------------------------------
11
12export {
13 isFlagActivityValid
14}
diff --git a/server/helpers/custom-validators/activitypub/misc.ts b/server/helpers/custom-validators/activitypub/misc.ts
index 4e2c57f04..f1762d11c 100644
--- a/server/helpers/custom-validators/activitypub/misc.ts
+++ b/server/helpers/custom-validators/activitypub/misc.ts
@@ -28,15 +28,20 @@ function isBaseActivityValid (activity: any, type: string) {
28 return (activity['@context'] === undefined || Array.isArray(activity['@context'])) && 28 return (activity['@context'] === undefined || Array.isArray(activity['@context'])) &&
29 activity.type === type && 29 activity.type === type &&
30 isActivityPubUrlValid(activity.id) && 30 isActivityPubUrlValid(activity.id) &&
31 exists(activity.actor) && 31 isObjectValid(activity.actor) &&
32 (isActivityPubUrlValid(activity.actor) || isActivityPubUrlValid(activity.actor.id)) && 32 isUrlCollectionValid(activity.to) &&
33 ( 33 isUrlCollectionValid(activity.cc)
34 activity.to === undefined || 34}
35 (Array.isArray(activity.to) && activity.to.every(t => isActivityPubUrlValid(t))) 35
36 ) && 36function isUrlCollectionValid (collection: any) {
37 return collection === undefined ||
38 (Array.isArray(collection) && collection.every(t => isActivityPubUrlValid(t)))
39}
40
41function isObjectValid (object: any) {
42 return exists(object) &&
37 ( 43 (
38 activity.cc === undefined || 44 isActivityPubUrlValid(object) || isActivityPubUrlValid(object.id)
39 (Array.isArray(activity.cc) && activity.cc.every(t => isActivityPubUrlValid(t)))
40 ) 45 )
41} 46}
42 47
@@ -57,5 +62,6 @@ export {
57 isUrlValid, 62 isUrlValid,
58 isActivityPubUrlValid, 63 isActivityPubUrlValid,
59 isBaseActivityValid, 64 isBaseActivityValid,
60 setValidAttributedTo 65 setValidAttributedTo,
66 isObjectValid
61} 67}
diff --git a/server/helpers/custom-validators/activitypub/rate.ts b/server/helpers/custom-validators/activitypub/rate.ts
index e70bd94b8..ba68e8074 100644
--- a/server/helpers/custom-validators/activitypub/rate.ts
+++ b/server/helpers/custom-validators/activitypub/rate.ts
@@ -1,20 +1,13 @@
1import { isActivityPubUrlValid, isBaseActivityValid } from './misc' 1import { isActivityPubUrlValid, isObjectValid } from './misc'
2
3function isLikeActivityValid (activity: any) {
4 return isBaseActivityValid(activity, 'Like') &&
5 isActivityPubUrlValid(activity.object)
6}
7 2
8function isDislikeActivityValid (activity: any) { 3function isDislikeActivityValid (activity: any) {
9 return isBaseActivityValid(activity, 'Create') && 4 return activity.type === 'Dislike' &&
10 activity.object.type === 'Dislike' && 5 isActivityPubUrlValid(activity.actor) &&
11 isActivityPubUrlValid(activity.object.actor) && 6 isObjectValid(activity.object)
12 isActivityPubUrlValid(activity.object.object)
13} 7}
14 8
15// --------------------------------------------------------------------------- 9// ---------------------------------------------------------------------------
16 10
17export { 11export {
18 isLikeActivityValid,
19 isDislikeActivityValid 12 isDislikeActivityValid
20} 13}
diff --git a/server/helpers/custom-validators/activitypub/undo.ts b/server/helpers/custom-validators/activitypub/undo.ts
deleted file mode 100644
index 578035893..000000000
--- a/server/helpers/custom-validators/activitypub/undo.ts
+++ /dev/null
@@ -1,20 +0,0 @@
1import { isActorFollowActivityValid } from './actor'
2import { isBaseActivityValid } from './misc'
3import { isDislikeActivityValid, isLikeActivityValid } from './rate'
4import { isAnnounceActivityValid } from './announce'
5import { isCacheFileCreateActivityValid } from './cache-file'
6
7function isUndoActivityValid (activity: any) {
8 return isBaseActivityValid(activity, 'Undo') &&
9 (
10 isActorFollowActivityValid(activity.object) ||
11 isLikeActivityValid(activity.object) ||
12 isDislikeActivityValid(activity.object) ||
13 isAnnounceActivityValid(activity.object) ||
14 isCacheFileCreateActivityValid(activity.object)
15 )
16}
17
18export {
19 isUndoActivityValid
20}
diff --git a/server/helpers/custom-validators/activitypub/video-comments.ts b/server/helpers/custom-validators/activitypub/video-comments.ts
index 051c4565a..0415db21c 100644
--- a/server/helpers/custom-validators/activitypub/video-comments.ts
+++ b/server/helpers/custom-validators/activitypub/video-comments.ts
@@ -3,11 +3,6 @@ import { ACTIVITY_PUB, CONSTRAINTS_FIELDS } from '../../../initializers'
3import { exists, isArray, isDateValid } from '../misc' 3import { exists, isArray, isDateValid } from '../misc'
4import { isActivityPubUrlValid, isBaseActivityValid } from './misc' 4import { isActivityPubUrlValid, isBaseActivityValid } from './misc'
5 5
6function isVideoCommentCreateActivityValid (activity: any) {
7 return isBaseActivityValid(activity, 'Create') &&
8 sanitizeAndCheckVideoCommentObject(activity.object)
9}
10
11function sanitizeAndCheckVideoCommentObject (comment: any) { 6function sanitizeAndCheckVideoCommentObject (comment: any) {
12 if (!comment || comment.type !== 'Note') return false 7 if (!comment || comment.type !== 'Note') return false
13 8
@@ -25,15 +20,9 @@ function sanitizeAndCheckVideoCommentObject (comment: any) {
25 ) // Only accept public comments 20 ) // Only accept public comments
26} 21}
27 22
28function isVideoCommentDeleteActivityValid (activity: any) {
29 return isBaseActivityValid(activity, 'Delete')
30}
31
32// --------------------------------------------------------------------------- 23// ---------------------------------------------------------------------------
33 24
34export { 25export {
35 isVideoCommentCreateActivityValid,
36 isVideoCommentDeleteActivityValid,
37 sanitizeAndCheckVideoCommentObject 26 sanitizeAndCheckVideoCommentObject
38} 27}
39 28
diff --git a/server/helpers/custom-validators/activitypub/videos.ts b/server/helpers/custom-validators/activitypub/videos.ts
index 95fe824b9..53ad0588d 100644
--- a/server/helpers/custom-validators/activitypub/videos.ts
+++ b/server/helpers/custom-validators/activitypub/videos.ts
@@ -1,7 +1,7 @@
1import * as validator from 'validator' 1import * as validator from 'validator'
2import { ACTIVITY_PUB, CONSTRAINTS_FIELDS } from '../../../initializers' 2import { ACTIVITY_PUB, CONSTRAINTS_FIELDS } from '../../../initializers'
3import { peertubeTruncate } from '../../core-utils' 3import { peertubeTruncate } from '../../core-utils'
4import { exists, isBooleanValid, isDateValid, isUUIDValid } from '../misc' 4import { exists, isArray, isBooleanValid, isDateValid, isUUIDValid } from '../misc'
5import { 5import {
6 isVideoDurationValid, 6 isVideoDurationValid,
7 isVideoNameValid, 7 isVideoNameValid,
@@ -12,29 +12,12 @@ import {
12} from '../videos' 12} from '../videos'
13import { isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc' 13import { isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc'
14import { VideoState } from '../../../../shared/models/videos' 14import { VideoState } from '../../../../shared/models/videos'
15import { isVideoAbuseReasonValid } from '../video-abuses'
16
17function sanitizeAndCheckVideoTorrentCreateActivity (activity: any) {
18 return isBaseActivityValid(activity, 'Create') &&
19 sanitizeAndCheckVideoTorrentObject(activity.object)
20}
21 15
22function sanitizeAndCheckVideoTorrentUpdateActivity (activity: any) { 16function sanitizeAndCheckVideoTorrentUpdateActivity (activity: any) {
23 return isBaseActivityValid(activity, 'Update') && 17 return isBaseActivityValid(activity, 'Update') &&
24 sanitizeAndCheckVideoTorrentObject(activity.object) 18 sanitizeAndCheckVideoTorrentObject(activity.object)
25} 19}
26 20
27function isVideoTorrentDeleteActivityValid (activity: any) {
28 return isBaseActivityValid(activity, 'Delete')
29}
30
31function isVideoFlagValid (activity: any) {
32 return isBaseActivityValid(activity, 'Create') &&
33 activity.object.type === 'Flag' &&
34 isVideoAbuseReasonValid(activity.object.content) &&
35 isActivityPubUrlValid(activity.object.object)
36}
37
38function isActivityPubVideoDurationValid (value: string) { 21function isActivityPubVideoDurationValid (value: string) {
39 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration 22 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
40 return exists(value) && 23 return exists(value) &&
@@ -56,6 +39,7 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
56 // Default attributes 39 // Default attributes
57 if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED 40 if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED
58 if (!isBooleanValid(video.waitTranscoding)) video.waitTranscoding = false 41 if (!isBooleanValid(video.waitTranscoding)) video.waitTranscoding = false
42 if (!isBooleanValid(video.downloadEnabled)) video.downloadEnabled = true
59 43
60 return isActivityPubUrlValid(video.id) && 44 return isActivityPubUrlValid(video.id) &&
61 isVideoNameValid(video.name) && 45 isVideoNameValid(video.name) &&
@@ -67,6 +51,7 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
67 isVideoViewsValid(video.views) && 51 isVideoViewsValid(video.views) &&
68 isBooleanValid(video.sensitive) && 52 isBooleanValid(video.sensitive) &&
69 isBooleanValid(video.commentsEnabled) && 53 isBooleanValid(video.commentsEnabled) &&
54 isBooleanValid(video.downloadEnabled) &&
70 isDateValid(video.published) && 55 isDateValid(video.published) &&
71 isDateValid(video.updated) && 56 isDateValid(video.updated) &&
72 (!video.content || isRemoteVideoContentValid(video.mediaType, video.content)) && 57 (!video.content || isRemoteVideoContentValid(video.mediaType, video.content)) &&
@@ -97,17 +82,19 @@ function isRemoteVideoUrlValid (url: any) {
97 ACTIVITY_PUB.URL_MIME_TYPES.MAGNET.indexOf(url.mediaType || url.mimeType) !== -1 && 82 ACTIVITY_PUB.URL_MIME_TYPES.MAGNET.indexOf(url.mediaType || url.mimeType) !== -1 &&
98 validator.isLength(url.href, { min: 5 }) && 83 validator.isLength(url.href, { min: 5 }) &&
99 validator.isInt(url.height + '', { min: 0 }) 84 validator.isInt(url.height + '', { min: 0 })
85 ) ||
86 (
87 (url.mediaType || url.mimeType) === 'application/x-mpegURL' &&
88 isActivityPubUrlValid(url.href) &&
89 isArray(url.tag)
100 ) 90 )
101} 91}
102 92
103// --------------------------------------------------------------------------- 93// ---------------------------------------------------------------------------
104 94
105export { 95export {
106 sanitizeAndCheckVideoTorrentCreateActivity,
107 sanitizeAndCheckVideoTorrentUpdateActivity, 96 sanitizeAndCheckVideoTorrentUpdateActivity,
108 isVideoTorrentDeleteActivityValid,
109 isRemoteStringIdentifierValid, 97 isRemoteStringIdentifierValid,
110 isVideoFlagValid,
111 sanitizeAndCheckVideoTorrentObject, 98 sanitizeAndCheckVideoTorrentObject,
112 isRemoteVideoUrlValid 99 isRemoteVideoUrlValid
113} 100}
diff --git a/server/helpers/custom-validators/activitypub/view.ts b/server/helpers/custom-validators/activitypub/view.ts
index 7a3aca6f5..41d16469f 100644
--- a/server/helpers/custom-validators/activitypub/view.ts
+++ b/server/helpers/custom-validators/activitypub/view.ts
@@ -1,11 +1,11 @@
1import { isActivityPubUrlValid, isBaseActivityValid } from './misc' 1import { isActivityPubUrlValid } from './misc'
2 2
3function isViewActivityValid (activity: any) { 3function isViewActivityValid (activity: any) {
4 return isBaseActivityValid(activity, 'Create') && 4 return activity.type === 'View' &&
5 activity.object.type === 'View' && 5 isActivityPubUrlValid(activity.actor) &&
6 isActivityPubUrlValid(activity.object.actor) && 6 isActivityPubUrlValid(activity.object)
7 isActivityPubUrlValid(activity.object.object)
8} 7}
8
9// --------------------------------------------------------------------------- 9// ---------------------------------------------------------------------------
10 10
11export { 11export {
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts
index b6f0ebe6f..76647fea2 100644
--- a/server/helpers/custom-validators/misc.ts
+++ b/server/helpers/custom-validators/misc.ts
@@ -13,6 +13,10 @@ function isNotEmptyIntArray (value: any) {
13 return Array.isArray(value) && value.every(v => validator.isInt('' + v)) && value.length !== 0 13 return Array.isArray(value) && value.every(v => validator.isInt('' + v)) && value.length !== 0
14} 14}
15 15
16function isArrayOf (value: any, validator: (value: any) => boolean) {
17 return isArray(value) && value.every(v => validator(v))
18}
19
16function isDateValid (value: string) { 20function isDateValid (value: string) {
17 return exists(value) && validator.isISO8601(value) 21 return exists(value) && validator.isISO8601(value)
18} 22}
@@ -82,6 +86,7 @@ function isFileValid (
82 86
83export { 87export {
84 exists, 88 exists,
89 isArrayOf,
85 isNotEmptyIntArray, 90 isNotEmptyIntArray,
86 isArray, 91 isArray,
87 isIdValid, 92 isIdValid,
diff --git a/server/helpers/custom-validators/videos.ts b/server/helpers/custom-validators/videos.ts
index ce4492a30..dd04aa5f6 100644
--- a/server/helpers/custom-validators/videos.ts
+++ b/server/helpers/custom-validators/videos.ts
@@ -88,8 +88,8 @@ function isVideoFileExtnameValid (value: string) {
88 88
89function isVideoFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) { 89function isVideoFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) {
90 const videoFileTypesRegex = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT) 90 const videoFileTypesRegex = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT)
91 .map(m => `(${m})`) 91 .map(m => `(${m})`)
92 .join('|') 92 .join('|')
93 93
94 return isFileValid(files, videoFileTypesRegex, 'videofile', null) 94 return isFileValid(files, videoFileTypesRegex, 'videofile', null)
95} 95}
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts
index c7296054d..133b1b03b 100644
--- a/server/helpers/ffmpeg-utils.ts
+++ b/server/helpers/ffmpeg-utils.ts
@@ -1,5 +1,5 @@
1import * as ffmpeg from 'fluent-ffmpeg' 1import * as ffmpeg from 'fluent-ffmpeg'
2import { join } from 'path' 2import { dirname, join } from 'path'
3import { getTargetBitrate, VideoResolution } from '../../shared/models/videos' 3import { getTargetBitrate, VideoResolution } from '../../shared/models/videos'
4import { CONFIG, FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants' 4import { CONFIG, FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants'
5import { processImage } from './image-utils' 5import { processImage } from './image-utils'
@@ -29,19 +29,28 @@ function computeResolutionsToTranscode (videoFileHeight: number) {
29 return resolutionsEnabled 29 return resolutionsEnabled
30} 30}
31 31
32async function getVideoFileResolution (path: string) { 32async function getVideoFileSize (path: string) {
33 const videoStream = await getVideoFileStream(path) 33 const videoStream = await getVideoFileStream(path)
34 34
35 return { 35 return {
36 videoFileResolution: Math.min(videoStream.height, videoStream.width), 36 width: videoStream.width,
37 isPortraitMode: videoStream.height > videoStream.width 37 height: videoStream.height
38 }
39}
40
41async function getVideoFileResolution (path: string) {
42 const size = await getVideoFileSize(path)
43
44 return {
45 videoFileResolution: Math.min(size.height, size.width),
46 isPortraitMode: size.height > size.width
38 } 47 }
39} 48}
40 49
41async function getVideoFileFPS (path: string) { 50async function getVideoFileFPS (path: string) {
42 const videoStream = await getVideoFileStream(path) 51 const videoStream = await getVideoFileStream(path)
43 52
44 for (const key of [ 'r_frame_rate' , 'avg_frame_rate' ]) { 53 for (const key of [ 'avg_frame_rate', 'r_frame_rate' ]) {
45 const valuesText: string = videoStream[key] 54 const valuesText: string = videoStream[key]
46 if (!valuesText) continue 55 if (!valuesText) continue
47 56
@@ -110,8 +119,12 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima
110type TranscodeOptions = { 119type TranscodeOptions = {
111 inputPath: string 120 inputPath: string
112 outputPath: string 121 outputPath: string
113 resolution?: VideoResolution 122 resolution: VideoResolution
114 isPortraitMode?: boolean 123 isPortraitMode?: boolean
124
125 hlsPlaylist?: {
126 videoFilename: string
127 }
115} 128}
116 129
117function transcode (options: TranscodeOptions) { 130function transcode (options: TranscodeOptions) {
@@ -150,6 +163,18 @@ function transcode (options: TranscodeOptions) {
150 command = command.withFPS(fps) 163 command = command.withFPS(fps)
151 } 164 }
152 165
166 if (options.hlsPlaylist) {
167 const videoPath = `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
168
169 command = command.outputOption('-hls_time 4')
170 .outputOption('-hls_list_size 0')
171 .outputOption('-hls_playlist_type vod')
172 .outputOption('-hls_segment_filename ' + videoPath)
173 .outputOption('-hls_segment_type fmp4')
174 .outputOption('-f hls')
175 .outputOption('-hls_flags single_file')
176 }
177
153 command 178 command
154 .on('error', (err, stdout, stderr) => { 179 .on('error', (err, stdout, stderr) => {
155 logger.error('Error in transcoding job.', { stdout, stderr }) 180 logger.error('Error in transcoding job.', { stdout, stderr })
@@ -166,6 +191,7 @@ function transcode (options: TranscodeOptions) {
166// --------------------------------------------------------------------------- 191// ---------------------------------------------------------------------------
167 192
168export { 193export {
194 getVideoFileSize,
169 getVideoFileResolution, 195 getVideoFileResolution,
170 getDurationFromVideoFile, 196 getDurationFromVideoFile,
171 generateImageFromVideoFile, 197 generateImageFromVideoFile,
diff --git a/server/helpers/requests.ts b/server/helpers/requests.ts
index 3fc776f1a..5c6dc5e19 100644
--- a/server/helpers/requests.ts
+++ b/server/helpers/requests.ts
@@ -7,7 +7,7 @@ import { join } from 'path'
7 7
8function doRequest <T> ( 8function doRequest <T> (
9 requestOptions: request.CoreOptions & request.UriOptions & { activityPub?: boolean } 9 requestOptions: request.CoreOptions & request.UriOptions & { activityPub?: boolean }
10): Bluebird<{ response: request.RequestResponse, body: any }> { 10): Bluebird<{ response: request.RequestResponse, body: T }> {
11 if (requestOptions.activityPub === true) { 11 if (requestOptions.activityPub === true) {
12 if (!Array.isArray(requestOptions.headers)) requestOptions.headers = {} 12 if (!Array.isArray(requestOptions.headers)) requestOptions.headers = {}
13 requestOptions.headers['accept'] = ACTIVITY_PUB.ACCEPT_HEADER 13 requestOptions.headers['accept'] = ACTIVITY_PUB.ACCEPT_HEADER
diff --git a/server/helpers/utils.ts b/server/helpers/utils.ts
index 3c3406e38..cb0e823c5 100644
--- a/server/helpers/utils.ts
+++ b/server/helpers/utils.ts
@@ -7,7 +7,6 @@ import { join } from 'path'
7import { Instance as ParseTorrent } from 'parse-torrent' 7import { Instance as ParseTorrent } from 'parse-torrent'
8import { remove } from 'fs-extra' 8import { remove } from 'fs-extra'
9import * as memoizee from 'memoizee' 9import * as memoizee from 'memoizee'
10import { isArray } from './custom-validators/misc'
11 10
12function deleteFileAsync (path: string) { 11function deleteFileAsync (path: string) {
13 remove(path) 12 remove(path)
diff --git a/server/helpers/video.ts b/server/helpers/video.ts
index 1bd21467d..c90fe06c7 100644
--- a/server/helpers/video.ts
+++ b/server/helpers/video.ts
@@ -1,10 +1,12 @@
1import { VideoModel } from '../models/video/video' 1import { VideoModel } from '../models/video/video'
2 2
3type VideoFetchType = 'all' | 'only-video' | 'id' | 'none' 3type VideoFetchType = 'all' | 'only-video' | 'only-video-with-rights' | 'id' | 'none'
4 4
5function fetchVideo (id: number | string, fetchType: VideoFetchType, userId?: number) { 5function fetchVideo (id: number | string, fetchType: VideoFetchType, userId?: number) {
6 if (fetchType === 'all') return VideoModel.loadAndPopulateAccountAndServerAndTags(id, undefined, userId) 6 if (fetchType === 'all') return VideoModel.loadAndPopulateAccountAndServerAndTags(id, undefined, userId)
7 7
8 if (fetchType === 'only-video-with-rights') return VideoModel.loadWithRights(id)
9
8 if (fetchType === 'only-video') return VideoModel.load(id) 10 if (fetchType === 'only-video') return VideoModel.load(id)
9 11
10 if (fetchType === 'id' || fetchType === 'none') return VideoModel.loadOnlyId(id) 12 if (fetchType === 'id' || fetchType === 'none') return VideoModel.loadOnlyId(id)
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts
index 7905d9ffa..29fdb263e 100644
--- a/server/initializers/checker-before-init.ts
+++ b/server/initializers/checker-before-init.ts
@@ -12,7 +12,7 @@ function checkMissedConfig () {
12 'database.hostname', 'database.port', 'database.suffix', 'database.username', 'database.password', 'database.pool.max', 12 'database.hostname', 'database.port', 'database.suffix', 'database.username', 'database.password', 'database.pool.max',
13 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address', 13 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address',
14 'storage.avatars', 'storage.videos', 'storage.logs', 'storage.previews', 'storage.thumbnails', 'storage.torrents', 'storage.cache', 14 'storage.avatars', 'storage.videos', 'storage.logs', 'storage.previews', 'storage.thumbnails', 'storage.torrents', 'storage.cache',
15 'storage.redundancy', 'storage.tmp', 15 'storage.redundancy', 'storage.tmp', 'storage.playlists',
16 'log.level', 16 'log.level',
17 'user.video_quota', 'user.video_quota_daily', 17 'user.video_quota', 'user.video_quota_daily',
18 'cache.previews.size', 'admin.email', 'contact_form.enabled', 18 'cache.previews.size', 'admin.email', 'contact_form.enabled',
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 4a88aef87..3656a23f9 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -16,7 +16,7 @@ let config: IConfig = require('config')
16 16
17// --------------------------------------------------------------------------- 17// ---------------------------------------------------------------------------
18 18
19const LAST_MIGRATION_VERSION = 315 19const LAST_MIGRATION_VERSION = 340
20 20
21// --------------------------------------------------------------------------- 21// ---------------------------------------------------------------------------
22 22
@@ -192,6 +192,7 @@ const CONFIG = {
192 AVATARS_DIR: buildPath(config.get<string>('storage.avatars')), 192 AVATARS_DIR: buildPath(config.get<string>('storage.avatars')),
193 LOG_DIR: buildPath(config.get<string>('storage.logs')), 193 LOG_DIR: buildPath(config.get<string>('storage.logs')),
194 VIDEOS_DIR: buildPath(config.get<string>('storage.videos')), 194 VIDEOS_DIR: buildPath(config.get<string>('storage.videos')),
195 PLAYLISTS_DIR: buildPath(config.get<string>('storage.playlists')),
195 REDUNDANCY_DIR: buildPath(config.get<string>('storage.redundancy')), 196 REDUNDANCY_DIR: buildPath(config.get<string>('storage.redundancy')),
196 THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')), 197 THUMBNAILS_DIR: buildPath(config.get<string>('storage.thumbnails')),
197 PREVIEWS_DIR: buildPath(config.get<string>('storage.previews')), 198 PREVIEWS_DIR: buildPath(config.get<string>('storage.previews')),
@@ -259,6 +260,9 @@ const CONFIG = {
259 get '480p' () { return config.get<boolean>('transcoding.resolutions.480p') }, 260 get '480p' () { return config.get<boolean>('transcoding.resolutions.480p') },
260 get '720p' () { return config.get<boolean>('transcoding.resolutions.720p') }, 261 get '720p' () { return config.get<boolean>('transcoding.resolutions.720p') },
261 get '1080p' () { return config.get<boolean>('transcoding.resolutions.1080p') } 262 get '1080p' () { return config.get<boolean>('transcoding.resolutions.1080p') }
263 },
264 HLS: {
265 get ENABLED () { return config.get<boolean>('transcoding.hls.enabled') }
262 } 266 }
263 }, 267 },
264 IMPORT: { 268 IMPORT: {
@@ -316,8 +320,8 @@ let CONSTRAINTS_FIELDS = {
316 BLOCKED_REASON: { min: 3, max: 250 } // Length 320 BLOCKED_REASON: { min: 3, max: 250 } // Length
317 }, 321 },
318 VIDEO_ABUSES: { 322 VIDEO_ABUSES: {
319 REASON: { min: 2, max: 300 }, // Length 323 REASON: { min: 2, max: 3000 }, // Length
320 MODERATION_COMMENT: { min: 2, max: 300 } // Length 324 MODERATION_COMMENT: { min: 2, max: 3000 } // Length
321 }, 325 },
322 VIDEO_BLACKLIST: { 326 VIDEO_BLACKLIST: {
323 REASON: { min: 2, max: 300 } // Length 327 REASON: { min: 2, max: 300 } // Length
@@ -590,6 +594,9 @@ const STATIC_PATHS = {
590 TORRENTS: '/static/torrents/', 594 TORRENTS: '/static/torrents/',
591 WEBSEED: '/static/webseed/', 595 WEBSEED: '/static/webseed/',
592 REDUNDANCY: '/static/redundancy/', 596 REDUNDANCY: '/static/redundancy/',
597 PLAYLISTS: {
598 HLS: '/static/playlists/hls'
599 },
593 AVATARS: '/static/avatars/', 600 AVATARS: '/static/avatars/',
594 VIDEO_CAPTIONS: '/static/video-captions/' 601 VIDEO_CAPTIONS: '/static/video-captions/'
595} 602}
@@ -632,6 +639,9 @@ const CACHE = {
632 } 639 }
633} 640}
634 641
642const HLS_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.PLAYLISTS_DIR, 'hls')
643const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls')
644
635const MEMOIZE_TTL = { 645const MEMOIZE_TTL = {
636 OVERVIEWS_SAMPLE: 1000 * 3600 * 4 // 4 hours 646 OVERVIEWS_SAMPLE: 1000 * 3600 * 4 // 4 hours
637} 647}
@@ -701,6 +711,8 @@ if (isTestInstance() === true) {
701 CACHE.VIDEO_CAPTIONS.MAX_AGE = 3000 711 CACHE.VIDEO_CAPTIONS.MAX_AGE = 3000
702 MEMOIZE_TTL.OVERVIEWS_SAMPLE = 1 712 MEMOIZE_TTL.OVERVIEWS_SAMPLE = 1
703 ROUTE_CACHE_LIFETIME.OVERVIEWS.VIDEOS = '0ms' 713 ROUTE_CACHE_LIFETIME.OVERVIEWS.VIDEOS = '0ms'
714
715 RATES_LIMIT.LOGIN.MAX = 20
704} 716}
705 717
706updateWebserverUrls() 718updateWebserverUrls()
@@ -709,6 +721,7 @@ updateWebserverUrls()
709 721
710export { 722export {
711 API_VERSION, 723 API_VERSION,
724 HLS_REDUNDANCY_DIRECTORY,
712 AVATARS_SIZE, 725 AVATARS_SIZE,
713 ACCEPT_HEADERS, 726 ACCEPT_HEADERS,
714 BCRYPT_SALT_SIZE, 727 BCRYPT_SALT_SIZE,
@@ -733,6 +746,7 @@ export {
733 PRIVATE_RSA_KEY_SIZE, 746 PRIVATE_RSA_KEY_SIZE,
734 ROUTE_CACHE_LIFETIME, 747 ROUTE_CACHE_LIFETIME,
735 SORTABLE_COLUMNS, 748 SORTABLE_COLUMNS,
749 HLS_PLAYLIST_DIRECTORY,
736 FEEDS, 750 FEEDS,
737 JOB_TTL, 751 JOB_TTL,
738 NSFW_POLICY_TYPES, 752 NSFW_POLICY_TYPES,
@@ -795,7 +809,9 @@ function buildVideoMimetypeExt () {
795 'video/quicktime': '.mov', 809 'video/quicktime': '.mov',
796 'video/x-msvideo': '.avi', 810 'video/x-msvideo': '.avi',
797 'video/x-flv': '.flv', 811 'video/x-flv': '.flv',
798 'video/x-matroska': '.mkv' 812 'video/x-matroska': '.mkv',
813 'application/octet-stream': '.mkv',
814 'video/avi': '.avi'
799 }) 815 })
800 } 816 }
801 817
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index 84ad2079b..fe296142d 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -33,6 +33,7 @@ import { AccountBlocklistModel } from '../models/account/account-blocklist'
33import { ServerBlocklistModel } from '../models/server/server-blocklist' 33import { ServerBlocklistModel } from '../models/server/server-blocklist'
34import { UserNotificationModel } from '../models/account/user-notification' 34import { UserNotificationModel } from '../models/account/user-notification'
35import { UserNotificationSettingModel } from '../models/account/user-notification-setting' 35import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
36import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
36 37
37require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 38require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
38 39
@@ -99,7 +100,8 @@ async function initDatabaseModels (silent: boolean) {
99 AccountBlocklistModel, 100 AccountBlocklistModel,
100 ServerBlocklistModel, 101 ServerBlocklistModel,
101 UserNotificationModel, 102 UserNotificationModel,
102 UserNotificationSettingModel 103 UserNotificationSettingModel,
104 VideoStreamingPlaylistModel
103 ]) 105 ])
104 106
105 // Check extensions exist in the database 107 // Check extensions exist in the database
diff --git a/server/initializers/installer.ts b/server/initializers/installer.ts
index b9a9da183..2b22e16fe 100644
--- a/server/initializers/installer.ts
+++ b/server/initializers/installer.ts
@@ -6,7 +6,7 @@ import { UserModel } from '../models/account/user'
6import { ApplicationModel } from '../models/application/application' 6import { ApplicationModel } from '../models/application/application'
7import { OAuthClientModel } from '../models/oauth/oauth-client' 7import { OAuthClientModel } from '../models/oauth/oauth-client'
8import { applicationExist, clientsExist, usersExist } from './checker-after-init' 8import { applicationExist, clientsExist, usersExist } from './checker-after-init'
9import { CACHE, CONFIG, LAST_MIGRATION_VERSION } from './constants' 9import { CACHE, CONFIG, HLS_PLAYLIST_DIRECTORY, LAST_MIGRATION_VERSION } from './constants'
10import { sequelizeTypescript } from './database' 10import { sequelizeTypescript } from './database'
11import { remove, ensureDir } from 'fs-extra' 11import { remove, ensureDir } from 'fs-extra'
12 12
@@ -73,6 +73,9 @@ function createDirectoriesIfNotExist () {
73 tasks.push(ensureDir(dir)) 73 tasks.push(ensureDir(dir))
74 } 74 }
75 75
76 // Playlist directories
77 tasks.push(ensureDir(HLS_PLAYLIST_DIRECTORY))
78
76 return Promise.all(tasks) 79 return Promise.all(tasks)
77} 80}
78 81
diff --git a/server/initializers/migrations/0320-blacklist-unfederate.ts b/server/initializers/migrations/0320-blacklist-unfederate.ts
new file mode 100644
index 000000000..6fb7bbb90
--- /dev/null
+++ b/server/initializers/migrations/0320-blacklist-unfederate.ts
@@ -0,0 +1,27 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction,
5 queryInterface: Sequelize.QueryInterface,
6 sequelize: Sequelize.Sequelize
7}): Promise<void> {
8
9 {
10 const data = {
11 type: Sequelize.BOOLEAN,
12 allowNull: false,
13 defaultValue: false
14 }
15
16 await utils.queryInterface.addColumn('videoBlacklist', 'unfederated', data)
17 }
18}
19
20function down (options) {
21 throw new Error('Not implemented.')
22}
23
24export {
25 up,
26 down
27}
diff --git a/server/initializers/migrations/0325-video-abuse-fields.ts b/server/initializers/migrations/0325-video-abuse-fields.ts
new file mode 100644
index 000000000..fca6d666f
--- /dev/null
+++ b/server/initializers/migrations/0325-video-abuse-fields.ts
@@ -0,0 +1,37 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction,
5 queryInterface: Sequelize.QueryInterface,
6 sequelize: Sequelize.Sequelize
7}): Promise<void> {
8
9 {
10 const data = {
11 type: Sequelize.STRING(3000),
12 allowNull: false,
13 defaultValue: null
14 }
15
16 await utils.queryInterface.changeColumn('videoAbuse', 'reason', data)
17 }
18
19 {
20 const data = {
21 type: Sequelize.STRING(3000),
22 allowNull: true,
23 defaultValue: null
24 }
25
26 await utils.queryInterface.changeColumn('videoAbuse', 'moderationComment', data)
27 }
28}
29
30function down (options) {
31 throw new Error('Not implemented.')
32}
33
34export {
35 up,
36 down
37}
diff --git a/server/initializers/migrations/0330-video-streaming-playlist.ts b/server/initializers/migrations/0330-video-streaming-playlist.ts
new file mode 100644
index 000000000..c85a762ab
--- /dev/null
+++ b/server/initializers/migrations/0330-video-streaming-playlist.ts
@@ -0,0 +1,51 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction,
5 queryInterface: Sequelize.QueryInterface,
6 sequelize: Sequelize.Sequelize
7}): Promise<void> {
8
9 {
10 const query = `
11 CREATE TABLE IF NOT EXISTS "videoStreamingPlaylist"
12(
13 "id" SERIAL,
14 "type" INTEGER NOT NULL,
15 "playlistUrl" VARCHAR(2000) NOT NULL,
16 "p2pMediaLoaderInfohashes" VARCHAR(255)[] NOT NULL,
17 "segmentsSha256Url" VARCHAR(255) NOT NULL,
18 "videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
19 "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
20 "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
21 PRIMARY KEY ("id")
22);`
23 await utils.sequelize.query(query)
24 }
25
26 {
27 const data = {
28 type: Sequelize.INTEGER,
29 allowNull: true,
30 defaultValue: null
31 }
32
33 await utils.queryInterface.changeColumn('videoRedundancy', 'videoFileId', data)
34 }
35
36 {
37 const query = 'ALTER TABLE "videoRedundancy" ADD COLUMN "videoStreamingPlaylistId" INTEGER NULL ' +
38 'REFERENCES "videoStreamingPlaylist" ("id") ON DELETE CASCADE ON UPDATE CASCADE'
39
40 await utils.sequelize.query(query)
41 }
42}
43
44function down (options) {
45 throw new Error('Not implemented.')
46}
47
48export {
49 up,
50 down
51}
diff --git a/server/initializers/migrations/0335-video-downloading-enabled.ts b/server/initializers/migrations/0335-video-downloading-enabled.ts
new file mode 100644
index 000000000..e79466447
--- /dev/null
+++ b/server/initializers/migrations/0335-video-downloading-enabled.ts
@@ -0,0 +1,27 @@
1import * as Sequelize from 'sequelize'
2import { Migration } from '../../models/migrations'
3
4async function up (utils: {
5 transaction: Sequelize.Transaction,
6 queryInterface: Sequelize.QueryInterface,
7 sequelize: Sequelize.Sequelize
8}): Promise<void> {
9 const data = {
10 type: Sequelize.BOOLEAN,
11 allowNull: false,
12 defaultValue: true
13 } as Migration.Boolean
14 await utils.queryInterface.addColumn('video', 'downloadEnabled', data)
15
16 data.defaultValue = null
17 return utils.queryInterface.changeColumn('video', 'downloadEnabled', data)
18}
19
20function down (options) {
21 throw new Error('Not implemented.')
22}
23
24export {
25 up,
26 down
27}
diff --git a/server/initializers/migrations/0295-add-originally-published-at.ts b/server/initializers/migrations/0340-add-originally-published-at.ts
index e02cca480..ab8d66854 100644
--- a/server/initializers/migrations/0295-add-originally-published-at.ts
+++ b/server/initializers/migrations/0340-add-originally-published-at.ts
@@ -19,17 +19,6 @@ async function up (utils: {
19 const query = 'UPDATE video SET "originallyPublishedAt" = video."publishedAt"' 19 const query = 'UPDATE video SET "originallyPublishedAt" = video."publishedAt"'
20 await utils.sequelize.query(query) 20 await utils.sequelize.query(query)
21 } 21 }
22
23 // Sequelize does not alter the column with NOW as default value
24 {
25 const data = {
26 type: Sequelize.DATE,
27 allowNull: false,
28 defaultValue: Sequelize.NOW
29 }
30 await utils.queryInterface.changeColumn('video', 'originallyPublishedAt', data)
31 }
32
33} 22}
34 23
35function down (options) { 24function down (options) {
diff --git a/server/lib/activitypub/actor.ts b/server/lib/activitypub/actor.ts
index f7bf7c65a..a3f379b76 100644
--- a/server/lib/activitypub/actor.ts
+++ b/server/lib/activitypub/actor.ts
@@ -4,7 +4,7 @@ import * as url from 'url'
4import * as uuidv4 from 'uuid/v4' 4import * as uuidv4 from 'uuid/v4'
5import { ActivityPubActor, ActivityPubActorType } from '../../../shared/models/activitypub' 5import { ActivityPubActor, ActivityPubActorType } from '../../../shared/models/activitypub'
6import { ActivityPubAttributedTo } from '../../../shared/models/activitypub/objects' 6import { ActivityPubAttributedTo } from '../../../shared/models/activitypub/objects'
7import { checkUrlsSameHost, getAPUrl } from '../../helpers/activitypub' 7import { checkUrlsSameHost, getAPId } from '../../helpers/activitypub'
8import { isActorObjectValid, normalizeActor } from '../../helpers/custom-validators/activitypub/actor' 8import { isActorObjectValid, normalizeActor } from '../../helpers/custom-validators/activitypub/actor'
9import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 9import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
10import { retryTransactionWrapper, updateInstanceWithAnother } from '../../helpers/database-utils' 10import { retryTransactionWrapper, updateInstanceWithAnother } from '../../helpers/database-utils'
@@ -42,7 +42,7 @@ async function getOrCreateActorAndServerAndModel (
42 recurseIfNeeded = true, 42 recurseIfNeeded = true,
43 updateCollections = false 43 updateCollections = false
44) { 44) {
45 const actorUrl = getAPUrl(activityActor) 45 const actorUrl = getAPId(activityActor)
46 let created = false 46 let created = false
47 47
48 let actor = await fetchActorByUrl(actorUrl, fetchType) 48 let actor = await fetchActorByUrl(actorUrl, fetchType)
@@ -201,6 +201,69 @@ async function addFetchOutboxJob (actor: ActorModel) {
201 return JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload }) 201 return JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
202} 202}
203 203
204async function refreshActorIfNeeded (
205 actorArg: ActorModel,
206 fetchedType: ActorFetchByUrlType
207): Promise<{ actor: ActorModel, refreshed: boolean }> {
208 if (!actorArg.isOutdated()) return { actor: actorArg, refreshed: false }
209
210 // We need more attributes
211 const actor = fetchedType === 'all' ? actorArg : await ActorModel.loadByUrlAndPopulateAccountAndChannel(actorArg.url)
212
213 try {
214 let actorUrl: string
215 try {
216 actorUrl = await getUrlFromWebfinger(actor.preferredUsername + '@' + actor.getHost())
217 } catch (err) {
218 logger.warn('Cannot get actor URL from webfinger, keeping the old one.', err)
219 actorUrl = actor.url
220 }
221
222 const { result, statusCode } = await fetchRemoteActor(actorUrl)
223
224 if (statusCode === 404) {
225 logger.info('Deleting actor %s because there is a 404 in refresh actor.', actor.url)
226 actor.Account ? actor.Account.destroy() : actor.VideoChannel.destroy()
227 return { actor: undefined, refreshed: false }
228 }
229
230 if (result === undefined) {
231 logger.warn('Cannot fetch remote actor in refresh actor.')
232 return { actor, refreshed: false }
233 }
234
235 return sequelizeTypescript.transaction(async t => {
236 updateInstanceWithAnother(actor, result.actor)
237
238 if (result.avatarName !== undefined) {
239 await updateActorAvatarInstance(actor, result.avatarName, t)
240 }
241
242 // Force update
243 actor.setDataValue('updatedAt', new Date())
244 await actor.save({ transaction: t })
245
246 if (actor.Account) {
247 actor.Account.set('name', result.name)
248 actor.Account.set('description', result.summary)
249
250 await actor.Account.save({ transaction: t })
251 } else if (actor.VideoChannel) {
252 actor.VideoChannel.set('name', result.name)
253 actor.VideoChannel.set('description', result.summary)
254 actor.VideoChannel.set('support', result.support)
255
256 await actor.VideoChannel.save({ transaction: t })
257 }
258
259 return { refreshed: true, actor }
260 })
261 } catch (err) {
262 logger.warn('Cannot refresh actor.', { err })
263 return { actor, refreshed: false }
264 }
265}
266
204export { 267export {
205 getOrCreateActorAndServerAndModel, 268 getOrCreateActorAndServerAndModel,
206 buildActorInstance, 269 buildActorInstance,
@@ -208,6 +271,7 @@ export {
208 fetchActorTotalItems, 271 fetchActorTotalItems,
209 fetchAvatarIfExists, 272 fetchAvatarIfExists,
210 updateActorInstance, 273 updateActorInstance,
274 refreshActorIfNeeded,
211 updateActorAvatarInstance, 275 updateActorAvatarInstance,
212 addFetchOutboxJob 276 addFetchOutboxJob
213} 277}
@@ -291,12 +355,12 @@ async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode?: numbe
291 355
292 logger.info('Fetching remote actor %s.', actorUrl) 356 logger.info('Fetching remote actor %s.', actorUrl)
293 357
294 const requestResult = await doRequest(options) 358 const requestResult = await doRequest<ActivityPubActor>(options)
295 normalizeActor(requestResult.body) 359 normalizeActor(requestResult.body)
296 360
297 const actorJSON: ActivityPubActor = requestResult.body 361 const actorJSON = requestResult.body
298 if (isActorObjectValid(actorJSON) === false) { 362 if (isActorObjectValid(actorJSON) === false) {
299 logger.debug('Remote actor JSON is not valid.', { actorJSON: actorJSON }) 363 logger.debug('Remote actor JSON is not valid.', { actorJSON })
300 return { result: undefined, statusCode: requestResult.response.statusCode } 364 return { result: undefined, statusCode: requestResult.response.statusCode }
301 } 365 }
302 366
@@ -372,59 +436,3 @@ async function saveVideoChannel (actor: ActorModel, result: FetchRemoteActorResu
372 436
373 return videoChannelCreated 437 return videoChannelCreated
374} 438}
375
376async function refreshActorIfNeeded (
377 actorArg: ActorModel,
378 fetchedType: ActorFetchByUrlType
379): Promise<{ actor: ActorModel, refreshed: boolean }> {
380 if (!actorArg.isOutdated()) return { actor: actorArg, refreshed: false }
381
382 // We need more attributes
383 const actor = fetchedType === 'all' ? actorArg : await ActorModel.loadByUrlAndPopulateAccountAndChannel(actorArg.url)
384
385 try {
386 const actorUrl = await getUrlFromWebfinger(actor.preferredUsername + '@' + actor.getHost())
387 const { result, statusCode } = await fetchRemoteActor(actorUrl)
388
389 if (statusCode === 404) {
390 logger.info('Deleting actor %s because there is a 404 in refresh actor.', actor.url)
391 actor.Account ? actor.Account.destroy() : actor.VideoChannel.destroy()
392 return { actor: undefined, refreshed: false }
393 }
394
395 if (result === undefined) {
396 logger.warn('Cannot fetch remote actor in refresh actor.')
397 return { actor, refreshed: false }
398 }
399
400 return sequelizeTypescript.transaction(async t => {
401 updateInstanceWithAnother(actor, result.actor)
402
403 if (result.avatarName !== undefined) {
404 await updateActorAvatarInstance(actor, result.avatarName, t)
405 }
406
407 // Force update
408 actor.setDataValue('updatedAt', new Date())
409 await actor.save({ transaction: t })
410
411 if (actor.Account) {
412 actor.Account.set('name', result.name)
413 actor.Account.set('description', result.summary)
414
415 await actor.Account.save({ transaction: t })
416 } else if (actor.VideoChannel) {
417 actor.VideoChannel.set('name', result.name)
418 actor.VideoChannel.set('description', result.summary)
419 actor.VideoChannel.set('support', result.support)
420
421 await actor.VideoChannel.save({ transaction: t })
422 }
423
424 return { refreshed: true, actor }
425 })
426 } catch (err) {
427 logger.warn('Cannot refresh actor.', { err })
428 return { actor, refreshed: false }
429 }
430}
diff --git a/server/lib/activitypub/cache-file.ts b/server/lib/activitypub/cache-file.ts
index f6f068b45..9a40414bb 100644
--- a/server/lib/activitypub/cache-file.ts
+++ b/server/lib/activitypub/cache-file.ts
@@ -1,11 +1,28 @@
1import { CacheFileObject } from '../../../shared/index' 1import { ActivityPlaylistUrlObject, ActivityVideoUrlObject, CacheFileObject } from '../../../shared/index'
2import { VideoModel } from '../../models/video/video' 2import { VideoModel } from '../../models/video/video'
3import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' 3import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
4import { Transaction } from 'sequelize' 4import { Transaction } from 'sequelize'
5import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
5 6
6function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject, video: VideoModel, byActor: { id?: number }) { 7function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject, video: VideoModel, byActor: { id?: number }) {
7 const url = cacheFileObject.url
8 8
9 if (cacheFileObject.url.mediaType === 'application/x-mpegURL') {
10 const url = cacheFileObject.url
11
12 const playlist = video.VideoStreamingPlaylists.find(t => t.type === VideoStreamingPlaylistType.HLS)
13 if (!playlist) throw new Error('Cannot find HLS playlist of video ' + video.url)
14
15 return {
16 expiresOn: new Date(cacheFileObject.expires),
17 url: cacheFileObject.id,
18 fileUrl: url.href,
19 strategy: null,
20 videoStreamingPlaylistId: playlist.id,
21 actorId: byActor.id
22 }
23 }
24
25 const url = cacheFileObject.url
9 const videoFile = video.VideoFiles.find(f => { 26 const videoFile = video.VideoFiles.find(f => {
10 return f.resolution === url.height && f.fps === url.fps 27 return f.resolution === url.height && f.fps === url.fps
11 }) 28 })
@@ -15,7 +32,7 @@ function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject
15 return { 32 return {
16 expiresOn: new Date(cacheFileObject.expires), 33 expiresOn: new Date(cacheFileObject.expires),
17 url: cacheFileObject.id, 34 url: cacheFileObject.id,
18 fileUrl: cacheFileObject.url.href, 35 fileUrl: url.href,
19 strategy: null, 36 strategy: null,
20 videoFileId: videoFile.id, 37 videoFileId: videoFile.id,
21 actorId: byActor.id 38 actorId: byActor.id
diff --git a/server/lib/activitypub/process/process-accept.ts b/server/lib/activitypub/process/process-accept.ts
index 605705ad3..ebb275e34 100644
--- a/server/lib/activitypub/process/process-accept.ts
+++ b/server/lib/activitypub/process/process-accept.ts
@@ -2,7 +2,6 @@ import { ActivityAccept } from '../../../../shared/models/activitypub'
2import { ActorModel } from '../../../models/activitypub/actor' 2import { ActorModel } from '../../../models/activitypub/actor'
3import { ActorFollowModel } from '../../../models/activitypub/actor-follow' 3import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
4import { addFetchOutboxJob } from '../actor' 4import { addFetchOutboxJob } from '../actor'
5import { Notifier } from '../../notifier'
6 5
7async function processAcceptActivity (activity: ActivityAccept, targetActor: ActorModel, inboxActor?: ActorModel) { 6async function processAcceptActivity (activity: ActivityAccept, targetActor: ActorModel, inboxActor?: ActorModel) {
8 if (inboxActor === undefined) throw new Error('Need to accept on explicit inbox.') 7 if (inboxActor === undefined) throw new Error('Need to accept on explicit inbox.')
diff --git a/server/lib/activitypub/process/process-create.ts b/server/lib/activitypub/process/process-create.ts
index 2e04ee843..5f4d793a5 100644
--- a/server/lib/activitypub/process/process-create.ts
+++ b/server/lib/activitypub/process/process-create.ts
@@ -1,36 +1,44 @@
1import { ActivityCreate, CacheFileObject, VideoAbuseState, VideoTorrentObject } from '../../../../shared' 1import { ActivityCreate, CacheFileObject, VideoTorrentObject } from '../../../../shared'
2import { DislikeObject, VideoAbuseObject, ViewObject } from '../../../../shared/models/activitypub/objects'
3import { VideoCommentObject } from '../../../../shared/models/activitypub/objects/video-comment-object' 2import { VideoCommentObject } from '../../../../shared/models/activitypub/objects/video-comment-object'
4import { retryTransactionWrapper } from '../../../helpers/database-utils' 3import { retryTransactionWrapper } from '../../../helpers/database-utils'
5import { logger } from '../../../helpers/logger' 4import { logger } from '../../../helpers/logger'
6import { sequelizeTypescript } from '../../../initializers' 5import { sequelizeTypescript } from '../../../initializers'
7import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
8import { ActorModel } from '../../../models/activitypub/actor' 6import { ActorModel } from '../../../models/activitypub/actor'
9import { VideoAbuseModel } from '../../../models/video/video-abuse'
10import { addVideoComment, resolveThread } from '../video-comments' 7import { addVideoComment, resolveThread } from '../video-comments'
11import { getOrCreateVideoAndAccountAndChannel } from '../videos' 8import { getOrCreateVideoAndAccountAndChannel } from '../videos'
12import { forwardVideoRelatedActivity } from '../send/utils' 9import { forwardVideoRelatedActivity } from '../send/utils'
13import { Redis } from '../../redis'
14import { createOrUpdateCacheFile } from '../cache-file' 10import { createOrUpdateCacheFile } from '../cache-file'
15import { getVideoDislikeActivityPubUrl } from '../url'
16import { Notifier } from '../../notifier' 11import { Notifier } from '../../notifier'
12import { processViewActivity } from './process-view'
13import { processDislikeActivity } from './process-dislike'
14import { processFlagActivity } from './process-flag'
17 15
18async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) { 16async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) {
19 const activityObject = activity.object 17 const activityObject = activity.object
20 const activityType = activityObject.type 18 const activityType = activityObject.type
21 19
22 if (activityType === 'View') { 20 if (activityType === 'View') {
23 return processCreateView(byActor, activity) 21 return processViewActivity(activity, byActor)
24 } else if (activityType === 'Dislike') { 22 }
25 return retryTransactionWrapper(processCreateDislike, byActor, activity) 23
26 } else if (activityType === 'Video') { 24 if (activityType === 'Dislike') {
25 return retryTransactionWrapper(processDislikeActivity, activity, byActor)
26 }
27
28 if (activityType === 'Flag') {
29 return retryTransactionWrapper(processFlagActivity, activity, byActor)
30 }
31
32 if (activityType === 'Video') {
27 return processCreateVideo(activity) 33 return processCreateVideo(activity)
28 } else if (activityType === 'Flag') { 34 }
29 return retryTransactionWrapper(processCreateVideoAbuse, byActor, activityObject as VideoAbuseObject) 35
30 } else if (activityType === 'Note') { 36 if (activityType === 'Note') {
31 return retryTransactionWrapper(processCreateVideoComment, byActor, activity) 37 return retryTransactionWrapper(processCreateVideoComment, activity, byActor)
32 } else if (activityType === 'CacheFile') { 38 }
33 return retryTransactionWrapper(processCacheFile, byActor, activity) 39
40 if (activityType === 'CacheFile') {
41 return retryTransactionWrapper(processCacheFile, activity, byActor)
34 } 42 }
35 43
36 logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id }) 44 logger.warn('Unknown activity object type %s when creating activity.', activityType, { activity: activity.id })
@@ -55,56 +63,7 @@ async function processCreateVideo (activity: ActivityCreate) {
55 return video 63 return video
56} 64}
57 65
58async function processCreateDislike (byActor: ActorModel, activity: ActivityCreate) { 66async function processCacheFile (activity: ActivityCreate, byActor: ActorModel) {
59 const dislike = activity.object as DislikeObject
60 const byAccount = byActor.Account
61
62 if (!byAccount) throw new Error('Cannot create dislike with the non account actor ' + byActor.url)
63
64 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: dislike.object })
65
66 return sequelizeTypescript.transaction(async t => {
67 const rate = {
68 type: 'dislike' as 'dislike',
69 videoId: video.id,
70 accountId: byAccount.id
71 }
72
73 const [ , created ] = await AccountVideoRateModel.findOrCreate({
74 where: rate,
75 defaults: Object.assign({}, rate, { url: getVideoDislikeActivityPubUrl(byActor, video) }),
76 transaction: t
77 })
78 if (created === true) await video.increment('dislikes', { transaction: t })
79
80 if (video.isOwned() && created === true) {
81 // Don't resend the activity to the sender
82 const exceptions = [ byActor ]
83
84 await forwardVideoRelatedActivity(activity, t, exceptions, video)
85 }
86 })
87}
88
89async function processCreateView (byActor: ActorModel, activity: ActivityCreate) {
90 const view = activity.object as ViewObject
91
92 const options = {
93 videoObject: view.object,
94 fetchType: 'only-video' as 'only-video'
95 }
96 const { video } = await getOrCreateVideoAndAccountAndChannel(options)
97
98 await Redis.Instance.addVideoView(video.id)
99
100 if (video.isOwned()) {
101 // Don't resend the activity to the sender
102 const exceptions = [ byActor ]
103 await forwardVideoRelatedActivity(activity, undefined, exceptions, video)
104 }
105}
106
107async function processCacheFile (byActor: ActorModel, activity: ActivityCreate) {
108 const cacheFile = activity.object as CacheFileObject 67 const cacheFile = activity.object as CacheFileObject
109 68
110 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: cacheFile.object }) 69 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: cacheFile.object })
@@ -120,32 +79,7 @@ async function processCacheFile (byActor: ActorModel, activity: ActivityCreate)
120 } 79 }
121} 80}
122 81
123async function processCreateVideoAbuse (byActor: ActorModel, videoAbuseToCreateData: VideoAbuseObject) { 82async function processCreateVideoComment (activity: ActivityCreate, byActor: ActorModel) {
124 logger.debug('Reporting remote abuse for video %s.', videoAbuseToCreateData.object)
125
126 const account = byActor.Account
127 if (!account) throw new Error('Cannot create dislike with the non account actor ' + byActor.url)
128
129 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoAbuseToCreateData.object })
130
131 return sequelizeTypescript.transaction(async t => {
132 const videoAbuseData = {
133 reporterAccountId: account.id,
134 reason: videoAbuseToCreateData.content,
135 videoId: video.id,
136 state: VideoAbuseState.PENDING
137 }
138
139 const videoAbuseInstance = await VideoAbuseModel.create(videoAbuseData, { transaction: t })
140 videoAbuseInstance.Video = video
141
142 Notifier.Instance.notifyOnNewVideoAbuse(videoAbuseInstance)
143
144 logger.info('Remote abuse for video uuid %s created', videoAbuseToCreateData.object)
145 })
146}
147
148async function processCreateVideoComment (byActor: ActorModel, activity: ActivityCreate) {
149 const commentObject = activity.object as VideoCommentObject 83 const commentObject = activity.object as VideoCommentObject
150 const byAccount = byActor.Account 84 const byAccount = byActor.Account
151 85
diff --git a/server/lib/activitypub/process/process-dislike.ts b/server/lib/activitypub/process/process-dislike.ts
new file mode 100644
index 000000000..bfd69e07a
--- /dev/null
+++ b/server/lib/activitypub/process/process-dislike.ts
@@ -0,0 +1,52 @@
1import { ActivityCreate, ActivityDislike } from '../../../../shared'
2import { DislikeObject } from '../../../../shared/models/activitypub/objects'
3import { retryTransactionWrapper } from '../../../helpers/database-utils'
4import { sequelizeTypescript } from '../../../initializers'
5import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
6import { ActorModel } from '../../../models/activitypub/actor'
7import { getOrCreateVideoAndAccountAndChannel } from '../videos'
8import { forwardVideoRelatedActivity } from '../send/utils'
9import { getVideoDislikeActivityPubUrl } from '../url'
10
11async function processDislikeActivity (activity: ActivityCreate | ActivityDislike, byActor: ActorModel) {
12 return retryTransactionWrapper(processDislike, activity, byActor)
13}
14
15// ---------------------------------------------------------------------------
16
17export {
18 processDislikeActivity
19}
20
21// ---------------------------------------------------------------------------
22
23async function processDislike (activity: ActivityCreate | ActivityDislike, byActor: ActorModel) {
24 const dislikeObject = activity.type === 'Dislike' ? activity.object : (activity.object as DislikeObject).object
25 const byAccount = byActor.Account
26
27 if (!byAccount) throw new Error('Cannot create dislike with the non account actor ' + byActor.url)
28
29 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: dislikeObject })
30
31 return sequelizeTypescript.transaction(async t => {
32 const rate = {
33 type: 'dislike' as 'dislike',
34 videoId: video.id,
35 accountId: byAccount.id
36 }
37
38 const [ , created ] = await AccountVideoRateModel.findOrCreate({
39 where: rate,
40 defaults: Object.assign({}, rate, { url: getVideoDislikeActivityPubUrl(byActor, video) }),
41 transaction: t
42 })
43 if (created === true) await video.increment('dislikes', { transaction: t })
44
45 if (video.isOwned() && created === true) {
46 // Don't resend the activity to the sender
47 const exceptions = [ byActor ]
48
49 await forwardVideoRelatedActivity(activity, t, exceptions, video)
50 }
51 })
52}
diff --git a/server/lib/activitypub/process/process-flag.ts b/server/lib/activitypub/process/process-flag.ts
new file mode 100644
index 000000000..79ce6fb41
--- /dev/null
+++ b/server/lib/activitypub/process/process-flag.ts
@@ -0,0 +1,49 @@
1import { ActivityCreate, ActivityFlag, VideoAbuseState } from '../../../../shared'
2import { VideoAbuseObject } from '../../../../shared/models/activitypub/objects'
3import { retryTransactionWrapper } from '../../../helpers/database-utils'
4import { logger } from '../../../helpers/logger'
5import { sequelizeTypescript } from '../../../initializers'
6import { ActorModel } from '../../../models/activitypub/actor'
7import { VideoAbuseModel } from '../../../models/video/video-abuse'
8import { getOrCreateVideoAndAccountAndChannel } from '../videos'
9import { Notifier } from '../../notifier'
10import { getAPId } from '../../../helpers/activitypub'
11
12async function processFlagActivity (activity: ActivityCreate | ActivityFlag, byActor: ActorModel) {
13 return retryTransactionWrapper(processCreateVideoAbuse, activity, byActor)
14}
15
16// ---------------------------------------------------------------------------
17
18export {
19 processFlagActivity
20}
21
22// ---------------------------------------------------------------------------
23
24async function processCreateVideoAbuse (activity: ActivityCreate | ActivityFlag, byActor: ActorModel) {
25 const flag = activity.type === 'Flag' ? activity : (activity.object as VideoAbuseObject)
26
27 logger.debug('Reporting remote abuse for video %s.', getAPId(flag.object))
28
29 const account = byActor.Account
30 if (!account) throw new Error('Cannot create dislike with the non account actor ' + byActor.url)
31
32 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: flag.object })
33
34 return sequelizeTypescript.transaction(async t => {
35 const videoAbuseData = {
36 reporterAccountId: account.id,
37 reason: flag.content,
38 videoId: video.id,
39 state: VideoAbuseState.PENDING
40 }
41
42 const videoAbuseInstance = await VideoAbuseModel.create(videoAbuseData, { transaction: t })
43 videoAbuseInstance.Video = video
44
45 Notifier.Instance.notifyOnNewVideoAbuse(videoAbuseInstance)
46
47 logger.info('Remote abuse for video uuid %s created', flag.object)
48 })
49}
diff --git a/server/lib/activitypub/process/process-follow.ts b/server/lib/activitypub/process/process-follow.ts
index a67892440..0cd537187 100644
--- a/server/lib/activitypub/process/process-follow.ts
+++ b/server/lib/activitypub/process/process-follow.ts
@@ -6,9 +6,10 @@ import { ActorModel } from '../../../models/activitypub/actor'
6import { ActorFollowModel } from '../../../models/activitypub/actor-follow' 6import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
7import { sendAccept } from '../send' 7import { sendAccept } from '../send'
8import { Notifier } from '../../notifier' 8import { Notifier } from '../../notifier'
9import { getAPId } from '../../../helpers/activitypub'
9 10
10async function processFollowActivity (activity: ActivityFollow, byActor: ActorModel) { 11async function processFollowActivity (activity: ActivityFollow, byActor: ActorModel) {
11 const activityObject = activity.object 12 const activityObject = getAPId(activity.object)
12 13
13 return retryTransactionWrapper(processFollow, byActor, activityObject) 14 return retryTransactionWrapper(processFollow, byActor, activityObject)
14} 15}
diff --git a/server/lib/activitypub/process/process-like.ts b/server/lib/activitypub/process/process-like.ts
index e8e97eece..2a04167d7 100644
--- a/server/lib/activitypub/process/process-like.ts
+++ b/server/lib/activitypub/process/process-like.ts
@@ -6,6 +6,7 @@ import { ActorModel } from '../../../models/activitypub/actor'
6import { forwardVideoRelatedActivity } from '../send/utils' 6import { forwardVideoRelatedActivity } from '../send/utils'
7import { getOrCreateVideoAndAccountAndChannel } from '../videos' 7import { getOrCreateVideoAndAccountAndChannel } from '../videos'
8import { getVideoLikeActivityPubUrl } from '../url' 8import { getVideoLikeActivityPubUrl } from '../url'
9import { getAPId } from '../../../helpers/activitypub'
9 10
10async function processLikeActivity (activity: ActivityLike, byActor: ActorModel) { 11async function processLikeActivity (activity: ActivityLike, byActor: ActorModel) {
11 return retryTransactionWrapper(processLikeVideo, byActor, activity) 12 return retryTransactionWrapper(processLikeVideo, byActor, activity)
@@ -20,7 +21,7 @@ export {
20// --------------------------------------------------------------------------- 21// ---------------------------------------------------------------------------
21 22
22async function processLikeVideo (byActor: ActorModel, activity: ActivityLike) { 23async function processLikeVideo (byActor: ActorModel, activity: ActivityLike) {
23 const videoUrl = activity.object 24 const videoUrl = getAPId(activity.object)
24 25
25 const byAccount = byActor.Account 26 const byAccount = byActor.Account
26 if (!byAccount) throw new Error('Cannot create like with the non account actor ' + byActor.url) 27 if (!byAccount) throw new Error('Cannot create like with the non account actor ' + byActor.url)
diff --git a/server/lib/activitypub/process/process-undo.ts b/server/lib/activitypub/process/process-undo.ts
index 438a013b6..ed0177a67 100644
--- a/server/lib/activitypub/process/process-undo.ts
+++ b/server/lib/activitypub/process/process-undo.ts
@@ -26,6 +26,10 @@ async function processUndoActivity (activity: ActivityUndo, byActor: ActorModel)
26 } 26 }
27 } 27 }
28 28
29 if (activityToUndo.type === 'Dislike') {
30 return retryTransactionWrapper(processUndoDislike, byActor, activity)
31 }
32
29 if (activityToUndo.type === 'Follow') { 33 if (activityToUndo.type === 'Follow') {
30 return retryTransactionWrapper(processUndoFollow, byActor, activityToUndo) 34 return retryTransactionWrapper(processUndoFollow, byActor, activityToUndo)
31 } 35 }
@@ -72,7 +76,9 @@ async function processUndoLike (byActor: ActorModel, activity: ActivityUndo) {
72} 76}
73 77
74async function processUndoDislike (byActor: ActorModel, activity: ActivityUndo) { 78async function processUndoDislike (byActor: ActorModel, activity: ActivityUndo) {
75 const dislike = activity.object.object as DislikeObject 79 const dislike = activity.object.type === 'Dislike'
80 ? activity.object
81 : activity.object.object as DislikeObject
76 82
77 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: dislike.object }) 83 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: dislike.object })
78 84
diff --git a/server/lib/activitypub/process/process-view.ts b/server/lib/activitypub/process/process-view.ts
new file mode 100644
index 000000000..8f66d3630
--- /dev/null
+++ b/server/lib/activitypub/process/process-view.ts
@@ -0,0 +1,35 @@
1import { ActorModel } from '../../../models/activitypub/actor'
2import { getOrCreateVideoAndAccountAndChannel } from '../videos'
3import { forwardVideoRelatedActivity } from '../send/utils'
4import { Redis } from '../../redis'
5import { ActivityCreate, ActivityView, ViewObject } from '../../../../shared/models/activitypub'
6
7async function processViewActivity (activity: ActivityView | ActivityCreate, byActor: ActorModel) {
8 return processCreateView(activity, byActor)
9}
10
11// ---------------------------------------------------------------------------
12
13export {
14 processViewActivity
15}
16
17// ---------------------------------------------------------------------------
18
19async function processCreateView (activity: ActivityView | ActivityCreate, byActor: ActorModel) {
20 const videoObject = activity.type === 'View' ? activity.object : (activity.object as ViewObject).object
21
22 const options = {
23 videoObject: videoObject,
24 fetchType: 'only-video' as 'only-video'
25 }
26 const { video } = await getOrCreateVideoAndAccountAndChannel(options)
27
28 await Redis.Instance.addVideoView(video.id)
29
30 if (video.isOwned()) {
31 // Don't resend the activity to the sender
32 const exceptions = [ byActor ]
33 await forwardVideoRelatedActivity(activity, undefined, exceptions, video)
34 }
35}
diff --git a/server/lib/activitypub/process/process.ts b/server/lib/activitypub/process/process.ts
index bcc5cac7a..9dd241402 100644
--- a/server/lib/activitypub/process/process.ts
+++ b/server/lib/activitypub/process/process.ts
@@ -1,5 +1,5 @@
1import { Activity, ActivityType } from '../../../../shared/models/activitypub' 1import { Activity, ActivityType } from '../../../../shared/models/activitypub'
2import { checkUrlsSameHost, getAPUrl } from '../../../helpers/activitypub' 2import { checkUrlsSameHost, getAPId } from '../../../helpers/activitypub'
3import { logger } from '../../../helpers/logger' 3import { logger } from '../../../helpers/logger'
4import { ActorModel } from '../../../models/activitypub/actor' 4import { ActorModel } from '../../../models/activitypub/actor'
5import { processAcceptActivity } from './process-accept' 5import { processAcceptActivity } from './process-accept'
@@ -12,6 +12,9 @@ import { processRejectActivity } from './process-reject'
12import { processUndoActivity } from './process-undo' 12import { processUndoActivity } from './process-undo'
13import { processUpdateActivity } from './process-update' 13import { processUpdateActivity } from './process-update'
14import { getOrCreateActorAndServerAndModel } from '../actor' 14import { getOrCreateActorAndServerAndModel } from '../actor'
15import { processDislikeActivity } from './process-dislike'
16import { processFlagActivity } from './process-flag'
17import { processViewActivity } from './process-view'
15 18
16const processActivity: { [ P in ActivityType ]: (activity: Activity, byActor: ActorModel, inboxActor?: ActorModel) => Promise<any> } = { 19const processActivity: { [ P in ActivityType ]: (activity: Activity, byActor: ActorModel, inboxActor?: ActorModel) => Promise<any> } = {
17 Create: processCreateActivity, 20 Create: processCreateActivity,
@@ -22,7 +25,10 @@ const processActivity: { [ P in ActivityType ]: (activity: Activity, byActor: Ac
22 Reject: processRejectActivity, 25 Reject: processRejectActivity,
23 Announce: processAnnounceActivity, 26 Announce: processAnnounceActivity,
24 Undo: processUndoActivity, 27 Undo: processUndoActivity,
25 Like: processLikeActivity 28 Like: processLikeActivity,
29 Dislike: processDislikeActivity,
30 Flag: processFlagActivity,
31 View: processViewActivity
26} 32}
27 33
28async function processActivities ( 34async function processActivities (
@@ -35,12 +41,12 @@ async function processActivities (
35 const actorsCache: { [ url: string ]: ActorModel } = {} 41 const actorsCache: { [ url: string ]: ActorModel } = {}
36 42
37 for (const activity of activities) { 43 for (const activity of activities) {
38 if (!options.signatureActor && [ 'Create', 'Announce', 'Like' ].indexOf(activity.type) === -1) { 44 if (!options.signatureActor && [ 'Create', 'Announce', 'Like' ].includes(activity.type) === false) {
39 logger.error('Cannot process activity %s (type: %s) without the actor signature.', activity.id, activity.type) 45 logger.error('Cannot process activity %s (type: %s) without the actor signature.', activity.id, activity.type)
40 continue 46 continue
41 } 47 }
42 48
43 const actorUrl = getAPUrl(activity.actor) 49 const actorUrl = getAPId(activity.actor)
44 50
45 // When we fetch remote data, we don't have signature 51 // When we fetch remote data, we don't have signature
46 if (options.signatureActor && actorUrl !== options.signatureActor.url) { 52 if (options.signatureActor && actorUrl !== options.signatureActor.url) {
diff --git a/server/lib/activitypub/send/send-create.ts b/server/lib/activitypub/send/send-create.ts
index e3fca0a17..ef20e404c 100644
--- a/server/lib/activitypub/send/send-create.ts
+++ b/server/lib/activitypub/send/send-create.ts
@@ -3,9 +3,7 @@ import { ActivityAudience, ActivityCreate } from '../../../../shared/models/acti
3import { VideoPrivacy } from '../../../../shared/models/videos' 3import { VideoPrivacy } from '../../../../shared/models/videos'
4import { ActorModel } from '../../../models/activitypub/actor' 4import { ActorModel } from '../../../models/activitypub/actor'
5import { VideoModel } from '../../../models/video/video' 5import { VideoModel } from '../../../models/video/video'
6import { VideoAbuseModel } from '../../../models/video/video-abuse'
7import { VideoCommentModel } from '../../../models/video/video-comment' 6import { VideoCommentModel } from '../../../models/video/video-comment'
8import { getVideoAbuseActivityPubUrl, getVideoDislikeActivityPubUrl, getVideoViewActivityPubUrl } from '../url'
9import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils' 7import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils'
10import { audiencify, getActorsInvolvedInVideo, getAudience, getAudienceFromFollowersOf, getVideoCommentAudience } from '../audience' 8import { audiencify, getActorsInvolvedInVideo, getAudience, getAudienceFromFollowersOf, getVideoCommentAudience } from '../audience'
11import { logger } from '../../../helpers/logger' 9import { logger } from '../../../helpers/logger'
@@ -25,31 +23,14 @@ async function sendCreateVideo (video: VideoModel, t: Transaction) {
25 return broadcastToFollowers(createActivity, byActor, [ byActor ], t) 23 return broadcastToFollowers(createActivity, byActor, [ byActor ], t)
26} 24}
27 25
28async function sendVideoAbuse (byActor: ActorModel, videoAbuse: VideoAbuseModel, video: VideoModel) { 26async function sendCreateCacheFile (byActor: ActorModel, video: VideoModel, fileRedundancy: VideoRedundancyModel) {
29 if (!video.VideoChannel.Account.Actor.serverId) return // Local
30
31 const url = getVideoAbuseActivityPubUrl(videoAbuse)
32
33 logger.info('Creating job to send video abuse %s.', url)
34
35 // Custom audience, we only send the abuse to the origin instance
36 const audience = { to: [ video.VideoChannel.Account.Actor.url ], cc: [] }
37 const createActivity = buildCreateActivity(url, byActor, videoAbuse.toActivityPubObject(), audience)
38
39 return unicastTo(createActivity, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
40}
41
42async function sendCreateCacheFile (byActor: ActorModel, fileRedundancy: VideoRedundancyModel) {
43 logger.info('Creating job to send file cache of %s.', fileRedundancy.url) 27 logger.info('Creating job to send file cache of %s.', fileRedundancy.url)
44 28
45 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(fileRedundancy.VideoFile.Video.id)
46 const redundancyObject = fileRedundancy.toActivityPubObject()
47
48 return sendVideoRelatedCreateActivity({ 29 return sendVideoRelatedCreateActivity({
49 byActor, 30 byActor,
50 video, 31 video,
51 url: fileRedundancy.url, 32 url: fileRedundancy.url,
52 object: redundancyObject 33 object: fileRedundancy.toActivityPubObject()
53 }) 34 })
54} 35}
55 36
@@ -91,37 +72,6 @@ async function sendCreateVideoComment (comment: VideoCommentModel, t: Transactio
91 return unicastTo(createActivity, byActor, comment.Video.VideoChannel.Account.Actor.sharedInboxUrl) 72 return unicastTo(createActivity, byActor, comment.Video.VideoChannel.Account.Actor.sharedInboxUrl)
92} 73}
93 74
94async function sendCreateView (byActor: ActorModel, video: VideoModel, t: Transaction) {
95 logger.info('Creating job to send view of %s.', video.url)
96
97 const url = getVideoViewActivityPubUrl(byActor, video)
98 const viewActivity = buildViewActivity(url, byActor, video)
99
100 return sendVideoRelatedCreateActivity({
101 // Use the server actor to send the view
102 byActor,
103 video,
104 url,
105 object: viewActivity,
106 transaction: t
107 })
108}
109
110async function sendCreateDislike (byActor: ActorModel, video: VideoModel, t: Transaction) {
111 logger.info('Creating job to dislike %s.', video.url)
112
113 const url = getVideoDislikeActivityPubUrl(byActor, video)
114 const dislikeActivity = buildDislikeActivity(url, byActor, video)
115
116 return sendVideoRelatedCreateActivity({
117 byActor,
118 video,
119 url,
120 object: dislikeActivity,
121 transaction: t
122 })
123}
124
125function buildCreateActivity (url: string, byActor: ActorModel, object: any, audience?: ActivityAudience): ActivityCreate { 75function buildCreateActivity (url: string, byActor: ActorModel, object: any, audience?: ActivityAudience): ActivityCreate {
126 if (!audience) audience = getAudience(byActor) 76 if (!audience) audience = getAudience(byActor)
127 77
@@ -136,33 +86,11 @@ function buildCreateActivity (url: string, byActor: ActorModel, object: any, aud
136 ) 86 )
137} 87}
138 88
139function buildDislikeActivity (url: string, byActor: ActorModel, video: VideoModel) {
140 return {
141 id: url,
142 type: 'Dislike',
143 actor: byActor.url,
144 object: video.url
145 }
146}
147
148function buildViewActivity (url: string, byActor: ActorModel, video: VideoModel) {
149 return {
150 id: url,
151 type: 'View',
152 actor: byActor.url,
153 object: video.url
154 }
155}
156
157// --------------------------------------------------------------------------- 89// ---------------------------------------------------------------------------
158 90
159export { 91export {
160 sendCreateVideo, 92 sendCreateVideo,
161 sendVideoAbuse,
162 buildCreateActivity, 93 buildCreateActivity,
163 sendCreateView,
164 sendCreateDislike,
165 buildDislikeActivity,
166 sendCreateVideoComment, 94 sendCreateVideoComment,
167 sendCreateCacheFile 95 sendCreateCacheFile
168} 96}
diff --git a/server/lib/activitypub/send/send-dislike.ts b/server/lib/activitypub/send/send-dislike.ts
new file mode 100644
index 000000000..a88436f2c
--- /dev/null
+++ b/server/lib/activitypub/send/send-dislike.ts
@@ -0,0 +1,41 @@
1import { Transaction } from 'sequelize'
2import { ActorModel } from '../../../models/activitypub/actor'
3import { VideoModel } from '../../../models/video/video'
4import { getVideoDislikeActivityPubUrl } from '../url'
5import { logger } from '../../../helpers/logger'
6import { ActivityAudience, ActivityDislike } from '../../../../shared/models/activitypub'
7import { sendVideoRelatedActivity } from './utils'
8import { audiencify, getAudience } from '../audience'
9
10async function sendDislike (byActor: ActorModel, video: VideoModel, t: Transaction) {
11 logger.info('Creating job to dislike %s.', video.url)
12
13 const activityBuilder = (audience: ActivityAudience) => {
14 const url = getVideoDislikeActivityPubUrl(byActor, video)
15
16 return buildDislikeActivity(url, byActor, video, audience)
17 }
18
19 return sendVideoRelatedActivity(activityBuilder, { byActor, video, transaction: t })
20}
21
22function buildDislikeActivity (url: string, byActor: ActorModel, video: VideoModel, audience?: ActivityAudience): ActivityDislike {
23 if (!audience) audience = getAudience(byActor)
24
25 return audiencify(
26 {
27 id: url,
28 type: 'Dislike' as 'Dislike',
29 actor: byActor.url,
30 object: video.url
31 },
32 audience
33 )
34}
35
36// ---------------------------------------------------------------------------
37
38export {
39 sendDislike,
40 buildDislikeActivity
41}
diff --git a/server/lib/activitypub/send/send-flag.ts b/server/lib/activitypub/send/send-flag.ts
new file mode 100644
index 000000000..96a7311b9
--- /dev/null
+++ b/server/lib/activitypub/send/send-flag.ts
@@ -0,0 +1,39 @@
1import { ActorModel } from '../../../models/activitypub/actor'
2import { VideoModel } from '../../../models/video/video'
3import { VideoAbuseModel } from '../../../models/video/video-abuse'
4import { getVideoAbuseActivityPubUrl } from '../url'
5import { unicastTo } from './utils'
6import { logger } from '../../../helpers/logger'
7import { ActivityAudience, ActivityFlag } from '../../../../shared/models/activitypub'
8import { audiencify, getAudience } from '../audience'
9
10async function sendVideoAbuse (byActor: ActorModel, videoAbuse: VideoAbuseModel, video: VideoModel) {
11 if (!video.VideoChannel.Account.Actor.serverId) return // Local user
12
13 const url = getVideoAbuseActivityPubUrl(videoAbuse)
14
15 logger.info('Creating job to send video abuse %s.', url)
16
17 // Custom audience, we only send the abuse to the origin instance
18 const audience = { to: [ video.VideoChannel.Account.Actor.url ], cc: [] }
19 const flagActivity = buildFlagActivity(url, byActor, videoAbuse, audience)
20
21 return unicastTo(flagActivity, byActor, video.VideoChannel.Account.Actor.sharedInboxUrl)
22}
23
24function buildFlagActivity (url: string, byActor: ActorModel, videoAbuse: VideoAbuseModel, audience: ActivityAudience): ActivityFlag {
25 if (!audience) audience = getAudience(byActor)
26
27 const activity = Object.assign(
28 { id: url, actor: byActor.url },
29 videoAbuse.toActivityPubObject()
30 )
31
32 return audiencify(activity, audience)
33}
34
35// ---------------------------------------------------------------------------
36
37export {
38 sendVideoAbuse
39}
diff --git a/server/lib/activitypub/send/send-undo.ts b/server/lib/activitypub/send/send-undo.ts
index bf1b6e117..ecbf605d6 100644
--- a/server/lib/activitypub/send/send-undo.ts
+++ b/server/lib/activitypub/send/send-undo.ts
@@ -2,7 +2,7 @@ import { Transaction } from 'sequelize'
2import { 2import {
3 ActivityAnnounce, 3 ActivityAnnounce,
4 ActivityAudience, 4 ActivityAudience,
5 ActivityCreate, 5 ActivityCreate, ActivityDislike,
6 ActivityFollow, 6 ActivityFollow,
7 ActivityLike, 7 ActivityLike,
8 ActivityUndo 8 ActivityUndo
@@ -13,13 +13,14 @@ import { VideoModel } from '../../../models/video/video'
13import { getActorFollowActivityPubUrl, getUndoActivityPubUrl, getVideoDislikeActivityPubUrl, getVideoLikeActivityPubUrl } from '../url' 13import { getActorFollowActivityPubUrl, getUndoActivityPubUrl, getVideoDislikeActivityPubUrl, getVideoLikeActivityPubUrl } from '../url'
14import { broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils' 14import { broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils'
15import { audiencify, getAudience } from '../audience' 15import { audiencify, getAudience } from '../audience'
16import { buildCreateActivity, buildDislikeActivity } from './send-create' 16import { buildCreateActivity } from './send-create'
17import { buildFollowActivity } from './send-follow' 17import { buildFollowActivity } from './send-follow'
18import { buildLikeActivity } from './send-like' 18import { buildLikeActivity } from './send-like'
19import { VideoShareModel } from '../../../models/video/video-share' 19import { VideoShareModel } from '../../../models/video/video-share'
20import { buildAnnounceWithVideoAudience } from './send-announce' 20import { buildAnnounceWithVideoAudience } from './send-announce'
21import { logger } from '../../../helpers/logger' 21import { logger } from '../../../helpers/logger'
22import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy' 22import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
23import { buildDislikeActivity } from './send-dislike'
23 24
24async function sendUndoFollow (actorFollow: ActorFollowModel, t: Transaction) { 25async function sendUndoFollow (actorFollow: ActorFollowModel, t: Transaction) {
25 const me = actorFollow.ActorFollower 26 const me = actorFollow.ActorFollower
@@ -65,15 +66,15 @@ async function sendUndoDislike (byActor: ActorModel, video: VideoModel, t: Trans
65 66
66 const dislikeUrl = getVideoDislikeActivityPubUrl(byActor, video) 67 const dislikeUrl = getVideoDislikeActivityPubUrl(byActor, video)
67 const dislikeActivity = buildDislikeActivity(dislikeUrl, byActor, video) 68 const dislikeActivity = buildDislikeActivity(dislikeUrl, byActor, video)
68 const createDislikeActivity = buildCreateActivity(dislikeUrl, byActor, dislikeActivity)
69 69
70 return sendUndoVideoRelatedActivity({ byActor, video, url: dislikeUrl, activity: createDislikeActivity, transaction: t }) 70 return sendUndoVideoRelatedActivity({ byActor, video, url: dislikeUrl, activity: dislikeActivity, transaction: t })
71} 71}
72 72
73async function sendUndoCacheFile (byActor: ActorModel, redundancyModel: VideoRedundancyModel, t: Transaction) { 73async function sendUndoCacheFile (byActor: ActorModel, redundancyModel: VideoRedundancyModel, t: Transaction) {
74 logger.info('Creating job to undo cache file %s.', redundancyModel.url) 74 logger.info('Creating job to undo cache file %s.', redundancyModel.url)
75 75
76 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(redundancyModel.VideoFile.Video.id) 76 const videoId = redundancyModel.getVideo().id
77 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
77 const createActivity = buildCreateActivity(redundancyModel.url, byActor, redundancyModel.toActivityPubObject()) 78 const createActivity = buildCreateActivity(redundancyModel.url, byActor, redundancyModel.toActivityPubObject())
78 79
79 return sendUndoVideoRelatedActivity({ byActor, video, url: redundancyModel.url, activity: createActivity, transaction: t }) 80 return sendUndoVideoRelatedActivity({ byActor, video, url: redundancyModel.url, activity: createActivity, transaction: t })
@@ -94,7 +95,7 @@ export {
94function undoActivityData ( 95function undoActivityData (
95 url: string, 96 url: string,
96 byActor: ActorModel, 97 byActor: ActorModel,
97 object: ActivityFollow | ActivityLike | ActivityCreate | ActivityAnnounce, 98 object: ActivityFollow | ActivityLike | ActivityDislike | ActivityCreate | ActivityAnnounce,
98 audience?: ActivityAudience 99 audience?: ActivityAudience
99): ActivityUndo { 100): ActivityUndo {
100 if (!audience) audience = getAudience(byActor) 101 if (!audience) audience = getAudience(byActor)
@@ -114,7 +115,7 @@ async function sendUndoVideoRelatedActivity (options: {
114 byActor: ActorModel, 115 byActor: ActorModel,
115 video: VideoModel, 116 video: VideoModel,
116 url: string, 117 url: string,
117 activity: ActivityFollow | ActivityLike | ActivityCreate | ActivityAnnounce, 118 activity: ActivityFollow | ActivityLike | ActivityDislike | ActivityCreate | ActivityAnnounce,
118 transaction: Transaction 119 transaction: Transaction
119}) { 120}) {
120 const activityBuilder = (audience: ActivityAudience) => { 121 const activityBuilder = (audience: ActivityAudience) => {
diff --git a/server/lib/activitypub/send/send-update.ts b/server/lib/activitypub/send/send-update.ts
index a68f03edf..839f66470 100644
--- a/server/lib/activitypub/send/send-update.ts
+++ b/server/lib/activitypub/send/send-update.ts
@@ -61,7 +61,7 @@ async function sendUpdateActor (accountOrChannel: AccountModel | VideoChannelMod
61async function sendUpdateCacheFile (byActor: ActorModel, redundancyModel: VideoRedundancyModel) { 61async function sendUpdateCacheFile (byActor: ActorModel, redundancyModel: VideoRedundancyModel) {
62 logger.info('Creating job to update cache file %s.', redundancyModel.url) 62 logger.info('Creating job to update cache file %s.', redundancyModel.url)
63 63
64 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(redundancyModel.VideoFile.Video.id) 64 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(redundancyModel.getVideo().id)
65 65
66 const activityBuilder = (audience: ActivityAudience) => { 66 const activityBuilder = (audience: ActivityAudience) => {
67 const redundancyObject = redundancyModel.toActivityPubObject() 67 const redundancyObject = redundancyModel.toActivityPubObject()
diff --git a/server/lib/activitypub/send/send-view.ts b/server/lib/activitypub/send/send-view.ts
new file mode 100644
index 000000000..8ad126be0
--- /dev/null
+++ b/server/lib/activitypub/send/send-view.ts
@@ -0,0 +1,40 @@
1import { Transaction } from 'sequelize'
2import { ActivityAudience, ActivityView } from '../../../../shared/models/activitypub'
3import { ActorModel } from '../../../models/activitypub/actor'
4import { VideoModel } from '../../../models/video/video'
5import { getVideoLikeActivityPubUrl } from '../url'
6import { sendVideoRelatedActivity } from './utils'
7import { audiencify, getAudience } from '../audience'
8import { logger } from '../../../helpers/logger'
9
10async function sendView (byActor: ActorModel, video: VideoModel, t: Transaction) {
11 logger.info('Creating job to send view of %s.', video.url)
12
13 const activityBuilder = (audience: ActivityAudience) => {
14 const url = getVideoLikeActivityPubUrl(byActor, video)
15
16 return buildViewActivity(url, byActor, video, audience)
17 }
18
19 return sendVideoRelatedActivity(activityBuilder, { byActor, video, transaction: t })
20}
21
22function buildViewActivity (url: string, byActor: ActorModel, video: VideoModel, audience?: ActivityAudience): ActivityView {
23 if (!audience) audience = getAudience(byActor)
24
25 return audiencify(
26 {
27 id: url,
28 type: 'View' as 'View',
29 actor: byActor.url,
30 object: video.url
31 },
32 audience
33 )
34}
35
36// ---------------------------------------------------------------------------
37
38export {
39 sendView
40}
diff --git a/server/lib/activitypub/share.ts b/server/lib/activitypub/share.ts
index 5dcba778c..1767df0ae 100644
--- a/server/lib/activitypub/share.ts
+++ b/server/lib/activitypub/share.ts
@@ -11,7 +11,7 @@ import { doRequest } from '../../helpers/requests'
11import { getOrCreateActorAndServerAndModel } from './actor' 11import { getOrCreateActorAndServerAndModel } from './actor'
12import { logger } from '../../helpers/logger' 12import { logger } from '../../helpers/logger'
13import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers' 13import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers'
14import { checkUrlsSameHost, getAPUrl } from '../../helpers/activitypub' 14import { checkUrlsSameHost, getAPId } from '../../helpers/activitypub'
15 15
16async function shareVideoByServerAndChannel (video: VideoModel, t: Transaction) { 16async function shareVideoByServerAndChannel (video: VideoModel, t: Transaction) {
17 if (video.privacy === VideoPrivacy.PRIVATE) return undefined 17 if (video.privacy === VideoPrivacy.PRIVATE) return undefined
@@ -41,7 +41,7 @@ async function addVideoShares (shareUrls: string[], instance: VideoModel) {
41 }) 41 })
42 if (!body || !body.actor) throw new Error('Body or body actor is invalid') 42 if (!body || !body.actor) throw new Error('Body or body actor is invalid')
43 43
44 const actorUrl = getAPUrl(body.actor) 44 const actorUrl = getAPId(body.actor)
45 if (checkUrlsSameHost(shareUrl, actorUrl) !== true) { 45 if (checkUrlsSameHost(shareUrl, actorUrl) !== true) {
46 throw new Error(`Actor url ${actorUrl} has not the same host than the share url ${shareUrl}`) 46 throw new Error(`Actor url ${actorUrl} has not the same host than the share url ${shareUrl}`)
47 } 47 }
@@ -78,7 +78,7 @@ async function shareByServer (video: VideoModel, t: Transaction) {
78 const serverActor = await getServerActor() 78 const serverActor = await getServerActor()
79 79
80 const serverShareUrl = getVideoAnnounceActivityPubUrl(serverActor, video) 80 const serverShareUrl = getVideoAnnounceActivityPubUrl(serverActor, video)
81 return VideoShareModel.findOrCreate({ 81 const [ serverShare ] = await VideoShareModel.findOrCreate({
82 defaults: { 82 defaults: {
83 actorId: serverActor.id, 83 actorId: serverActor.id,
84 videoId: video.id, 84 videoId: video.id,
@@ -88,16 +88,14 @@ async function shareByServer (video: VideoModel, t: Transaction) {
88 url: serverShareUrl 88 url: serverShareUrl
89 }, 89 },
90 transaction: t 90 transaction: t
91 }).then(([ serverShare, created ]) => {
92 if (created) return sendVideoAnnounce(serverActor, serverShare, video, t)
93
94 return undefined
95 }) 91 })
92
93 return sendVideoAnnounce(serverActor, serverShare, video, t)
96} 94}
97 95
98async function shareByVideoChannel (video: VideoModel, t: Transaction) { 96async function shareByVideoChannel (video: VideoModel, t: Transaction) {
99 const videoChannelShareUrl = getVideoAnnounceActivityPubUrl(video.VideoChannel.Actor, video) 97 const videoChannelShareUrl = getVideoAnnounceActivityPubUrl(video.VideoChannel.Actor, video)
100 return VideoShareModel.findOrCreate({ 98 const [ videoChannelShare ] = await VideoShareModel.findOrCreate({
101 defaults: { 99 defaults: {
102 actorId: video.VideoChannel.actorId, 100 actorId: video.VideoChannel.actorId,
103 videoId: video.id, 101 videoId: video.id,
@@ -107,11 +105,9 @@ async function shareByVideoChannel (video: VideoModel, t: Transaction) {
107 url: videoChannelShareUrl 105 url: videoChannelShareUrl
108 }, 106 },
109 transaction: t 107 transaction: t
110 }).then(([ videoChannelShare, created ]) => {
111 if (created) return sendVideoAnnounce(video.VideoChannel.Actor, videoChannelShare, video, t)
112
113 return undefined
114 }) 108 })
109
110 return sendVideoAnnounce(video.VideoChannel.Actor, videoChannelShare, video, t)
115} 111}
116 112
117async function undoShareByVideoChannel (video: VideoModel, oldVideoChannel: VideoChannelModel, t: Transaction) { 113async function undoShareByVideoChannel (video: VideoModel, oldVideoChannel: VideoChannelModel, t: Transaction) {
diff --git a/server/lib/activitypub/url.ts b/server/lib/activitypub/url.ts
index 38f15448c..4229fe094 100644
--- a/server/lib/activitypub/url.ts
+++ b/server/lib/activitypub/url.ts
@@ -5,6 +5,8 @@ import { VideoModel } from '../../models/video/video'
5import { VideoAbuseModel } from '../../models/video/video-abuse' 5import { VideoAbuseModel } from '../../models/video/video-abuse'
6import { VideoCommentModel } from '../../models/video/video-comment' 6import { VideoCommentModel } from '../../models/video/video-comment'
7import { VideoFileModel } from '../../models/video/video-file' 7import { VideoFileModel } from '../../models/video/video-file'
8import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model'
9import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
8 10
9function getVideoActivityPubUrl (video: VideoModel) { 11function getVideoActivityPubUrl (video: VideoModel) {
10 return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid 12 return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid
@@ -16,6 +18,10 @@ function getVideoCacheFileActivityPubUrl (videoFile: VideoFileModel) {
16 return `${CONFIG.WEBSERVER.URL}/redundancy/videos/${videoFile.Video.uuid}/${videoFile.resolution}${suffixFPS}` 18 return `${CONFIG.WEBSERVER.URL}/redundancy/videos/${videoFile.Video.uuid}/${videoFile.resolution}${suffixFPS}`
17} 19}
18 20
21function getVideoCacheStreamingPlaylistActivityPubUrl (video: VideoModel, playlist: VideoStreamingPlaylistModel) {
22 return `${CONFIG.WEBSERVER.URL}/redundancy/video-playlists/${playlist.getStringType()}/${video.uuid}`
23}
24
19function getVideoCommentActivityPubUrl (video: VideoModel, videoComment: VideoCommentModel) { 25function getVideoCommentActivityPubUrl (video: VideoModel, videoComment: VideoCommentModel) {
20 return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid + '/comments/' + videoComment.id 26 return CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid + '/comments/' + videoComment.id
21} 27}
@@ -92,6 +98,7 @@ function getUndoActivityPubUrl (originalUrl: string) {
92 98
93export { 99export {
94 getVideoActivityPubUrl, 100 getVideoActivityPubUrl,
101 getVideoCacheStreamingPlaylistActivityPubUrl,
95 getVideoChannelActivityPubUrl, 102 getVideoChannelActivityPubUrl,
96 getAccountActivityPubUrl, 103 getAccountActivityPubUrl,
97 getVideoAbuseActivityPubUrl, 104 getVideoAbuseActivityPubUrl,
diff --git a/server/lib/activitypub/video-rates.ts b/server/lib/activitypub/video-rates.ts
index 2cce67f0c..7aac79118 100644
--- a/server/lib/activitypub/video-rates.ts
+++ b/server/lib/activitypub/video-rates.ts
@@ -1,7 +1,7 @@
1import { Transaction } from 'sequelize' 1import { Transaction } from 'sequelize'
2import { AccountModel } from '../../models/account/account' 2import { AccountModel } from '../../models/account/account'
3import { VideoModel } from '../../models/video/video' 3import { VideoModel } from '../../models/video/video'
4import { sendCreateDislike, sendLike, sendUndoDislike, sendUndoLike } from './send' 4import { sendLike, sendUndoDislike, sendUndoLike } from './send'
5import { VideoRateType } from '../../../shared/models/videos' 5import { VideoRateType } from '../../../shared/models/videos'
6import * as Bluebird from 'bluebird' 6import * as Bluebird from 'bluebird'
7import { getOrCreateActorAndServerAndModel } from './actor' 7import { getOrCreateActorAndServerAndModel } from './actor'
@@ -9,9 +9,10 @@ import { AccountVideoRateModel } from '../../models/account/account-video-rate'
9import { logger } from '../../helpers/logger' 9import { logger } from '../../helpers/logger'
10import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers' 10import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers'
11import { doRequest } from '../../helpers/requests' 11import { doRequest } from '../../helpers/requests'
12import { checkUrlsSameHost, getAPUrl } from '../../helpers/activitypub' 12import { checkUrlsSameHost, getAPId } from '../../helpers/activitypub'
13import { ActorModel } from '../../models/activitypub/actor' 13import { ActorModel } from '../../models/activitypub/actor'
14import { getVideoDislikeActivityPubUrl, getVideoLikeActivityPubUrl } from './url' 14import { getVideoDislikeActivityPubUrl, getVideoLikeActivityPubUrl } from './url'
15import { sendDislike } from './send/send-dislike'
15 16
16async function createRates (ratesUrl: string[], video: VideoModel, rate: VideoRateType) { 17async function createRates (ratesUrl: string[], video: VideoModel, rate: VideoRateType) {
17 let rateCounts = 0 18 let rateCounts = 0
@@ -26,7 +27,7 @@ async function createRates (ratesUrl: string[], video: VideoModel, rate: VideoRa
26 }) 27 })
27 if (!body || !body.actor) throw new Error('Body or body actor is invalid') 28 if (!body || !body.actor) throw new Error('Body or body actor is invalid')
28 29
29 const actorUrl = getAPUrl(body.actor) 30 const actorUrl = getAPId(body.actor)
30 if (checkUrlsSameHost(actorUrl, rateUrl) !== true) { 31 if (checkUrlsSameHost(actorUrl, rateUrl) !== true) {
31 throw new Error(`Rate url ${rateUrl} has not the same host than actor url ${actorUrl}`) 32 throw new Error(`Rate url ${rateUrl} has not the same host than actor url ${actorUrl}`)
32 } 33 }
@@ -82,7 +83,7 @@ async function sendVideoRateChange (account: AccountModel,
82 // Like 83 // Like
83 if (likes > 0) await sendLike(actor, video, t) 84 if (likes > 0) await sendLike(actor, video, t)
84 // Dislike 85 // Dislike
85 if (dislikes > 0) await sendCreateDislike(actor, video, t) 86 if (dislikes > 0) await sendDislike(actor, video, t)
86} 87}
87 88
88function getRateUrl (rateType: VideoRateType, actor: ActorModel, video: VideoModel) { 89function getRateUrl (rateType: VideoRateType, actor: ActorModel, video: VideoModel) {
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts
index 893768769..710929aac 100644
--- a/server/lib/activitypub/videos.ts
+++ b/server/lib/activitypub/videos.ts
@@ -2,7 +2,14 @@ import * as Bluebird from 'bluebird'
2import * as sequelize from 'sequelize' 2import * 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 { ActivityIconObject, ActivityUrlObject, ActivityVideoUrlObject, VideoState } from '../../../shared/index' 5import {
6 ActivityIconObject,
7 ActivityPlaylistSegmentHashesObject,
8 ActivityPlaylistUrlObject,
9 ActivityUrlObject,
10 ActivityVideoUrlObject,
11 VideoState
12} from '../../../shared/index'
6import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' 13import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
7import { VideoPrivacy } from '../../../shared/models/videos' 14import { VideoPrivacy } from '../../../shared/models/videos'
8import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos' 15import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos'
@@ -28,8 +35,11 @@ import { createRates } from './video-rates'
28import { addVideoShares, shareVideoByServerAndChannel } from './share' 35import { addVideoShares, shareVideoByServerAndChannel } from './share'
29import { AccountModel } from '../../models/account/account' 36import { AccountModel } from '../../models/account/account'
30import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video' 37import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
31import { checkUrlsSameHost, getAPUrl } from '../../helpers/activitypub' 38import { checkUrlsSameHost, getAPId } from '../../helpers/activitypub'
32import { Notifier } from '../notifier' 39import { Notifier } from '../notifier'
40import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
41import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
42import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
33 43
34async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) { 44async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) {
35 // If the video is not private and published, we federate it 45 // If the video is not private and published, we federate it
@@ -155,7 +165,7 @@ async function syncVideoExternalAttributes (video: VideoModel, fetchedVideo: Vid
155} 165}
156 166
157async function getOrCreateVideoAndAccountAndChannel (options: { 167async function getOrCreateVideoAndAccountAndChannel (options: {
158 videoObject: VideoTorrentObject | string, 168 videoObject: { id: string } | string,
159 syncParam?: SyncParam, 169 syncParam?: SyncParam,
160 fetchType?: VideoFetchByUrlType, 170 fetchType?: VideoFetchByUrlType,
161 allowRefresh?: boolean // true by default 171 allowRefresh?: boolean // true by default
@@ -166,7 +176,7 @@ async function getOrCreateVideoAndAccountAndChannel (options: {
166 const allowRefresh = options.allowRefresh !== false 176 const allowRefresh = options.allowRefresh !== false
167 177
168 // Get video url 178 // Get video url
169 const videoUrl = getAPUrl(options.videoObject) 179 const videoUrl = getAPId(options.videoObject)
170 180
171 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType) 181 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
172 if (videoFromDatabase) { 182 if (videoFromDatabase) {
@@ -179,7 +189,7 @@ async function getOrCreateVideoAndAccountAndChannel (options: {
179 } 189 }
180 190
181 if (syncParam.refreshVideo === true) videoFromDatabase = await refreshVideoIfNeeded(refreshOptions) 191 if (syncParam.refreshVideo === true) videoFromDatabase = await refreshVideoIfNeeded(refreshOptions)
182 else await JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', videoUrl: videoFromDatabase.url } }) 192 else await JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', url: videoFromDatabase.url } })
183 } 193 }
184 194
185 return { video: videoFromDatabase, created: false } 195 return { video: videoFromDatabase, created: false }
@@ -233,6 +243,7 @@ async function updateVideoFromAP (options: {
233 options.video.set('support', videoData.support) 243 options.video.set('support', videoData.support)
234 options.video.set('nsfw', videoData.nsfw) 244 options.video.set('nsfw', videoData.nsfw)
235 options.video.set('commentsEnabled', videoData.commentsEnabled) 245 options.video.set('commentsEnabled', videoData.commentsEnabled)
246 options.video.set('downloadEnabled', videoData.downloadEnabled)
236 options.video.set('waitTranscoding', videoData.waitTranscoding) 247 options.video.set('waitTranscoding', videoData.waitTranscoding)
237 options.video.set('state', videoData.state) 248 options.video.set('state', videoData.state)
238 options.video.set('duration', videoData.duration) 249 options.video.set('duration', videoData.duration)
@@ -264,6 +275,25 @@ async function updateVideoFromAP (options: {
264 } 275 }
265 276
266 { 277 {
278 const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(options.video, options.videoObject)
279 const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
280
281 // Remove video files that do not exist anymore
282 const destroyTasks = options.video.VideoStreamingPlaylists
283 .filter(f => !newStreamingPlaylists.find(newPlaylist => newPlaylist.hasSameUniqueKeysThan(f)))
284 .map(f => f.destroy(sequelizeOptions))
285 await Promise.all(destroyTasks)
286
287 // Update or add other one
288 const upsertTasks = streamingPlaylistAttributes.map(a => {
289 return VideoStreamingPlaylistModel.upsert<VideoStreamingPlaylistModel>(a, { returning: true, transaction: t })
290 .then(([ streamingPlaylist ]) => streamingPlaylist)
291 })
292
293 options.video.VideoStreamingPlaylists = await Promise.all(upsertTasks)
294 }
295
296 {
267 // Update Tags 297 // Update Tags
268 const tags = options.videoObject.tag.map(tag => tag.name) 298 const tags = options.videoObject.tag.map(tag => tag.name)
269 const tagInstances = await TagModel.findOrCreateTags(tags, t) 299 const tagInstances = await TagModel.findOrCreateTags(tags, t)
@@ -367,13 +397,25 @@ export {
367 397
368// --------------------------------------------------------------------------- 398// ---------------------------------------------------------------------------
369 399
370function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject { 400function isAPVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
371 const mimeTypes = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT) 401 const mimeTypes = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT)
372 402
373 const urlMediaType = url.mediaType || url.mimeType 403 const urlMediaType = url.mediaType || url.mimeType
374 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/') 404 return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
375} 405}
376 406
407function isAPStreamingPlaylistUrlObject (url: ActivityUrlObject): url is ActivityPlaylistUrlObject {
408 const urlMediaType = url.mediaType || url.mimeType
409
410 return urlMediaType === 'application/x-mpegURL'
411}
412
413function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject {
414 const urlMediaType = tag.mediaType || tag.mimeType
415
416 return tag.name === 'sha256' && tag.type === 'Link' && urlMediaType === 'application/json'
417}
418
377async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) { 419async function createVideo (videoObject: VideoTorrentObject, channelActor: ActorModel, waitThumbnail = false) {
378 logger.debug('Adding remote video %s.', videoObject.id) 420 logger.debug('Adding remote video %s.', videoObject.id)
379 421
@@ -394,8 +436,14 @@ async function createVideo (videoObject: VideoTorrentObject, channelActor: Actor
394 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t })) 436 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
395 await Promise.all(videoFilePromises) 437 await Promise.all(videoFilePromises)
396 438
439 const videoStreamingPlaylists = streamingPlaylistActivityUrlToDBAttributes(videoCreated, videoObject)
440 const playlistPromises = videoStreamingPlaylists.map(p => VideoStreamingPlaylistModel.create(p, { transaction: t }))
441 await Promise.all(playlistPromises)
442
397 // Process tags 443 // Process tags
398 const tags = videoObject.tag.map(t => t.name) 444 const tags = videoObject.tag
445 .filter(t => t.type === 'Hashtag')
446 .map(t => t.name)
399 const tagInstances = await TagModel.findOrCreateTags(tags, t) 447 const tagInstances = await TagModel.findOrCreateTags(tags, t)
400 await videoCreated.$set('Tags', tagInstances, sequelizeOptions) 448 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
401 449
@@ -456,6 +504,7 @@ async function videoActivityObjectToDBAttributes (
456 support, 504 support,
457 nsfw: videoObject.sensitive, 505 nsfw: videoObject.sensitive,
458 commentsEnabled: videoObject.commentsEnabled, 506 commentsEnabled: videoObject.commentsEnabled,
507 downloadEnabled: videoObject.downloadEnabled,
459 waitTranscoding: videoObject.waitTranscoding, 508 waitTranscoding: videoObject.waitTranscoding,
460 state: videoObject.state, 509 state: videoObject.state,
461 channelId: videoChannel.id, 510 channelId: videoChannel.id,
@@ -473,13 +522,13 @@ async function videoActivityObjectToDBAttributes (
473} 522}
474 523
475function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) { 524function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
476 const fileUrls = videoObject.url.filter(u => isActivityVideoUrlObject(u)) as ActivityVideoUrlObject[] 525 const fileUrls = videoObject.url.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
477 526
478 if (fileUrls.length === 0) { 527 if (fileUrls.length === 0) {
479 throw new Error('Cannot find video files for ' + video.url) 528 throw new Error('Cannot find video files for ' + video.url)
480 } 529 }
481 530
482 const attributes: VideoFileModel[] = [] 531 const attributes: FilteredModelAttributes<VideoFileModel>[] = []
483 for (const fileUrl of fileUrls) { 532 for (const fileUrl of fileUrls) {
484 // Fetch associated magnet uri 533 // Fetch associated magnet uri
485 const magnet = videoObject.url.find(u => { 534 const magnet = videoObject.url.find(u => {
@@ -502,7 +551,45 @@ function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: Vid
502 size: fileUrl.size, 551 size: fileUrl.size,
503 videoId: video.id, 552 videoId: video.id,
504 fps: fileUrl.fps || -1 553 fps: fileUrl.fps || -1
505 } as VideoFileModel 554 }
555
556 attributes.push(attribute)
557 }
558
559 return attributes
560}
561
562function streamingPlaylistActivityUrlToDBAttributes (video: VideoModel, videoObject: VideoTorrentObject) {
563 const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[]
564 if (playlistUrls.length === 0) return []
565
566 const attributes: FilteredModelAttributes<VideoStreamingPlaylistModel>[] = []
567 for (const playlistUrlObject of playlistUrls) {
568 const p2pMediaLoaderInfohashes = playlistUrlObject.tag
569 .filter(t => t.type === 'Infohash')
570 .map(t => t.name)
571 if (p2pMediaLoaderInfohashes.length === 0) {
572 logger.warn('No infohashes found in AP playlist object.', { playlistUrl: playlistUrlObject })
573 continue
574 }
575
576 const segmentsSha256UrlObject = playlistUrlObject.tag
577 .find(t => {
578 return isAPPlaylistSegmentHashesUrlObject(t)
579 }) as ActivityPlaylistSegmentHashesObject
580 if (!segmentsSha256UrlObject) {
581 logger.warn('No segment sha256 URL found in AP playlist object.', { playlistUrl: playlistUrlObject })
582 continue
583 }
584
585 const attribute = {
586 type: VideoStreamingPlaylistType.HLS,
587 playlistUrl: playlistUrlObject.href,
588 segmentsSha256Url: segmentsSha256UrlObject.href,
589 p2pMediaLoaderInfohashes,
590 videoId: video.id
591 }
592
506 attributes.push(attribute) 593 attributes.push(attribute)
507 } 594 }
508 595
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts
index 1875ec1fc..b2c376e20 100644
--- a/server/lib/client-html.ts
+++ b/server/lib/client-html.ts
@@ -1,7 +1,7 @@
1import * as express from 'express' 1import * as express from 'express'
2import * as Bluebird from 'bluebird' 2import * as Bluebird from 'bluebird'
3import { buildFileLocale, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '../../shared/models/i18n/i18n' 3import { buildFileLocale, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '../../shared/models/i18n/i18n'
4import { CONFIG, CUSTOM_HTML_TAG_COMMENTS, EMBED_SIZE, STATIC_PATHS } from '../initializers' 4import { CONFIG, CUSTOM_HTML_TAG_COMMENTS, EMBED_SIZE } from '../initializers'
5import { join } from 'path' 5import { join } from 'path'
6import { escapeHTML } from '../helpers/core-utils' 6import { escapeHTML } from '../helpers/core-utils'
7import { VideoModel } from '../models/video/video' 7import { VideoModel } from '../models/video/video'
@@ -187,8 +187,8 @@ export class ClientHtml {
187 // Schema.org 187 // Schema.org
188 tagsString += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>` 188 tagsString += `<script type="application/ld+json">${JSON.stringify(schemaTags)}</script>`
189 189
190 // SEO 190 // SEO, use origin video url so Google does not index remote videos
191 tagsString += `<link rel="canonical" href="${videoUrl}" />` 191 tagsString += `<link rel="canonical" href="${video.url}" />`
192 192
193 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.OPENGRAPH_AND_OEMBED, tagsString) 193 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.OPENGRAPH_AND_OEMBED, tagsString)
194 } 194 }
diff --git a/server/lib/emailer.ts b/server/lib/emailer.ts
index f384a254e..672414cc0 100644
--- a/server/lib/emailer.ts
+++ b/server/lib/emailer.ts
@@ -296,9 +296,9 @@ class Emailer {
296 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) 296 return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
297 } 297 }
298 298
299 addForgetPasswordEmailJob (to: string, resetPasswordUrl: string) { 299 addPasswordResetEmailJob (to: string, resetPasswordUrl: string) {
300 const text = `Hi dear user,\n\n` + 300 const text = `Hi dear user,\n\n` +
301 `It seems you forgot your password on ${CONFIG.WEBSERVER.HOST}! ` + 301 `A reset password procedure for your account ${to} has been requested on ${CONFIG.WEBSERVER.HOST} ` +
302 `Please follow this link to reset it: ${resetPasswordUrl}\n\n` + 302 `Please follow this link to reset it: ${resetPasswordUrl}\n\n` +
303 `If you are not the person who initiated this request, please ignore this email.\n\n` + 303 `If you are not the person who initiated this request, please ignore this email.\n\n` +
304 `Cheers,\n` + 304 `Cheers,\n` +
diff --git a/server/lib/hls.ts b/server/lib/hls.ts
new file mode 100644
index 000000000..3575981f4
--- /dev/null
+++ b/server/lib/hls.ts
@@ -0,0 +1,164 @@
1import { VideoModel } from '../models/video/video'
2import { basename, join, dirname } from 'path'
3import { CONFIG, HLS_PLAYLIST_DIRECTORY } from '../initializers'
4import { close, ensureDir, move, open, outputJSON, pathExists, read, readFile, remove, writeFile } from 'fs-extra'
5import { getVideoFileSize } from '../helpers/ffmpeg-utils'
6import { sha256 } from '../helpers/core-utils'
7import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
8import { logger } from '../helpers/logger'
9import { doRequest, doRequestAndSaveToFile } from '../helpers/requests'
10import { generateRandomString } from '../helpers/utils'
11import { flatten, uniq } from 'lodash'
12
13async function updateMasterHLSPlaylist (video: VideoModel) {
14 const directory = join(HLS_PLAYLIST_DIRECTORY, video.uuid)
15 const masterPlaylists: string[] = [ '#EXTM3U', '#EXT-X-VERSION:3' ]
16 const masterPlaylistPath = join(directory, VideoStreamingPlaylistModel.getMasterHlsPlaylistFilename())
17
18 for (const file of video.VideoFiles) {
19 // If we did not generated a playlist for this resolution, skip
20 const filePlaylistPath = join(directory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(file.resolution))
21 if (await pathExists(filePlaylistPath) === false) continue
22
23 const videoFilePath = video.getVideoFilePath(file)
24
25 const size = await getVideoFileSize(videoFilePath)
26
27 const bandwidth = 'BANDWIDTH=' + video.getBandwidthBits(file)
28 const resolution = `RESOLUTION=${size.width}x${size.height}`
29
30 let line = `#EXT-X-STREAM-INF:${bandwidth},${resolution}`
31 if (file.fps) line += ',FRAME-RATE=' + file.fps
32
33 masterPlaylists.push(line)
34 masterPlaylists.push(VideoStreamingPlaylistModel.getHlsPlaylistFilename(file.resolution))
35 }
36
37 await writeFile(masterPlaylistPath, masterPlaylists.join('\n') + '\n')
38}
39
40async function updateSha256Segments (video: VideoModel) {
41 const json: { [filename: string]: { [range: string]: string } } = {}
42
43 const playlistDirectory = join(HLS_PLAYLIST_DIRECTORY, video.uuid)
44
45 // For all the resolutions available for this video
46 for (const file of video.VideoFiles) {
47 const rangeHashes: { [range: string]: string } = {}
48
49 const videoPath = join(playlistDirectory, VideoStreamingPlaylistModel.getHlsVideoName(video.uuid, file.resolution))
50 const playlistPath = join(playlistDirectory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(file.resolution))
51
52 // Maybe the playlist is not generated for this resolution yet
53 if (!await pathExists(playlistPath)) continue
54
55 const playlistContent = await readFile(playlistPath)
56 const ranges = getRangesFromPlaylist(playlistContent.toString())
57
58 const fd = await open(videoPath, 'r')
59 for (const range of ranges) {
60 const buf = Buffer.alloc(range.length)
61 await read(fd, buf, 0, range.length, range.offset)
62
63 rangeHashes[`${range.offset}-${range.offset + range.length - 1}`] = sha256(buf)
64 }
65 await close(fd)
66
67 const videoFilename = VideoStreamingPlaylistModel.getHlsVideoName(video.uuid, file.resolution)
68 json[videoFilename] = rangeHashes
69 }
70
71 const outputPath = join(playlistDirectory, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename())
72 await outputJSON(outputPath, json)
73}
74
75function getRangesFromPlaylist (playlistContent: string) {
76 const ranges: { offset: number, length: number }[] = []
77 const lines = playlistContent.split('\n')
78 const regex = /^#EXT-X-BYTERANGE:(\d+)@(\d+)$/
79
80 for (const line of lines) {
81 const captured = regex.exec(line)
82
83 if (captured) {
84 ranges.push({ length: parseInt(captured[1], 10), offset: parseInt(captured[2], 10) })
85 }
86 }
87
88 return ranges
89}
90
91function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, timeout: number) {
92 let timer
93
94 logger.info('Importing HLS playlist %s', playlistUrl)
95
96 return new Promise<string>(async (res, rej) => {
97 const tmpDirectory = join(CONFIG.STORAGE.TMP_DIR, await generateRandomString(10))
98
99 await ensureDir(tmpDirectory)
100
101 timer = setTimeout(() => {
102 deleteTmpDirectory(tmpDirectory)
103
104 return rej(new Error('HLS download timeout.'))
105 }, timeout)
106
107 try {
108 // Fetch master playlist
109 const subPlaylistUrls = await fetchUniqUrls(playlistUrl)
110
111 const subRequests = subPlaylistUrls.map(u => fetchUniqUrls(u))
112 const fileUrls = uniq(flatten(await Promise.all(subRequests)))
113
114 logger.debug('Will download %d HLS files.', fileUrls.length, { fileUrls })
115
116 for (const fileUrl of fileUrls) {
117 const destPath = join(tmpDirectory, basename(fileUrl))
118
119 await doRequestAndSaveToFile({ uri: fileUrl }, destPath)
120 }
121
122 clearTimeout(timer)
123
124 await move(tmpDirectory, destinationDir, { overwrite: true })
125
126 return res()
127 } catch (err) {
128 deleteTmpDirectory(tmpDirectory)
129
130 return rej(err)
131 }
132 })
133
134 function deleteTmpDirectory (directory: string) {
135 remove(directory)
136 .catch(err => logger.error('Cannot delete path on HLS download error.', { err }))
137 }
138
139 async function fetchUniqUrls (playlistUrl: string) {
140 const { body } = await doRequest<string>({ uri: playlistUrl })
141
142 if (!body) return []
143
144 const urls = body.split('\n')
145 .filter(line => line.endsWith('.m3u8') || line.endsWith('.mp4'))
146 .map(url => {
147 if (url.startsWith('http://') || url.startsWith('https://')) return url
148
149 return `${dirname(playlistUrl)}/${url}`
150 })
151
152 return uniq(urls)
153 }
154}
155
156// ---------------------------------------------------------------------------
157
158export {
159 updateMasterHLSPlaylist,
160 updateSha256Segments,
161 downloadPlaylistSegments
162}
163
164// ---------------------------------------------------------------------------
diff --git a/server/lib/job-queue/handlers/activitypub-refresher.ts b/server/lib/job-queue/handlers/activitypub-refresher.ts
index 671b0f487..454b975fe 100644
--- a/server/lib/job-queue/handlers/activitypub-refresher.ts
+++ b/server/lib/job-queue/handlers/activitypub-refresher.ts
@@ -1,30 +1,33 @@
1import * as Bull from 'bull' 1import * as Bull from 'bull'
2import { logger } from '../../../helpers/logger' 2import { logger } from '../../../helpers/logger'
3import { fetchVideoByUrl } from '../../../helpers/video' 3import { fetchVideoByUrl } from '../../../helpers/video'
4import { refreshVideoIfNeeded } from '../../activitypub' 4import { refreshVideoIfNeeded, refreshActorIfNeeded } from '../../activitypub'
5import { ActorModel } from '../../../models/activitypub/actor'
5 6
6export type RefreshPayload = { 7export type RefreshPayload = {
7 videoUrl: string 8 type: 'video' | 'actor'
8 type: 'video' 9 url: string
9} 10}
10 11
11async function refreshAPObject (job: Bull.Job) { 12async function refreshAPObject (job: Bull.Job) {
12 const payload = job.data as RefreshPayload 13 const payload = job.data as RefreshPayload
13 14
14 logger.info('Processing AP refresher in job %d for video %s.', job.id, payload.videoUrl) 15 logger.info('Processing AP refresher in job %d for %s.', job.id, payload.url)
15 16
16 if (payload.type === 'video') return refreshAPVideo(payload.videoUrl) 17 if (payload.type === 'video') return refreshVideo(payload.url)
18 if (payload.type === 'actor') return refreshActor(payload.url)
17} 19}
18 20
19// --------------------------------------------------------------------------- 21// ---------------------------------------------------------------------------
20 22
21export { 23export {
24 refreshActor,
22 refreshAPObject 25 refreshAPObject
23} 26}
24 27
25// --------------------------------------------------------------------------- 28// ---------------------------------------------------------------------------
26 29
27async function refreshAPVideo (videoUrl: string) { 30async function refreshVideo (videoUrl: string) {
28 const fetchType = 'all' as 'all' 31 const fetchType = 'all' as 'all'
29 const syncParam = { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true } 32 const syncParam = { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true }
30 33
@@ -39,3 +42,13 @@ async function refreshAPVideo (videoUrl: string) {
39 await refreshVideoIfNeeded(refreshOptions) 42 await refreshVideoIfNeeded(refreshOptions)
40 } 43 }
41} 44}
45
46async function refreshActor (actorUrl: string) {
47 const fetchType = 'all' as 'all'
48 const actor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(actorUrl)
49
50 if (actor) {
51 await refreshActorIfNeeded(actor, fetchType)
52 }
53
54}
diff --git a/server/lib/job-queue/handlers/video-file.ts b/server/lib/job-queue/handlers/video-file.ts
index 593e43cc5..04983155c 100644
--- a/server/lib/job-queue/handlers/video-file.ts
+++ b/server/lib/job-queue/handlers/video-file.ts
@@ -5,17 +5,18 @@ import { VideoModel } from '../../../models/video/video'
5import { JobQueue } from '../job-queue' 5import { JobQueue } from '../job-queue'
6import { federateVideoIfNeeded } from '../../activitypub' 6import { federateVideoIfNeeded } from '../../activitypub'
7import { retryTransactionWrapper } from '../../../helpers/database-utils' 7import { retryTransactionWrapper } from '../../../helpers/database-utils'
8import { sequelizeTypescript } from '../../../initializers' 8import { sequelizeTypescript, CONFIG } from '../../../initializers'
9import * as Bluebird from 'bluebird' 9import * as Bluebird from 'bluebird'
10import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils' 10import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils'
11import { importVideoFile, optimizeVideofile, transcodeOriginalVideofile } from '../../video-transcoding' 11import { generateHlsPlaylist, importVideoFile, optimizeVideofile, transcodeOriginalVideofile } from '../../video-transcoding'
12import { Notifier } from '../../notifier' 12import { Notifier } from '../../notifier'
13 13
14export type VideoFilePayload = { 14export type VideoFilePayload = {
15 videoUUID: string 15 videoUUID: string
16 isNewVideo?: boolean
17 resolution?: VideoResolution 16 resolution?: VideoResolution
17 isNewVideo?: boolean
18 isPortraitMode?: boolean 18 isPortraitMode?: boolean
19 generateHlsPlaylist?: boolean
19} 20}
20 21
21export type VideoFileImportPayload = { 22export type VideoFileImportPayload = {
@@ -51,21 +52,38 @@ async function processVideoFile (job: Bull.Job) {
51 return undefined 52 return undefined
52 } 53 }
53 54
54 // Transcoding in other resolution 55 if (payload.generateHlsPlaylist) {
55 if (payload.resolution) { 56 await generateHlsPlaylist(video, payload.resolution, payload.isPortraitMode || false)
57
58 await retryTransactionWrapper(onHlsPlaylistGenerationSuccess, video)
59 } else if (payload.resolution) { // Transcoding in other resolution
56 await transcodeOriginalVideofile(video, payload.resolution, payload.isPortraitMode || false) 60 await transcodeOriginalVideofile(video, payload.resolution, payload.isPortraitMode || false)
57 61
58 await retryTransactionWrapper(onVideoFileTranscoderOrImportSuccess, video) 62 await retryTransactionWrapper(onVideoFileTranscoderOrImportSuccess, video, payload)
59 } else { 63 } else {
60 await optimizeVideofile(video) 64 await optimizeVideofile(video)
61 65
62 await retryTransactionWrapper(onVideoFileOptimizerSuccess, video, payload.isNewVideo) 66 await retryTransactionWrapper(onVideoFileOptimizerSuccess, video, payload)
63 } 67 }
64 68
65 return video 69 return video
66} 70}
67 71
68async function onVideoFileTranscoderOrImportSuccess (video: VideoModel) { 72async function onHlsPlaylistGenerationSuccess (video: VideoModel) {
73 if (video === undefined) return undefined
74
75 await sequelizeTypescript.transaction(async t => {
76 // Maybe the video changed in database, refresh it
77 let videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
78 // Video does not exist anymore
79 if (!videoDatabase) return undefined
80
81 // If the video was not published, we consider it is a new one for other instances
82 await federateVideoIfNeeded(videoDatabase, false, t)
83 })
84}
85
86async function onVideoFileTranscoderOrImportSuccess (video: VideoModel, payload?: VideoFilePayload) {
69 if (video === undefined) return undefined 87 if (video === undefined) return undefined
70 88
71 const { videoDatabase, videoPublished } = await sequelizeTypescript.transaction(async t => { 89 const { videoDatabase, videoPublished } = await sequelizeTypescript.transaction(async t => {
@@ -91,13 +109,16 @@ async function onVideoFileTranscoderOrImportSuccess (video: VideoModel) {
91 return { videoDatabase, videoPublished } 109 return { videoDatabase, videoPublished }
92 }) 110 })
93 111
94 if (videoPublished) { 112 // don't notify prior to scheduled video update
113 if (videoPublished && !videoDatabase.ScheduleVideoUpdate) {
95 Notifier.Instance.notifyOnNewVideo(videoDatabase) 114 Notifier.Instance.notifyOnNewVideo(videoDatabase)
96 Notifier.Instance.notifyOnPendingVideoPublished(videoDatabase) 115 Notifier.Instance.notifyOnPendingVideoPublished(videoDatabase)
97 } 116 }
117
118 await createHlsJobIfEnabled(payload)
98} 119}
99 120
100async function onVideoFileOptimizerSuccess (videoArg: VideoModel, isNewVideo: boolean) { 121async function onVideoFileOptimizerSuccess (videoArg: VideoModel, payload: VideoFilePayload) {
101 if (videoArg === undefined) return undefined 122 if (videoArg === undefined) return undefined
102 123
103 // Outside the transaction (IO on disk) 124 // Outside the transaction (IO on disk)
@@ -144,13 +165,18 @@ async function onVideoFileOptimizerSuccess (videoArg: VideoModel, isNewVideo: bo
144 logger.info('No transcoding jobs created for video %s (no resolutions).', videoDatabase.uuid, { privacy: videoDatabase.privacy }) 165 logger.info('No transcoding jobs created for video %s (no resolutions).', videoDatabase.uuid, { privacy: videoDatabase.privacy })
145 } 166 }
146 167
147 await federateVideoIfNeeded(videoDatabase, isNewVideo, t) 168 await federateVideoIfNeeded(videoDatabase, payload.isNewVideo, t)
148 169
149 return { videoDatabase, videoPublished } 170 return { videoDatabase, videoPublished }
150 }) 171 })
151 172
152 if (isNewVideo) Notifier.Instance.notifyOnNewVideo(videoDatabase) 173 // don't notify prior to scheduled video update
153 if (videoPublished) Notifier.Instance.notifyOnPendingVideoPublished(videoDatabase) 174 if (!videoDatabase.ScheduleVideoUpdate) {
175 if (payload.isNewVideo) Notifier.Instance.notifyOnNewVideo(videoDatabase)
176 if (videoPublished) Notifier.Instance.notifyOnPendingVideoPublished(videoDatabase)
177 }
178
179 await createHlsJobIfEnabled(Object.assign({}, payload, { resolution: videoDatabase.getOriginalFile().resolution }))
154} 180}
155 181
156// --------------------------------------------------------------------------- 182// ---------------------------------------------------------------------------
@@ -159,3 +185,20 @@ export {
159 processVideoFile, 185 processVideoFile,
160 processVideoFileImport 186 processVideoFileImport
161} 187}
188
189// ---------------------------------------------------------------------------
190
191function createHlsJobIfEnabled (payload?: VideoFilePayload) {
192 // Generate HLS playlist?
193 if (payload && CONFIG.TRANSCODING.HLS.ENABLED) {
194 const hlsTranscodingPayload = {
195 videoUUID: payload.videoUUID,
196 resolution: payload.resolution,
197 isPortraitMode: payload.isPortraitMode,
198
199 generateHlsPlaylist: true
200 }
201
202 return JobQueue.Instance.createJob({ type: 'video-file', payload: hlsTranscodingPayload })
203 }
204}
diff --git a/server/lib/schedulers/videos-redundancy-scheduler.ts b/server/lib/schedulers/videos-redundancy-scheduler.ts
index f643ee226..1a48f2bd0 100644
--- a/server/lib/schedulers/videos-redundancy-scheduler.ts
+++ b/server/lib/schedulers/videos-redundancy-scheduler.ts
@@ -1,5 +1,5 @@
1import { AbstractScheduler } from './abstract-scheduler' 1import { AbstractScheduler } from './abstract-scheduler'
2import { CONFIG, REDUNDANCY, VIDEO_IMPORT_TIMEOUT } from '../../initializers' 2import { CONFIG, HLS_REDUNDANCY_DIRECTORY, REDUNDANCY, VIDEO_IMPORT_TIMEOUT } from '../../initializers'
3import { logger } from '../../helpers/logger' 3import { logger } from '../../helpers/logger'
4import { VideosRedundancy } from '../../../shared/models/redundancy' 4import { VideosRedundancy } from '../../../shared/models/redundancy'
5import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' 5import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
@@ -9,9 +9,19 @@ import { join } from 'path'
9import { move } from 'fs-extra' 9import { move } from 'fs-extra'
10import { getServerActor } from '../../helpers/utils' 10import { getServerActor } from '../../helpers/utils'
11import { sendCreateCacheFile, sendUpdateCacheFile } from '../activitypub/send' 11import { sendCreateCacheFile, sendUpdateCacheFile } from '../activitypub/send'
12import { getVideoCacheFileActivityPubUrl } from '../activitypub/url' 12import { getVideoCacheFileActivityPubUrl, getVideoCacheStreamingPlaylistActivityPubUrl } from '../activitypub/url'
13import { removeVideoRedundancy } from '../redundancy' 13import { removeVideoRedundancy } from '../redundancy'
14import { getOrCreateVideoAndAccountAndChannel } from '../activitypub' 14import { getOrCreateVideoAndAccountAndChannel } from '../activitypub'
15import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
16import { VideoModel } from '../../models/video/video'
17import { downloadPlaylistSegments } from '../hls'
18
19type CandidateToDuplicate = {
20 redundancy: VideosRedundancy,
21 video: VideoModel,
22 files: VideoFileModel[],
23 streamingPlaylists: VideoStreamingPlaylistModel[]
24}
15 25
16export class VideosRedundancyScheduler extends AbstractScheduler { 26export class VideosRedundancyScheduler extends AbstractScheduler {
17 27
@@ -24,28 +34,32 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
24 } 34 }
25 35
26 protected async internalExecute () { 36 protected async internalExecute () {
27 for (const obj of CONFIG.REDUNDANCY.VIDEOS.STRATEGIES) { 37 for (const redundancyConfig of CONFIG.REDUNDANCY.VIDEOS.STRATEGIES) {
28 logger.info('Running redundancy scheduler for strategy %s.', obj.strategy) 38 logger.info('Running redundancy scheduler for strategy %s.', redundancyConfig.strategy)
29 39
30 try { 40 try {
31 const videoToDuplicate = await this.findVideoToDuplicate(obj) 41 const videoToDuplicate = await this.findVideoToDuplicate(redundancyConfig)
32 if (!videoToDuplicate) continue 42 if (!videoToDuplicate) continue
33 43
34 const videoFiles = videoToDuplicate.VideoFiles 44 const candidateToDuplicate = {
35 videoFiles.forEach(f => f.Video = videoToDuplicate) 45 video: videoToDuplicate,
46 redundancy: redundancyConfig,
47 files: videoToDuplicate.VideoFiles,
48 streamingPlaylists: videoToDuplicate.VideoStreamingPlaylists
49 }
36 50
37 await this.purgeCacheIfNeeded(obj, videoFiles) 51 await this.purgeCacheIfNeeded(candidateToDuplicate)
38 52
39 if (await this.isTooHeavy(obj, videoFiles)) { 53 if (await this.isTooHeavy(candidateToDuplicate)) {
40 logger.info('Video %s is too big for our cache, skipping.', videoToDuplicate.url) 54 logger.info('Video %s is too big for our cache, skipping.', videoToDuplicate.url)
41 continue 55 continue
42 } 56 }
43 57
44 logger.info('Will duplicate video %s in redundancy scheduler "%s".', videoToDuplicate.url, obj.strategy) 58 logger.info('Will duplicate video %s in redundancy scheduler "%s".', videoToDuplicate.url, redundancyConfig.strategy)
45 59
46 await this.createVideoRedundancy(obj, videoFiles) 60 await this.createVideoRedundancies(candidateToDuplicate)
47 } catch (err) { 61 } catch (err) {
48 logger.error('Cannot run videos redundancy %s.', obj.strategy, { err }) 62 logger.error('Cannot run videos redundancy %s.', redundancyConfig.strategy, { err })
49 } 63 }
50 } 64 }
51 65
@@ -63,25 +77,35 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
63 77
64 for (const redundancyModel of expired) { 78 for (const redundancyModel of expired) {
65 try { 79 try {
66 await this.extendsOrDeleteRedundancy(redundancyModel) 80 const redundancyConfig = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.find(s => s.strategy === redundancyModel.strategy)
81 const candidate = {
82 redundancy: redundancyConfig,
83 video: null,
84 files: [],
85 streamingPlaylists: []
86 }
87
88 // If the administrator disabled the redundancy or decreased the cache size, remove this redundancy instead of extending it
89 if (!redundancyConfig || await this.isTooHeavy(candidate)) {
90 logger.info('Destroying redundancy %s because the cache size %s is too heavy.', redundancyModel.url, redundancyModel.strategy)
91 await removeVideoRedundancy(redundancyModel)
92 } else {
93 await this.extendsRedundancy(redundancyModel)
94 }
67 } catch (err) { 95 } catch (err) {
68 logger.error('Cannot extend expiration of %s video from our redundancy system.', this.buildEntryLogId(redundancyModel)) 96 logger.error(
97 'Cannot extend or remove expiration of %s video from our redundancy system.', this.buildEntryLogId(redundancyModel),
98 { err }
99 )
69 } 100 }
70 } 101 }
71 } 102 }
72 103
73 private async extendsOrDeleteRedundancy (redundancyModel: VideoRedundancyModel) { 104 private async extendsRedundancy (redundancyModel: VideoRedundancyModel) {
74 // Refresh the video, maybe it was deleted
75 const video = await this.loadAndRefreshVideo(redundancyModel.VideoFile.Video.url)
76
77 if (!video) {
78 logger.info('Destroying existing redundancy %s, because the associated video does not exist anymore.', redundancyModel.url)
79
80 await redundancyModel.destroy()
81 return
82 }
83
84 const redundancy = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.find(s => s.strategy === redundancyModel.strategy) 105 const redundancy = CONFIG.REDUNDANCY.VIDEOS.STRATEGIES.find(s => s.strategy === redundancyModel.strategy)
106 // Redundancy strategy disabled, remove our redundancy instead of extending expiration
107 if (!redundancy) await removeVideoRedundancy(redundancyModel)
108
85 await this.extendsExpirationOf(redundancyModel, redundancy.minLifetime) 109 await this.extendsExpirationOf(redundancyModel, redundancy.minLifetime)
86 } 110 }
87 111
@@ -112,49 +136,93 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
112 } 136 }
113 } 137 }
114 138
115 private async createVideoRedundancy (redundancy: VideosRedundancy, filesToDuplicate: VideoFileModel[]) { 139 private async createVideoRedundancies (data: CandidateToDuplicate) {
116 const serverActor = await getServerActor() 140 const video = await this.loadAndRefreshVideo(data.video.url)
141
142 if (!video) {
143 logger.info('Video %s we want to duplicate does not existing anymore, skipping.', data.video.url)
117 144
118 for (const file of filesToDuplicate) { 145 return
119 const video = await this.loadAndRefreshVideo(file.Video.url) 146 }
120 147
148 for (const file of data.files) {
121 const existingRedundancy = await VideoRedundancyModel.loadLocalByFileId(file.id) 149 const existingRedundancy = await VideoRedundancyModel.loadLocalByFileId(file.id)
122 if (existingRedundancy) { 150 if (existingRedundancy) {
123 await this.extendsOrDeleteRedundancy(existingRedundancy) 151 await this.extendsRedundancy(existingRedundancy)
124 152
125 continue 153 continue
126 } 154 }
127 155
128 if (!video) { 156 await this.createVideoFileRedundancy(data.redundancy, video, file)
129 logger.info('Video %s we want to duplicate does not existing anymore, skipping.', file.Video.url) 157 }
158
159 for (const streamingPlaylist of data.streamingPlaylists) {
160 const existingRedundancy = await VideoRedundancyModel.loadLocalByStreamingPlaylistId(streamingPlaylist.id)
161 if (existingRedundancy) {
162 await this.extendsRedundancy(existingRedundancy)
130 163
131 continue 164 continue
132 } 165 }
133 166
134 logger.info('Duplicating %s - %d in videos redundancy with "%s" strategy.', video.url, file.resolution, redundancy.strategy) 167 await this.createStreamingPlaylistRedundancy(data.redundancy, video, streamingPlaylist)
168 }
169 }
135 170
136 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls() 171 private async createVideoFileRedundancy (redundancy: VideosRedundancy, video: VideoModel, file: VideoFileModel) {
137 const magnetUri = video.generateMagnetUri(file, baseUrlHttp, baseUrlWs) 172 file.Video = video
138 173
139 const tmpPath = await downloadWebTorrentVideo({ magnetUri }, VIDEO_IMPORT_TIMEOUT) 174 const serverActor = await getServerActor()
140 175
141 const destPath = join(CONFIG.STORAGE.REDUNDANCY_DIR, video.getVideoFilename(file)) 176 logger.info('Duplicating %s - %d in videos redundancy with "%s" strategy.', video.url, file.resolution, redundancy.strategy)
142 await move(tmpPath, destPath)
143 177
144 const createdModel = await VideoRedundancyModel.create({ 178 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
145 expiresOn: this.buildNewExpiration(redundancy.minLifetime), 179 const magnetUri = video.generateMagnetUri(file, baseUrlHttp, baseUrlWs)
146 url: getVideoCacheFileActivityPubUrl(file),
147 fileUrl: video.getVideoRedundancyUrl(file, CONFIG.WEBSERVER.URL),
148 strategy: redundancy.strategy,
149 videoFileId: file.id,
150 actorId: serverActor.id
151 })
152 createdModel.VideoFile = file
153 180
154 await sendCreateCacheFile(serverActor, createdModel) 181 const tmpPath = await downloadWebTorrentVideo({ magnetUri }, VIDEO_IMPORT_TIMEOUT)
155 182
156 logger.info('Duplicated %s - %d -> %s.', video.url, file.resolution, createdModel.url) 183 const destPath = join(CONFIG.STORAGE.REDUNDANCY_DIR, video.getVideoFilename(file))
157 } 184 await move(tmpPath, destPath)
185
186 const createdModel = await VideoRedundancyModel.create({
187 expiresOn: this.buildNewExpiration(redundancy.minLifetime),
188 url: getVideoCacheFileActivityPubUrl(file),
189 fileUrl: video.getVideoRedundancyUrl(file, CONFIG.WEBSERVER.URL),
190 strategy: redundancy.strategy,
191 videoFileId: file.id,
192 actorId: serverActor.id
193 })
194
195 createdModel.VideoFile = file
196
197 await sendCreateCacheFile(serverActor, video, createdModel)
198
199 logger.info('Duplicated %s - %d -> %s.', video.url, file.resolution, createdModel.url)
200 }
201
202 private async createStreamingPlaylistRedundancy (redundancy: VideosRedundancy, video: VideoModel, playlist: VideoStreamingPlaylistModel) {
203 playlist.Video = video
204
205 const serverActor = await getServerActor()
206
207 logger.info('Duplicating %s streaming playlist in videos redundancy with "%s" strategy.', video.url, redundancy.strategy)
208
209 const destDirectory = join(HLS_REDUNDANCY_DIRECTORY, video.uuid)
210 await downloadPlaylistSegments(playlist.playlistUrl, destDirectory, VIDEO_IMPORT_TIMEOUT)
211
212 const createdModel = await VideoRedundancyModel.create({
213 expiresOn: this.buildNewExpiration(redundancy.minLifetime),
214 url: getVideoCacheStreamingPlaylistActivityPubUrl(video, playlist),
215 fileUrl: playlist.getVideoRedundancyUrl(CONFIG.WEBSERVER.URL),
216 strategy: redundancy.strategy,
217 videoStreamingPlaylistId: playlist.id,
218 actorId: serverActor.id
219 })
220
221 createdModel.VideoStreamingPlaylist = playlist
222
223 await sendCreateCacheFile(serverActor, video, createdModel)
224
225 logger.info('Duplicated playlist %s -> %s.', playlist.playlistUrl, createdModel.url)
158 } 226 }
159 227
160 private async extendsExpirationOf (redundancy: VideoRedundancyModel, expiresAfterMs: number) { 228 private async extendsExpirationOf (redundancy: VideoRedundancyModel, expiresAfterMs: number) {
@@ -168,8 +236,9 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
168 await sendUpdateCacheFile(serverActor, redundancy) 236 await sendUpdateCacheFile(serverActor, redundancy)
169 } 237 }
170 238
171 private async purgeCacheIfNeeded (redundancy: VideosRedundancy, filesToDuplicate: VideoFileModel[]) { 239 private async purgeCacheIfNeeded (candidateToDuplicate: CandidateToDuplicate) {
172 while (this.isTooHeavy(redundancy, filesToDuplicate)) { 240 while (this.isTooHeavy(candidateToDuplicate)) {
241 const redundancy = candidateToDuplicate.redundancy
173 const toDelete = await VideoRedundancyModel.loadOldestLocalThatAlreadyExpired(redundancy.strategy, redundancy.minLifetime) 242 const toDelete = await VideoRedundancyModel.loadOldestLocalThatAlreadyExpired(redundancy.strategy, redundancy.minLifetime)
174 if (!toDelete) return 243 if (!toDelete) return
175 244
@@ -177,11 +246,11 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
177 } 246 }
178 } 247 }
179 248
180 private async isTooHeavy (redundancy: VideosRedundancy, filesToDuplicate: VideoFileModel[]) { 249 private async isTooHeavy (candidateToDuplicate: CandidateToDuplicate) {
181 const maxSize = redundancy.size 250 const maxSize = candidateToDuplicate.redundancy.size
182 251
183 const totalDuplicated = await VideoRedundancyModel.getTotalDuplicated(redundancy.strategy) 252 const totalDuplicated = await VideoRedundancyModel.getTotalDuplicated(candidateToDuplicate.redundancy.strategy)
184 const totalWillDuplicate = totalDuplicated + this.getTotalFileSizes(filesToDuplicate) 253 const totalWillDuplicate = totalDuplicated + this.getTotalFileSizes(candidateToDuplicate.files, candidateToDuplicate.streamingPlaylists)
185 254
186 return totalWillDuplicate > maxSize 255 return totalWillDuplicate > maxSize
187 } 256 }
@@ -191,13 +260,15 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
191 } 260 }
192 261
193 private buildEntryLogId (object: VideoRedundancyModel) { 262 private buildEntryLogId (object: VideoRedundancyModel) {
194 return `${object.VideoFile.Video.url}-${object.VideoFile.resolution}` 263 if (object.VideoFile) return `${object.VideoFile.Video.url}-${object.VideoFile.resolution}`
264
265 return `${object.VideoStreamingPlaylist.playlistUrl}`
195 } 266 }
196 267
197 private getTotalFileSizes (files: VideoFileModel[]) { 268 private getTotalFileSizes (files: VideoFileModel[], playlists: VideoStreamingPlaylistModel[]) {
198 const fileReducer = (previous: number, current: VideoFileModel) => previous + current.size 269 const fileReducer = (previous: number, current: VideoFileModel) => previous + current.size
199 270
200 return files.reduce(fileReducer, 0) 271 return files.reduce(fileReducer, 0) * playlists.length
201 } 272 }
202 273
203 private async loadAndRefreshVideo (videoUrl: string) { 274 private async loadAndRefreshVideo (videoUrl: string) {
diff --git a/server/lib/video-transcoding.ts b/server/lib/video-transcoding.ts
index 4460f46e4..086b860a2 100644
--- a/server/lib/video-transcoding.ts
+++ b/server/lib/video-transcoding.ts
@@ -1,11 +1,14 @@
1import { CONFIG } from '../initializers' 1import { CONFIG, HLS_PLAYLIST_DIRECTORY } from '../initializers'
2import { extname, join } from 'path' 2import { extname, join } from 'path'
3import { getVideoFileFPS, getVideoFileResolution, transcode } from '../helpers/ffmpeg-utils' 3import { getVideoFileFPS, getVideoFileResolution, transcode } from '../helpers/ffmpeg-utils'
4import { copy, remove, move, stat } from 'fs-extra' 4import { copy, ensureDir, move, remove, stat } from 'fs-extra'
5import { logger } from '../helpers/logger' 5import { logger } from '../helpers/logger'
6import { VideoResolution } from '../../shared/models/videos' 6import { VideoResolution } from '../../shared/models/videos'
7import { VideoFileModel } from '../models/video/video-file' 7import { VideoFileModel } from '../models/video/video-file'
8import { VideoModel } from '../models/video/video' 8import { VideoModel } from '../models/video/video'
9import { updateMasterHLSPlaylist, updateSha256Segments } from './hls'
10import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
11import { VideoStreamingPlaylistType } from '../../shared/models/videos/video-streaming-playlist.type'
9 12
10async function optimizeVideofile (video: VideoModel, inputVideoFileArg?: VideoFileModel) { 13async function optimizeVideofile (video: VideoModel, inputVideoFileArg?: VideoFileModel) {
11 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR 14 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
@@ -17,7 +20,8 @@ async function optimizeVideofile (video: VideoModel, inputVideoFileArg?: VideoFi
17 20
18 const transcodeOptions = { 21 const transcodeOptions = {
19 inputPath: videoInputPath, 22 inputPath: videoInputPath,
20 outputPath: videoTranscodedPath 23 outputPath: videoTranscodedPath,
24 resolution: inputVideoFile.resolution
21 } 25 }
22 26
23 // Could be very long! 27 // Could be very long!
@@ -47,7 +51,7 @@ async function optimizeVideofile (video: VideoModel, inputVideoFileArg?: VideoFi
47 } 51 }
48} 52}
49 53
50async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoResolution, isPortraitMode: boolean) { 54async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoResolution, isPortrait: boolean) {
51 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR 55 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
52 const extname = '.mp4' 56 const extname = '.mp4'
53 57
@@ -60,13 +64,13 @@ async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoR
60 size: 0, 64 size: 0,
61 videoId: video.id 65 videoId: video.id
62 }) 66 })
63 const videoOutputPath = join(videosDirectory, video.getVideoFilename(newVideoFile)) 67 const videoOutputPath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename(newVideoFile))
64 68
65 const transcodeOptions = { 69 const transcodeOptions = {
66 inputPath: videoInputPath, 70 inputPath: videoInputPath,
67 outputPath: videoOutputPath, 71 outputPath: videoOutputPath,
68 resolution, 72 resolution,
69 isPortraitMode 73 isPortraitMode: isPortrait
70 } 74 }
71 75
72 await transcode(transcodeOptions) 76 await transcode(transcodeOptions)
@@ -84,6 +88,41 @@ async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoR
84 video.VideoFiles.push(newVideoFile) 88 video.VideoFiles.push(newVideoFile)
85} 89}
86 90
91async function generateHlsPlaylist (video: VideoModel, resolution: VideoResolution, isPortraitMode: boolean) {
92 const baseHlsDirectory = join(HLS_PLAYLIST_DIRECTORY, video.uuid)
93 await ensureDir(join(HLS_PLAYLIST_DIRECTORY, video.uuid))
94
95 const videoInputPath = join(CONFIG.STORAGE.VIDEOS_DIR, video.getVideoFilename(video.getOriginalFile()))
96 const outputPath = join(baseHlsDirectory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution))
97
98 const transcodeOptions = {
99 inputPath: videoInputPath,
100 outputPath,
101 resolution,
102 isPortraitMode,
103
104 hlsPlaylist: {
105 videoFilename: VideoStreamingPlaylistModel.getHlsVideoName(video.uuid, resolution)
106 }
107 }
108
109 await transcode(transcodeOptions)
110
111 await updateMasterHLSPlaylist(video)
112 await updateSha256Segments(video)
113
114 const playlistUrl = CONFIG.WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid)
115
116 await VideoStreamingPlaylistModel.upsert({
117 videoId: video.id,
118 playlistUrl,
119 segmentsSha256Url: CONFIG.WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid),
120 p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrl, video.VideoFiles),
121
122 type: VideoStreamingPlaylistType.HLS
123 })
124}
125
87async function importVideoFile (video: VideoModel, inputFilePath: string) { 126async function importVideoFile (video: VideoModel, inputFilePath: string) {
88 const { videoFileResolution } = await getVideoFileResolution(inputFilePath) 127 const { videoFileResolution } = await getVideoFileResolution(inputFilePath)
89 const { size } = await stat(inputFilePath) 128 const { size } = await stat(inputFilePath)
@@ -125,6 +164,7 @@ async function importVideoFile (video: VideoModel, inputFilePath: string) {
125} 164}
126 165
127export { 166export {
167 generateHlsPlaylist,
128 optimizeVideofile, 168 optimizeVideofile,
129 transcodeOriginalVideofile, 169 transcodeOriginalVideofile,
130 importVideoFile 170 importVideoFile
diff --git a/server/middlewares/csp.ts b/server/middlewares/csp.ts
index 8b919af0d..5fa9d1ab5 100644
--- a/server/middlewares/csp.ts
+++ b/server/middlewares/csp.ts
@@ -16,7 +16,7 @@ const baseDirectives = Object.assign({},
16 baseUri: ["'self'"], 16 baseUri: ["'self'"],
17 manifestSrc: ["'self'"], 17 manifestSrc: ["'self'"],
18 frameSrc: ["'self'"], // instead of deprecated child-src / self because of test-embed 18 frameSrc: ["'self'"], // instead of deprecated child-src / self because of test-embed
19 workerSrc: ["'self'"] // instead of deprecated child-src 19 workerSrc: ["'self'", 'blob:'] // instead of deprecated child-src
20 }, 20 },
21 CONFIG.SERVICES['CSP-LOGGER'] ? { reportUri: CONFIG.SERVICES['CSP-LOGGER'] } : {}, 21 CONFIG.SERVICES['CSP-LOGGER'] ? { reportUri: CONFIG.SERVICES['CSP-LOGGER'] } : {},
22 CONFIG.WEBSERVER.SCHEME === 'https' ? { upgradeInsecureRequests: true } : {} 22 CONFIG.WEBSERVER.SCHEME === 'https' ? { upgradeInsecureRequests: true } : {}
diff --git a/server/middlewares/validators/redundancy.ts b/server/middlewares/validators/redundancy.ts
index c72ab78b2..329322509 100644
--- a/server/middlewares/validators/redundancy.ts
+++ b/server/middlewares/validators/redundancy.ts
@@ -13,7 +13,7 @@ import { ActorFollowModel } from '../../models/activitypub/actor-follow'
13import { SERVER_ACTOR_NAME } from '../../initializers' 13import { SERVER_ACTOR_NAME } from '../../initializers'
14import { ServerModel } from '../../models/server/server' 14import { ServerModel } from '../../models/server/server'
15 15
16const videoRedundancyGetValidator = [ 16const videoFileRedundancyGetValidator = [
17 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'), 17 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
18 param('resolution') 18 param('resolution')
19 .customSanitizer(toIntOrNull) 19 .customSanitizer(toIntOrNull)
@@ -24,7 +24,7 @@ const videoRedundancyGetValidator = [
24 .custom(exists).withMessage('Should have a valid fps'), 24 .custom(exists).withMessage('Should have a valid fps'),
25 25
26 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 26 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
27 logger.debug('Checking videoRedundancyGetValidator parameters', { parameters: req.params }) 27 logger.debug('Checking videoFileRedundancyGetValidator parameters', { parameters: req.params })
28 28
29 if (areValidationErrors(req, res)) return 29 if (areValidationErrors(req, res)) return
30 if (!await isVideoExist(req.params.videoId, res)) return 30 if (!await isVideoExist(req.params.videoId, res)) return
@@ -38,7 +38,31 @@ const videoRedundancyGetValidator = [
38 res.locals.videoFile = videoFile 38 res.locals.videoFile = videoFile
39 39
40 const videoRedundancy = await VideoRedundancyModel.loadLocalByFileId(videoFile.id) 40 const videoRedundancy = await VideoRedundancyModel.loadLocalByFileId(videoFile.id)
41 if (!videoRedundancy)return res.status(404).json({ error: 'Video redundancy not found.' }) 41 if (!videoRedundancy) return res.status(404).json({ error: 'Video redundancy not found.' })
42 res.locals.videoRedundancy = videoRedundancy
43
44 return next()
45 }
46]
47
48const videoPlaylistRedundancyGetValidator = [
49 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'),
50 param('streamingPlaylistType').custom(exists).withMessage('Should have a valid streaming playlist type'),
51
52 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
53 logger.debug('Checking videoPlaylistRedundancyGetValidator parameters', { parameters: req.params })
54
55 if (areValidationErrors(req, res)) return
56 if (!await isVideoExist(req.params.videoId, res)) return
57
58 const video: VideoModel = res.locals.video
59 const videoStreamingPlaylist = video.VideoStreamingPlaylists.find(p => p === req.params.streamingPlaylistType)
60
61 if (!videoStreamingPlaylist) return res.status(404).json({ error: 'Video playlist not found.' })
62 res.locals.videoStreamingPlaylist = videoStreamingPlaylist
63
64 const videoRedundancy = await VideoRedundancyModel.loadLocalByStreamingPlaylistId(videoStreamingPlaylist.id)
65 if (!videoRedundancy) return res.status(404).json({ error: 'Video redundancy not found.' })
42 res.locals.videoRedundancy = videoRedundancy 66 res.locals.videoRedundancy = videoRedundancy
43 67
44 return next() 68 return next()
@@ -75,6 +99,7 @@ const updateServerRedundancyValidator = [
75// --------------------------------------------------------------------------- 99// ---------------------------------------------------------------------------
76 100
77export { 101export {
78 videoRedundancyGetValidator, 102 videoFileRedundancyGetValidator,
103 videoPlaylistRedundancyGetValidator,
79 updateServerRedundancyValidator 104 updateServerRedundancyValidator
80} 105}
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts
index 1bb0bfb1b..a52e3060a 100644
--- a/server/middlewares/validators/users.ts
+++ b/server/middlewares/validators/users.ts
@@ -113,6 +113,7 @@ const deleteMeValidator = [
113 113
114const usersUpdateValidator = [ 114const usersUpdateValidator = [
115 param('id').isInt().not().isEmpty().withMessage('Should have a valid id'), 115 param('id').isInt().not().isEmpty().withMessage('Should have a valid id'),
116 body('password').optional().custom(isUserPasswordValid).withMessage('Should have a valid password'),
116 body('email').optional().isEmail().withMessage('Should have a valid email attribute'), 117 body('email').optional().isEmail().withMessage('Should have a valid email attribute'),
117 body('emailVerified').optional().isBoolean().withMessage('Should have a valid email verified attribute'), 118 body('emailVerified').optional().isBoolean().withMessage('Should have a valid email verified attribute'),
118 body('videoQuota').optional().custom(isUserVideoQuotaValid).withMessage('Should have a valid user quota'), 119 body('videoQuota').optional().custom(isUserVideoQuotaValid).withMessage('Should have a valid user quota'),
@@ -233,6 +234,7 @@ const usersAskResetPasswordValidator = [
233 logger.debug('Checking usersAskResetPassword parameters', { parameters: req.body }) 234 logger.debug('Checking usersAskResetPassword parameters', { parameters: req.body })
234 235
235 if (areValidationErrors(req, res)) return 236 if (areValidationErrors(req, res)) return
237
236 const exists = await checkUserEmailExist(req.body.email, res, false) 238 const exists = await checkUserEmailExist(req.body.email, res, false)
237 if (!exists) { 239 if (!exists) {
238 logger.debug('User with email %s does not exist (asking reset password).', req.body.email) 240 logger.debug('User with email %s does not exist (asking reset password).', req.body.email)
diff --git a/server/middlewares/validators/videos/video-blacklist.ts b/server/middlewares/validators/videos/video-blacklist.ts
index 13da7acff..2688f63ae 100644
--- a/server/middlewares/validators/videos/video-blacklist.ts
+++ b/server/middlewares/validators/videos/video-blacklist.ts
@@ -1,10 +1,11 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, param } from 'express-validator/check' 2import { body, param } from 'express-validator/check'
3import { isIdOrUUIDValid } from '../../../helpers/custom-validators/misc' 3import { isBooleanValid, isIdOrUUIDValid } from '../../../helpers/custom-validators/misc'
4import { isVideoExist } from '../../../helpers/custom-validators/videos' 4import { isVideoExist } from '../../../helpers/custom-validators/videos'
5import { logger } from '../../../helpers/logger' 5import { logger } from '../../../helpers/logger'
6import { areValidationErrors } from '../utils' 6import { areValidationErrors } from '../utils'
7import { isVideoBlacklistExist, isVideoBlacklistReasonValid } from '../../../helpers/custom-validators/video-blacklist' 7import { isVideoBlacklistExist, isVideoBlacklistReasonValid } from '../../../helpers/custom-validators/video-blacklist'
8import { VideoModel } from '../../../models/video/video'
8 9
9const videosBlacklistRemoveValidator = [ 10const videosBlacklistRemoveValidator = [
10 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), 11 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
@@ -22,6 +23,10 @@ const videosBlacklistRemoveValidator = [
22 23
23const videosBlacklistAddValidator = [ 24const videosBlacklistAddValidator = [
24 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), 25 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
26 body('unfederate')
27 .optional()
28 .toBoolean()
29 .custom(isBooleanValid).withMessage('Should have a valid unfederate boolean'),
25 body('reason') 30 body('reason')
26 .optional() 31 .optional()
27 .custom(isVideoBlacklistReasonValid).withMessage('Should have a valid reason'), 32 .custom(isVideoBlacklistReasonValid).withMessage('Should have a valid reason'),
@@ -32,6 +37,14 @@ const videosBlacklistAddValidator = [
32 if (areValidationErrors(req, res)) return 37 if (areValidationErrors(req, res)) return
33 if (!await isVideoExist(req.params.videoId, res)) return 38 if (!await isVideoExist(req.params.videoId, res)) return
34 39
40 const video: VideoModel = res.locals.video
41 if (req.body.unfederate === true && video.remote === true) {
42 return res
43 .status(409)
44 .send({ error: 'You cannot unfederate a remote video.' })
45 .end()
46 }
47
35 return next() 48 return next()
36 } 49 }
37] 50]
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts
index 194d12c6e..159727e28 100644
--- a/server/middlewares/validators/videos/videos.ts
+++ b/server/middlewares/validators/videos/videos.ts
@@ -341,11 +341,14 @@ function getCommonVideoAttributes () {
341 .optional() 341 .optional()
342 .toBoolean() 342 .toBoolean()
343 .custom(isBooleanValid).withMessage('Should have comments enabled boolean'), 343 .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
344 body('originallyPublishedAt') 344 body('downloadEnabled')
345 .optional() 345 .optional()
346 .customSanitizer(toValueOrNull) 346 .toBoolean()
347 .custom(isVideoOriginallyPublishedAtValid).withMessage('Should have a valid original publication date'), 347 .custom(isBooleanValid).withMessage('Should have downloading enabled boolean'),
348 348 body('originallyPublishedAt')
349 .optional()
350 .customSanitizer(toValueOrNull)
351 .custom(isVideoOriginallyPublishedAtValid).withMessage('Should have a valid original publication date'),
349 body('scheduleUpdate') 352 body('scheduleUpdate')
350 .optional() 353 .optional()
351 .customSanitizer(toValueOrNull), 354 .customSanitizer(toValueOrNull),
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index a99e9b1ad..84ef0b30d 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -288,6 +288,10 @@ export class AccountModel extends Model<AccountModel> {
288 return this.Actor.isOwned() 288 return this.Actor.isOwned()
289 } 289 }
290 290
291 isOutdated () {
292 return this.Actor.isOutdated()
293 }
294
291 getDisplayName () { 295 getDisplayName () {
292 return this.name 296 return this.name
293 } 297 }
diff --git a/server/models/account/user-notification.ts b/server/models/account/user-notification.ts
index 9e4f982a3..6cdbb827b 100644
--- a/server/models/account/user-notification.ts
+++ b/server/models/account/user-notification.ts
@@ -27,11 +27,33 @@ import { VideoBlacklistModel } from '../video/video-blacklist'
27import { VideoImportModel } from '../video/video-import' 27import { VideoImportModel } from '../video/video-import'
28import { ActorModel } from '../activitypub/actor' 28import { ActorModel } from '../activitypub/actor'
29import { ActorFollowModel } from '../activitypub/actor-follow' 29import { ActorFollowModel } from '../activitypub/actor-follow'
30import { AvatarModel } from '../avatar/avatar'
31import { ServerModel } from '../server/server'
30 32
31enum ScopeNames { 33enum ScopeNames {
32 WITH_ALL = 'WITH_ALL' 34 WITH_ALL = 'WITH_ALL'
33} 35}
34 36
37function buildActorWithAvatarInclude () {
38 return {
39 attributes: [ 'preferredUsername' ],
40 model: () => ActorModel.unscoped(),
41 required: true,
42 include: [
43 {
44 attributes: [ 'filename' ],
45 model: () => AvatarModel.unscoped(),
46 required: false
47 },
48 {
49 attributes: [ 'host' ],
50 model: () => ServerModel.unscoped(),
51 required: false
52 }
53 ]
54 }
55}
56
35function buildVideoInclude (required: boolean) { 57function buildVideoInclude (required: boolean) {
36 return { 58 return {
37 attributes: [ 'id', 'uuid', 'name' ], 59 attributes: [ 'id', 'uuid', 'name' ],
@@ -40,19 +62,21 @@ function buildVideoInclude (required: boolean) {
40 } 62 }
41} 63}
42 64
43function buildChannelInclude (required: boolean) { 65function buildChannelInclude (required: boolean, withActor = false) {
44 return { 66 return {
45 required, 67 required,
46 attributes: [ 'id', 'name' ], 68 attributes: [ 'id', 'name' ],
47 model: () => VideoChannelModel.unscoped() 69 model: () => VideoChannelModel.unscoped(),
70 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
48 } 71 }
49} 72}
50 73
51function buildAccountInclude (required: boolean) { 74function buildAccountInclude (required: boolean, withActor = false) {
52 return { 75 return {
53 required, 76 required,
54 attributes: [ 'id', 'name' ], 77 attributes: [ 'id', 'name' ],
55 model: () => AccountModel.unscoped() 78 model: () => AccountModel.unscoped(),
79 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
56 } 80 }
57} 81}
58 82
@@ -60,47 +84,40 @@ function buildAccountInclude (required: boolean) {
60 [ScopeNames.WITH_ALL]: { 84 [ScopeNames.WITH_ALL]: {
61 include: [ 85 include: [
62 Object.assign(buildVideoInclude(false), { 86 Object.assign(buildVideoInclude(false), {
63 include: [ buildChannelInclude(true) ] 87 include: [ buildChannelInclude(true, true) ]
64 }), 88 }),
89
65 { 90 {
66 attributes: [ 'id', 'originCommentId' ], 91 attributes: [ 'id', 'originCommentId' ],
67 model: () => VideoCommentModel.unscoped(), 92 model: () => VideoCommentModel.unscoped(),
68 required: false, 93 required: false,
69 include: [ 94 include: [
70 buildAccountInclude(true), 95 buildAccountInclude(true, true),
71 buildVideoInclude(true) 96 buildVideoInclude(true)
72 ] 97 ]
73 }, 98 },
99
74 { 100 {
75 attributes: [ 'id' ], 101 attributes: [ 'id' ],
76 model: () => VideoAbuseModel.unscoped(), 102 model: () => VideoAbuseModel.unscoped(),
77 required: false, 103 required: false,
78 include: [ buildVideoInclude(true) ] 104 include: [ buildVideoInclude(true) ]
79 }, 105 },
106
80 { 107 {
81 attributes: [ 'id' ], 108 attributes: [ 'id' ],
82 model: () => VideoBlacklistModel.unscoped(), 109 model: () => VideoBlacklistModel.unscoped(),
83 required: false, 110 required: false,
84 include: [ buildVideoInclude(true) ] 111 include: [ buildVideoInclude(true) ]
85 }, 112 },
113
86 { 114 {
87 attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ], 115 attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ],
88 model: () => VideoImportModel.unscoped(), 116 model: () => VideoImportModel.unscoped(),
89 required: false, 117 required: false,
90 include: [ buildVideoInclude(false) ] 118 include: [ buildVideoInclude(false) ]
91 }, 119 },
92 { 120
93 attributes: [ 'id', 'name' ],
94 model: () => AccountModel.unscoped(),
95 required: false,
96 include: [
97 {
98 attributes: [ 'id', 'preferredUsername' ],
99 model: () => ActorModel.unscoped(),
100 required: true
101 }
102 ]
103 },
104 { 121 {
105 attributes: [ 'id' ], 122 attributes: [ 'id' ],
106 model: () => ActorFollowModel.unscoped(), 123 model: () => ActorFollowModel.unscoped(),
@@ -111,7 +128,23 @@ function buildAccountInclude (required: boolean) {
111 model: () => ActorModel.unscoped(), 128 model: () => ActorModel.unscoped(),
112 required: true, 129 required: true,
113 as: 'ActorFollower', 130 as: 'ActorFollower',
114 include: [ buildAccountInclude(true) ] 131 include: [
132 {
133 attributes: [ 'id', 'name' ],
134 model: () => AccountModel.unscoped(),
135 required: true
136 },
137 {
138 attributes: [ 'filename' ],
139 model: () => AvatarModel.unscoped(),
140 required: false
141 },
142 {
143 attributes: [ 'host' ],
144 model: () => ServerModel.unscoped(),
145 required: false
146 }
147 ]
115 }, 148 },
116 { 149 {
117 attributes: [ 'preferredUsername' ], 150 attributes: [ 'preferredUsername' ],
@@ -124,7 +157,9 @@ function buildAccountInclude (required: boolean) {
124 ] 157 ]
125 } 158 }
126 ] 159 ]
127 } 160 },
161
162 buildAccountInclude(false, true)
128 ] 163 ]
129 } 164 }
130}) 165})
@@ -132,10 +167,63 @@ function buildAccountInclude (required: boolean) {
132 tableName: 'userNotification', 167 tableName: 'userNotification',
133 indexes: [ 168 indexes: [
134 { 169 {
135 fields: [ 'videoId' ] 170 fields: [ 'userId' ]
171 },
172 {
173 fields: [ 'videoId' ],
174 where: {
175 videoId: {
176 [Op.ne]: null
177 }
178 }
136 }, 179 },
137 { 180 {
138 fields: [ 'commentId' ] 181 fields: [ 'commentId' ],
182 where: {
183 commentId: {
184 [Op.ne]: null
185 }
186 }
187 },
188 {
189 fields: [ 'videoAbuseId' ],
190 where: {
191 videoAbuseId: {
192 [Op.ne]: null
193 }
194 }
195 },
196 {
197 fields: [ 'videoBlacklistId' ],
198 where: {
199 videoBlacklistId: {
200 [Op.ne]: null
201 }
202 }
203 },
204 {
205 fields: [ 'videoImportId' ],
206 where: {
207 videoImportId: {
208 [Op.ne]: null
209 }
210 }
211 },
212 {
213 fields: [ 'accountId' ],
214 where: {
215 accountId: {
216 [Op.ne]: null
217 }
218 }
219 },
220 {
221 fields: [ 'actorFollowId' ],
222 where: {
223 actorFollowId: {
224 [Op.ne]: null
225 }
226 }
139 } 227 }
140 ] 228 ]
141}) 229})
@@ -297,12 +385,9 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
297 } 385 }
298 386
299 toFormattedJSON (): UserNotification { 387 toFormattedJSON (): UserNotification {
300 const video = this.Video ? Object.assign(this.formatVideo(this.Video), { 388 const video = this.Video
301 channel: { 389 ? Object.assign(this.formatVideo(this.Video),{ channel: this.formatActor(this.Video.VideoChannel) })
302 id: this.Video.VideoChannel.id, 390 : undefined
303 displayName: this.Video.VideoChannel.getDisplayName()
304 }
305 }) : undefined
306 391
307 const videoImport = this.VideoImport ? { 392 const videoImport = this.VideoImport ? {
308 id: this.VideoImport.id, 393 id: this.VideoImport.id,
@@ -315,10 +400,7 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
315 const comment = this.Comment ? { 400 const comment = this.Comment ? {
316 id: this.Comment.id, 401 id: this.Comment.id,
317 threadId: this.Comment.getThreadId(), 402 threadId: this.Comment.getThreadId(),
318 account: { 403 account: this.formatActor(this.Comment.Account),
319 id: this.Comment.Account.id,
320 displayName: this.Comment.Account.getDisplayName()
321 },
322 video: this.formatVideo(this.Comment.Video) 404 video: this.formatVideo(this.Comment.Video)
323 } : undefined 405 } : undefined
324 406
@@ -332,17 +414,16 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
332 video: this.formatVideo(this.VideoBlacklist.Video) 414 video: this.formatVideo(this.VideoBlacklist.Video)
333 } : undefined 415 } : undefined
334 416
335 const account = this.Account ? { 417 const account = this.Account ? this.formatActor(this.Account) : undefined
336 id: this.Account.id,
337 displayName: this.Account.getDisplayName(),
338 name: this.Account.Actor.preferredUsername
339 } : undefined
340 418
341 const actorFollow = this.ActorFollow ? { 419 const actorFollow = this.ActorFollow ? {
342 id: this.ActorFollow.id, 420 id: this.ActorFollow.id,
343 follower: { 421 follower: {
422 id: this.ActorFollow.ActorFollower.Account.id,
344 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(), 423 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
345 name: this.ActorFollow.ActorFollower.preferredUsername 424 name: this.ActorFollow.ActorFollower.preferredUsername,
425 avatar: this.ActorFollow.ActorFollower.Avatar ? { path: this.ActorFollow.ActorFollower.Avatar.getWebserverPath() } : undefined,
426 host: this.ActorFollow.ActorFollower.getHost()
346 }, 427 },
347 following: { 428 following: {
348 type: this.ActorFollow.ActorFollowing.VideoChannel ? 'channel' as 'channel' : 'account' as 'account', 429 type: this.ActorFollow.ActorFollowing.VideoChannel ? 'channel' as 'channel' : 'account' as 'account',
@@ -374,4 +455,18 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
374 name: video.name 455 name: video.name
375 } 456 }
376 } 457 }
458
459 private formatActor (accountOrChannel: AccountModel | VideoChannelModel) {
460 const avatar = accountOrChannel.Actor.Avatar
461 ? { path: accountOrChannel.Actor.Avatar.getWebserverPath() }
462 : undefined
463
464 return {
465 id: accountOrChannel.id,
466 displayName: accountOrChannel.getDisplayName(),
467 name: accountOrChannel.Actor.preferredUsername,
468 host: accountOrChannel.Actor.getHost(),
469 avatar
470 }
471 }
377} 472}
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts
index 8b6cd146a..b722bed14 100644
--- a/server/models/redundancy/video-redundancy.ts
+++ b/server/models/redundancy/video-redundancy.ts
@@ -28,6 +28,7 @@ import { sample } from 'lodash'
28import { isTestInstance } from '../../helpers/core-utils' 28import { isTestInstance } from '../../helpers/core-utils'
29import * as Bluebird from 'bluebird' 29import * as Bluebird from 'bluebird'
30import * as Sequelize from 'sequelize' 30import * as Sequelize from 'sequelize'
31import { VideoStreamingPlaylistModel } from '../video/video-streaming-playlist'
31 32
32export enum ScopeNames { 33export enum ScopeNames {
33 WITH_VIDEO = 'WITH_VIDEO' 34 WITH_VIDEO = 'WITH_VIDEO'
@@ -38,7 +39,17 @@ export enum ScopeNames {
38 include: [ 39 include: [
39 { 40 {
40 model: () => VideoFileModel, 41 model: () => VideoFileModel,
41 required: true, 42 required: false,
43 include: [
44 {
45 model: () => VideoModel,
46 required: true
47 }
48 ]
49 },
50 {
51 model: () => VideoStreamingPlaylistModel,
52 required: false,
42 include: [ 53 include: [
43 { 54 {
44 model: () => VideoModel, 55 model: () => VideoModel,
@@ -97,12 +108,24 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
97 108
98 @BelongsTo(() => VideoFileModel, { 109 @BelongsTo(() => VideoFileModel, {
99 foreignKey: { 110 foreignKey: {
100 allowNull: false 111 allowNull: true
101 }, 112 },
102 onDelete: 'cascade' 113 onDelete: 'cascade'
103 }) 114 })
104 VideoFile: VideoFileModel 115 VideoFile: VideoFileModel
105 116
117 @ForeignKey(() => VideoStreamingPlaylistModel)
118 @Column
119 videoStreamingPlaylistId: number
120
121 @BelongsTo(() => VideoStreamingPlaylistModel, {
122 foreignKey: {
123 allowNull: true
124 },
125 onDelete: 'cascade'
126 })
127 VideoStreamingPlaylist: VideoStreamingPlaylistModel
128
106 @ForeignKey(() => ActorModel) 129 @ForeignKey(() => ActorModel)
107 @Column 130 @Column
108 actorId: number 131 actorId: number
@@ -119,13 +142,25 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
119 static async removeFile (instance: VideoRedundancyModel) { 142 static async removeFile (instance: VideoRedundancyModel) {
120 if (!instance.isOwned()) return 143 if (!instance.isOwned()) return
121 144
122 const videoFile = await VideoFileModel.loadWithVideo(instance.videoFileId) 145 if (instance.videoFileId) {
146 const videoFile = await VideoFileModel.loadWithVideo(instance.videoFileId)
123 147
124 const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}` 148 const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}`
125 logger.info('Removing duplicated video file %s.', logIdentifier) 149 logger.info('Removing duplicated video file %s.', logIdentifier)
126 150
127 videoFile.Video.removeFile(videoFile, true) 151 videoFile.Video.removeFile(videoFile, true)
128 .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err })) 152 .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err }))
153 }
154
155 if (instance.videoStreamingPlaylistId) {
156 const videoStreamingPlaylist = await VideoStreamingPlaylistModel.loadWithVideo(instance.videoStreamingPlaylistId)
157
158 const videoUUID = videoStreamingPlaylist.Video.uuid
159 logger.info('Removing duplicated video streaming playlist %s.', videoUUID)
160
161 videoStreamingPlaylist.Video.removeStreamingPlaylist(true)
162 .catch(err => logger.error('Cannot delete video streaming playlist files of %s.', videoUUID, { err }))
163 }
129 164
130 return undefined 165 return undefined
131 } 166 }
@@ -143,6 +178,19 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
143 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query) 178 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
144 } 179 }
145 180
181 static async loadLocalByStreamingPlaylistId (videoStreamingPlaylistId: number) {
182 const actor = await getServerActor()
183
184 const query = {
185 where: {
186 actorId: actor.id,
187 videoStreamingPlaylistId
188 }
189 }
190
191 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
192 }
193
146 static loadByUrl (url: string, transaction?: Sequelize.Transaction) { 194 static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
147 const query = { 195 const query = {
148 where: { 196 where: {
@@ -191,7 +239,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
191 const ids = rows.map(r => r.id) 239 const ids = rows.map(r => r.id)
192 const id = sample(ids) 240 const id = sample(ids)
193 241
194 return VideoModel.loadWithFile(id, undefined, !isTestInstance()) 242 return VideoModel.loadWithFiles(id, undefined, !isTestInstance())
195 } 243 }
196 244
197 static async findMostViewToDuplicate (randomizedFactor: number) { 245 static async findMostViewToDuplicate (randomizedFactor: number) {
@@ -333,40 +381,44 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
333 381
334 static async listLocalOfServer (serverId: number) { 382 static async listLocalOfServer (serverId: number) {
335 const actor = await getServerActor() 383 const actor = await getServerActor()
336 384 const buildVideoInclude = () => ({
337 const query = { 385 model: VideoModel,
338 where: { 386 required: true,
339 actorId: actor.id
340 },
341 include: [ 387 include: [
342 { 388 {
343 model: VideoFileModel, 389 attributes: [],
390 model: VideoChannelModel.unscoped(),
344 required: true, 391 required: true,
345 include: [ 392 include: [
346 { 393 {
347 model: VideoModel, 394 attributes: [],
395 model: ActorModel.unscoped(),
348 required: true, 396 required: true,
349 include: [ 397 where: {
350 { 398 serverId
351 attributes: [], 399 }
352 model: VideoChannelModel.unscoped(),
353 required: true,
354 include: [
355 {
356 attributes: [],
357 model: ActorModel.unscoped(),
358 required: true,
359 where: {
360 serverId
361 }
362 }
363 ]
364 }
365 ]
366 } 400 }
367 ] 401 ]
368 } 402 }
369 ] 403 ]
404 })
405
406 const query = {
407 where: {
408 actorId: actor.id
409 },
410 include: [
411 {
412 model: VideoFileModel,
413 required: false,
414 include: [ buildVideoInclude() ]
415 },
416 {
417 model: VideoStreamingPlaylistModel,
418 required: false,
419 include: [ buildVideoInclude() ]
420 }
421 ]
370 } 422 }
371 423
372 return VideoRedundancyModel.findAll(query) 424 return VideoRedundancyModel.findAll(query)
@@ -395,7 +447,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
395 ] 447 ]
396 } 448 }
397 449
398 return VideoRedundancyModel.find(query as any) // FIXME: typings 450 return VideoRedundancyModel.findOne(query as any) // FIXME: typings
399 .then((r: any) => ({ 451 .then((r: any) => ({
400 totalUsed: parseInt(r.totalUsed.toString(), 10), 452 totalUsed: parseInt(r.totalUsed.toString(), 10),
401 totalVideos: r.totalVideos, 453 totalVideos: r.totalVideos,
@@ -403,11 +455,32 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
403 })) 455 }))
404 } 456 }
405 457
458 getVideo () {
459 if (this.VideoFile) return this.VideoFile.Video
460
461 return this.VideoStreamingPlaylist.Video
462 }
463
406 isOwned () { 464 isOwned () {
407 return !!this.strategy 465 return !!this.strategy
408 } 466 }
409 467
410 toActivityPubObject (): CacheFileObject { 468 toActivityPubObject (): CacheFileObject {
469 if (this.VideoStreamingPlaylist) {
470 return {
471 id: this.url,
472 type: 'CacheFile' as 'CacheFile',
473 object: this.VideoStreamingPlaylist.Video.url,
474 expires: this.expiresOn.toISOString(),
475 url: {
476 type: 'Link',
477 mimeType: 'application/x-mpegURL',
478 mediaType: 'application/x-mpegURL',
479 href: this.fileUrl
480 }
481 }
482 }
483
411 return { 484 return {
412 id: this.url, 485 id: this.url,
413 type: 'CacheFile' as 'CacheFile', 486 type: 'CacheFile' as 'CacheFile',
@@ -431,7 +504,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
431 504
432 const notIn = Sequelize.literal( 505 const notIn = Sequelize.literal(
433 '(' + 506 '(' +
434 `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id}` + 507 `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id} AND "videoFileId" IS NOT NULL` +
435 ')' 508 ')'
436 ) 509 )
437 510
diff --git a/server/models/video/video-abuse.ts b/server/models/video/video-abuse.ts
index 4c9e2d05e..cc47644f2 100644
--- a/server/models/video/video-abuse.ts
+++ b/server/models/video/video-abuse.ts
@@ -1,17 +1,4 @@
1import { 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2 AfterCreate,
3 AllowNull,
4 BelongsTo,
5 Column,
6 CreatedAt,
7 DataType,
8 Default,
9 ForeignKey,
10 Is,
11 Model,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { VideoAbuseObject } from '../../../shared/models/activitypub/objects' 2import { VideoAbuseObject } from '../../../shared/models/activitypub/objects'
16import { VideoAbuse } from '../../../shared/models/videos' 3import { VideoAbuse } from '../../../shared/models/videos'
17import { 4import {
@@ -19,7 +6,6 @@ import {
19 isVideoAbuseReasonValid, 6 isVideoAbuseReasonValid,
20 isVideoAbuseStateValid 7 isVideoAbuseStateValid
21} from '../../helpers/custom-validators/video-abuses' 8} from '../../helpers/custom-validators/video-abuses'
22import { Emailer } from '../../lib/emailer'
23import { AccountModel } from '../account/account' 9import { AccountModel } from '../account/account'
24import { getSort, throwIfNotValid } from '../utils' 10import { getSort, throwIfNotValid } from '../utils'
25import { VideoModel } from './video' 11import { VideoModel } from './video'
@@ -40,8 +26,9 @@ import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers'
40export class VideoAbuseModel extends Model<VideoAbuseModel> { 26export class VideoAbuseModel extends Model<VideoAbuseModel> {
41 27
42 @AllowNull(false) 28 @AllowNull(false)
29 @Default(null)
43 @Is('VideoAbuseReason', value => throwIfNotValid(value, isVideoAbuseReasonValid, 'reason')) 30 @Is('VideoAbuseReason', value => throwIfNotValid(value, isVideoAbuseReasonValid, 'reason'))
44 @Column 31 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.REASON.max))
45 reason: string 32 reason: string
46 33
47 @AllowNull(false) 34 @AllowNull(false)
diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts
index 23e992685..3b567e488 100644
--- a/server/models/video/video-blacklist.ts
+++ b/server/models/video/video-blacklist.ts
@@ -1,21 +1,7 @@
1import { 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2 AfterCreate,
3 AfterDestroy,
4 AllowNull,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 ForeignKey,
10 Is,
11 Model,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { getSortOnModel, SortType, throwIfNotValid } from '../utils' 2import { getSortOnModel, SortType, throwIfNotValid } from '../utils'
16import { VideoModel } from './video' 3import { VideoModel } from './video'
17import { isVideoBlacklistReasonValid } from '../../helpers/custom-validators/video-blacklist' 4import { isVideoBlacklistReasonValid } from '../../helpers/custom-validators/video-blacklist'
18import { Emailer } from '../../lib/emailer'
19import { VideoBlacklist } from '../../../shared/models/videos' 5import { VideoBlacklist } from '../../../shared/models/videos'
20import { CONSTRAINTS_FIELDS } from '../../initializers' 6import { CONSTRAINTS_FIELDS } from '../../initializers'
21 7
@@ -35,6 +21,10 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
35 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_BLACKLIST.REASON.max)) 21 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_BLACKLIST.REASON.max))
36 reason: string 22 reason: string
37 23
24 @AllowNull(false)
25 @Column
26 unfederated: boolean
27
38 @CreatedAt 28 @CreatedAt
39 createdAt: Date 29 createdAt: Date
40 30
@@ -93,6 +83,7 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
93 createdAt: this.createdAt, 83 createdAt: this.createdAt,
94 updatedAt: this.updatedAt, 84 updatedAt: this.updatedAt,
95 reason: this.reason, 85 reason: this.reason,
86 unfederated: this.unfederated,
96 87
97 video: { 88 video: {
98 id: video.id, 89 id: video.id,
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index 86bf0461a..5598d80f6 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -470,4 +470,8 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
470 getDisplayName () { 470 getDisplayName () {
471 return this.name 471 return this.name
472 } 472 }
473
474 isOutdated () {
475 return this.Actor.isOutdated()
476 }
473} 477}
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
index 0fd868cd6..7d1e371b9 100644
--- a/server/models/video/video-file.ts
+++ b/server/models/video/video-file.ts
@@ -62,7 +62,7 @@ export class VideoFileModel extends Model<VideoFileModel> {
62 extname: string 62 extname: string
63 63
64 @AllowNull(false) 64 @AllowNull(false)
65 @Is('VideoFileSize', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash')) 65 @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash'))
66 @Column 66 @Column
67 infoHash: string 67 infoHash: string
68 68
@@ -86,14 +86,14 @@ export class VideoFileModel extends Model<VideoFileModel> {
86 86
87 @HasMany(() => VideoRedundancyModel, { 87 @HasMany(() => VideoRedundancyModel, {
88 foreignKey: { 88 foreignKey: {
89 allowNull: false 89 allowNull: true
90 }, 90 },
91 onDelete: 'CASCADE', 91 onDelete: 'CASCADE',
92 hooks: true 92 hooks: true
93 }) 93 })
94 RedundancyVideos: VideoRedundancyModel[] 94 RedundancyVideos: VideoRedundancyModel[]
95 95
96 static isInfohashExists (infoHash: string) { 96 static doesInfohashExist (infoHash: string) {
97 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1' 97 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
98 const options = { 98 const options = {
99 type: Sequelize.QueryTypes.SELECT, 99 type: Sequelize.QueryTypes.SELECT,
@@ -120,6 +120,26 @@ export class VideoFileModel extends Model<VideoFileModel> {
120 return VideoFileModel.findById(id, options) 120 return VideoFileModel.findById(id, options)
121 } 121 }
122 122
123 static async getStats () {
124 let totalLocalVideoFilesSize = await VideoFileModel.sum('size', {
125 include: [
126 {
127 attributes: [],
128 model: VideoModel.unscoped(),
129 where: {
130 remote: false
131 }
132 }
133 ]
134 } as any)
135 // Sequelize could return null...
136 if (!totalLocalVideoFilesSize) totalLocalVideoFilesSize = 0
137
138 return {
139 totalLocalVideoFilesSize
140 }
141 }
142
123 hasSameUniqueKeysThan (other: VideoFileModel) { 143 hasSameUniqueKeysThan (other: VideoFileModel) {
124 return this.fps === other.fps && 144 return this.fps === other.fps &&
125 this.resolution === other.resolution && 145 this.resolution === other.resolution &&
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts
index 7a9513cbe..c63285e25 100644
--- a/server/models/video/video-format-utils.ts
+++ b/server/models/video/video-format-utils.ts
@@ -1,7 +1,12 @@
1import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos' 1import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
2import { VideoModel } from './video' 2import { VideoModel } from './video'
3import { VideoFileModel } from './video-file' 3import { VideoFileModel } from './video-file'
4import { ActivityUrlObject, VideoTorrentObject } from '../../../shared/models/activitypub/objects' 4import {
5 ActivityPlaylistInfohashesObject,
6 ActivityPlaylistSegmentHashesObject,
7 ActivityUrlObject,
8 VideoTorrentObject
9} from '../../../shared/models/activitypub/objects'
5import { CONFIG, MIMETYPES, THUMBNAILS_SIZE } from '../../initializers' 10import { CONFIG, MIMETYPES, THUMBNAILS_SIZE } from '../../initializers'
6import { VideoCaptionModel } from './video-caption' 11import { VideoCaptionModel } from './video-caption'
7import { 12import {
@@ -11,6 +16,8 @@ import {
11 getVideoSharesActivityPubUrl 16 getVideoSharesActivityPubUrl
12} from '../../lib/activitypub' 17} from '../../lib/activitypub'
13import { isArray } from '../../helpers/custom-validators/misc' 18import { isArray } from '../../helpers/custom-validators/misc'
19import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model'
20import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
14 21
15export type VideoFormattingJSONOptions = { 22export type VideoFormattingJSONOptions = {
16 completeDescription?: boolean 23 completeDescription?: boolean
@@ -121,7 +128,12 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
121 } 128 }
122 }) 129 })
123 130
131 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
132
124 const tags = video.Tags ? video.Tags.map(t => t.name) : [] 133 const tags = video.Tags ? video.Tags.map(t => t.name) : []
134
135 const streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists)
136
125 const detailsJson = { 137 const detailsJson = {
126 support: video.support, 138 support: video.support,
127 descriptionPath: video.getDescriptionAPIPath(), 139 descriptionPath: video.getDescriptionAPIPath(),
@@ -129,12 +141,17 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
129 account: video.VideoChannel.Account.toFormattedJSON(), 141 account: video.VideoChannel.Account.toFormattedJSON(),
130 tags, 142 tags,
131 commentsEnabled: video.commentsEnabled, 143 commentsEnabled: video.commentsEnabled,
144 downloadEnabled: video.downloadEnabled,
132 waitTranscoding: video.waitTranscoding, 145 waitTranscoding: video.waitTranscoding,
133 state: { 146 state: {
134 id: video.state, 147 id: video.state,
135 label: VideoModel.getStateLabel(video.state) 148 label: VideoModel.getStateLabel(video.state)
136 }, 149 },
137 files: [] 150
151 trackerUrls: video.getTrackerUrls(baseUrlHttp, baseUrlWs),
152
153 files: [],
154 streamingPlaylists
138 } 155 }
139 156
140 // Format and sort video files 157 // Format and sort video files
@@ -143,6 +160,25 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
143 return Object.assign(formattedJson, detailsJson) 160 return Object.assign(formattedJson, detailsJson)
144} 161}
145 162
163function streamingPlaylistsModelToFormattedJSON (video: VideoModel, playlists: VideoStreamingPlaylistModel[]): VideoStreamingPlaylist[] {
164 if (isArray(playlists) === false) return []
165
166 return playlists
167 .map(playlist => {
168 const redundancies = isArray(playlist.RedundancyVideos)
169 ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl }))
170 : []
171
172 return {
173 id: playlist.id,
174 type: playlist.type,
175 playlistUrl: playlist.playlistUrl,
176 segmentsSha256Url: playlist.segmentsSha256Url,
177 redundancies
178 } as VideoStreamingPlaylist
179 })
180}
181
146function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] { 182function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] {
147 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls() 183 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
148 184
@@ -233,6 +269,28 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
233 }) 269 })
234 } 270 }
235 271
272 for (const playlist of (video.VideoStreamingPlaylists || [])) {
273 let tag: (ActivityPlaylistSegmentHashesObject | ActivityPlaylistInfohashesObject)[]
274
275 tag = playlist.p2pMediaLoaderInfohashes
276 .map(i => ({ type: 'Infohash' as 'Infohash', name: i }))
277 tag.push({
278 type: 'Link',
279 name: 'sha256',
280 mimeType: 'application/json' as 'application/json',
281 mediaType: 'application/json' as 'application/json',
282 href: playlist.segmentsSha256Url
283 })
284
285 url.push({
286 type: 'Link',
287 mimeType: 'application/x-mpegURL' as 'application/x-mpegURL',
288 mediaType: 'application/x-mpegURL' as 'application/x-mpegURL',
289 href: playlist.playlistUrl,
290 tag
291 })
292 }
293
236 // Add video url too 294 // Add video url too
237 url.push({ 295 url.push({
238 type: 'Link', 296 type: 'Link',
@@ -264,6 +322,7 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
264 waitTranscoding: video.waitTranscoding, 322 waitTranscoding: video.waitTranscoding,
265 state: video.state, 323 state: video.state,
266 commentsEnabled: video.commentsEnabled, 324 commentsEnabled: video.commentsEnabled,
325 downloadEnabled: video.downloadEnabled,
267 published: video.publishedAt.toISOString(), 326 published: video.publishedAt.toISOString(),
268 originallyPublishedAt: video.originallyPublishedAt ? 327 originallyPublishedAt: video.originallyPublishedAt ?
269 video.originallyPublishedAt.toISOString() : 328 video.originallyPublishedAt.toISOString() :
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
new file mode 100644
index 000000000..bf6f7b0c4
--- /dev/null
+++ b/server/models/video/video-streaming-playlist.ts
@@ -0,0 +1,158 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, HasMany, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
3import { throwIfNotValid } from '../utils'
4import { VideoModel } from './video'
5import * as Sequelize from 'sequelize'
6import { VideoRedundancyModel } from '../redundancy/video-redundancy'
7import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
8import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
9import { CONSTRAINTS_FIELDS, STATIC_PATHS } from '../../initializers'
10import { VideoFileModel } from './video-file'
11import { join } from 'path'
12import { sha1 } from '../../helpers/core-utils'
13import { isArrayOf } from '../../helpers/custom-validators/misc'
14
15@Table({
16 tableName: 'videoStreamingPlaylist',
17 indexes: [
18 {
19 fields: [ 'videoId' ]
20 },
21 {
22 fields: [ 'videoId', 'type' ],
23 unique: true
24 },
25 {
26 fields: [ 'p2pMediaLoaderInfohashes' ],
27 using: 'gin'
28 }
29 ]
30})
31export class VideoStreamingPlaylistModel extends Model<VideoStreamingPlaylistModel> {
32 @CreatedAt
33 createdAt: Date
34
35 @UpdatedAt
36 updatedAt: Date
37
38 @AllowNull(false)
39 @Column
40 type: VideoStreamingPlaylistType
41
42 @AllowNull(false)
43 @Is('PlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'playlist url'))
44 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
45 playlistUrl: string
46
47 @AllowNull(false)
48 @Is('VideoStreamingPlaylistInfoHashes', value => throwIfNotValid(value, v => isArrayOf(v, isVideoFileInfoHashValid), 'info hashes'))
49 @Column(DataType.ARRAY(DataType.STRING))
50 p2pMediaLoaderInfohashes: string[]
51
52 @AllowNull(false)
53 @Is('VideoStreamingSegmentsSha256Url', value => throwIfNotValid(value, isActivityPubUrlValid, 'segments sha256 url'))
54 @Column
55 segmentsSha256Url: string
56
57 @ForeignKey(() => VideoModel)
58 @Column
59 videoId: number
60
61 @BelongsTo(() => VideoModel, {
62 foreignKey: {
63 allowNull: false
64 },
65 onDelete: 'CASCADE'
66 })
67 Video: VideoModel
68
69 @HasMany(() => VideoRedundancyModel, {
70 foreignKey: {
71 allowNull: false
72 },
73 onDelete: 'CASCADE',
74 hooks: true
75 })
76 RedundancyVideos: VideoRedundancyModel[]
77
78 static doesInfohashExist (infoHash: string) {
79 const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1'
80 const options = {
81 type: Sequelize.QueryTypes.SELECT,
82 bind: { infoHash },
83 raw: true
84 }
85
86 return VideoModel.sequelize.query(query, options)
87 .then(results => {
88 return results.length === 1
89 })
90 }
91
92 static buildP2PMediaLoaderInfoHashes (playlistUrl: string, videoFiles: VideoFileModel[]) {
93 const hashes: string[] = []
94
95 // https://github.com/Novage/p2p-media-loader/blob/master/p2p-media-loader-core/lib/p2p-media-manager.ts#L97
96 for (let i = 0; i < videoFiles.length; i++) {
97 hashes.push(sha1(`1${playlistUrl}+V${i}`))
98 }
99
100 return hashes
101 }
102
103 static loadWithVideo (id: number) {
104 const options = {
105 include: [
106 {
107 model: VideoModel.unscoped(),
108 required: true
109 }
110 ]
111 }
112
113 return VideoStreamingPlaylistModel.findById(id, options)
114 }
115
116 static getHlsPlaylistFilename (resolution: number) {
117 return resolution + '.m3u8'
118 }
119
120 static getMasterHlsPlaylistFilename () {
121 return 'master.m3u8'
122 }
123
124 static getHlsSha256SegmentsFilename () {
125 return 'segments-sha256.json'
126 }
127
128 static getHlsVideoName (uuid: string, resolution: number) {
129 return `${uuid}-${resolution}-fragmented.mp4`
130 }
131
132 static getHlsMasterPlaylistStaticPath (videoUUID: string) {
133 return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getMasterHlsPlaylistFilename())
134 }
135
136 static getHlsPlaylistStaticPath (videoUUID: string, resolution: number) {
137 return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution))
138 }
139
140 static getHlsSha256SegmentsStaticPath (videoUUID: string) {
141 return join(STATIC_PATHS.PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename())
142 }
143
144 getStringType () {
145 if (this.type === VideoStreamingPlaylistType.HLS) return 'hls'
146
147 return 'unknown'
148 }
149
150 getVideoRedundancyUrl (baseUrlHttp: string) {
151 return baseUrlHttp + STATIC_PATHS.REDUNDANCY + this.getStringType() + '/' + this.Video.uuid
152 }
153
154 hasSameUniqueKeysThan (other: VideoStreamingPlaylistModel) {
155 return this.type === other.type &&
156 this.videoId === other.videoId
157 }
158}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 806b6e046..73626b6a0 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -52,7 +52,7 @@ import {
52 ACTIVITY_PUB, 52 ACTIVITY_PUB,
53 API_VERSION, 53 API_VERSION,
54 CONFIG, 54 CONFIG,
55 CONSTRAINTS_FIELDS, 55 CONSTRAINTS_FIELDS, HLS_PLAYLIST_DIRECTORY, HLS_REDUNDANCY_DIRECTORY,
56 PREVIEWS_SIZE, 56 PREVIEWS_SIZE,
57 REMOTE_SCHEME, 57 REMOTE_SCHEME,
58 STATIC_DOWNLOAD_PATHS, 58 STATIC_DOWNLOAD_PATHS,
@@ -95,6 +95,7 @@ import * as validator from 'validator'
95import { UserVideoHistoryModel } from '../account/user-video-history' 95import { UserVideoHistoryModel } from '../account/user-video-history'
96import { UserModel } from '../account/user' 96import { UserModel } from '../account/user'
97import { VideoImportModel } from './video-import' 97import { VideoImportModel } from './video-import'
98import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
98 99
99// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation 100// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
100const indexes: Sequelize.DefineIndexesOptions[] = [ 101const indexes: Sequelize.DefineIndexesOptions[] = [
@@ -160,7 +161,9 @@ export enum ScopeNames {
160 WITH_FILES = 'WITH_FILES', 161 WITH_FILES = 'WITH_FILES',
161 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', 162 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
162 WITH_BLACKLISTED = 'WITH_BLACKLISTED', 163 WITH_BLACKLISTED = 'WITH_BLACKLISTED',
163 WITH_USER_HISTORY = 'WITH_USER_HISTORY' 164 WITH_USER_HISTORY = 'WITH_USER_HISTORY',
165 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
166 WITH_USER_ID = 'WITH_USER_ID'
164} 167}
165 168
166type ForAPIOptions = { 169type ForAPIOptions = {
@@ -464,6 +467,22 @@ type AvailableForListIDsOptions = {
464 467
465 return query 468 return query
466 }, 469 },
470 [ ScopeNames.WITH_USER_ID ]: {
471 include: [
472 {
473 attributes: [ 'accountId' ],
474 model: () => VideoChannelModel.unscoped(),
475 required: true,
476 include: [
477 {
478 attributes: [ 'userId' ],
479 model: () => AccountModel.unscoped(),
480 required: true
481 }
482 ]
483 }
484 ]
485 },
467 [ ScopeNames.WITH_ACCOUNT_DETAILS ]: { 486 [ ScopeNames.WITH_ACCOUNT_DETAILS ]: {
468 include: [ 487 include: [
469 { 488 {
@@ -528,22 +547,55 @@ type AvailableForListIDsOptions = {
528 } 547 }
529 ] 548 ]
530 }, 549 },
531 [ ScopeNames.WITH_FILES ]: { 550 [ ScopeNames.WITH_FILES ]: (withRedundancies = false) => {
532 include: [ 551 let subInclude: any[] = []
533 { 552
534 model: () => VideoFileModel.unscoped(), 553 if (withRedundancies === true) {
535 // FIXME: typings 554 subInclude = [
536 [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join 555 {
537 required: false, 556 attributes: [ 'fileUrl' ],
538 include: [ 557 model: VideoRedundancyModel.unscoped(),
539 { 558 required: false
540 attributes: [ 'fileUrl' ], 559 }
541 model: () => VideoRedundancyModel.unscoped(), 560 ]
542 required: false 561 }
543 } 562
544 ] 563 return {
545 } 564 include: [
546 ] 565 {
566 model: VideoFileModel.unscoped(),
567 // FIXME: typings
568 [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join
569 required: false,
570 include: subInclude
571 }
572 ]
573 }
574 },
575 [ ScopeNames.WITH_STREAMING_PLAYLISTS ]: (withRedundancies = false) => {
576 let subInclude: any[] = []
577
578 if (withRedundancies === true) {
579 subInclude = [
580 {
581 attributes: [ 'fileUrl' ],
582 model: VideoRedundancyModel.unscoped(),
583 required: false
584 }
585 ]
586 }
587
588 return {
589 include: [
590 {
591 model: VideoStreamingPlaylistModel.unscoped(),
592 // FIXME: typings
593 [ 'separate' as any ]: true, // We may have multiple streaming playlists, having multiple redundancies so let's separate this join
594 required: false,
595 include: subInclude
596 }
597 ]
598 }
547 }, 599 },
548 [ ScopeNames.WITH_SCHEDULED_UPDATE ]: { 600 [ ScopeNames.WITH_SCHEDULED_UPDATE ]: {
549 include: [ 601 include: [
@@ -666,6 +718,10 @@ export class VideoModel extends Model<VideoModel> {
666 718
667 @AllowNull(false) 719 @AllowNull(false)
668 @Column 720 @Column
721 downloadEnabled: boolean
722
723 @AllowNull(false)
724 @Column
669 waitTranscoding: boolean 725 waitTranscoding: boolean
670 726
671 @AllowNull(false) 727 @AllowNull(false)
@@ -726,6 +782,16 @@ export class VideoModel extends Model<VideoModel> {
726 }) 782 })
727 VideoFiles: VideoFileModel[] 783 VideoFiles: VideoFileModel[]
728 784
785 @HasMany(() => VideoStreamingPlaylistModel, {
786 foreignKey: {
787 name: 'videoId',
788 allowNull: false
789 },
790 hooks: true,
791 onDelete: 'cascade'
792 })
793 VideoStreamingPlaylists: VideoStreamingPlaylistModel[]
794
729 @HasMany(() => VideoShareModel, { 795 @HasMany(() => VideoShareModel, {
730 foreignKey: { 796 foreignKey: {
731 name: 'videoId', 797 name: 'videoId',
@@ -851,6 +917,9 @@ export class VideoModel extends Model<VideoModel> {
851 tasks.push(instance.removeFile(file)) 917 tasks.push(instance.removeFile(file))
852 tasks.push(instance.removeTorrent(file)) 918 tasks.push(instance.removeTorrent(file))
853 }) 919 })
920
921 // Remove playlists file
922 tasks.push(instance.removeStreamingPlaylist())
854 } 923 }
855 924
856 // Do not wait video deletion because we could be in a transaction 925 // Do not wait video deletion because we could be in a transaction
@@ -862,10 +931,6 @@ export class VideoModel extends Model<VideoModel> {
862 return undefined 931 return undefined
863 } 932 }
864 933
865 static list () {
866 return VideoModel.scope(ScopeNames.WITH_FILES).findAll()
867 }
868
869 static listLocal () { 934 static listLocal () {
870 const query = { 935 const query = {
871 where: { 936 where: {
@@ -873,7 +938,7 @@ export class VideoModel extends Model<VideoModel> {
873 } 938 }
874 } 939 }
875 940
876 return VideoModel.scope(ScopeNames.WITH_FILES).findAll(query) 941 return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ]).findAll(query)
877 } 942 }
878 943
879 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) { 944 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
@@ -1204,6 +1269,16 @@ export class VideoModel extends Model<VideoModel> {
1204 return VideoModel.findOne(options) 1269 return VideoModel.findOne(options)
1205 } 1270 }
1206 1271
1272 static loadWithRights (id: number | string, t?: Sequelize.Transaction) {
1273 const where = VideoModel.buildWhereIdOrUUID(id)
1274 const options = {
1275 where,
1276 transaction: t
1277 }
1278
1279 return VideoModel.scope([ ScopeNames.WITH_BLACKLISTED, ScopeNames.WITH_USER_ID ]).findOne(options)
1280 }
1281
1207 static loadOnlyId (id: number | string, t?: Sequelize.Transaction) { 1282 static loadOnlyId (id: number | string, t?: Sequelize.Transaction) {
1208 const where = VideoModel.buildWhereIdOrUUID(id) 1283 const where = VideoModel.buildWhereIdOrUUID(id)
1209 1284
@@ -1216,8 +1291,8 @@ export class VideoModel extends Model<VideoModel> {
1216 return VideoModel.findOne(options) 1291 return VideoModel.findOne(options)
1217 } 1292 }
1218 1293
1219 static loadWithFile (id: number, t?: Sequelize.Transaction, logging?: boolean) { 1294 static loadWithFiles (id: number, t?: Sequelize.Transaction, logging?: boolean) {
1220 return VideoModel.scope(ScopeNames.WITH_FILES) 1295 return VideoModel.scope([ ScopeNames.WITH_FILES, ScopeNames.WITH_STREAMING_PLAYLISTS ])
1221 .findById(id, { transaction: t, logging }) 1296 .findById(id, { transaction: t, logging })
1222 } 1297 }
1223 1298
@@ -1228,9 +1303,7 @@ export class VideoModel extends Model<VideoModel> {
1228 } 1303 }
1229 } 1304 }
1230 1305
1231 return VideoModel 1306 return VideoModel.findOne(options)
1232 .scope([ ScopeNames.WITH_FILES ])
1233 .findOne(options)
1234 } 1307 }
1235 1308
1236 static loadByUrl (url: string, transaction?: Sequelize.Transaction) { 1309 static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
@@ -1252,7 +1325,11 @@ export class VideoModel extends Model<VideoModel> {
1252 transaction 1325 transaction
1253 } 1326 }
1254 1327
1255 return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query) 1328 return VideoModel.scope([
1329 ScopeNames.WITH_ACCOUNT_DETAILS,
1330 ScopeNames.WITH_FILES,
1331 ScopeNames.WITH_STREAMING_PLAYLISTS
1332 ]).findOne(query)
1256 } 1333 }
1257 1334
1258 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) { 1335 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) {
@@ -1267,9 +1344,37 @@ export class VideoModel extends Model<VideoModel> {
1267 const scopes = [ 1344 const scopes = [
1268 ScopeNames.WITH_TAGS, 1345 ScopeNames.WITH_TAGS,
1269 ScopeNames.WITH_BLACKLISTED, 1346 ScopeNames.WITH_BLACKLISTED,
1347 ScopeNames.WITH_ACCOUNT_DETAILS,
1348 ScopeNames.WITH_SCHEDULED_UPDATE,
1270 ScopeNames.WITH_FILES, 1349 ScopeNames.WITH_FILES,
1350 ScopeNames.WITH_STREAMING_PLAYLISTS
1351 ]
1352
1353 if (userId) {
1354 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] } as any) // FIXME: typings
1355 }
1356
1357 return VideoModel
1358 .scope(scopes)
1359 .findOne(options)
1360 }
1361
1362 static loadForGetAPI (id: number | string, t?: Sequelize.Transaction, userId?: number) {
1363 const where = VideoModel.buildWhereIdOrUUID(id)
1364
1365 const options = {
1366 order: [ [ 'Tags', 'name', 'ASC' ] ],
1367 where,
1368 transaction: t
1369 }
1370
1371 const scopes = [
1372 ScopeNames.WITH_TAGS,
1373 ScopeNames.WITH_BLACKLISTED,
1271 ScopeNames.WITH_ACCOUNT_DETAILS, 1374 ScopeNames.WITH_ACCOUNT_DETAILS,
1272 ScopeNames.WITH_SCHEDULED_UPDATE 1375 ScopeNames.WITH_SCHEDULED_UPDATE,
1376 { method: [ ScopeNames.WITH_FILES, true ] } as any, // FIXME: typings
1377 { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] } as any // FIXME: typings
1273 ] 1378 ]
1274 1379
1275 if (userId) { 1380 if (userId) {
@@ -1616,6 +1721,14 @@ export class VideoModel extends Model<VideoModel> {
1616 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err })) 1721 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
1617 } 1722 }
1618 1723
1724 removeStreamingPlaylist (isRedundancy = false) {
1725 const baseDir = isRedundancy ? HLS_REDUNDANCY_DIRECTORY : HLS_PLAYLIST_DIRECTORY
1726
1727 const filePath = join(baseDir, this.uuid)
1728 return remove(filePath)
1729 .catch(err => logger.warn('Cannot delete playlist directory %s.', filePath, { err }))
1730 }
1731
1619 isOutdated () { 1732 isOutdated () {
1620 if (this.isOwned()) return false 1733 if (this.isOwned()) return false
1621 1734
@@ -1650,7 +1763,7 @@ export class VideoModel extends Model<VideoModel> {
1650 1763
1651 generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) { 1764 generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
1652 const xs = this.getTorrentUrl(videoFile, baseUrlHttp) 1765 const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
1653 const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ] 1766 const announce = this.getTrackerUrls(baseUrlHttp, baseUrlWs)
1654 let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ] 1767 let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
1655 1768
1656 const redundancies = videoFile.RedundancyVideos 1769 const redundancies = videoFile.RedundancyVideos
@@ -1667,6 +1780,10 @@ export class VideoModel extends Model<VideoModel> {
1667 return magnetUtil.encode(magnetHash) 1780 return magnetUtil.encode(magnetHash)
1668 } 1781 }
1669 1782
1783 getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) {
1784 return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
1785 }
1786
1670 getThumbnailUrl (baseUrlHttp: string) { 1787 getThumbnailUrl (baseUrlHttp: string) {
1671 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName() 1788 return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
1672 } 1789 }
@@ -1690,4 +1807,8 @@ export class VideoModel extends Model<VideoModel> {
1690 getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) { 1807 getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
1691 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile) 1808 return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile)
1692 } 1809 }
1810
1811 getBandwidthBits (videoFile: VideoFileModel) {
1812 return Math.ceil((videoFile.size * 8) / this.duration)
1813 }
1693} 1814}
diff --git a/server/tests/api/check-params/accounts.ts b/server/tests/api/check-params/accounts.ts
index 567fd072c..68f9519c6 100644
--- a/server/tests/api/check-params/accounts.ts
+++ b/server/tests/api/check-params/accounts.ts
@@ -10,7 +10,7 @@ import {
10} from '../../../../shared/utils/requests/check-api-params' 10} from '../../../../shared/utils/requests/check-api-params'
11import { getAccount } from '../../../../shared/utils/users/accounts' 11import { getAccount } from '../../../../shared/utils/users/accounts'
12 12
13describe('Test users API validators', function () { 13describe('Test accounts API validators', function () {
14 const path = '/api/v1/accounts/' 14 const path = '/api/v1/accounts/'
15 let server: ServerInfo 15 let server: ServerInfo
16 16
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts
index 4038ecbf0..07de2b5a5 100644
--- a/server/tests/api/check-params/config.ts
+++ b/server/tests/api/check-params/config.ts
@@ -65,6 +65,9 @@ describe('Test config API validators', function () {
65 '480p': true, 65 '480p': true,
66 '720p': false, 66 '720p': false,
67 '1080p': false 67 '1080p': false
68 },
69 hls: {
70 enabled: false
68 } 71 }
69 }, 72 },
70 import: { 73 import: {
diff --git a/server/tests/api/check-params/contact-form.ts b/server/tests/api/check-params/contact-form.ts
index 2407ac0b5..c7e014b1f 100644
--- a/server/tests/api/check-params/contact-form.ts
+++ b/server/tests/api/check-params/contact-form.ts
@@ -46,6 +46,8 @@ describe('Test contact form API validators', function () {
46 }) 46 })
47 47
48 it('Should not accept a contact form if it is disabled in the configuration', async function () { 48 it('Should not accept a contact form if it is disabled in the configuration', async function () {
49 this.timeout(10000)
50
49 killallServers([ server ]) 51 killallServers([ server ])
50 52
51 // Contact form is disabled 53 // Contact form is disabled
@@ -54,6 +56,8 @@ describe('Test contact form API validators', function () {
54 }) 56 })
55 57
56 it('Should not accept a contact form if from email is invalid', async function () { 58 it('Should not accept a contact form if from email is invalid', async function () {
59 this.timeout(10000)
60
57 killallServers([ server ]) 61 killallServers([ server ])
58 62
59 // Email & contact form enabled 63 // Email & contact form enabled
diff --git a/server/tests/api/check-params/users.ts b/server/tests/api/check-params/users.ts
index a3e8e2e9c..13be8b460 100644
--- a/server/tests/api/check-params/users.ts
+++ b/server/tests/api/check-params/users.ts
@@ -464,6 +464,24 @@ describe('Test users API validators', function () {
464 await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields }) 464 await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields })
465 }) 465 })
466 466
467 it('Should fail with a too small password', async function () {
468 const fields = {
469 currentPassword: 'my super password',
470 password: 'bla'
471 }
472
473 await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields })
474 })
475
476 it('Should fail with a too long password', async function () {
477 const fields = {
478 currentPassword: 'my super password',
479 password: 'super'.repeat(61)
480 }
481
482 await makePutBodyRequest({ url: server.url, path: path + userId, token: server.accessToken, fields })
483 })
484
467 it('Should fail with an non authenticated user', async function () { 485 it('Should fail with an non authenticated user', async function () {
468 const fields = { 486 const fields = {
469 videoQuota: 42 487 videoQuota: 42
diff --git a/server/tests/api/check-params/video-abuses.ts b/server/tests/api/check-params/video-abuses.ts
index a79ab4201..3b8f5f14d 100644
--- a/server/tests/api/check-params/video-abuses.ts
+++ b/server/tests/api/check-params/video-abuses.ts
@@ -113,8 +113,8 @@ describe('Test video abuses API validators', function () {
113 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 113 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
114 }) 114 })
115 115
116 it('Should fail with a reason too big', async function () { 116 it('Should fail with a too big reason', async function () {
117 const fields = { reason: 'super'.repeat(61) } 117 const fields = { reason: 'super'.repeat(605) }
118 118
119 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 119 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
120 }) 120 })
@@ -154,7 +154,7 @@ describe('Test video abuses API validators', function () {
154 }) 154 })
155 155
156 it('Should fail with a bad moderation comment', async function () { 156 it('Should fail with a bad moderation comment', async function () {
157 const body = { moderationComment: 'b'.repeat(305) } 157 const body = { moderationComment: 'b'.repeat(3001) }
158 await updateVideoAbuse(server.url, server.accessToken, server.video.uuid, videoAbuseId, body, 400) 158 await updateVideoAbuse(server.url, server.accessToken, server.video.uuid, videoAbuseId, body, 400)
159 }) 159 })
160 160
diff --git a/server/tests/api/check-params/video-blacklist.ts b/server/tests/api/check-params/video-blacklist.ts
index 8e1206db3..6b82643f4 100644
--- a/server/tests/api/check-params/video-blacklist.ts
+++ b/server/tests/api/check-params/video-blacklist.ts
@@ -4,17 +4,20 @@ import 'mocha'
4 4
5import { 5import {
6 createUser, 6 createUser,
7 doubleFollow,
8 flushAndRunMultipleServers,
7 flushTests, 9 flushTests,
8 getBlacklistedVideosList, getVideo, getVideoWithToken, 10 getBlacklistedVideosList,
11 getVideo,
12 getVideoWithToken,
9 killallServers, 13 killallServers,
10 makePostBodyRequest, 14 makePostBodyRequest,
11 makePutBodyRequest, 15 makePutBodyRequest,
12 removeVideoFromBlacklist, 16 removeVideoFromBlacklist,
13 runServer,
14 ServerInfo, 17 ServerInfo,
15 setAccessTokensToServers, 18 setAccessTokensToServers,
16 uploadVideo, 19 uploadVideo,
17 userLogin 20 userLogin, waitJobs
18} from '../../../../shared/utils' 21} from '../../../../shared/utils'
19import { 22import {
20 checkBadCountPagination, 23 checkBadCountPagination,
@@ -25,8 +28,9 @@ import { VideoDetails } from '../../../../shared/models/videos'
25import { expect } from 'chai' 28import { expect } from 'chai'
26 29
27describe('Test video blacklist API validators', function () { 30describe('Test video blacklist API validators', function () {
28 let server: ServerInfo 31 let servers: ServerInfo[]
29 let notBlacklistedVideoId: number 32 let notBlacklistedVideoId: number
33 let remoteVideoUUID: string
30 let userAccessToken1 = '' 34 let userAccessToken1 = ''
31 let userAccessToken2 = '' 35 let userAccessToken2 = ''
32 36
@@ -36,75 +40,89 @@ describe('Test video blacklist API validators', function () {
36 this.timeout(120000) 40 this.timeout(120000)
37 41
38 await flushTests() 42 await flushTests()
43 servers = await flushAndRunMultipleServers(2)
39 44
40 server = await runServer(1) 45 await setAccessTokensToServers(servers)
41 46 await doubleFollow(servers[0], servers[1])
42 await setAccessTokensToServers([ server ])
43 47
44 { 48 {
45 const username = 'user1' 49 const username = 'user1'
46 const password = 'my super password' 50 const password = 'my super password'
47 await createUser(server.url, server.accessToken, username, password) 51 await createUser(servers[0].url, servers[0].accessToken, username, password)
48 userAccessToken1 = await userLogin(server, { username, password }) 52 userAccessToken1 = await userLogin(servers[0], { username, password })
49 } 53 }
50 54
51 { 55 {
52 const username = 'user2' 56 const username = 'user2'
53 const password = 'my super password' 57 const password = 'my super password'
54 await createUser(server.url, server.accessToken, username, password) 58 await createUser(servers[0].url, servers[0].accessToken, username, password)
55 userAccessToken2 = await userLogin(server, { username, password }) 59 userAccessToken2 = await userLogin(servers[0], { username, password })
56 } 60 }
57 61
58 { 62 {
59 const res = await uploadVideo(server.url, userAccessToken1, {}) 63 const res = await uploadVideo(servers[0].url, userAccessToken1, {})
60 server.video = res.body.video 64 servers[0].video = res.body.video
61 } 65 }
62 66
63 { 67 {
64 const res = await uploadVideo(server.url, server.accessToken, {}) 68 const res = await uploadVideo(servers[0].url, servers[0].accessToken, {})
65 notBlacklistedVideoId = res.body.video.uuid 69 notBlacklistedVideoId = res.body.video.uuid
66 } 70 }
71
72 {
73 const res = await uploadVideo(servers[1].url, servers[1].accessToken, {})
74 remoteVideoUUID = res.body.video.uuid
75 }
76
77 await waitJobs(servers)
67 }) 78 })
68 79
69 describe('When adding a video in blacklist', function () { 80 describe('When adding a video in blacklist', function () {
70 const basePath = '/api/v1/videos/' 81 const basePath = '/api/v1/videos/'
71 82
72 it('Should fail with nothing', async function () { 83 it('Should fail with nothing', async function () {
73 const path = basePath + server.video + '/blacklist' 84 const path = basePath + servers[0].video + '/blacklist'
74 const fields = {} 85 const fields = {}
75 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 86 await makePostBodyRequest({ url: servers[0].url, path, token: servers[0].accessToken, fields })
76 }) 87 })
77 88
78 it('Should fail with a wrong video', async function () { 89 it('Should fail with a wrong video', async function () {
79 const wrongPath = '/api/v1/videos/blabla/blacklist' 90 const wrongPath = '/api/v1/videos/blabla/blacklist'
80 const fields = {} 91 const fields = {}
81 await makePostBodyRequest({ url: server.url, path: wrongPath, token: server.accessToken, fields }) 92 await makePostBodyRequest({ url: servers[0].url, path: wrongPath, token: servers[0].accessToken, fields })
82 }) 93 })
83 94
84 it('Should fail with a non authenticated user', async function () { 95 it('Should fail with a non authenticated user', async function () {
85 const path = basePath + server.video + '/blacklist' 96 const path = basePath + servers[0].video + '/blacklist'
86 const fields = {} 97 const fields = {}
87 await makePostBodyRequest({ url: server.url, path, token: 'hello', fields, statusCodeExpected: 401 }) 98 await makePostBodyRequest({ url: servers[0].url, path, token: 'hello', fields, statusCodeExpected: 401 })
88 }) 99 })
89 100
90 it('Should fail with a non admin user', async function () { 101 it('Should fail with a non admin user', async function () {
91 const path = basePath + server.video + '/blacklist' 102 const path = basePath + servers[0].video + '/blacklist'
92 const fields = {} 103 const fields = {}
93 await makePostBodyRequest({ url: server.url, path, token: userAccessToken2, fields, statusCodeExpected: 403 }) 104 await makePostBodyRequest({ url: servers[0].url, path, token: userAccessToken2, fields, statusCodeExpected: 403 })
94 }) 105 })
95 106
96 it('Should fail with an invalid reason', async function () { 107 it('Should fail with an invalid reason', async function () {
97 const path = basePath + server.video.uuid + '/blacklist' 108 const path = basePath + servers[0].video.uuid + '/blacklist'
98 const fields = { reason: 'a'.repeat(305) } 109 const fields = { reason: 'a'.repeat(305) }
99 110
100 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 111 await makePostBodyRequest({ url: servers[0].url, path, token: servers[0].accessToken, fields })
112 })
113
114 it('Should fail to unfederate a remote video', async function () {
115 const path = basePath + remoteVideoUUID + '/blacklist'
116 const fields = { unfederate: true }
117
118 await makePostBodyRequest({ url: servers[0].url, path, token: servers[0].accessToken, fields, statusCodeExpected: 409 })
101 }) 119 })
102 120
103 it('Should succeed with the correct params', async function () { 121 it('Should succeed with the correct params', async function () {
104 const path = basePath + server.video.uuid + '/blacklist' 122 const path = basePath + servers[0].video.uuid + '/blacklist'
105 const fields = { } 123 const fields = { }
106 124
107 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 204 }) 125 await makePostBodyRequest({ url: servers[0].url, path, token: servers[0].accessToken, fields, statusCodeExpected: 204 })
108 }) 126 })
109 }) 127 })
110 128
@@ -114,61 +132,61 @@ describe('Test video blacklist API validators', function () {
114 it('Should fail with a wrong video', async function () { 132 it('Should fail with a wrong video', async function () {
115 const wrongPath = '/api/v1/videos/blabla/blacklist' 133 const wrongPath = '/api/v1/videos/blabla/blacklist'
116 const fields = {} 134 const fields = {}
117 await makePutBodyRequest({ url: server.url, path: wrongPath, token: server.accessToken, fields }) 135 await makePutBodyRequest({ url: servers[0].url, path: wrongPath, token: servers[0].accessToken, fields })
118 }) 136 })
119 137
120 it('Should fail with a video not blacklisted', async function () { 138 it('Should fail with a video not blacklisted', async function () {
121 const path = '/api/v1/videos/' + notBlacklistedVideoId + '/blacklist' 139 const path = '/api/v1/videos/' + notBlacklistedVideoId + '/blacklist'
122 const fields = {} 140 const fields = {}
123 await makePutBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 404 }) 141 await makePutBodyRequest({ url: servers[0].url, path, token: servers[0].accessToken, fields, statusCodeExpected: 404 })
124 }) 142 })
125 143
126 it('Should fail with a non authenticated user', async function () { 144 it('Should fail with a non authenticated user', async function () {
127 const path = basePath + server.video + '/blacklist' 145 const path = basePath + servers[0].video + '/blacklist'
128 const fields = {} 146 const fields = {}
129 await makePutBodyRequest({ url: server.url, path, token: 'hello', fields, statusCodeExpected: 401 }) 147 await makePutBodyRequest({ url: servers[0].url, path, token: 'hello', fields, statusCodeExpected: 401 })
130 }) 148 })
131 149
132 it('Should fail with a non admin user', async function () { 150 it('Should fail with a non admin user', async function () {
133 const path = basePath + server.video + '/blacklist' 151 const path = basePath + servers[0].video + '/blacklist'
134 const fields = {} 152 const fields = {}
135 await makePutBodyRequest({ url: server.url, path, token: userAccessToken2, fields, statusCodeExpected: 403 }) 153 await makePutBodyRequest({ url: servers[0].url, path, token: userAccessToken2, fields, statusCodeExpected: 403 })
136 }) 154 })
137 155
138 it('Should fail with an invalid reason', async function () { 156 it('Should fail with an invalid reason', async function () {
139 const path = basePath + server.video.uuid + '/blacklist' 157 const path = basePath + servers[0].video.uuid + '/blacklist'
140 const fields = { reason: 'a'.repeat(305) } 158 const fields = { reason: 'a'.repeat(305) }
141 159
142 await makePutBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 160 await makePutBodyRequest({ url: servers[0].url, path, token: servers[0].accessToken, fields })
143 }) 161 })
144 162
145 it('Should succeed with the correct params', async function () { 163 it('Should succeed with the correct params', async function () {
146 const path = basePath + server.video.uuid + '/blacklist' 164 const path = basePath + servers[0].video.uuid + '/blacklist'
147 const fields = { reason: 'hello' } 165 const fields = { reason: 'hello' }
148 166
149 await makePutBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 204 }) 167 await makePutBodyRequest({ url: servers[0].url, path, token: servers[0].accessToken, fields, statusCodeExpected: 204 })
150 }) 168 })
151 }) 169 })
152 170
153 describe('When getting blacklisted video', function () { 171 describe('When getting blacklisted video', function () {
154 172
155 it('Should fail with a non authenticated user', async function () { 173 it('Should fail with a non authenticated user', async function () {
156 await getVideo(server.url, server.video.uuid, 401) 174 await getVideo(servers[0].url, servers[0].video.uuid, 401)
157 }) 175 })
158 176
159 it('Should fail with another user', async function () { 177 it('Should fail with another user', async function () {
160 await getVideoWithToken(server.url, userAccessToken2, server.video.uuid, 403) 178 await getVideoWithToken(servers[0].url, userAccessToken2, servers[0].video.uuid, 403)
161 }) 179 })
162 180
163 it('Should succeed with the owner authenticated user', async function () { 181 it('Should succeed with the owner authenticated user', async function () {
164 const res = await getVideoWithToken(server.url, userAccessToken1, server.video.uuid, 200) 182 const res = await getVideoWithToken(servers[0].url, userAccessToken1, servers[0].video.uuid, 200)
165 const video: VideoDetails = res.body 183 const video: VideoDetails = res.body
166 184
167 expect(video.blacklisted).to.be.true 185 expect(video.blacklisted).to.be.true
168 }) 186 })
169 187
170 it('Should succeed with an admin', async function () { 188 it('Should succeed with an admin', async function () {
171 const res = await getVideoWithToken(server.url, server.accessToken, server.video.uuid, 200) 189 const res = await getVideoWithToken(servers[0].url, servers[0].accessToken, servers[0].video.uuid, 200)
172 const video: VideoDetails = res.body 190 const video: VideoDetails = res.body
173 191
174 expect(video.blacklisted).to.be.true 192 expect(video.blacklisted).to.be.true
@@ -177,24 +195,24 @@ describe('Test video blacklist API validators', function () {
177 195
178 describe('When removing a video in blacklist', function () { 196 describe('When removing a video in blacklist', function () {
179 it('Should fail with a non authenticated user', async function () { 197 it('Should fail with a non authenticated user', async function () {
180 await removeVideoFromBlacklist(server.url, 'fake token', server.video.uuid, 401) 198 await removeVideoFromBlacklist(servers[0].url, 'fake token', servers[0].video.uuid, 401)
181 }) 199 })
182 200
183 it('Should fail with a non admin user', async function () { 201 it('Should fail with a non admin user', async function () {
184 await removeVideoFromBlacklist(server.url, userAccessToken2, server.video.uuid, 403) 202 await removeVideoFromBlacklist(servers[0].url, userAccessToken2, servers[0].video.uuid, 403)
185 }) 203 })
186 204
187 it('Should fail with an incorrect id', async function () { 205 it('Should fail with an incorrect id', async function () {
188 await removeVideoFromBlacklist(server.url, server.accessToken, 'hello', 400) 206 await removeVideoFromBlacklist(servers[0].url, servers[0].accessToken, 'hello', 400)
189 }) 207 })
190 208
191 it('Should fail with a not blacklisted video', async function () { 209 it('Should fail with a not blacklisted video', async function () {
192 // The video was not added to the blacklist so it should fail 210 // The video was not added to the blacklist so it should fail
193 await removeVideoFromBlacklist(server.url, server.accessToken, notBlacklistedVideoId, 404) 211 await removeVideoFromBlacklist(servers[0].url, servers[0].accessToken, notBlacklistedVideoId, 404)
194 }) 212 })
195 213
196 it('Should succeed with the correct params', async function () { 214 it('Should succeed with the correct params', async function () {
197 await removeVideoFromBlacklist(server.url, server.accessToken, server.video.uuid, 204) 215 await removeVideoFromBlacklist(servers[0].url, servers[0].accessToken, servers[0].video.uuid, 204)
198 }) 216 })
199 }) 217 })
200 218
@@ -202,28 +220,28 @@ describe('Test video blacklist API validators', function () {
202 const basePath = '/api/v1/videos/blacklist/' 220 const basePath = '/api/v1/videos/blacklist/'
203 221
204 it('Should fail with a non authenticated user', async function () { 222 it('Should fail with a non authenticated user', async function () {
205 await getBlacklistedVideosList(server.url, 'fake token', 401) 223 await getBlacklistedVideosList(servers[0].url, 'fake token', 401)
206 }) 224 })
207 225
208 it('Should fail with a non admin user', async function () { 226 it('Should fail with a non admin user', async function () {
209 await getBlacklistedVideosList(server.url, userAccessToken2, 403) 227 await getBlacklistedVideosList(servers[0].url, userAccessToken2, 403)
210 }) 228 })
211 229
212 it('Should fail with a bad start pagination', async function () { 230 it('Should fail with a bad start pagination', async function () {
213 await checkBadStartPagination(server.url, basePath, server.accessToken) 231 await checkBadStartPagination(servers[0].url, basePath, servers[0].accessToken)
214 }) 232 })
215 233
216 it('Should fail with a bad count pagination', async function () { 234 it('Should fail with a bad count pagination', async function () {
217 await checkBadCountPagination(server.url, basePath, server.accessToken) 235 await checkBadCountPagination(servers[0].url, basePath, servers[0].accessToken)
218 }) 236 })
219 237
220 it('Should fail with an incorrect sort', async function () { 238 it('Should fail with an incorrect sort', async function () {
221 await checkBadSortPagination(server.url, basePath, server.accessToken) 239 await checkBadSortPagination(servers[0].url, basePath, servers[0].accessToken)
222 }) 240 })
223 }) 241 })
224 242
225 after(async function () { 243 after(async function () {
226 killallServers([ server ]) 244 killallServers(servers)
227 245
228 // Keep the logs if the test failed 246 // Keep the logs if the test failed
229 if (this['ok']) { 247 if (this['ok']) {
diff --git a/server/tests/api/check-params/video-imports.ts b/server/tests/api/check-params/video-imports.ts
index 7bf187007..6dd9f15f7 100644
--- a/server/tests/api/check-params/video-imports.ts
+++ b/server/tests/api/check-params/video-imports.ts
@@ -88,6 +88,7 @@ describe('Test video imports API validator', function () {
88 language: 'pt', 88 language: 'pt',
89 nsfw: false, 89 nsfw: false,
90 commentsEnabled: true, 90 commentsEnabled: true,
91 downloadEnabled: true,
91 waitTranscoding: true, 92 waitTranscoding: true,
92 description: 'my super description', 93 description: 'my super description',
93 support: 'my super support text', 94 support: 'my super support text',
diff --git a/server/tests/api/check-params/videos.ts b/server/tests/api/check-params/videos.ts
index f26b91435..878ffe025 100644
--- a/server/tests/api/check-params/videos.ts
+++ b/server/tests/api/check-params/videos.ts
@@ -179,6 +179,7 @@ describe('Test videos API validator', function () {
179 language: 'pt', 179 language: 'pt',
180 nsfw: false, 180 nsfw: false,
181 commentsEnabled: true, 181 commentsEnabled: true,
182 downloadEnabled: true,
182 waitTranscoding: true, 183 waitTranscoding: true,
183 description: 'my super description', 184 description: 'my super description',
184 support: 'my super support text', 185 support: 'my super support text',
@@ -428,6 +429,7 @@ describe('Test videos API validator', function () {
428 language: 'pt', 429 language: 'pt',
429 nsfw: false, 430 nsfw: false,
430 commentsEnabled: false, 431 commentsEnabled: false,
432 downloadEnabled: false,
431 description: 'my super description', 433 description: 'my super description',
432 privacy: VideoPrivacy.PUBLIC, 434 privacy: VideoPrivacy.PUBLIC,
433 tags: [ 'tag1', 'tag2' ] 435 tags: [ 'tag1', 'tag2' ]
diff --git a/server/tests/api/redundancy/redundancy.ts b/server/tests/api/redundancy/redundancy.ts
index 9d3ce8153..778611fff 100644
--- a/server/tests/api/redundancy/redundancy.ts
+++ b/server/tests/api/redundancy/redundancy.ts
@@ -17,7 +17,7 @@ import {
17 viewVideo, 17 viewVideo,
18 wait, 18 wait,
19 waitUntilLog, 19 waitUntilLog,
20 checkVideoFilesWereRemoved, removeVideo, getVideoWithToken 20 checkVideoFilesWereRemoved, removeVideo, getVideoWithToken, reRunServer, checkSegmentHash
21} from '../../../../shared/utils' 21} from '../../../../shared/utils'
22import { waitJobs } from '../../../../shared/utils/server/jobs' 22import { waitJobs } from '../../../../shared/utils/server/jobs'
23 23
@@ -48,6 +48,11 @@ function checkMagnetWebseeds (file: { magnetUri: string, resolution: { id: numbe
48 48
49async function runServers (strategy: VideoRedundancyStrategy, additionalParams: any = {}) { 49async function runServers (strategy: VideoRedundancyStrategy, additionalParams: any = {}) {
50 const config = { 50 const config = {
51 transcoding: {
52 hls: {
53 enabled: true
54 }
55 },
51 redundancy: { 56 redundancy: {
52 videos: { 57 videos: {
53 check_interval: '5 seconds', 58 check_interval: '5 seconds',
@@ -85,7 +90,7 @@ async function runServers (strategy: VideoRedundancyStrategy, additionalParams:
85 await waitJobs(servers) 90 await waitJobs(servers)
86} 91}
87 92
88async function check1WebSeed (strategy: VideoRedundancyStrategy, videoUUID?: string) { 93async function check1WebSeed (videoUUID?: string) {
89 if (!videoUUID) videoUUID = video1Server2UUID 94 if (!videoUUID) videoUUID = video1Server2UUID
90 95
91 const webseeds = [ 96 const webseeds = [
@@ -93,47 +98,17 @@ async function check1WebSeed (strategy: VideoRedundancyStrategy, videoUUID?: str
93 ] 98 ]
94 99
95 for (const server of servers) { 100 for (const server of servers) {
96 { 101 // With token to avoid issues with video follow constraints
97 // With token to avoid issues with video follow constraints 102 const res = await getVideoWithToken(server.url, server.accessToken, videoUUID)
98 const res = await getVideoWithToken(server.url, server.accessToken, videoUUID)
99 103
100 const video: VideoDetails = res.body 104 const video: VideoDetails = res.body
101 for (const f of video.files) { 105 for (const f of video.files) {
102 checkMagnetWebseeds(f, webseeds, server) 106 checkMagnetWebseeds(f, webseeds, server)
103 }
104 } 107 }
105 } 108 }
106} 109}
107 110
108async function checkStatsWith2Webseed (strategy: VideoRedundancyStrategy) { 111async function check2Webseeds (videoUUID?: string) {
109 const res = await getStats(servers[0].url)
110 const data: ServerStats = res.body
111
112 expect(data.videosRedundancy).to.have.lengthOf(1)
113 const stat = data.videosRedundancy[0]
114
115 expect(stat.strategy).to.equal(strategy)
116 expect(stat.totalSize).to.equal(204800)
117 expect(stat.totalUsed).to.be.at.least(1).and.below(204801)
118 expect(stat.totalVideoFiles).to.equal(4)
119 expect(stat.totalVideos).to.equal(1)
120}
121
122async function checkStatsWith1Webseed (strategy: VideoRedundancyStrategy) {
123 const res = await getStats(servers[0].url)
124 const data: ServerStats = res.body
125
126 expect(data.videosRedundancy).to.have.lengthOf(1)
127
128 const stat = data.videosRedundancy[0]
129 expect(stat.strategy).to.equal(strategy)
130 expect(stat.totalSize).to.equal(204800)
131 expect(stat.totalUsed).to.equal(0)
132 expect(stat.totalVideoFiles).to.equal(0)
133 expect(stat.totalVideos).to.equal(0)
134}
135
136async function check2Webseeds (strategy: VideoRedundancyStrategy, videoUUID?: string) {
137 if (!videoUUID) videoUUID = video1Server2UUID 112 if (!videoUUID) videoUUID = video1Server2UUID
138 113
139 const webseeds = [ 114 const webseeds = [
@@ -158,7 +133,7 @@ async function check2Webseeds (strategy: VideoRedundancyStrategy, videoUUID?: st
158 await makeGetRequest({ 133 await makeGetRequest({
159 url: servers[1].url, 134 url: servers[1].url,
160 statusCodeExpected: 200, 135 statusCodeExpected: 200,
161 path: '/static/webseed/' + `${videoUUID}-${file.resolution.id}.mp4`, 136 path: `/static/webseed/${videoUUID}-${file.resolution.id}.mp4`,
162 contentType: null 137 contentType: null
163 }) 138 })
164 } 139 }
@@ -174,6 +149,85 @@ async function check2Webseeds (strategy: VideoRedundancyStrategy, videoUUID?: st
174 } 149 }
175} 150}
176 151
152async function check0PlaylistRedundancies (videoUUID?: string) {
153 if (!videoUUID) videoUUID = video1Server2UUID
154
155 for (const server of servers) {
156 // With token to avoid issues with video follow constraints
157 const res = await getVideoWithToken(server.url, server.accessToken, videoUUID)
158 const video: VideoDetails = res.body
159
160 expect(video.streamingPlaylists).to.be.an('array')
161 expect(video.streamingPlaylists).to.have.lengthOf(1)
162 expect(video.streamingPlaylists[0].redundancies).to.have.lengthOf(0)
163 }
164}
165
166async function check1PlaylistRedundancies (videoUUID?: string) {
167 if (!videoUUID) videoUUID = video1Server2UUID
168
169 for (const server of servers) {
170 const res = await getVideo(server.url, videoUUID)
171 const video: VideoDetails = res.body
172
173 expect(video.streamingPlaylists).to.have.lengthOf(1)
174 expect(video.streamingPlaylists[0].redundancies).to.have.lengthOf(1)
175
176 const redundancy = video.streamingPlaylists[0].redundancies[0]
177
178 expect(redundancy.baseUrl).to.equal(servers[0].url + '/static/redundancy/hls/' + videoUUID)
179 }
180
181 const baseUrlPlaylist = servers[1].url + '/static/playlists/hls'
182 const baseUrlSegment = servers[0].url + '/static/redundancy/hls'
183
184 const res = await getVideo(servers[0].url, videoUUID)
185 const hlsPlaylist = (res.body as VideoDetails).streamingPlaylists[0]
186
187 for (const resolution of [ 240, 360, 480, 720 ]) {
188 await checkSegmentHash(baseUrlPlaylist, baseUrlSegment, videoUUID, resolution, hlsPlaylist)
189 }
190
191 for (const directory of [ 'test1/redundancy/hls', 'test2/playlists/hls' ]) {
192 const files = await readdir(join(root(), directory, videoUUID))
193 expect(files).to.have.length.at.least(4)
194
195 for (const resolution of [ 240, 360, 480, 720 ]) {
196 const filename = `${videoUUID}-${resolution}-fragmented.mp4`
197
198 expect(files.find(f => f === filename)).to.not.be.undefined
199 }
200 }
201}
202
203async function checkStatsWith2Webseed (strategy: VideoRedundancyStrategy) {
204 const res = await getStats(servers[0].url)
205 const data: ServerStats = res.body
206
207 expect(data.videosRedundancy).to.have.lengthOf(1)
208 const stat = data.videosRedundancy[0]
209
210 expect(stat.strategy).to.equal(strategy)
211 expect(stat.totalSize).to.equal(204800)
212 expect(stat.totalUsed).to.be.at.least(1).and.below(204801)
213 expect(stat.totalVideoFiles).to.equal(4)
214 expect(stat.totalVideos).to.equal(1)
215}
216
217async function checkStatsWith1Webseed (strategy: VideoRedundancyStrategy) {
218 const res = await getStats(servers[0].url)
219 const data: ServerStats = res.body
220
221 expect(data.videosRedundancy).to.have.lengthOf(1)
222
223 const stat = data.videosRedundancy[0]
224 expect(stat.strategy).to.equal(strategy)
225 expect(stat.totalSize).to.equal(204800)
226 expect(stat.totalUsed).to.equal(0)
227 expect(stat.totalVideoFiles).to.equal(0)
228 expect(stat.totalVideos).to.equal(0)
229}
230
177async function enableRedundancyOnServer1 () { 231async function enableRedundancyOnServer1 () {
178 await updateRedundancy(servers[ 0 ].url, servers[ 0 ].accessToken, servers[ 1 ].host, true) 232 await updateRedundancy(servers[ 0 ].url, servers[ 0 ].accessToken, servers[ 1 ].host, true)
179 233
@@ -220,7 +274,8 @@ describe('Test videos redundancy', function () {
220 }) 274 })
221 275
222 it('Should have 1 webseed on the first video', async function () { 276 it('Should have 1 webseed on the first video', async function () {
223 await check1WebSeed(strategy) 277 await check1WebSeed()
278 await check0PlaylistRedundancies()
224 await checkStatsWith1Webseed(strategy) 279 await checkStatsWith1Webseed(strategy)
225 }) 280 })
226 281
@@ -229,27 +284,29 @@ describe('Test videos redundancy', function () {
229 }) 284 })
230 285
231 it('Should have 2 webseeds on the first video', async function () { 286 it('Should have 2 webseeds on the first video', async function () {
232 this.timeout(40000) 287 this.timeout(80000)
233 288
234 await waitJobs(servers) 289 await waitJobs(servers)
235 await waitUntilLog(servers[0], 'Duplicated ', 4) 290 await waitUntilLog(servers[0], 'Duplicated ', 5)
236 await waitJobs(servers) 291 await waitJobs(servers)
237 292
238 await check2Webseeds(strategy) 293 await check2Webseeds()
294 await check1PlaylistRedundancies()
239 await checkStatsWith2Webseed(strategy) 295 await checkStatsWith2Webseed(strategy)
240 }) 296 })
241 297
242 it('Should undo redundancy on server 1 and remove duplicated videos', async function () { 298 it('Should undo redundancy on server 1 and remove duplicated videos', async function () {
243 this.timeout(40000) 299 this.timeout(80000)
244 300
245 await disableRedundancyOnServer1() 301 await disableRedundancyOnServer1()
246 302
247 await waitJobs(servers) 303 await waitJobs(servers)
248 await wait(5000) 304 await wait(5000)
249 305
250 await check1WebSeed(strategy) 306 await check1WebSeed()
307 await check0PlaylistRedundancies()
251 308
252 await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ 'videos' ]) 309 await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ 'videos', join('playlists', 'hls') ])
253 }) 310 })
254 311
255 after(function () { 312 after(function () {
@@ -267,7 +324,8 @@ describe('Test videos redundancy', function () {
267 }) 324 })
268 325
269 it('Should have 1 webseed on the first video', async function () { 326 it('Should have 1 webseed on the first video', async function () {
270 await check1WebSeed(strategy) 327 await check1WebSeed()
328 await check0PlaylistRedundancies()
271 await checkStatsWith1Webseed(strategy) 329 await checkStatsWith1Webseed(strategy)
272 }) 330 })
273 331
@@ -276,25 +334,27 @@ describe('Test videos redundancy', function () {
276 }) 334 })
277 335
278 it('Should have 2 webseeds on the first video', async function () { 336 it('Should have 2 webseeds on the first video', async function () {
279 this.timeout(40000) 337 this.timeout(80000)
280 338
281 await waitJobs(servers) 339 await waitJobs(servers)
282 await waitUntilLog(servers[0], 'Duplicated ', 4) 340 await waitUntilLog(servers[0], 'Duplicated ', 5)
283 await waitJobs(servers) 341 await waitJobs(servers)
284 342
285 await check2Webseeds(strategy) 343 await check2Webseeds()
344 await check1PlaylistRedundancies()
286 await checkStatsWith2Webseed(strategy) 345 await checkStatsWith2Webseed(strategy)
287 }) 346 })
288 347
289 it('Should unfollow on server 1 and remove duplicated videos', async function () { 348 it('Should unfollow on server 1 and remove duplicated videos', async function () {
290 this.timeout(40000) 349 this.timeout(80000)
291 350
292 await unfollow(servers[0].url, servers[0].accessToken, servers[1]) 351 await unfollow(servers[0].url, servers[0].accessToken, servers[1])
293 352
294 await waitJobs(servers) 353 await waitJobs(servers)
295 await wait(5000) 354 await wait(5000)
296 355
297 await check1WebSeed(strategy) 356 await check1WebSeed()
357 await check0PlaylistRedundancies()
298 358
299 await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ 'videos' ]) 359 await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ 'videos' ])
300 }) 360 })
@@ -314,7 +374,8 @@ describe('Test videos redundancy', function () {
314 }) 374 })
315 375
316 it('Should have 1 webseed on the first video', async function () { 376 it('Should have 1 webseed on the first video', async function () {
317 await check1WebSeed(strategy) 377 await check1WebSeed()
378 await check0PlaylistRedundancies()
318 await checkStatsWith1Webseed(strategy) 379 await checkStatsWith1Webseed(strategy)
319 }) 380 })
320 381
@@ -323,18 +384,19 @@ describe('Test videos redundancy', function () {
323 }) 384 })
324 385
325 it('Should still have 1 webseed on the first video', async function () { 386 it('Should still have 1 webseed on the first video', async function () {
326 this.timeout(40000) 387 this.timeout(80000)
327 388
328 await waitJobs(servers) 389 await waitJobs(servers)
329 await wait(15000) 390 await wait(15000)
330 await waitJobs(servers) 391 await waitJobs(servers)
331 392
332 await check1WebSeed(strategy) 393 await check1WebSeed()
394 await check0PlaylistRedundancies()
333 await checkStatsWith1Webseed(strategy) 395 await checkStatsWith1Webseed(strategy)
334 }) 396 })
335 397
336 it('Should view 2 times the first video to have > min_views config', async function () { 398 it('Should view 2 times the first video to have > min_views config', async function () {
337 this.timeout(40000) 399 this.timeout(80000)
338 400
339 await viewVideo(servers[ 0 ].url, video1Server2UUID) 401 await viewVideo(servers[ 0 ].url, video1Server2UUID)
340 await viewVideo(servers[ 2 ].url, video1Server2UUID) 402 await viewVideo(servers[ 2 ].url, video1Server2UUID)
@@ -344,13 +406,14 @@ describe('Test videos redundancy', function () {
344 }) 406 })
345 407
346 it('Should have 2 webseeds on the first video', async function () { 408 it('Should have 2 webseeds on the first video', async function () {
347 this.timeout(40000) 409 this.timeout(80000)
348 410
349 await waitJobs(servers) 411 await waitJobs(servers)
350 await waitUntilLog(servers[0], 'Duplicated ', 4) 412 await waitUntilLog(servers[0], 'Duplicated ', 5)
351 await waitJobs(servers) 413 await waitJobs(servers)
352 414
353 await check2Webseeds(strategy) 415 await check2Webseeds()
416 await check1PlaylistRedundancies()
354 await checkStatsWith2Webseed(strategy) 417 await checkStatsWith2Webseed(strategy)
355 }) 418 })
356 419
@@ -405,7 +468,7 @@ describe('Test videos redundancy', function () {
405 }) 468 })
406 469
407 it('Should still have 2 webseeds after 10 seconds', async function () { 470 it('Should still have 2 webseeds after 10 seconds', async function () {
408 this.timeout(40000) 471 this.timeout(80000)
409 472
410 await wait(10000) 473 await wait(10000)
411 474
@@ -420,7 +483,7 @@ describe('Test videos redundancy', function () {
420 }) 483 })
421 484
422 it('Should stop server 1 and expire video redundancy', async function () { 485 it('Should stop server 1 and expire video redundancy', async function () {
423 this.timeout(40000) 486 this.timeout(80000)
424 487
425 killallServers([ servers[0] ]) 488 killallServers([ servers[0] ])
426 489
@@ -446,10 +509,11 @@ describe('Test videos redundancy', function () {
446 await enableRedundancyOnServer1() 509 await enableRedundancyOnServer1()
447 510
448 await waitJobs(servers) 511 await waitJobs(servers)
449 await waitUntilLog(servers[0], 'Duplicated ', 4) 512 await waitUntilLog(servers[0], 'Duplicated ', 5)
450 await waitJobs(servers) 513 await waitJobs(servers)
451 514
452 await check2Webseeds(strategy) 515 await check2Webseeds()
516 await check1PlaylistRedundancies()
453 await checkStatsWith2Webseed(strategy) 517 await checkStatsWith2Webseed(strategy)
454 518
455 const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 2 server 2' }) 519 const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 2 server 2' })
@@ -467,8 +531,10 @@ describe('Test videos redundancy', function () {
467 await wait(1000) 531 await wait(1000)
468 532
469 try { 533 try {
470 await check1WebSeed(strategy, video1Server2UUID) 534 await check1WebSeed(video1Server2UUID)
471 await check2Webseeds(strategy, video2Server2UUID) 535 await check0PlaylistRedundancies(video1Server2UUID)
536 await check2Webseeds(video2Server2UUID)
537 await check1PlaylistRedundancies(video2Server2UUID)
472 538
473 checked = true 539 checked = true
474 } catch { 540 } catch {
@@ -477,6 +543,26 @@ describe('Test videos redundancy', function () {
477 } 543 }
478 }) 544 })
479 545
546 it('Should disable strategy and remove redundancies', async function () {
547 this.timeout(80000)
548
549 await waitJobs(servers)
550
551 killallServers([ servers[ 0 ] ])
552 await reRunServer(servers[ 0 ], {
553 redundancy: {
554 videos: {
555 check_interval: '1 second',
556 strategies: []
557 }
558 }
559 })
560
561 await waitJobs(servers)
562
563 await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ join('redundancy', 'hls') ])
564 })
565
480 after(function () { 566 after(function () {
481 return cleanServers() 567 return cleanServers()
482 }) 568 })
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts
index bebfc7398..0dfe6e4fe 100644
--- a/server/tests/api/server/config.ts
+++ b/server/tests/api/server/config.ts
@@ -57,6 +57,8 @@ function checkInitialConfig (data: CustomConfig) {
57 expect(data.transcoding.resolutions['480p']).to.be.true 57 expect(data.transcoding.resolutions['480p']).to.be.true
58 expect(data.transcoding.resolutions['720p']).to.be.true 58 expect(data.transcoding.resolutions['720p']).to.be.true
59 expect(data.transcoding.resolutions['1080p']).to.be.true 59 expect(data.transcoding.resolutions['1080p']).to.be.true
60 expect(data.transcoding.hls.enabled).to.be.true
61
60 expect(data.import.videos.http.enabled).to.be.true 62 expect(data.import.videos.http.enabled).to.be.true
61 expect(data.import.videos.torrent.enabled).to.be.true 63 expect(data.import.videos.torrent.enabled).to.be.true
62} 64}
@@ -95,6 +97,7 @@ function checkUpdatedConfig (data: CustomConfig) {
95 expect(data.transcoding.resolutions['480p']).to.be.true 97 expect(data.transcoding.resolutions['480p']).to.be.true
96 expect(data.transcoding.resolutions['720p']).to.be.false 98 expect(data.transcoding.resolutions['720p']).to.be.false
97 expect(data.transcoding.resolutions['1080p']).to.be.false 99 expect(data.transcoding.resolutions['1080p']).to.be.false
100 expect(data.transcoding.hls.enabled).to.be.false
98 101
99 expect(data.import.videos.http.enabled).to.be.false 102 expect(data.import.videos.http.enabled).to.be.false
100 expect(data.import.videos.torrent.enabled).to.be.false 103 expect(data.import.videos.torrent.enabled).to.be.false
@@ -205,6 +208,9 @@ describe('Test config', function () {
205 '480p': true, 208 '480p': true,
206 '720p': false, 209 '720p': false,
207 '1080p': false 210 '1080p': false
211 },
212 hls: {
213 enabled: false
208 } 214 }
209 }, 215 },
210 import: { 216 import: {
diff --git a/server/tests/api/server/follows.ts b/server/tests/api/server/follows.ts
index b0fc5d293..ad4c87c73 100644
--- a/server/tests/api/server/follows.ts
+++ b/server/tests/api/server/follows.ts
@@ -348,6 +348,7 @@ describe('Test follows', function () {
348 }, 348 },
349 isLocal, 349 isLocal,
350 commentsEnabled: true, 350 commentsEnabled: true,
351 downloadEnabled: true,
351 duration: 5, 352 duration: 5,
352 tags: [ 'tag1', 'tag2', 'tag3' ], 353 tags: [ 'tag1', 'tag2', 'tag3' ],
353 privacy: VideoPrivacy.PUBLIC, 354 privacy: VideoPrivacy.PUBLIC,
diff --git a/server/tests/api/server/handle-down.ts b/server/tests/api/server/handle-down.ts
index cd7baadad..cd5acbe16 100644
--- a/server/tests/api/server/handle-down.ts
+++ b/server/tests/api/server/handle-down.ts
@@ -76,6 +76,7 @@ describe('Test handle downs', function () {
76 tags: [ 'tag1p1', 'tag2p1' ], 76 tags: [ 'tag1p1', 'tag2p1' ],
77 privacy: VideoPrivacy.PUBLIC, 77 privacy: VideoPrivacy.PUBLIC,
78 commentsEnabled: true, 78 commentsEnabled: true,
79 downloadEnabled: true,
79 channel: { 80 channel: {
80 name: 'root_channel', 81 name: 'root_channel',
81 displayName: 'Main root channel', 82 displayName: 'Main root channel',
diff --git a/server/tests/api/server/redundancy.ts b/server/tests/api/server/redundancy.ts
deleted file mode 100644
index 8053d0491..000000000
--- a/server/tests/api/server/redundancy.ts
+++ /dev/null
@@ -1,479 +0,0 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import { VideoDetails } from '../../../../shared/models/videos'
6import {
7 doubleFollow,
8 flushAndRunMultipleServers,
9 getFollowingListPaginationAndSort,
10 getVideo,
11 immutableAssign,
12 killallServers, makeGetRequest,
13 root,
14 ServerInfo,
15 setAccessTokensToServers, unfollow,
16 uploadVideo,
17 viewVideo,
18 wait,
19 waitUntilLog,
20 checkVideoFilesWereRemoved, removeVideo
21} from '../../../../shared/utils'
22import { waitJobs } from '../../../../shared/utils/server/jobs'
23import * as magnetUtil from 'magnet-uri'
24import { updateRedundancy } from '../../../../shared/utils/server/redundancy'
25import { ActorFollow } from '../../../../shared/models/actors'
26import { readdir } from 'fs-extra'
27import { join } from 'path'
28import { VideoRedundancyStrategy } from '../../../../shared/models/redundancy'
29import { getStats } from '../../../../shared/utils/server/stats'
30import { ServerStats } from '../../../../shared/models/server/server-stats.model'
31
32const expect = chai.expect
33
34let servers: ServerInfo[] = []
35let video1Server2UUID: string
36
37function checkMagnetWebseeds (file: { magnetUri: string, resolution: { id: number } }, baseWebseeds: string[], server: ServerInfo) {
38 const parsed = magnetUtil.decode(file.magnetUri)
39
40 for (const ws of baseWebseeds) {
41 const found = parsed.urlList.find(url => url === `${ws}-${file.resolution.id}.mp4`)
42 expect(found, `Webseed ${ws} not found in ${file.magnetUri} on server ${server.url}`).to.not.be.undefined
43 }
44
45 expect(parsed.urlList).to.have.lengthOf(baseWebseeds.length)
46}
47
48async function runServers (strategy: VideoRedundancyStrategy, additionalParams: any = {}) {
49 const config = {
50 redundancy: {
51 videos: {
52 check_interval: '5 seconds',
53 strategies: [
54 immutableAssign({
55 min_lifetime: '1 hour',
56 strategy: strategy,
57 size: '100KB'
58 }, additionalParams)
59 ]
60 }
61 }
62 }
63 servers = await flushAndRunMultipleServers(3, config)
64
65 // Get the access tokens
66 await setAccessTokensToServers(servers)
67
68 {
69 const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 1 server 2' })
70 video1Server2UUID = res.body.video.uuid
71
72 await viewVideo(servers[ 1 ].url, video1Server2UUID)
73 }
74
75 await waitJobs(servers)
76
77 // Server 1 and server 2 follow each other
78 await doubleFollow(servers[ 0 ], servers[ 1 ])
79 // Server 1 and server 3 follow each other
80 await doubleFollow(servers[ 0 ], servers[ 2 ])
81 // Server 2 and server 3 follow each other
82 await doubleFollow(servers[ 1 ], servers[ 2 ])
83
84 await waitJobs(servers)
85}
86
87async function check1WebSeed (strategy: VideoRedundancyStrategy, videoUUID?: string) {
88 if (!videoUUID) videoUUID = video1Server2UUID
89
90 const webseeds = [
91 'http://localhost:9002/static/webseed/' + videoUUID
92 ]
93
94 for (const server of servers) {
95 {
96 const res = await getVideo(server.url, videoUUID)
97
98 const video: VideoDetails = res.body
99 for (const f of video.files) {
100 checkMagnetWebseeds(f, webseeds, server)
101 }
102 }
103 }
104}
105
106async function checkStatsWith2Webseed (strategy: VideoRedundancyStrategy) {
107 const res = await getStats(servers[0].url)
108 const data: ServerStats = res.body
109
110 expect(data.videosRedundancy).to.have.lengthOf(1)
111 const stat = data.videosRedundancy[0]
112
113 expect(stat.strategy).to.equal(strategy)
114 expect(stat.totalSize).to.equal(102400)
115 expect(stat.totalUsed).to.be.at.least(1).and.below(102401)
116 expect(stat.totalVideoFiles).to.equal(4)
117 expect(stat.totalVideos).to.equal(1)
118}
119
120async function checkStatsWith1Webseed (strategy: VideoRedundancyStrategy) {
121 const res = await getStats(servers[0].url)
122 const data: ServerStats = res.body
123
124 expect(data.videosRedundancy).to.have.lengthOf(1)
125
126 const stat = data.videosRedundancy[0]
127 expect(stat.strategy).to.equal(strategy)
128 expect(stat.totalSize).to.equal(102400)
129 expect(stat.totalUsed).to.equal(0)
130 expect(stat.totalVideoFiles).to.equal(0)
131 expect(stat.totalVideos).to.equal(0)
132}
133
134async function check2Webseeds (strategy: VideoRedundancyStrategy, videoUUID?: string) {
135 if (!videoUUID) videoUUID = video1Server2UUID
136
137 const webseeds = [
138 'http://localhost:9001/static/webseed/' + videoUUID,
139 'http://localhost:9002/static/webseed/' + videoUUID
140 ]
141
142 for (const server of servers) {
143 const res = await getVideo(server.url, videoUUID)
144
145 const video: VideoDetails = res.body
146
147 for (const file of video.files) {
148 checkMagnetWebseeds(file, webseeds, server)
149
150 // Only servers 1 and 2 have the video
151 if (server.serverNumber !== 3) {
152 await makeGetRequest({
153 url: server.url,
154 statusCodeExpected: 200,
155 path: '/static/webseed/' + `${videoUUID}-${file.resolution.id}.mp4`,
156 contentType: null
157 })
158 }
159 }
160 }
161
162 for (const directory of [ 'test1', 'test2' ]) {
163 const files = await readdir(join(root(), directory, 'videos'))
164 expect(files).to.have.length.at.least(4)
165
166 for (const resolution of [ 240, 360, 480, 720 ]) {
167 expect(files.find(f => f === `${videoUUID}-${resolution}.mp4`)).to.not.be.undefined
168 }
169 }
170}
171
172async function enableRedundancyOnServer1 () {
173 await updateRedundancy(servers[ 0 ].url, servers[ 0 ].accessToken, servers[ 1 ].host, true)
174
175 const res = await getFollowingListPaginationAndSort(servers[ 0 ].url, 0, 5, '-createdAt')
176 const follows: ActorFollow[] = res.body.data
177 const server2 = follows.find(f => f.following.host === 'localhost:9002')
178 const server3 = follows.find(f => f.following.host === 'localhost:9003')
179
180 expect(server3).to.not.be.undefined
181 expect(server3.following.hostRedundancyAllowed).to.be.false
182
183 expect(server2).to.not.be.undefined
184 expect(server2.following.hostRedundancyAllowed).to.be.true
185}
186
187async function disableRedundancyOnServer1 () {
188 await updateRedundancy(servers[ 0 ].url, servers[ 0 ].accessToken, servers[ 1 ].host, false)
189
190 const res = await getFollowingListPaginationAndSort(servers[ 0 ].url, 0, 5, '-createdAt')
191 const follows: ActorFollow[] = res.body.data
192 const server2 = follows.find(f => f.following.host === 'localhost:9002')
193 const server3 = follows.find(f => f.following.host === 'localhost:9003')
194
195 expect(server3).to.not.be.undefined
196 expect(server3.following.hostRedundancyAllowed).to.be.false
197
198 expect(server2).to.not.be.undefined
199 expect(server2.following.hostRedundancyAllowed).to.be.false
200}
201
202async function cleanServers () {
203 killallServers(servers)
204}
205
206describe('Test videos redundancy', function () {
207
208 describe('With most-views strategy', function () {
209 const strategy = 'most-views'
210
211 before(function () {
212 this.timeout(120000)
213
214 return runServers(strategy)
215 })
216
217 it('Should have 1 webseed on the first video', async function () {
218 await check1WebSeed(strategy)
219 await checkStatsWith1Webseed(strategy)
220 })
221
222 it('Should enable redundancy on server 1', function () {
223 return enableRedundancyOnServer1()
224 })
225
226 it('Should have 2 webseed on the first video', async function () {
227 this.timeout(40000)
228
229 await waitJobs(servers)
230 await waitUntilLog(servers[0], 'Duplicated ', 4)
231 await waitJobs(servers)
232
233 await check2Webseeds(strategy)
234 await checkStatsWith2Webseed(strategy)
235 })
236
237 it('Should undo redundancy on server 1 and remove duplicated videos', async function () {
238 this.timeout(40000)
239
240 await disableRedundancyOnServer1()
241
242 await waitJobs(servers)
243 await wait(5000)
244
245 await check1WebSeed(strategy)
246
247 await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ 'videos' ])
248 })
249
250 after(function () {
251 return cleanServers()
252 })
253 })
254
255 describe('With trending strategy', function () {
256 const strategy = 'trending'
257
258 before(function () {
259 this.timeout(120000)
260
261 return runServers(strategy)
262 })
263
264 it('Should have 1 webseed on the first video', async function () {
265 await check1WebSeed(strategy)
266 await checkStatsWith1Webseed(strategy)
267 })
268
269 it('Should enable redundancy on server 1', function () {
270 return enableRedundancyOnServer1()
271 })
272
273 it('Should have 2 webseed on the first video', async function () {
274 this.timeout(40000)
275
276 await waitJobs(servers)
277 await waitUntilLog(servers[0], 'Duplicated ', 4)
278 await waitJobs(servers)
279
280 await check2Webseeds(strategy)
281 await checkStatsWith2Webseed(strategy)
282 })
283
284 it('Should unfollow on server 1 and remove duplicated videos', async function () {
285 this.timeout(40000)
286
287 await unfollow(servers[0].url, servers[0].accessToken, servers[1])
288
289 await waitJobs(servers)
290 await wait(5000)
291
292 await check1WebSeed(strategy)
293
294 await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ 'videos' ])
295 })
296
297 after(function () {
298 return cleanServers()
299 })
300 })
301
302 describe('With recently added strategy', function () {
303 const strategy = 'recently-added'
304
305 before(function () {
306 this.timeout(120000)
307
308 return runServers(strategy, { min_views: 3 })
309 })
310
311 it('Should have 1 webseed on the first video', async function () {
312 await check1WebSeed(strategy)
313 await checkStatsWith1Webseed(strategy)
314 })
315
316 it('Should enable redundancy on server 1', function () {
317 return enableRedundancyOnServer1()
318 })
319
320 it('Should still have 1 webseed on the first video', async function () {
321 this.timeout(40000)
322
323 await waitJobs(servers)
324 await wait(15000)
325 await waitJobs(servers)
326
327 await check1WebSeed(strategy)
328 await checkStatsWith1Webseed(strategy)
329 })
330
331 it('Should view 2 times the first video to have > min_views config', async function () {
332 this.timeout(40000)
333
334 await viewVideo(servers[ 0 ].url, video1Server2UUID)
335 await viewVideo(servers[ 2 ].url, video1Server2UUID)
336
337 await wait(10000)
338 await waitJobs(servers)
339 })
340
341 it('Should have 2 webseed on the first video', async function () {
342 this.timeout(40000)
343
344 await waitJobs(servers)
345 await waitUntilLog(servers[0], 'Duplicated ', 4)
346 await waitJobs(servers)
347
348 await check2Webseeds(strategy)
349 await checkStatsWith2Webseed(strategy)
350 })
351
352 it('Should remove the video and the redundancy files', async function () {
353 this.timeout(20000)
354
355 await removeVideo(servers[1].url, servers[1].accessToken, video1Server2UUID)
356
357 await waitJobs(servers)
358
359 for (const server of servers) {
360 await checkVideoFilesWereRemoved(video1Server2UUID, server.serverNumber)
361 }
362 })
363
364 after(function () {
365 return cleanServers()
366 })
367 })
368
369 describe('Test expiration', function () {
370 const strategy = 'recently-added'
371
372 async function checkContains (servers: ServerInfo[], str: string) {
373 for (const server of servers) {
374 const res = await getVideo(server.url, video1Server2UUID)
375 const video: VideoDetails = res.body
376
377 for (const f of video.files) {
378 expect(f.magnetUri).to.contain(str)
379 }
380 }
381 }
382
383 async function checkNotContains (servers: ServerInfo[], str: string) {
384 for (const server of servers) {
385 const res = await getVideo(server.url, video1Server2UUID)
386 const video: VideoDetails = res.body
387
388 for (const f of video.files) {
389 expect(f.magnetUri).to.not.contain(str)
390 }
391 }
392 }
393
394 before(async function () {
395 this.timeout(120000)
396
397 await runServers(strategy, { min_lifetime: '7 seconds', min_views: 0 })
398
399 await enableRedundancyOnServer1()
400 })
401
402 it('Should still have 2 webseeds after 10 seconds', async function () {
403 this.timeout(40000)
404
405 await wait(10000)
406
407 try {
408 await checkContains(servers, 'http%3A%2F%2Flocalhost%3A9001')
409 } catch {
410 // Maybe a server deleted a redundancy in the scheduler
411 await wait(2000)
412
413 await checkContains(servers, 'http%3A%2F%2Flocalhost%3A9001')
414 }
415 })
416
417 it('Should stop server 1 and expire video redundancy', async function () {
418 this.timeout(40000)
419
420 killallServers([ servers[0] ])
421
422 await wait(15000)
423
424 await checkNotContains([ servers[1], servers[2] ], 'http%3A%2F%2Flocalhost%3A9001')
425 })
426
427 after(function () {
428 return killallServers([ servers[1], servers[2] ])
429 })
430 })
431
432 describe('Test file replacement', function () {
433 let video2Server2UUID: string
434 const strategy = 'recently-added'
435
436 before(async function () {
437 this.timeout(120000)
438
439 await runServers(strategy, { min_lifetime: '7 seconds', min_views: 0 })
440
441 await enableRedundancyOnServer1()
442
443 await waitJobs(servers)
444 await waitUntilLog(servers[0], 'Duplicated ', 4)
445 await waitJobs(servers)
446
447 await check2Webseeds(strategy)
448 await checkStatsWith2Webseed(strategy)
449
450 const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 2 server 2' })
451 video2Server2UUID = res.body.video.uuid
452 })
453
454 it('Should cache video 2 webseed on the first video', async function () {
455 this.timeout(120000)
456
457 await waitJobs(servers)
458
459 let checked = false
460
461 while (checked === false) {
462 await wait(1000)
463
464 try {
465 await check1WebSeed(strategy, video1Server2UUID)
466 await check2Webseeds(strategy, video2Server2UUID)
467
468 checked = true
469 } catch {
470 checked = false
471 }
472 }
473 })
474
475 after(function () {
476 return cleanServers()
477 })
478 })
479})
diff --git a/server/tests/api/server/reverse-proxy.ts b/server/tests/api/server/reverse-proxy.ts
index d4c08c346..ee0fffd5a 100644
--- a/server/tests/api/server/reverse-proxy.ts
+++ b/server/tests/api/server/reverse-proxy.ts
@@ -95,7 +95,7 @@ describe('Test application behind a reverse proxy', function () {
95 it('Should rate limit logins', async function () { 95 it('Should rate limit logins', async function () {
96 const user = { username: 'root', password: 'fail' } 96 const user = { username: 'root', password: 'fail' }
97 97
98 for (let i = 0; i < 14; i++) { 98 for (let i = 0; i < 19; i++) {
99 await userLogin(server, user, 400) 99 await userLogin(server, user, 400)
100 } 100 }
101 101
diff --git a/server/tests/api/server/stats.ts b/server/tests/api/server/stats.ts
index 517b4e542..aaa6c62f7 100644
--- a/server/tests/api/server/stats.ts
+++ b/server/tests/api/server/stats.ts
@@ -39,7 +39,7 @@ describe('Test stats (excluding redundancy)', function () {
39 } 39 }
40 await createUser(servers[0].url, servers[0].accessToken, user.username, user.password) 40 await createUser(servers[0].url, servers[0].accessToken, user.username, user.password)
41 41
42 const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, {}) 42 const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { fixture: 'video_short.webm' })
43 const videoUUID = resVideo.body.video.uuid 43 const videoUUID = resVideo.body.video.uuid
44 44
45 await addVideoCommentThread(servers[0].url, servers[0].accessToken, videoUUID, 'comment') 45 await addVideoCommentThread(servers[0].url, servers[0].accessToken, videoUUID, 'comment')
@@ -60,6 +60,7 @@ describe('Test stats (excluding redundancy)', function () {
60 expect(data.totalLocalVideoComments).to.equal(1) 60 expect(data.totalLocalVideoComments).to.equal(1)
61 expect(data.totalLocalVideos).to.equal(1) 61 expect(data.totalLocalVideos).to.equal(1)
62 expect(data.totalLocalVideoViews).to.equal(1) 62 expect(data.totalLocalVideoViews).to.equal(1)
63 expect(data.totalLocalVideoFilesSize).to.equal(218910)
63 expect(data.totalUsers).to.equal(2) 64 expect(data.totalUsers).to.equal(2)
64 expect(data.totalVideoComments).to.equal(1) 65 expect(data.totalVideoComments).to.equal(1)
65 expect(data.totalVideos).to.equal(1) 66 expect(data.totalVideos).to.equal(1)
@@ -74,6 +75,7 @@ describe('Test stats (excluding redundancy)', function () {
74 expect(data.totalLocalVideoComments).to.equal(0) 75 expect(data.totalLocalVideoComments).to.equal(0)
75 expect(data.totalLocalVideos).to.equal(0) 76 expect(data.totalLocalVideos).to.equal(0)
76 expect(data.totalLocalVideoViews).to.equal(0) 77 expect(data.totalLocalVideoViews).to.equal(0)
78 expect(data.totalLocalVideoFilesSize).to.equal(0)
77 expect(data.totalUsers).to.equal(1) 79 expect(data.totalUsers).to.equal(1)
78 expect(data.totalVideoComments).to.equal(1) 80 expect(data.totalVideoComments).to.equal(1)
79 expect(data.totalVideos).to.equal(1) 81 expect(data.totalVideos).to.equal(1)
diff --git a/server/tests/api/users/user-notifications.ts b/server/tests/api/users/user-notifications.ts
index 5260d64cc..69e51677e 100644
--- a/server/tests/api/users/user-notifications.ts
+++ b/server/tests/api/users/user-notifications.ts
@@ -165,6 +165,8 @@ describe('Test users notifications', function () {
165 }) 165 })
166 166
167 it('Should not send notifications if the user does not follow the video publisher', async function () { 167 it('Should not send notifications if the user does not follow the video publisher', async function () {
168 this.timeout(10000)
169
168 await uploadVideoByLocalAccount(servers) 170 await uploadVideoByLocalAccount(servers)
169 171
170 const notification = await getLastNotification(servers[ 0 ].url, userAccessToken) 172 const notification = await getLastNotification(servers[ 0 ].url, userAccessToken)
@@ -644,6 +646,8 @@ describe('Test users notifications', function () {
644 }) 646 })
645 647
646 it('Should not send a notification if transcoding is not enabled', async function () { 648 it('Should not send a notification if transcoding is not enabled', async function () {
649 this.timeout(10000)
650
647 const { name, uuid } = await uploadVideoByLocalAccount(servers) 651 const { name, uuid } = await uploadVideoByLocalAccount(servers)
648 await waitJobs(servers) 652 await waitJobs(servers)
649 653
@@ -717,6 +721,24 @@ describe('Test users notifications', function () {
717 await wait(6000) 721 await wait(6000)
718 await checkVideoIsPublished(baseParams, name, uuid, 'presence') 722 await checkVideoIsPublished(baseParams, name, uuid, 'presence')
719 }) 723 })
724
725 it('Should not send a notification before the video is published', async function () {
726 this.timeout(20000)
727
728 let updateAt = new Date(new Date().getTime() + 100000)
729
730 const data = {
731 privacy: VideoPrivacy.PRIVATE,
732 scheduleUpdate: {
733 updateAt: updateAt.toISOString(),
734 privacy: VideoPrivacy.PUBLIC
735 }
736 }
737 const { name, uuid } = await uploadVideoByRemoteAccount(servers, data)
738
739 await wait(6000)
740 await checkVideoIsPublished(baseParams, name, uuid, 'absence')
741 })
720 }) 742 })
721 743
722 describe('My video is imported', function () { 744 describe('My video is imported', function () {
@@ -781,6 +803,8 @@ describe('Test users notifications', function () {
781 }) 803 })
782 804
783 it('Should send a notification only to moderators when a user registers on the instance', async function () { 805 it('Should send a notification only to moderators when a user registers on the instance', async function () {
806 this.timeout(10000)
807
784 await registerUser(servers[0].url, 'user_45', 'password') 808 await registerUser(servers[0].url, 'user_45', 'password')
785 809
786 await waitJobs(servers) 810 await waitJobs(servers)
@@ -849,6 +873,8 @@ describe('Test users notifications', function () {
849 }) 873 })
850 874
851 it('Should notify when a local account is following one of our channel', async function () { 875 it('Should notify when a local account is following one of our channel', async function () {
876 this.timeout(10000)
877
852 await addUserSubscription(servers[0].url, servers[0].accessToken, 'user_1@localhost:9001') 878 await addUserSubscription(servers[0].url, servers[0].accessToken, 'user_1@localhost:9001')
853 879
854 await waitJobs(servers) 880 await waitJobs(servers)
@@ -857,6 +883,8 @@ describe('Test users notifications', function () {
857 }) 883 })
858 884
859 it('Should notify when a remote account is following one of our channel', async function () { 885 it('Should notify when a remote account is following one of our channel', async function () {
886 this.timeout(10000)
887
860 await addUserSubscription(servers[1].url, servers[1].accessToken, 'user_1@localhost:9001') 888 await addUserSubscription(servers[1].url, servers[1].accessToken, 'user_1@localhost:9001')
861 889
862 await waitJobs(servers) 890 await waitJobs(servers)
@@ -926,6 +954,8 @@ describe('Test users notifications', function () {
926 }) 954 })
927 955
928 it('Should not have notifications', async function () { 956 it('Should not have notifications', async function () {
957 this.timeout(10000)
958
929 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, { 959 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, {
930 newVideoFromSubscription: UserNotificationSettingValue.NONE 960 newVideoFromSubscription: UserNotificationSettingValue.NONE
931 })) 961 }))
@@ -943,6 +973,8 @@ describe('Test users notifications', function () {
943 }) 973 })
944 974
945 it('Should only have web notifications', async function () { 975 it('Should only have web notifications', async function () {
976 this.timeout(10000)
977
946 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, { 978 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, {
947 newVideoFromSubscription: UserNotificationSettingValue.WEB 979 newVideoFromSubscription: UserNotificationSettingValue.WEB
948 })) 980 }))
@@ -967,6 +999,8 @@ describe('Test users notifications', function () {
967 }) 999 })
968 1000
969 it('Should only have mail notifications', async function () { 1001 it('Should only have mail notifications', async function () {
1002 this.timeout(10000)
1003
970 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, { 1004 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, {
971 newVideoFromSubscription: UserNotificationSettingValue.EMAIL 1005 newVideoFromSubscription: UserNotificationSettingValue.EMAIL
972 })) 1006 }))
@@ -991,6 +1025,8 @@ describe('Test users notifications', function () {
991 }) 1025 })
992 1026
993 it('Should have email and web notifications', async function () { 1027 it('Should have email and web notifications', async function () {
1028 this.timeout(10000)
1029
994 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, { 1030 await updateMyNotificationSettings(servers[0].url, userAccessToken, immutableAssign(allNotificationSettings, {
995 newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL 1031 newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL
996 })) 1032 }))
diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts
index ad98ab1c7..c4465d541 100644
--- a/server/tests/api/users/users.ts
+++ b/server/tests/api/users/users.ts
@@ -501,6 +501,22 @@ describe('Test users', function () {
501 accessTokenUser = await userLogin(server, user) 501 accessTokenUser = await userLogin(server, user)
502 }) 502 })
503 503
504 it('Should be able to update another user password', async function () {
505 await updateUser({
506 url: server.url,
507 userId,
508 accessToken,
509 password: 'password updated'
510 })
511
512 await getMyUserVideoQuotaUsed(server.url, accessTokenUser, 401)
513
514 await userLogin(server, user, 400)
515
516 user.password = 'password updated'
517 accessTokenUser = await userLogin(server, user)
518 })
519
504 it('Should be able to list video blacklist by a moderator', async function () { 520 it('Should be able to list video blacklist by a moderator', async function () {
505 await getBlacklistedVideosList(server.url, accessTokenUser) 521 await getBlacklistedVideosList(server.url, accessTokenUser)
506 }) 522 })
diff --git a/server/tests/api/videos/index.ts b/server/tests/api/videos/index.ts
index 9bdb78491..a501a80b2 100644
--- a/server/tests/api/videos/index.ts
+++ b/server/tests/api/videos/index.ts
@@ -3,12 +3,12 @@ import './services'
3import './single-server' 3import './single-server'
4import './video-abuse' 4import './video-abuse'
5import './video-blacklist' 5import './video-blacklist'
6import './video-blacklist-management'
7import './video-captions' 6import './video-captions'
8import './video-change-ownership' 7import './video-change-ownership'
9import './video-channels' 8import './video-channels'
10import './video-comments' 9import './video-comments'
11import './video-description' 10import './video-description'
11import './video-hls'
12import './video-imports' 12import './video-imports'
13import './video-nsfw' 13import './video-nsfw'
14import './video-privacy' 14import './video-privacy'
diff --git a/server/tests/api/videos/multiple-servers.ts b/server/tests/api/videos/multiple-servers.ts
index 6c281e49e..1b471ba79 100644
--- a/server/tests/api/videos/multiple-servers.ts
+++ b/server/tests/api/videos/multiple-servers.ts
@@ -128,6 +128,7 @@ describe('Test multiple servers', function () {
128 tags: [ 'tag1p1', 'tag2p1' ], 128 tags: [ 'tag1p1', 'tag2p1' ],
129 privacy: VideoPrivacy.PUBLIC, 129 privacy: VideoPrivacy.PUBLIC,
130 commentsEnabled: true, 130 commentsEnabled: true,
131 downloadEnabled: true,
131 channel: { 132 channel: {
132 displayName: 'my channel', 133 displayName: 'my channel',
133 name: 'super_channel_name', 134 name: 'super_channel_name',
@@ -199,6 +200,7 @@ describe('Test multiple servers', function () {
199 }, 200 },
200 isLocal, 201 isLocal,
201 commentsEnabled: true, 202 commentsEnabled: true,
203 downloadEnabled: true,
202 duration: 5, 204 duration: 5,
203 tags: [ 'tag1p2', 'tag2p2', 'tag3p2' ], 205 tags: [ 'tag1p2', 'tag2p2', 'tag3p2' ],
204 privacy: VideoPrivacy.PUBLIC, 206 privacy: VideoPrivacy.PUBLIC,
@@ -307,6 +309,7 @@ describe('Test multiple servers', function () {
307 isLocal, 309 isLocal,
308 duration: 5, 310 duration: 5,
309 commentsEnabled: true, 311 commentsEnabled: true,
312 downloadEnabled: true,
310 tags: [ 'tag1p3' ], 313 tags: [ 'tag1p3' ],
311 privacy: VideoPrivacy.PUBLIC, 314 privacy: VideoPrivacy.PUBLIC,
312 channel: { 315 channel: {
@@ -338,6 +341,7 @@ describe('Test multiple servers', function () {
338 host: 'localhost:9003' 341 host: 'localhost:9003'
339 }, 342 },
340 commentsEnabled: true, 343 commentsEnabled: true,
344 downloadEnabled: true,
341 isLocal, 345 isLocal,
342 duration: 5, 346 duration: 5,
343 tags: [ 'tag2p3', 'tag3p3', 'tag4p3' ], 347 tags: [ 'tag2p3', 'tag3p3', 'tag4p3' ],
@@ -655,6 +659,7 @@ describe('Test multiple servers', function () {
655 isLocal, 659 isLocal,
656 duration: 5, 660 duration: 5,
657 commentsEnabled: true, 661 commentsEnabled: true,
662 downloadEnabled: true,
658 tags: [ 'tag_up_1', 'tag_up_2' ], 663 tags: [ 'tag_up_1', 'tag_up_2' ],
659 privacy: VideoPrivacy.PUBLIC, 664 privacy: VideoPrivacy.PUBLIC,
660 channel: { 665 channel: {
@@ -914,11 +919,12 @@ describe('Test multiple servers', function () {
914 } 919 }
915 }) 920 })
916 921
917 it('Should disable comments', async function () { 922 it('Should disable comments and download', async function () {
918 this.timeout(20000) 923 this.timeout(20000)
919 924
920 const attributes = { 925 const attributes = {
921 commentsEnabled: false 926 commentsEnabled: false,
927 downloadEnabled: false
922 } 928 }
923 929
924 await updateVideo(servers[0].url, servers[0].accessToken, videoUUID, attributes) 930 await updateVideo(servers[0].url, servers[0].accessToken, videoUUID, attributes)
@@ -928,6 +934,7 @@ describe('Test multiple servers', function () {
928 for (const server of servers) { 934 for (const server of servers) {
929 const res = await getVideo(server.url, videoUUID) 935 const res = await getVideo(server.url, videoUUID)
930 expect(res.body.commentsEnabled).to.be.false 936 expect(res.body.commentsEnabled).to.be.false
937 expect(res.body.downloadEnabled).to.be.false
931 938
932 const text = 'my super forbidden comment' 939 const text = 'my super forbidden comment'
933 await addVideoCommentThread(server.url, server.accessToken, videoUUID, text, 409) 940 await addVideoCommentThread(server.url, server.accessToken, videoUUID, text, 409)
@@ -976,6 +983,7 @@ describe('Test multiple servers', function () {
976 isLocal, 983 isLocal,
977 duration: 5, 984 duration: 5,
978 commentsEnabled: false, 985 commentsEnabled: false,
986 downloadEnabled: false,
979 tags: [ ], 987 tags: [ ],
980 privacy: VideoPrivacy.PUBLIC, 988 privacy: VideoPrivacy.PUBLIC,
981 channel: { 989 channel: {
diff --git a/server/tests/api/videos/single-server.ts b/server/tests/api/videos/single-server.ts
index 069dec67c..cfdcbaf3f 100644
--- a/server/tests/api/videos/single-server.ts
+++ b/server/tests/api/videos/single-server.ts
@@ -55,6 +55,7 @@ describe('Test a single server', function () {
55 tags: [ 'tag1', 'tag2', 'tag3' ], 55 tags: [ 'tag1', 'tag2', 'tag3' ],
56 privacy: VideoPrivacy.PUBLIC, 56 privacy: VideoPrivacy.PUBLIC,
57 commentsEnabled: true, 57 commentsEnabled: true,
58 downloadEnabled: true,
58 channel: { 59 channel: {
59 displayName: 'Main root channel', 60 displayName: 'Main root channel',
60 name: 'root_channel', 61 name: 'root_channel',
@@ -87,6 +88,7 @@ describe('Test a single server', function () {
87 privacy: VideoPrivacy.PUBLIC, 88 privacy: VideoPrivacy.PUBLIC,
88 duration: 5, 89 duration: 5,
89 commentsEnabled: false, 90 commentsEnabled: false,
91 downloadEnabled: false,
90 channel: { 92 channel: {
91 name: 'root_channel', 93 name: 'root_channel',
92 displayName: 'Main root channel', 94 displayName: 'Main root channel',
@@ -356,6 +358,7 @@ describe('Test a single server', function () {
356 nsfw: false, 358 nsfw: false,
357 description: 'my super description updated', 359 description: 'my super description updated',
358 commentsEnabled: false, 360 commentsEnabled: false,
361 downloadEnabled: false,
359 tags: [ 'tagup1', 'tagup2' ] 362 tags: [ 'tagup1', 'tagup2' ]
360 } 363 }
361 await updateVideo(server.url, server.accessToken, videoId, attributes) 364 await updateVideo(server.url, server.accessToken, videoId, attributes)
diff --git a/server/tests/api/videos/video-blacklist-management.ts b/server/tests/api/videos/video-blacklist-management.ts
deleted file mode 100644
index 61411e30d..000000000
--- a/server/tests/api/videos/video-blacklist-management.ts
+++ /dev/null
@@ -1,192 +0,0 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import { orderBy } from 'lodash'
5import 'mocha'
6import {
7 addVideoToBlacklist,
8 flushAndRunMultipleServers,
9 getBlacklistedVideosList,
10 getMyVideos,
11 getSortedBlacklistedVideosList,
12 getVideosList,
13 killallServers,
14 removeVideoFromBlacklist,
15 ServerInfo,
16 setAccessTokensToServers,
17 updateVideoBlacklist,
18 uploadVideo
19} from '../../../../shared/utils/index'
20import { doubleFollow } from '../../../../shared/utils/server/follows'
21import { waitJobs } from '../../../../shared/utils/server/jobs'
22import { VideoAbuse } from '../../../../shared/models/videos'
23
24const expect = chai.expect
25
26describe('Test video blacklist management', function () {
27 let servers: ServerInfo[] = []
28 let videoId: number
29
30 async function blacklistVideosOnServer (server: ServerInfo) {
31 const res = await getVideosList(server.url)
32
33 const videos = res.body.data
34 for (let video of videos) {
35 await addVideoToBlacklist(server.url, server.accessToken, video.id, 'super reason')
36 }
37 }
38
39 before(async function () {
40 this.timeout(50000)
41
42 // Run servers
43 servers = await flushAndRunMultipleServers(2)
44
45 // Get the access tokens
46 await setAccessTokensToServers(servers)
47
48 // Server 1 and server 2 follow each other
49 await doubleFollow(servers[0], servers[1])
50
51 // Upload 2 videos on server 2
52 await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'My 1st video', description: 'A video on server 2' })
53 await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'My 2nd video', description: 'A video on server 2' })
54
55 // Wait videos propagation, server 2 has transcoding enabled
56 await waitJobs(servers)
57
58 // Blacklist the two videos on server 1
59 await blacklistVideosOnServer(servers[0])
60 })
61
62 describe('When listing blacklisted videos', function () {
63 it('Should display all the blacklisted videos', async function () {
64 const res = await getBlacklistedVideosList(servers[0].url, servers[0].accessToken)
65
66 expect(res.body.total).to.equal(2)
67
68 const blacklistedVideos = res.body.data
69 expect(blacklistedVideos).to.be.an('array')
70 expect(blacklistedVideos.length).to.equal(2)
71
72 for (const blacklistedVideo of blacklistedVideos) {
73 expect(blacklistedVideo.reason).to.equal('super reason')
74 videoId = blacklistedVideo.video.id
75 }
76 })
77
78 it('Should get the correct sort when sorting by descending id', async function () {
79 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-id')
80 expect(res.body.total).to.equal(2)
81
82 const blacklistedVideos = res.body.data
83 expect(blacklistedVideos).to.be.an('array')
84 expect(blacklistedVideos.length).to.equal(2)
85
86 const result = orderBy(res.body.data, [ 'id' ], [ 'desc' ])
87
88 expect(blacklistedVideos).to.deep.equal(result)
89 })
90
91 it('Should get the correct sort when sorting by descending video name', async function () {
92 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-name')
93 expect(res.body.total).to.equal(2)
94
95 const blacklistedVideos = res.body.data
96 expect(blacklistedVideos).to.be.an('array')
97 expect(blacklistedVideos.length).to.equal(2)
98
99 const result = orderBy(res.body.data, [ 'name' ], [ 'desc' ])
100
101 expect(blacklistedVideos).to.deep.equal(result)
102 })
103
104 it('Should get the correct sort when sorting by ascending creation date', async function () {
105 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, 'createdAt')
106 expect(res.body.total).to.equal(2)
107
108 const blacklistedVideos = res.body.data
109 expect(blacklistedVideos).to.be.an('array')
110 expect(blacklistedVideos.length).to.equal(2)
111
112 const result = orderBy(res.body.data, [ 'createdAt' ])
113
114 expect(blacklistedVideos).to.deep.equal(result)
115 })
116 })
117
118 describe('When updating blacklisted videos', function () {
119 it('Should change the reason', async function () {
120 await updateVideoBlacklist(servers[0].url, servers[0].accessToken, videoId, 'my super reason updated')
121
122 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-name')
123 const video = res.body.data.find(b => b.video.id === videoId)
124
125 expect(video.reason).to.equal('my super reason updated')
126 })
127 })
128
129 describe('When listing my videos', function () {
130 it('Should display blacklisted videos', async function () {
131 await blacklistVideosOnServer(servers[1])
132
133 const res = await getMyVideos(servers[1].url, servers[1].accessToken, 0, 5)
134
135 expect(res.body.total).to.equal(2)
136 expect(res.body.data).to.have.lengthOf(2)
137
138 for (const video of res.body.data) {
139 expect(video.blacklisted).to.be.true
140 expect(video.blacklistedReason).to.equal('super reason')
141 }
142 })
143 })
144
145 describe('When removing a blacklisted video', function () {
146 let videoToRemove: VideoAbuse
147 let blacklist = []
148
149 it('Should not have any video in videos list on server 1', async function () {
150 const res = await getVideosList(servers[0].url)
151 expect(res.body.total).to.equal(0)
152 expect(res.body.data).to.be.an('array')
153 expect(res.body.data.length).to.equal(0)
154 })
155
156 it('Should remove a video from the blacklist on server 1', async function () {
157 // Get one video in the blacklist
158 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-name')
159 videoToRemove = res.body.data[0]
160 blacklist = res.body.data.slice(1)
161
162 // Remove it
163 await removeVideoFromBlacklist(servers[0].url, servers[0].accessToken, videoToRemove.video.id)
164 })
165
166 it('Should have the ex-blacklisted video in videos list on server 1', async function () {
167 const res = await getVideosList(servers[0].url)
168 expect(res.body.total).to.equal(1)
169
170 const videos = res.body.data
171 expect(videos).to.be.an('array')
172 expect(videos.length).to.equal(1)
173
174 expect(videos[0].name).to.equal(videoToRemove.video.name)
175 expect(videos[0].id).to.equal(videoToRemove.video.id)
176 })
177
178 it('Should not have the ex-blacklisted video in videos blacklist list on server 1', async function () {
179 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-name')
180 expect(res.body.total).to.equal(1)
181
182 const videos = res.body.data
183 expect(videos).to.be.an('array')
184 expect(videos.length).to.equal(1)
185 expect(videos).to.deep.equal(blacklist)
186 })
187 })
188
189 after(async function () {
190 killallServers(servers)
191 })
192})
diff --git a/server/tests/api/videos/video-blacklist.ts b/server/tests/api/videos/video-blacklist.ts
index 1cce82d2a..d39ad63b4 100644
--- a/server/tests/api/videos/video-blacklist.ts
+++ b/server/tests/api/videos/video-blacklist.ts
@@ -1,24 +1,43 @@
1/* tslint:disable:no-unused-expression */ 1/* tslint:disable:no-unused-expression */
2 2
3import * as chai from 'chai' 3import * as chai from 'chai'
4import { orderBy } from 'lodash'
4import 'mocha' 5import 'mocha'
5import { 6import {
6 addVideoToBlacklist, 7 addVideoToBlacklist,
7 flushAndRunMultipleServers, 8 flushAndRunMultipleServers,
9 getBlacklistedVideosList,
10 getMyVideos,
11 getSortedBlacklistedVideosList,
8 getVideosList, 12 getVideosList,
9 killallServers, 13 killallServers,
14 removeVideoFromBlacklist,
10 searchVideo, 15 searchVideo,
11 ServerInfo, 16 ServerInfo,
12 setAccessTokensToServers, 17 setAccessTokensToServers,
13 uploadVideo 18 updateVideo,
19 updateVideoBlacklist,
20 uploadVideo,
21 viewVideo
14} from '../../../../shared/utils/index' 22} from '../../../../shared/utils/index'
15import { doubleFollow } from '../../../../shared/utils/server/follows' 23import { doubleFollow } from '../../../../shared/utils/server/follows'
16import { waitJobs } from '../../../../shared/utils/server/jobs' 24import { waitJobs } from '../../../../shared/utils/server/jobs'
25import { VideoBlacklist } from '../../../../shared/models/videos'
17 26
18const expect = chai.expect 27const expect = chai.expect
19 28
20describe('Test video blacklists', function () { 29describe('Test video blacklist management', function () {
21 let servers: ServerInfo[] = [] 30 let servers: ServerInfo[] = []
31 let videoId: number
32
33 async function blacklistVideosOnServer (server: ServerInfo) {
34 const res = await getVideosList(server.url)
35
36 const videos = res.body.data
37 for (let video of videos) {
38 await addVideoToBlacklist(server.url, server.accessToken, video.id, 'super reason')
39 }
40 }
22 41
23 before(async function () { 42 before(async function () {
24 this.timeout(50000) 43 this.timeout(50000)
@@ -32,58 +51,270 @@ describe('Test video blacklists', function () {
32 // Server 1 and server 2 follow each other 51 // Server 1 and server 2 follow each other
33 await doubleFollow(servers[0], servers[1]) 52 await doubleFollow(servers[0], servers[1])
34 53
35 // Upload a video on server 2 54 // Upload 2 videos on server 2
36 const videoAttributes = { 55 await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'My 1st video', description: 'A video on server 2' })
37 name: 'my super name for server 2', 56 await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'My 2nd video', description: 'A video on server 2' })
38 description: 'my super description for server 2'
39 }
40 await uploadVideo(servers[1].url, servers[1].accessToken, videoAttributes)
41 57
42 // Wait videos propagation, server 2 has transcoding enabled 58 // Wait videos propagation, server 2 has transcoding enabled
43 await waitJobs(servers) 59 await waitJobs(servers)
44 60
45 const res = await getVideosList(servers[0].url) 61 // Blacklist the two videos on server 1
46 const videos = res.body.data 62 await blacklistVideosOnServer(servers[0])
63 })
64
65 describe('When listing/searching videos', function () {
47 66
48 expect(videos.length).to.equal(1) 67 it('Should not have the video blacklisted in videos list/search on server 1', async function () {
68 {
69 const res = await getVideosList(servers[ 0 ].url)
49 70
50 servers[0].remoteVideo = videos.find(video => video.name === 'my super name for server 2') 71 expect(res.body.total).to.equal(0)
72 expect(res.body.data).to.be.an('array')
73 expect(res.body.data.length).to.equal(0)
74 }
75
76 {
77 const res = await searchVideo(servers[ 0 ].url, 'name')
78
79 expect(res.body.total).to.equal(0)
80 expect(res.body.data).to.be.an('array')
81 expect(res.body.data.length).to.equal(0)
82 }
83 })
84
85 it('Should have the blacklisted video in videos list/search on server 2', async function () {
86 {
87 const res = await getVideosList(servers[ 1 ].url)
88
89 expect(res.body.total).to.equal(2)
90 expect(res.body.data).to.be.an('array')
91 expect(res.body.data.length).to.equal(2)
92 }
93
94 {
95 const res = await searchVideo(servers[ 1 ].url, 'video')
96
97 expect(res.body.total).to.equal(2)
98 expect(res.body.data).to.be.an('array')
99 expect(res.body.data.length).to.equal(2)
100 }
101 })
51 }) 102 })
52 103
53 it('Should blacklist a remote video on server 1', async function () { 104 describe('When listing blacklisted videos', function () {
54 await addVideoToBlacklist(servers[0].url, servers[0].accessToken, servers[0].remoteVideo.id) 105 it('Should display all the blacklisted videos', async function () {
106 const res = await getBlacklistedVideosList(servers[0].url, servers[0].accessToken)
107
108 expect(res.body.total).to.equal(2)
109
110 const blacklistedVideos = res.body.data
111 expect(blacklistedVideos).to.be.an('array')
112 expect(blacklistedVideos.length).to.equal(2)
113
114 for (const blacklistedVideo of blacklistedVideos) {
115 expect(blacklistedVideo.reason).to.equal('super reason')
116 videoId = blacklistedVideo.video.id
117 }
118 })
119
120 it('Should get the correct sort when sorting by descending id', async function () {
121 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-id')
122 expect(res.body.total).to.equal(2)
123
124 const blacklistedVideos = res.body.data
125 expect(blacklistedVideos).to.be.an('array')
126 expect(blacklistedVideos.length).to.equal(2)
127
128 const result = orderBy(res.body.data, [ 'id' ], [ 'desc' ])
129
130 expect(blacklistedVideos).to.deep.equal(result)
131 })
132
133 it('Should get the correct sort when sorting by descending video name', async function () {
134 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-name')
135 expect(res.body.total).to.equal(2)
136
137 const blacklistedVideos = res.body.data
138 expect(blacklistedVideos).to.be.an('array')
139 expect(blacklistedVideos.length).to.equal(2)
140
141 const result = orderBy(res.body.data, [ 'name' ], [ 'desc' ])
142
143 expect(blacklistedVideos).to.deep.equal(result)
144 })
145
146 it('Should get the correct sort when sorting by ascending creation date', async function () {
147 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, 'createdAt')
148 expect(res.body.total).to.equal(2)
149
150 const blacklistedVideos = res.body.data
151 expect(blacklistedVideos).to.be.an('array')
152 expect(blacklistedVideos.length).to.equal(2)
153
154 const result = orderBy(res.body.data, [ 'createdAt' ])
155
156 expect(blacklistedVideos).to.deep.equal(result)
157 })
55 }) 158 })
56 159
57 it('Should not have the video blacklisted in videos list on server 1', async function () { 160 describe('When updating blacklisted videos', function () {
58 const res = await getVideosList(servers[0].url) 161 it('Should change the reason', async function () {
162 await updateVideoBlacklist(servers[0].url, servers[0].accessToken, videoId, 'my super reason updated')
163
164 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-name')
165 const video = res.body.data.find(b => b.video.id === videoId)
59 166
60 expect(res.body.total).to.equal(0) 167 expect(video.reason).to.equal('my super reason updated')
61 expect(res.body.data).to.be.an('array') 168 })
62 expect(res.body.data.length).to.equal(0)
63 }) 169 })
64 170
65 it('Should not have the video blacklisted in videos search on server 1', async function () { 171 describe('When listing my videos', function () {
66 const res = await searchVideo(servers[0].url, 'name') 172 it('Should display blacklisted videos', async function () {
173 await blacklistVideosOnServer(servers[1])
174
175 const res = await getMyVideos(servers[1].url, servers[1].accessToken, 0, 5)
67 176
68 expect(res.body.total).to.equal(0) 177 expect(res.body.total).to.equal(2)
69 expect(res.body.data).to.be.an('array') 178 expect(res.body.data).to.have.lengthOf(2)
70 expect(res.body.data.length).to.equal(0) 179
180 for (const video of res.body.data) {
181 expect(video.blacklisted).to.be.true
182 expect(video.blacklistedReason).to.equal('super reason')
183 }
184 })
71 }) 185 })
72 186
73 it('Should have the blacklisted video in videos list on server 2', async function () { 187 describe('When removing a blacklisted video', function () {
74 const res = await getVideosList(servers[1].url) 188 let videoToRemove: VideoBlacklist
189 let blacklist = []
190
191 it('Should not have any video in videos list on server 1', async function () {
192 const res = await getVideosList(servers[0].url)
193 expect(res.body.total).to.equal(0)
194 expect(res.body.data).to.be.an('array')
195 expect(res.body.data.length).to.equal(0)
196 })
197
198 it('Should remove a video from the blacklist on server 1', async function () {
199 // Get one video in the blacklist
200 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-name')
201 videoToRemove = res.body.data[0]
202 blacklist = res.body.data.slice(1)
203
204 // Remove it
205 await removeVideoFromBlacklist(servers[0].url, servers[0].accessToken, videoToRemove.video.id)
206 })
207
208 it('Should have the ex-blacklisted video in videos list on server 1', async function () {
209 const res = await getVideosList(servers[0].url)
210 expect(res.body.total).to.equal(1)
211
212 const videos = res.body.data
213 expect(videos).to.be.an('array')
214 expect(videos.length).to.equal(1)
215
216 expect(videos[0].name).to.equal(videoToRemove.video.name)
217 expect(videos[0].id).to.equal(videoToRemove.video.id)
218 })
219
220 it('Should not have the ex-blacklisted video in videos blacklist list on server 1', async function () {
221 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, '-name')
222 expect(res.body.total).to.equal(1)
75 223
76 expect(res.body.total).to.equal(1) 224 const videos = res.body.data
77 expect(res.body.data).to.be.an('array') 225 expect(videos).to.be.an('array')
78 expect(res.body.data.length).to.equal(1) 226 expect(videos.length).to.equal(1)
227 expect(videos).to.deep.equal(blacklist)
228 })
79 }) 229 })
80 230
81 it('Should have the video blacklisted in videos search on server 2', async function () { 231 describe('When blacklisting local videos', function () {
82 const res = await searchVideo(servers[1].url, 'name') 232 let video3UUID: string
233 let video4UUID: string
234
235 before(async function () {
236 this.timeout(10000)
237
238 {
239 const res = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'Video 3' })
240 video3UUID = res.body.video.uuid
241 }
242 {
243 const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'Video 4' })
244 video4UUID = res.body.video.uuid
245 }
246
247 await waitJobs(servers)
248 })
249
250 it('Should blacklist video 3 and keep it federated', async function () {
251 this.timeout(10000)
252
253 await addVideoToBlacklist(servers[ 0 ].url, servers[ 0 ].accessToken, video3UUID, 'super reason', false)
254
255 await waitJobs(servers)
256
257 {
258 const res = await getVideosList(servers[ 0 ].url)
259 expect(res.body.data.find(v => v.uuid === video3UUID)).to.be.undefined
260 }
261
262 {
263 const res = await getVideosList(servers[ 1 ].url)
264 expect(res.body.data.find(v => v.uuid === video3UUID)).to.not.be.undefined
265 }
266 })
267
268 it('Should unfederate the video', async function () {
269 this.timeout(10000)
270
271 await addVideoToBlacklist(servers[ 0 ].url, servers[ 0 ].accessToken, video4UUID, 'super reason', true)
272
273 await waitJobs(servers)
274
275 for (const server of servers) {
276 const res = await getVideosList(server.url)
277 expect(res.body.data.find(v => v.uuid === video4UUID)).to.be.undefined
278 }
279 })
280
281 it('Should have the video unfederated even after an Update AP message', async function () {
282 this.timeout(10000)
283
284 await updateVideo(servers[ 0 ].url, servers[ 0 ].accessToken, video4UUID, { description: 'super description' })
285
286 await waitJobs(servers)
287
288 for (const server of servers) {
289 const res = await getVideosList(server.url)
290 expect(res.body.data.find(v => v.uuid === video4UUID)).to.be.undefined
291 }
292 })
293
294 it('Should have the correct video blacklist unfederate attribute', async function () {
295 const res = await getSortedBlacklistedVideosList(servers[0].url, servers[0].accessToken, 'createdAt')
296
297 const blacklistedVideos: VideoBlacklist[] = res.body.data
298 const video3Blacklisted = blacklistedVideos.find(b => b.video.uuid === video3UUID)
299 const video4Blacklisted = blacklistedVideos.find(b => b.video.uuid === video4UUID)
300
301 expect(video3Blacklisted.unfederated).to.be.false
302 expect(video4Blacklisted.unfederated).to.be.true
303 })
304
305 it('Should remove the video from blacklist and refederate the video', async function () {
306 this.timeout(10000)
307
308 await removeVideoFromBlacklist(servers[ 0 ].url, servers[ 0 ].accessToken, video4UUID)
309
310 await waitJobs(servers)
311
312 for (const server of servers) {
313 const res = await getVideosList(server.url)
314 expect(res.body.data.find(v => v.uuid === video4UUID)).to.not.be.undefined
315 }
316 })
83 317
84 expect(res.body.total).to.equal(1)
85 expect(res.body.data).to.be.an('array')
86 expect(res.body.data.length).to.equal(1)
87 }) 318 })
88 319
89 after(async function () { 320 after(async function () {
diff --git a/server/tests/api/videos/video-hls.ts b/server/tests/api/videos/video-hls.ts
new file mode 100644
index 000000000..a1214bad1
--- /dev/null
+++ b/server/tests/api/videos/video-hls.ts
@@ -0,0 +1,139 @@
1/* tslint:disable:no-unused-expression */
2
3import * as chai from 'chai'
4import 'mocha'
5import {
6 checkDirectoryIsEmpty,
7 checkSegmentHash,
8 checkTmpIsEmpty,
9 doubleFollow,
10 flushAndRunMultipleServers,
11 flushTests,
12 getPlaylist,
13 getVideo,
14 killallServers,
15 removeVideo,
16 ServerInfo,
17 setAccessTokensToServers,
18 updateVideo,
19 uploadVideo,
20 waitJobs
21} from '../../../../shared/utils'
22import { VideoDetails } from '../../../../shared/models/videos'
23import { VideoStreamingPlaylistType } from '../../../../shared/models/videos/video-streaming-playlist.type'
24import { join } from 'path'
25
26const expect = chai.expect
27
28async function checkHlsPlaylist (servers: ServerInfo[], videoUUID: string) {
29 const resolutions = [ 240, 360, 480, 720 ]
30
31 for (const server of servers) {
32 const res = await getVideo(server.url, videoUUID)
33 const videoDetails: VideoDetails = res.body
34
35 expect(videoDetails.streamingPlaylists).to.have.lengthOf(1)
36
37 const hlsPlaylist = videoDetails.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
38 expect(hlsPlaylist).to.not.be.undefined
39
40 {
41 const res2 = await getPlaylist(hlsPlaylist.playlistUrl)
42
43 const masterPlaylist = res2.text
44
45 expect(masterPlaylist).to.contain('#EXT-X-STREAM-INF:BANDWIDTH=55472,RESOLUTION=640x360,FRAME-RATE=25')
46
47 for (const resolution of resolutions) {
48 expect(masterPlaylist).to.contain(`${resolution}.m3u8`)
49 }
50 }
51
52 {
53 for (const resolution of resolutions) {
54 const res2 = await getPlaylist(`http://localhost:9001/static/playlists/hls/${videoUUID}/${resolution}.m3u8`)
55
56 const subPlaylist = res2.text
57 expect(subPlaylist).to.contain(`${videoUUID}-${resolution}-fragmented.mp4`)
58 }
59 }
60
61 {
62 const baseUrl = 'http://localhost:9001/static/playlists/hls'
63
64 for (const resolution of resolutions) {
65 await checkSegmentHash(baseUrl, baseUrl, videoUUID, resolution, hlsPlaylist)
66 }
67 }
68 }
69}
70
71describe('Test HLS videos', function () {
72 let servers: ServerInfo[] = []
73 let videoUUID = ''
74
75 before(async function () {
76 this.timeout(120000)
77
78 servers = await flushAndRunMultipleServers(2, { transcoding: { enabled: true, hls: { enabled: true } } })
79
80 // Get the access tokens
81 await setAccessTokensToServers(servers)
82
83 // Server 1 and server 2 follow each other
84 await doubleFollow(servers[0], servers[1])
85 })
86
87 it('Should upload a video and transcode it to HLS', async function () {
88 this.timeout(120000)
89
90 {
91 const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'video 1', fixture: 'video_short.webm' })
92 videoUUID = res.body.video.uuid
93 }
94
95 await waitJobs(servers)
96
97 await checkHlsPlaylist(servers, videoUUID)
98 })
99
100 it('Should update the video', async function () {
101 await updateVideo(servers[0].url, servers[0].accessToken, videoUUID, { name: 'video 1 updated' })
102
103 await waitJobs(servers)
104
105 await checkHlsPlaylist(servers, videoUUID)
106 })
107
108 it('Should delete the video', async function () {
109 await removeVideo(servers[0].url, servers[0].accessToken, videoUUID)
110
111 await waitJobs(servers)
112
113 for (const server of servers) {
114 await getVideo(server.url, videoUUID, 404)
115 }
116 })
117
118 it('Should have the playlists/segment deleted from the disk', async function () {
119 for (const server of servers) {
120 await checkDirectoryIsEmpty(server, 'videos')
121 await checkDirectoryIsEmpty(server, join('playlists', 'hls'))
122 }
123 })
124
125 it('Should have an empty tmp directory', async function () {
126 for (const server of servers) {
127 await checkTmpIsEmpty(server)
128 }
129 })
130
131 after(async function () {
132 killallServers(servers)
133
134 // Keep the logs if the test failed
135 if (this['ok']) {
136 await flushTests()
137 }
138 })
139})
diff --git a/server/tests/cli/update-host.ts b/server/tests/cli/update-host.ts
index 811ea6a9f..d38bb4331 100644
--- a/server/tests/cli/update-host.ts
+++ b/server/tests/cli/update-host.ts
@@ -86,6 +86,13 @@ describe('Test update host scripts', function () {
86 const { body } = await makeActivityPubGetRequest(server.url, '/videos/watch/' + video.uuid) 86 const { body } = await makeActivityPubGetRequest(server.url, '/videos/watch/' + video.uuid)
87 87
88 expect(body.id).to.equal('http://localhost:9002/videos/watch/' + video.uuid) 88 expect(body.id).to.equal('http://localhost:9002/videos/watch/' + video.uuid)
89
90 const res = await getVideo(server.url, video.uuid)
91 const videoDetails: VideoDetails = res.body
92
93 expect(videoDetails.trackerUrls[0]).to.include(server.host)
94 expect(videoDetails.streamingPlaylists[0].playlistUrl).to.include(server.host)
95 expect(videoDetails.streamingPlaylists[0].segmentsSha256Url).to.include(server.host)
89 } 96 }
90 }) 97 })
91 98
@@ -100,7 +107,7 @@ describe('Test update host scripts', function () {
100 } 107 }
101 }) 108 })
102 109
103 it('Should have update accounts url', async function () { 110 it('Should have updated accounts url', async function () {
104 const res = await getAccountsList(server.url) 111 const res = await getAccountsList(server.url)
105 expect(res.body.total).to.equal(3) 112 expect(res.body.total).to.equal(3)
106 113
@@ -112,7 +119,7 @@ describe('Test update host scripts', function () {
112 } 119 }
113 }) 120 })
114 121
115 it('Should update torrent hosts', async function () { 122 it('Should have updated torrent hosts', async function () {
116 this.timeout(30000) 123 this.timeout(30000)
117 124
118 const res = await getVideosList(server.url) 125 const res = await getVideosList(server.url)
diff --git a/server/tools/peertube-import-videos.ts b/server/tools/peertube-import-videos.ts
index 2874a2131..151c5a989 100644
--- a/server/tools/peertube-import-videos.ts
+++ b/server/tools/peertube-import-videos.ts
@@ -78,7 +78,11 @@ getSettings()
78 password: program['password'] 78 password: program['password']
79 } 79 }
80 80
81 run(user, program['url']).catch(err => console.error(err)) 81 run(user, program['url'])
82 .catch(err => {
83 console.error(err)
84 process.exit(-1)
85 })
82}) 86})
83 87
84async function promptPassword () { 88async function promptPassword () {
@@ -112,8 +116,12 @@ async function run (user, url: string) {
112 secret: res.body.client_secret 116 secret: res.body.client_secret
113 } 117 }
114 118
115 const res2 = await login(url, client, user) 119 try {
116 accessToken = res2.body.access_token 120 const res = await login(program[ 'url' ], client, user)
121 accessToken = res.body.access_token
122 } catch (err) {
123 throw new Error('Cannot authenticate. Please check your username/password.')
124 }
117 125
118 const youtubeDL = await safeGetYoutubeDL() 126 const youtubeDL = await safeGetYoutubeDL()
119 127
@@ -216,6 +224,7 @@ async function uploadVideoOnPeerTube (videoInfo: any, videoPath: string, cwd: st
216 nsfw: isNSFW(videoInfo), 224 nsfw: isNSFW(videoInfo),
217 waitTranscoding: true, 225 waitTranscoding: true,
218 commentsEnabled: true, 226 commentsEnabled: true,
227 downloadEnabled: true,
219 description: videoInfo.description || undefined, 228 description: videoInfo.description || undefined,
220 support: undefined, 229 support: undefined,
221 tags, 230 tags,
diff --git a/server/tools/peertube-upload.ts b/server/tools/peertube-upload.ts
index cc7bd9b4c..ebc62c965 100644
--- a/server/tools/peertube-upload.ts
+++ b/server/tools/peertube-upload.ts
@@ -30,6 +30,7 @@ if (!program['tags']) program['tags'] = []
30if (!program['nsfw']) program['nsfw'] = false 30if (!program['nsfw']) program['nsfw'] = false
31if (!program['privacy']) program['privacy'] = VideoPrivacy.PUBLIC 31if (!program['privacy']) program['privacy'] = VideoPrivacy.PUBLIC
32if (!program['commentsEnabled']) program['commentsEnabled'] = false 32if (!program['commentsEnabled']) program['commentsEnabled'] = false
33if (!program['downloadEnabled']) program['downloadEnabled'] = true
33 34
34getSettings() 35getSettings()
35 .then(settings => { 36 .then(settings => {
@@ -116,6 +117,7 @@ async function run () {
116 description: program['videoDescription'], 117 description: program['videoDescription'],
117 tags: program['tags'], 118 tags: program['tags'],
118 commentsEnabled: program['commentsEnabled'], 119 commentsEnabled: program['commentsEnabled'],
120 downloadEnabled: program['downloadEnabled'],
119 fixture: program['file'], 121 fixture: program['file'],
120 thumbnailfile: program['thumbnail'], 122 thumbnailfile: program['thumbnail'],
121 previewfile: program['preview'], 123 previewfile: program['preview'],