videos.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764
  1. import * as Bluebird from 'bluebird'
  2. import * as sequelize from 'sequelize'
  3. import * as magnetUtil from 'magnet-uri'
  4. import * as request from 'request'
  5. import {
  6. ActivityHashTagObject,
  7. ActivityMagnetUrlObject,
  8. ActivityPlaylistSegmentHashesObject,
  9. ActivityPlaylistUrlObject,
  10. ActivityTagObject,
  11. ActivityUrlObject,
  12. ActivityVideoUrlObject,
  13. VideoState
  14. } from '../../../shared/index'
  15. import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
  16. import { VideoPrivacy } from '../../../shared/models/videos'
  17. import { sanitizeAndCheckVideoTorrentObject } from '../../helpers/custom-validators/activitypub/videos'
  18. import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
  19. import { deleteNonExistingModels, resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils'
  20. import { logger } from '../../helpers/logger'
  21. import { doRequest } from '../../helpers/requests'
  22. import {
  23. ACTIVITY_PUB,
  24. MIMETYPES,
  25. P2P_MEDIA_LOADER_PEER_VERSION,
  26. PREVIEWS_SIZE,
  27. REMOTE_SCHEME,
  28. STATIC_PATHS, THUMBNAILS_SIZE
  29. } from '../../initializers/constants'
  30. import { TagModel } from '../../models/video/tag'
  31. import { VideoModel } from '../../models/video/video'
  32. import { VideoFileModel } from '../../models/video/video-file'
  33. import { getOrCreateActorAndServerAndModel } from './actor'
  34. import { addVideoComments } from './video-comments'
  35. import { crawlCollectionPage } from './crawl'
  36. import { sendCreateVideo, sendUpdateVideo } from './send'
  37. import { isArray } from '../../helpers/custom-validators/misc'
  38. import { VideoCaptionModel } from '../../models/video/video-caption'
  39. import { JobQueue } from '../job-queue'
  40. import { ActivitypubHttpFetcherPayload } from '../job-queue/handlers/activitypub-http-fetcher'
  41. import { createRates } from './video-rates'
  42. import { addVideoShares, shareVideoByServerAndChannel } from './share'
  43. import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video'
  44. import { buildRemoteVideoBaseUrl, checkUrlsSameHost, getAPId } from '../../helpers/activitypub'
  45. import { Notifier } from '../notifier'
  46. import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
  47. import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
  48. import { AccountVideoRateModel } from '../../models/account/account-video-rate'
  49. import { VideoShareModel } from '../../models/video/video-share'
  50. import { VideoCommentModel } from '../../models/video/video-comment'
  51. import { sequelizeTypescript } from '../../initializers/database'
  52. import { createPlaceholderThumbnail, createVideoMiniatureFromUrl } from '../thumbnail'
  53. import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
  54. import { join } from 'path'
  55. import { FilteredModelAttributes } from '../../typings/sequelize'
  56. import { autoBlacklistVideoIfNeeded } from '../video-blacklist'
  57. import { ActorFollowScoreCache } from '../files-cache'
  58. import {
  59. MAccountIdActor,
  60. MChannelAccountLight,
  61. MChannelDefault,
  62. MChannelId,
  63. MStreamingPlaylist,
  64. MVideo,
  65. MVideoAccountLight,
  66. MVideoAccountLightBlacklistAllFiles,
  67. MVideoAP,
  68. MVideoAPWithoutCaption,
  69. MVideoFile,
  70. MVideoFullLight,
  71. MVideoId, MVideoImmutable,
  72. MVideoThumbnail
  73. } from '../../typings/models'
  74. import { MThumbnail } from '../../typings/models/video/thumbnail'
  75. import { maxBy, minBy } from 'lodash'
  76. async function federateVideoIfNeeded (videoArg: MVideoAPWithoutCaption, isNewVideo: boolean, transaction?: sequelize.Transaction) {
  77. const video = videoArg as MVideoAP
  78. if (
  79. // Check this is not a blacklisted video, or unfederated blacklisted video
  80. (video.isBlacklisted() === false || (isNewVideo === false && video.VideoBlacklist.unfederated === false)) &&
  81. // Check the video is public/unlisted and published
  82. video.hasPrivacyForFederation() && video.state === VideoState.PUBLISHED
  83. ) {
  84. // Fetch more attributes that we will need to serialize in AP object
  85. if (isArray(video.VideoCaptions) === false) {
  86. video.VideoCaptions = await video.$get('VideoCaptions', {
  87. attributes: [ 'language' ],
  88. transaction
  89. })
  90. }
  91. if (isNewVideo) {
  92. // Now we'll add the video's meta data to our followers
  93. await sendCreateVideo(video, transaction)
  94. await shareVideoByServerAndChannel(video, transaction)
  95. } else {
  96. await sendUpdateVideo(video, transaction)
  97. }
  98. }
  99. }
  100. async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.RequestResponse, videoObject: VideoTorrentObject }> {
  101. const options = {
  102. uri: videoUrl,
  103. method: 'GET',
  104. json: true,
  105. activityPub: true
  106. }
  107. logger.info('Fetching remote video %s.', videoUrl)
  108. const { response, body } = await doRequest<any>(options)
  109. if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) {
  110. logger.debug('Remote video JSON is not valid.', { body })
  111. return { response, videoObject: undefined }
  112. }
  113. return { response, videoObject: body }
  114. }
  115. async function fetchRemoteVideoDescription (video: MVideoAccountLight) {
  116. const host = video.VideoChannel.Account.Actor.Server.host
  117. const path = video.getDescriptionAPIPath()
  118. const options = {
  119. uri: REMOTE_SCHEME.HTTP + '://' + host + path,
  120. json: true
  121. }
  122. const { body } = await doRequest<any>(options)
  123. return body.description ? body.description : ''
  124. }
  125. function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
  126. const channel = videoObject.attributedTo.find(a => a.type === 'Group')
  127. if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
  128. if (checkUrlsSameHost(channel.id, videoObject.id) !== true) {
  129. throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${videoObject.id}`)
  130. }
  131. return getOrCreateActorAndServerAndModel(channel.id, 'all')
  132. }
  133. type SyncParam = {
  134. likes: boolean
  135. dislikes: boolean
  136. shares: boolean
  137. comments: boolean
  138. thumbnail: boolean
  139. refreshVideo?: boolean
  140. }
  141. async function syncVideoExternalAttributes (video: MVideo, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) {
  142. logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
  143. const jobPayloads: ActivitypubHttpFetcherPayload[] = []
  144. if (syncParam.likes === true) {
  145. const handler = items => createRates(items, video, 'like')
  146. const cleaner = crawlStartDate => AccountVideoRateModel.cleanOldRatesOf(video.id, 'like' as 'like', crawlStartDate)
  147. await crawlCollectionPage<string>(fetchedVideo.likes, handler, cleaner)
  148. .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err, rootUrl: fetchedVideo.likes }))
  149. } else {
  150. jobPayloads.push({ uri: fetchedVideo.likes, videoId: video.id, type: 'video-likes' as 'video-likes' })
  151. }
  152. if (syncParam.dislikes === true) {
  153. const handler = items => createRates(items, video, 'dislike')
  154. const cleaner = crawlStartDate => AccountVideoRateModel.cleanOldRatesOf(video.id, 'dislike' as 'dislike', crawlStartDate)
  155. await crawlCollectionPage<string>(fetchedVideo.dislikes, handler, cleaner)
  156. .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err, rootUrl: fetchedVideo.dislikes }))
  157. } else {
  158. jobPayloads.push({ uri: fetchedVideo.dislikes, videoId: video.id, type: 'video-dislikes' as 'video-dislikes' })
  159. }
  160. if (syncParam.shares === true) {
  161. const handler = items => addVideoShares(items, video)
  162. const cleaner = crawlStartDate => VideoShareModel.cleanOldSharesOf(video.id, crawlStartDate)
  163. await crawlCollectionPage<string>(fetchedVideo.shares, handler, cleaner)
  164. .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err, rootUrl: fetchedVideo.shares }))
  165. } else {
  166. jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
  167. }
  168. if (syncParam.comments === true) {
  169. const handler = items => addVideoComments(items)
  170. const cleaner = crawlStartDate => VideoCommentModel.cleanOldCommentsOf(video.id, crawlStartDate)
  171. await crawlCollectionPage<string>(fetchedVideo.comments, handler, cleaner)
  172. .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err, rootUrl: fetchedVideo.comments }))
  173. } else {
  174. jobPayloads.push({ uri: fetchedVideo.comments, videoId: video.id, type: 'video-comments' as 'video-comments' })
  175. }
  176. await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJobWithPromise({ type: 'activitypub-http-fetcher', payload }))
  177. }
  178. type GetVideoResult <T> = Promise<{
  179. video: T
  180. created: boolean
  181. autoBlacklisted?: boolean
  182. }>
  183. type GetVideoParamAll = {
  184. videoObject: { id: string } | string
  185. syncParam?: SyncParam
  186. fetchType?: 'all'
  187. allowRefresh?: boolean
  188. }
  189. type GetVideoParamImmutable = {
  190. videoObject: { id: string } | string
  191. syncParam?: SyncParam
  192. fetchType: 'only-immutable-attributes'
  193. allowRefresh: false
  194. }
  195. type GetVideoParamOther = {
  196. videoObject: { id: string } | string
  197. syncParam?: SyncParam
  198. fetchType?: 'all' | 'only-video'
  199. allowRefresh?: boolean
  200. }
  201. function getOrCreateVideoAndAccountAndChannel (options: GetVideoParamAll): GetVideoResult<MVideoAccountLightBlacklistAllFiles>
  202. function getOrCreateVideoAndAccountAndChannel (options: GetVideoParamImmutable): GetVideoResult<MVideoImmutable>
  203. function getOrCreateVideoAndAccountAndChannel (
  204. options: GetVideoParamOther
  205. ): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail>
  206. async function getOrCreateVideoAndAccountAndChannel (
  207. options: GetVideoParamAll | GetVideoParamImmutable | GetVideoParamOther
  208. ): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail | MVideoImmutable> {
  209. // Default params
  210. const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
  211. const fetchType = options.fetchType || 'all'
  212. const allowRefresh = options.allowRefresh !== false
  213. // Get video url
  214. const videoUrl = getAPId(options.videoObject)
  215. let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
  216. if (videoFromDatabase) {
  217. // If allowRefresh is true, we could not call this function using 'only-immutable-attributes' fetch type
  218. if (allowRefresh === true && (videoFromDatabase as MVideoThumbnail).isOutdated()) {
  219. const refreshOptions = {
  220. video: videoFromDatabase as MVideoThumbnail,
  221. fetchedType: fetchType,
  222. syncParam
  223. }
  224. if (syncParam.refreshVideo === true) {
  225. videoFromDatabase = await refreshVideoIfNeeded(refreshOptions)
  226. } else {
  227. await JobQueue.Instance.createJobWithPromise({
  228. type: 'activitypub-refresher',
  229. payload: { type: 'video', url: videoFromDatabase.url }
  230. })
  231. }
  232. }
  233. return { video: videoFromDatabase, created: false }
  234. }
  235. const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
  236. if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
  237. const actor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
  238. const videoChannel = actor.VideoChannel
  239. const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(createVideo, fetchedVideo, videoChannel, syncParam.thumbnail)
  240. await syncVideoExternalAttributes(videoCreated, fetchedVideo, syncParam)
  241. return { video: videoCreated, created: true, autoBlacklisted }
  242. }
  243. async function updateVideoFromAP (options: {
  244. video: MVideoAccountLightBlacklistAllFiles
  245. videoObject: VideoTorrentObject
  246. account: MAccountIdActor
  247. channel: MChannelDefault
  248. overrideTo?: string[]
  249. }) {
  250. const { video, videoObject, account, channel, overrideTo } = options
  251. logger.debug('Updating remote video "%s".', options.videoObject.uuid, { account, channel })
  252. let videoFieldsSave: any
  253. const wasPrivateVideo = video.privacy === VideoPrivacy.PRIVATE
  254. const wasUnlistedVideo = video.privacy === VideoPrivacy.UNLISTED
  255. try {
  256. let thumbnailModel: MThumbnail
  257. try {
  258. thumbnailModel = await createVideoMiniatureFromUrl(getThumbnailFromIcons(videoObject).url, video, ThumbnailType.MINIATURE)
  259. } catch (err) {
  260. logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err })
  261. }
  262. const videoUpdated = await sequelizeTypescript.transaction(async t => {
  263. const sequelizeOptions = { transaction: t }
  264. videoFieldsSave = video.toJSON()
  265. // Check actor has the right to update the video
  266. const videoChannel = video.VideoChannel
  267. if (videoChannel.Account.id !== account.id) {
  268. throw new Error('Account ' + account.Actor.url + ' does not own video channel ' + videoChannel.Actor.url)
  269. }
  270. const to = overrideTo || videoObject.to
  271. const videoData = await videoActivityObjectToDBAttributes(channel, videoObject, to)
  272. video.name = videoData.name
  273. video.uuid = videoData.uuid
  274. video.url = videoData.url
  275. video.category = videoData.category
  276. video.licence = videoData.licence
  277. video.language = videoData.language
  278. video.description = videoData.description
  279. video.support = videoData.support
  280. video.nsfw = videoData.nsfw
  281. video.commentsEnabled = videoData.commentsEnabled
  282. video.downloadEnabled = videoData.downloadEnabled
  283. video.waitTranscoding = videoData.waitTranscoding
  284. video.state = videoData.state
  285. video.duration = videoData.duration
  286. video.createdAt = videoData.createdAt
  287. video.publishedAt = videoData.publishedAt
  288. video.originallyPublishedAt = videoData.originallyPublishedAt
  289. video.privacy = videoData.privacy
  290. video.channelId = videoData.channelId
  291. video.views = videoData.views
  292. const videoUpdated = await video.save(sequelizeOptions) as MVideoFullLight
  293. if (thumbnailModel) await videoUpdated.addAndSaveThumbnail(thumbnailModel, t)
  294. if (videoUpdated.getPreview()) {
  295. const previewUrl = videoUpdated.getPreview().getFileUrl(videoUpdated)
  296. const previewModel = createPlaceholderThumbnail(previewUrl, video, ThumbnailType.PREVIEW, PREVIEWS_SIZE)
  297. await videoUpdated.addAndSaveThumbnail(previewModel, t)
  298. }
  299. {
  300. const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoUpdated, videoObject.url)
  301. const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
  302. // Remove video files that do not exist anymore
  303. const destroyTasks = deleteNonExistingModels(videoUpdated.VideoFiles, newVideoFiles, t)
  304. await Promise.all(destroyTasks)
  305. // Update or add other one
  306. const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'video', t))
  307. videoUpdated.VideoFiles = await Promise.all(upsertTasks)
  308. }
  309. {
  310. const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(videoUpdated, videoObject, videoUpdated.VideoFiles)
  311. const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
  312. // Remove video playlists that do not exist anymore
  313. const destroyTasks = deleteNonExistingModels(videoUpdated.VideoStreamingPlaylists, newStreamingPlaylists, t)
  314. await Promise.all(destroyTasks)
  315. let oldStreamingPlaylistFiles: MVideoFile[] = []
  316. for (const videoStreamingPlaylist of videoUpdated.VideoStreamingPlaylists) {
  317. oldStreamingPlaylistFiles = oldStreamingPlaylistFiles.concat(videoStreamingPlaylist.VideoFiles)
  318. }
  319. videoUpdated.VideoStreamingPlaylists = []
  320. for (const playlistAttributes of streamingPlaylistAttributes) {
  321. const streamingPlaylistModel = await VideoStreamingPlaylistModel.upsert(playlistAttributes, { returning: true, transaction: t })
  322. .then(([ streamingPlaylist ]) => streamingPlaylist)
  323. const newVideoFiles: MVideoFile[] = videoFileActivityUrlToDBAttributes(streamingPlaylistModel, playlistAttributes.tagAPObject)
  324. .map(a => new VideoFileModel(a))
  325. const destroyTasks = deleteNonExistingModels(oldStreamingPlaylistFiles, newVideoFiles, t)
  326. await Promise.all(destroyTasks)
  327. // Update or add other one
  328. const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'streaming-playlist', t))
  329. streamingPlaylistModel.VideoFiles = await Promise.all(upsertTasks)
  330. videoUpdated.VideoStreamingPlaylists.push(streamingPlaylistModel)
  331. }
  332. }
  333. {
  334. // Update Tags
  335. const tags = videoObject.tag
  336. .filter(isAPHashTagObject)
  337. .map(tag => tag.name)
  338. const tagInstances = await TagModel.findOrCreateTags(tags, t)
  339. await videoUpdated.$set('Tags', tagInstances, sequelizeOptions)
  340. }
  341. {
  342. // Update captions
  343. await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(videoUpdated.id, t)
  344. const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
  345. return VideoCaptionModel.insertOrReplaceLanguage(videoUpdated.id, c.identifier, c.url, t)
  346. })
  347. await Promise.all(videoCaptionsPromises)
  348. }
  349. return videoUpdated
  350. })
  351. await autoBlacklistVideoIfNeeded({
  352. video: videoUpdated,
  353. user: undefined,
  354. isRemote: true,
  355. isNew: false,
  356. transaction: undefined
  357. })
  358. if (wasPrivateVideo || wasUnlistedVideo) Notifier.Instance.notifyOnNewVideoIfNeeded(videoUpdated) // Notify our users?
  359. logger.info('Remote video with uuid %s updated', videoObject.uuid)
  360. return videoUpdated
  361. } catch (err) {
  362. if (video !== undefined && videoFieldsSave !== undefined) {
  363. resetSequelizeInstance(video, videoFieldsSave)
  364. }
  365. // This is just a debug because we will retry the insert
  366. logger.debug('Cannot update the remote video.', { err })
  367. throw err
  368. }
  369. }
  370. async function refreshVideoIfNeeded (options: {
  371. video: MVideoThumbnail
  372. fetchedType: VideoFetchByUrlType
  373. syncParam: SyncParam
  374. }): Promise<MVideoThumbnail> {
  375. if (!options.video.isOutdated()) return options.video
  376. // We need more attributes if the argument video was fetched with not enough joints
  377. const video = options.fetchedType === 'all'
  378. ? options.video as MVideoAccountLightBlacklistAllFiles
  379. : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
  380. try {
  381. const { response, videoObject } = await fetchRemoteVideo(video.url)
  382. if (response.statusCode === 404) {
  383. logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
  384. // Video does not exist anymore
  385. await video.destroy()
  386. return undefined
  387. }
  388. if (videoObject === undefined) {
  389. logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
  390. await video.setAsRefreshed()
  391. return video
  392. }
  393. const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
  394. const updateOptions = {
  395. video,
  396. videoObject,
  397. account: channelActor.VideoChannel.Account,
  398. channel: channelActor.VideoChannel
  399. }
  400. await retryTransactionWrapper(updateVideoFromAP, updateOptions)
  401. await syncVideoExternalAttributes(video, videoObject, options.syncParam)
  402. ActorFollowScoreCache.Instance.addGoodServerId(video.VideoChannel.Actor.serverId)
  403. return video
  404. } catch (err) {
  405. logger.warn('Cannot refresh video %s.', options.video.url, { err })
  406. ActorFollowScoreCache.Instance.addBadServerId(video.VideoChannel.Actor.serverId)
  407. // Don't refresh in loop
  408. await video.setAsRefreshed()
  409. return video
  410. }
  411. }
  412. export {
  413. updateVideoFromAP,
  414. refreshVideoIfNeeded,
  415. federateVideoIfNeeded,
  416. fetchRemoteVideo,
  417. getOrCreateVideoAndAccountAndChannel,
  418. fetchRemoteVideoDescription,
  419. getOrCreateVideoChannelFromVideoObject
  420. }
  421. // ---------------------------------------------------------------------------
  422. function isAPVideoUrlObject (url: any): url is ActivityVideoUrlObject {
  423. const mimeTypes = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT)
  424. const urlMediaType = url.mediaType
  425. return mimeTypes.includes(urlMediaType) && urlMediaType.startsWith('video/')
  426. }
  427. function isAPStreamingPlaylistUrlObject (url: ActivityUrlObject): url is ActivityPlaylistUrlObject {
  428. return url && url.mediaType === 'application/x-mpegURL'
  429. }
  430. function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject {
  431. return tag && tag.name === 'sha256' && tag.type === 'Link' && tag.mediaType === 'application/json'
  432. }
  433. function isAPMagnetUrlObject (url: any): url is ActivityMagnetUrlObject {
  434. return url && url.mediaType === 'application/x-bittorrent;x-scheme-handler/magnet'
  435. }
  436. function isAPHashTagObject (url: any): url is ActivityHashTagObject {
  437. return url && url.type === 'Hashtag'
  438. }
  439. async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAccountLight, waitThumbnail = false) {
  440. logger.debug('Adding remote video %s.', videoObject.id)
  441. const videoData = await videoActivityObjectToDBAttributes(channel, videoObject, videoObject.to)
  442. const video = VideoModel.build(videoData) as MVideoThumbnail
  443. const promiseThumbnail = createVideoMiniatureFromUrl(getThumbnailFromIcons(videoObject).url, video, ThumbnailType.MINIATURE)
  444. .catch(err => {
  445. logger.error('Cannot create miniature from url.', { err })
  446. return undefined
  447. })
  448. let thumbnailModel: MThumbnail
  449. if (waitThumbnail === true) {
  450. thumbnailModel = await promiseThumbnail
  451. }
  452. const { autoBlacklisted, videoCreated } = await sequelizeTypescript.transaction(async t => {
  453. const sequelizeOptions = { transaction: t }
  454. const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight
  455. videoCreated.VideoChannel = channel
  456. if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
  457. const previewIcon = getPreviewFromIcons(videoObject)
  458. const previewUrl = previewIcon
  459. ? previewIcon.url
  460. : buildRemoteVideoBaseUrl(videoCreated, join(STATIC_PATHS.PREVIEWS, video.generatePreviewName()))
  461. const previewModel = createPlaceholderThumbnail(previewUrl, videoCreated, ThumbnailType.PREVIEW, PREVIEWS_SIZE)
  462. if (thumbnailModel) await videoCreated.addAndSaveThumbnail(previewModel, t)
  463. // Process files
  464. const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject.url)
  465. const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
  466. const videoFiles = await Promise.all(videoFilePromises)
  467. const streamingPlaylistsAttributes = streamingPlaylistActivityUrlToDBAttributes(videoCreated, videoObject, videoFiles)
  468. videoCreated.VideoStreamingPlaylists = []
  469. for (const playlistAttributes of streamingPlaylistsAttributes) {
  470. const playlistModel = await VideoStreamingPlaylistModel.create(playlistAttributes, { transaction: t })
  471. const playlistFiles = videoFileActivityUrlToDBAttributes(playlistModel, playlistAttributes.tagAPObject)
  472. const videoFilePromises = playlistFiles.map(f => VideoFileModel.create(f, { transaction: t }))
  473. playlistModel.VideoFiles = await Promise.all(videoFilePromises)
  474. videoCreated.VideoStreamingPlaylists.push(playlistModel)
  475. }
  476. // Process tags
  477. const tags = videoObject.tag
  478. .filter(isAPHashTagObject)
  479. .map(t => t.name)
  480. const tagInstances = await TagModel.findOrCreateTags(tags, t)
  481. await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
  482. // Process captions
  483. const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
  484. return VideoCaptionModel.insertOrReplaceLanguage(videoCreated.id, c.identifier, c.url, t)
  485. })
  486. await Promise.all(videoCaptionsPromises)
  487. videoCreated.VideoFiles = videoFiles
  488. videoCreated.Tags = tagInstances
  489. const autoBlacklisted = await autoBlacklistVideoIfNeeded({
  490. video: videoCreated,
  491. user: undefined,
  492. isRemote: true,
  493. isNew: true,
  494. transaction: t
  495. })
  496. logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
  497. return { autoBlacklisted, videoCreated }
  498. })
  499. if (waitThumbnail === false) {
  500. // Error is already caught above
  501. // eslint-disable-next-line @typescript-eslint/no-floating-promises
  502. promiseThumbnail.then(thumbnailModel => {
  503. if (!thumbnailModel) return
  504. thumbnailModel = videoCreated.id
  505. return thumbnailModel.save()
  506. })
  507. }
  508. return { autoBlacklisted, videoCreated }
  509. }
  510. function videoActivityObjectToDBAttributes (videoChannel: MChannelId, videoObject: VideoTorrentObject, to: string[] = []) {
  511. const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
  512. ? VideoPrivacy.PUBLIC
  513. : VideoPrivacy.UNLISTED
  514. const duration = videoObject.duration.replace(/[^\d]+/, '')
  515. const language = videoObject.language?.identifier
  516. const category = videoObject.category
  517. ? parseInt(videoObject.category.identifier, 10)
  518. : undefined
  519. const licence = videoObject.licence
  520. ? parseInt(videoObject.licence.identifier, 10)
  521. : undefined
  522. const description = videoObject.content || null
  523. const support = videoObject.support || null
  524. return {
  525. name: videoObject.name,
  526. uuid: videoObject.uuid,
  527. url: videoObject.id,
  528. category,
  529. licence,
  530. language,
  531. description,
  532. support,
  533. nsfw: videoObject.sensitive,
  534. commentsEnabled: videoObject.commentsEnabled,
  535. downloadEnabled: videoObject.downloadEnabled,
  536. waitTranscoding: videoObject.waitTranscoding,
  537. state: videoObject.state,
  538. channelId: videoChannel.id,
  539. duration: parseInt(duration, 10),
  540. createdAt: new Date(videoObject.published),
  541. publishedAt: new Date(videoObject.published),
  542. originallyPublishedAt: videoObject.originallyPublishedAt
  543. ? new Date(videoObject.originallyPublishedAt)
  544. : null,
  545. updatedAt: new Date(videoObject.updated),
  546. views: videoObject.views,
  547. likes: 0,
  548. dislikes: 0,
  549. remote: true,
  550. privacy
  551. }
  552. }
  553. function videoFileActivityUrlToDBAttributes (
  554. videoOrPlaylist: MVideo | MStreamingPlaylist,
  555. urls: (ActivityTagObject | ActivityUrlObject)[]
  556. ) {
  557. const fileUrls = urls.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
  558. if (fileUrls.length === 0) return []
  559. const attributes: FilteredModelAttributes<VideoFileModel>[] = []
  560. for (const fileUrl of fileUrls) {
  561. // Fetch associated magnet uri
  562. const magnet = urls.filter(isAPMagnetUrlObject)
  563. .find(u => u.height === fileUrl.height)
  564. if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
  565. const parsed = magnetUtil.decode(magnet.href)
  566. if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
  567. throw new Error('Cannot parse magnet URI ' + magnet.href)
  568. }
  569. const mediaType = fileUrl.mediaType
  570. const attribute = {
  571. extname: MIMETYPES.VIDEO.MIMETYPE_EXT[mediaType],
  572. infoHash: parsed.infoHash,
  573. resolution: fileUrl.height,
  574. size: fileUrl.size,
  575. fps: fileUrl.fps || -1,
  576. // This is a video file owned by a video or by a streaming playlist
  577. videoId: (videoOrPlaylist as MStreamingPlaylist).playlistUrl ? null : videoOrPlaylist.id,
  578. videoStreamingPlaylistId: (videoOrPlaylist as MStreamingPlaylist).playlistUrl ? videoOrPlaylist.id : null
  579. }
  580. attributes.push(attribute)
  581. }
  582. return attributes
  583. }
  584. function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObject: VideoTorrentObject, videoFiles: MVideoFile[]) {
  585. const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[]
  586. if (playlistUrls.length === 0) return []
  587. const attributes: (FilteredModelAttributes<VideoStreamingPlaylistModel> & { tagAPObject?: ActivityTagObject[] })[] = []
  588. for (const playlistUrlObject of playlistUrls) {
  589. const segmentsSha256UrlObject = playlistUrlObject.tag.find(isAPPlaylistSegmentHashesUrlObject)
  590. let files: unknown[] = playlistUrlObject.tag.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
  591. // FIXME: backward compatibility introduced in v2.1.0
  592. if (files.length === 0) files = videoFiles
  593. if (!segmentsSha256UrlObject) {
  594. logger.warn('No segment sha256 URL found in AP playlist object.', { playlistUrl: playlistUrlObject })
  595. continue
  596. }
  597. const attribute = {
  598. type: VideoStreamingPlaylistType.HLS,
  599. playlistUrl: playlistUrlObject.href,
  600. segmentsSha256Url: segmentsSha256UrlObject.href,
  601. p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrlObject.href, files),
  602. p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
  603. videoId: video.id,
  604. tagAPObject: playlistUrlObject.tag
  605. }
  606. attributes.push(attribute)
  607. }
  608. return attributes
  609. }
  610. function getThumbnailFromIcons (videoObject: VideoTorrentObject) {
  611. let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth)
  612. // Fallback if there are not valid icons
  613. if (validIcons.length === 0) validIcons = videoObject.icon
  614. return minBy(validIcons, 'width')
  615. }
  616. function getPreviewFromIcons (videoObject: VideoTorrentObject) {
  617. const validIcons = videoObject.icon.filter(i => i.width > PREVIEWS_SIZE.minWidth)
  618. // FIXME: don't put a fallback here for compatibility with PeerTube <2.2
  619. return maxBy(validIcons, 'width')
  620. }