]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Refactor AP playlists
authorChocobozzz <me@florianbigard.com>
Thu, 3 Jun 2021 12:30:09 +0000 (14:30 +0200)
committerChocobozzz <me@florianbigard.com>
Thu, 3 Jun 2021 14:40:32 +0000 (16:40 +0200)
17 files changed:
server/lib/activitypub/cache-file.ts
server/lib/activitypub/playlist.ts [deleted file]
server/lib/activitypub/playlists/create-update.ts [new file with mode: 0644]
server/lib/activitypub/playlists/index.ts [new file with mode: 0644]
server/lib/activitypub/playlists/refresh.ts [new file with mode: 0644]
server/lib/activitypub/playlists/shared/index.ts [new file with mode: 0644]
server/lib/activitypub/playlists/shared/object-to-model-attributes.ts [new file with mode: 0644]
server/lib/activitypub/playlists/shared/url-to-object.ts [new file with mode: 0644]
server/lib/activitypub/process/process-create.ts
server/lib/activitypub/process/process-update.ts
server/lib/activitypub/share.ts
server/lib/activitypub/video-comments.ts
server/lib/activitypub/video-rates.ts
server/lib/job-queue/handlers/activitypub-http-fetcher.ts
server/lib/job-queue/handlers/activitypub-refresher.ts
server/models/video/video-playlist.ts
shared/models/activitypub/objects/index.ts

index 2e6dd34e0365aad0f3920c61d38187d0b0c026b1..a16d2cd93f1a489f13a78b7d269d4560a68212ea 100644 (file)
@@ -1,54 +1,27 @@
-import { CacheFileObject } from '../../../shared/index'
-import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
 import { Transaction } from 'sequelize'
-import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
 import { MActorId, MVideoRedundancy, MVideoWithAllFiles } from '@server/types/models'
+import { CacheFileObject } from '../../../shared/index'
+import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
+import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
 
-function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId) {
-
-  if (cacheFileObject.url.mediaType === 'application/x-mpegURL') {
-    const url = cacheFileObject.url
-
-    const playlist = video.VideoStreamingPlaylists.find(t => t.type === VideoStreamingPlaylistType.HLS)
-    if (!playlist) throw new Error('Cannot find HLS playlist of video ' + video.url)
+async function createOrUpdateCacheFile (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId, t: Transaction) {
+  const redundancyModel = await VideoRedundancyModel.loadByUrl(cacheFileObject.id, t)
 
-    return {
-      expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null,
-      url: cacheFileObject.id,
-      fileUrl: url.href,
-      strategy: null,
-      videoStreamingPlaylistId: playlist.id,
-      actorId: byActor.id
-    }
+  if (redundancyModel) {
+    return updateCacheFile(cacheFileObject, redundancyModel, video, byActor, t)
   }
 
-  const url = cacheFileObject.url
-  const videoFile = video.VideoFiles.find(f => {
-    return f.resolution === url.height && f.fps === url.fps
-  })
-
-  if (!videoFile) throw new Error(`Cannot find video file ${url.height} ${url.fps} of video ${video.url}`)
-
-  return {
-    expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null,
-    url: cacheFileObject.id,
-    fileUrl: url.href,
-    strategy: null,
-    videoFileId: videoFile.id,
-    actorId: byActor.id
-  }
+  return createCacheFile(cacheFileObject, video, byActor, t)
 }
 
-async function createOrUpdateCacheFile (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId, t: Transaction) {
-  const redundancyModel = await VideoRedundancyModel.loadByUrl(cacheFileObject.id, t)
+// ---------------------------------------------------------------------------
 
-  if (!redundancyModel) {
-    await createCacheFile(cacheFileObject, video, byActor, t)
-  } else {
-    await updateCacheFile(cacheFileObject, redundancyModel, video, byActor, t)
-  }
+export {
+  createOrUpdateCacheFile
 }
 
+// ---------------------------------------------------------------------------
+
 function createCacheFile (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId, t: Transaction) {
   const attributes = cacheFileActivityObjectToDBAttributes(cacheFileObject, video, byActor)
 
@@ -74,9 +47,37 @@ function updateCacheFile (
   return redundancyModel.save({ transaction: t })
 }
 
-export {
-  createOrUpdateCacheFile,
-  createCacheFile,
-  updateCacheFile,
-  cacheFileActivityObjectToDBAttributes
+function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId) {
+
+  if (cacheFileObject.url.mediaType === 'application/x-mpegURL') {
+    const url = cacheFileObject.url
+
+    const playlist = video.VideoStreamingPlaylists.find(t => t.type === VideoStreamingPlaylistType.HLS)
+    if (!playlist) throw new Error('Cannot find HLS playlist of video ' + video.url)
+
+    return {
+      expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null,
+      url: cacheFileObject.id,
+      fileUrl: url.href,
+      strategy: null,
+      videoStreamingPlaylistId: playlist.id,
+      actorId: byActor.id
+    }
+  }
+
+  const url = cacheFileObject.url
+  const videoFile = video.VideoFiles.find(f => {
+    return f.resolution === url.height && f.fps === url.fps
+  })
+
+  if (!videoFile) throw new Error(`Cannot find video file ${url.height} ${url.fps} of video ${video.url}`)
+
+  return {
+    expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null,
+    url: cacheFileObject.id,
+    fileUrl: url.href,
+    strategy: null,
+    videoFileId: videoFile.id,
+    actorId: byActor.id
+  }
 }
diff --git a/server/lib/activitypub/playlist.ts b/server/lib/activitypub/playlist.ts
deleted file mode 100644 (file)
index 8fe6e79..0000000
+++ /dev/null
@@ -1,204 +0,0 @@
-import * as Bluebird from 'bluebird'
-import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
-import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object'
-import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
-import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
-import { checkUrlsSameHost } from '../../helpers/activitypub'
-import { isPlaylistElementObjectValid, isPlaylistObjectValid } from '../../helpers/custom-validators/activitypub/playlist'
-import { isArray } from '../../helpers/custom-validators/misc'
-import { logger } from '../../helpers/logger'
-import { doJSONRequest, PeerTubeRequestError } from '../../helpers/requests'
-import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants'
-import { sequelizeTypescript } from '../../initializers/database'
-import { VideoPlaylistModel } from '../../models/video/video-playlist'
-import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
-import { MAccountDefault, MAccountId, MVideoId } from '../../types/models'
-import { MVideoPlaylist, MVideoPlaylistId, MVideoPlaylistOwner } from '../../types/models/video/video-playlist'
-import { FilteredModelAttributes } from '../../types/sequelize'
-import { createPlaylistMiniatureFromUrl } from '../thumbnail'
-import { getOrCreateActorAndServerAndModel } from './actor'
-import { crawlCollectionPage } from './crawl'
-import { getOrCreateAPVideo } from './videos'
-
-function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount: MAccountId, to: string[]) {
-  const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
-    ? VideoPlaylistPrivacy.PUBLIC
-    : VideoPlaylistPrivacy.UNLISTED
-
-  return {
-    name: playlistObject.name,
-    description: playlistObject.content,
-    privacy,
-    url: playlistObject.id,
-    uuid: playlistObject.uuid,
-    ownerAccountId: byAccount.id,
-    videoChannelId: null,
-    createdAt: new Date(playlistObject.published),
-    updatedAt: new Date(playlistObject.updated)
-  }
-}
-
-function playlistElementObjectToDBAttributes (elementObject: PlaylistElementObject, videoPlaylist: MVideoPlaylistId, video: MVideoId) {
-  return {
-    position: elementObject.position,
-    url: elementObject.id,
-    startTimestamp: elementObject.startTimestamp || null,
-    stopTimestamp: elementObject.stopTimestamp || null,
-    videoPlaylistId: videoPlaylist.id,
-    videoId: video.id
-  }
-}
-
-async function createAccountPlaylists (playlistUrls: string[], account: MAccountDefault) {
-  await Bluebird.map(playlistUrls, async playlistUrl => {
-    try {
-      const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl)
-      if (exists === true) return
-
-      // Fetch url
-      const { body } = await doJSONRequest<PlaylistObject>(playlistUrl, { activityPub: true })
-
-      if (!isPlaylistObjectValid(body)) {
-        throw new Error(`Invalid playlist object when fetch account playlists: ${JSON.stringify(body)}`)
-      }
-
-      if (!isArray(body.to)) {
-        throw new Error('Playlist does not have an audience.')
-      }
-
-      return createOrUpdateVideoPlaylist(body, account, body.to)
-    } catch (err) {
-      logger.warn('Cannot add playlist element %s.', playlistUrl, { err })
-    }
-  }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
-}
-
-async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAccount: MAccountId, to: string[]) {
-  const playlistAttributes = playlistObjectToDBAttributes(playlistObject, byAccount, to)
-
-  if (isArray(playlistObject.attributedTo) && playlistObject.attributedTo.length === 1) {
-    const actor = await getOrCreateActorAndServerAndModel(playlistObject.attributedTo[0])
-
-    if (actor.VideoChannel) {
-      playlistAttributes.videoChannelId = actor.VideoChannel.id
-    } else {
-      logger.warn('Attributed to of video playlist %s is not a video channel.', playlistObject.id, { playlistObject })
-    }
-  }
-
-  const [ playlist ] = await VideoPlaylistModel.upsert<MVideoPlaylist>(playlistAttributes, { returning: true })
-
-  let accItems: string[] = []
-  await crawlCollectionPage<string>(playlistObject.id, items => {
-    accItems = accItems.concat(items)
-
-    return Promise.resolve()
-  })
-
-  const refreshedPlaylist = await VideoPlaylistModel.loadWithAccountAndChannel(playlist.id, null)
-
-  if (playlistObject.icon) {
-    try {
-      const thumbnailModel = await createPlaylistMiniatureFromUrl({ downloadUrl: playlistObject.icon.url, playlist: refreshedPlaylist })
-      await refreshedPlaylist.setAndSaveThumbnail(thumbnailModel, undefined)
-    } catch (err) {
-      logger.warn('Cannot generate thumbnail of %s.', playlistObject.id, { err })
-    }
-  } else if (refreshedPlaylist.hasThumbnail()) {
-    await refreshedPlaylist.Thumbnail.destroy()
-    refreshedPlaylist.Thumbnail = null
-  }
-
-  return resetVideoPlaylistElements(accItems, refreshedPlaylist)
-}
-
-async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner): Promise<MVideoPlaylistOwner> {
-  if (!videoPlaylist.isOutdated()) return videoPlaylist
-
-  try {
-    const { playlistObject } = await fetchRemoteVideoPlaylist(videoPlaylist.url)
-
-    if (playlistObject === undefined) {
-      logger.warn('Cannot refresh remote playlist %s: invalid body.', videoPlaylist.url)
-
-      await videoPlaylist.setAsRefreshed()
-      return videoPlaylist
-    }
-
-    const byAccount = videoPlaylist.OwnerAccount
-    await createOrUpdateVideoPlaylist(playlistObject, byAccount, playlistObject.to)
-
-    return videoPlaylist
-  } catch (err) {
-    if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) {
-      logger.info('Cannot refresh remote video playlist %s: it does not exist anymore. Deleting it.', videoPlaylist.url)
-
-      await videoPlaylist.destroy()
-      return undefined
-    }
-
-    logger.warn('Cannot refresh video playlist %s.', videoPlaylist.url, { err })
-
-    await videoPlaylist.setAsRefreshed()
-    return videoPlaylist
-  }
-}
-
-// ---------------------------------------------------------------------------
-
-export {
-  createAccountPlaylists,
-  playlistObjectToDBAttributes,
-  playlistElementObjectToDBAttributes,
-  createOrUpdateVideoPlaylist,
-  refreshVideoPlaylistIfNeeded
-}
-
-// ---------------------------------------------------------------------------
-
-async function resetVideoPlaylistElements (elementUrls: string[], playlist: MVideoPlaylist) {
-  const elementsToCreate: FilteredModelAttributes<VideoPlaylistElementModel>[] = []
-
-  await Bluebird.map(elementUrls, async elementUrl => {
-    try {
-      const { body } = await doJSONRequest<PlaylistElementObject>(elementUrl, { activityPub: true })
-
-      if (!isPlaylistElementObjectValid(body)) throw new Error(`Invalid body in video get playlist element ${elementUrl}`)
-
-      if (checkUrlsSameHost(body.id, elementUrl) !== true) {
-        throw new Error(`Playlist element url ${elementUrl} host is different from the AP object id ${body.id}`)
-      }
-
-      const { video } = await getOrCreateAPVideo({ videoObject: { id: body.url }, fetchType: 'only-video' })
-
-      elementsToCreate.push(playlistElementObjectToDBAttributes(body, playlist, video))
-    } catch (err) {
-      logger.warn('Cannot add playlist element %s.', elementUrl, { err })
-    }
-  }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
-
-  await sequelizeTypescript.transaction(async t => {
-    await VideoPlaylistElementModel.deleteAllOf(playlist.id, t)
-
-    for (const element of elementsToCreate) {
-      await VideoPlaylistElementModel.create(element, { transaction: t })
-    }
-  })
-
-  logger.info('Reset playlist %s with %s elements.', playlist.url, elementsToCreate.length)
-
-  return undefined
-}
-
-async function fetchRemoteVideoPlaylist (playlistUrl: string): Promise<{ statusCode: number, playlistObject: PlaylistObject }> {
-  logger.info('Fetching remote playlist %s.', playlistUrl)
-
-  const { body, statusCode } = await doJSONRequest<any>(playlistUrl, { activityPub: true })
-
-  if (isPlaylistObjectValid(body) === false || checkUrlsSameHost(body.id, playlistUrl) !== true) {
-    logger.debug('Remote video playlist JSON is not valid.', { body })
-    return { statusCode, playlistObject: undefined }
-  }
-
-  return { statusCode, playlistObject: body }
-}
diff --git a/server/lib/activitypub/playlists/create-update.ts b/server/lib/activitypub/playlists/create-update.ts
new file mode 100644 (file)
index 0000000..886b1f2
--- /dev/null
@@ -0,0 +1,146 @@
+import { isArray } from '@server/helpers/custom-validators/misc'
+import { logger, loggerTagsFactory } from '@server/helpers/logger'
+import { CRAWL_REQUEST_CONCURRENCY } from '@server/initializers/constants'
+import { sequelizeTypescript } from '@server/initializers/database'
+import { createPlaylistMiniatureFromUrl } from '@server/lib/thumbnail'
+import { VideoPlaylistModel } from '@server/models/video/video-playlist'
+import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element'
+import { FilteredModelAttributes } from '@server/types'
+import { MAccountDefault, MAccountId, MVideoPlaylist, MVideoPlaylistFull } from '@server/types/models'
+import { AttributesOnly } from '@shared/core-utils'
+import { PlaylistObject } from '@shared/models'
+import { getOrCreateActorAndServerAndModel } from '../actor'
+import { crawlCollectionPage } from '../crawl'
+import { getOrCreateAPVideo } from '../videos'
+import {
+  fetchRemotePlaylistElement,
+  fetchRemoteVideoPlaylist,
+  playlistElementObjectToDBAttributes,
+  playlistObjectToDBAttributes
+} from './shared'
+
+import Bluebird = require('bluebird')
+
+const lTags = loggerTagsFactory('ap', 'video-playlist')
+
+async function createAccountPlaylists (playlistUrls: string[], account: MAccountDefault) {
+  await Bluebird.map(playlistUrls, async playlistUrl => {
+    try {
+      const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl)
+      if (exists === true) return
+
+      const { playlistObject } = await fetchRemoteVideoPlaylist(playlistUrl)
+
+      if (playlistObject === undefined) {
+        throw new Error(`Cannot refresh remote playlist ${playlistUrl}: invalid body.`)
+      }
+
+      return createOrUpdateVideoPlaylist(playlistObject, account, playlistObject.to)
+    } catch (err) {
+      logger.warn('Cannot add playlist element %s.', playlistUrl, { err, ...lTags(playlistUrl) })
+    }
+  }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
+}
+
+async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAccount: MAccountId, to: string[]) {
+  const playlistAttributes = playlistObjectToDBAttributes(playlistObject, byAccount, to)
+
+  await setVideoChannelIfNeeded(playlistObject, playlistAttributes)
+
+  const [ upsertPlaylist ] = await VideoPlaylistModel.upsert<MVideoPlaylist>(playlistAttributes, { returning: true })
+
+  const playlistElementUrls = await fetchElementUrls(playlistObject)
+
+  // Refetch playlist from DB since elements fetching could be long in time
+  const playlist = await VideoPlaylistModel.loadWithAccountAndChannel(upsertPlaylist.id, null)
+
+  try {
+    await updatePlaylistThumbnail(playlistObject, playlist)
+  } catch (err) {
+    logger.warn('Cannot update thumbnail of %s.', playlistObject.id, { err, ...lTags(playlistObject.id, playlist.uuid, playlist.url) })
+  }
+
+  return rebuildVideoPlaylistElements(playlistElementUrls, playlist)
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  createAccountPlaylists,
+  createOrUpdateVideoPlaylist
+}
+
+// ---------------------------------------------------------------------------
+
+async function setVideoChannelIfNeeded (playlistObject: PlaylistObject, playlistAttributes: AttributesOnly<VideoPlaylistModel>) {
+  if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) return
+
+  const actor = await getOrCreateActorAndServerAndModel(playlistObject.attributedTo[0])
+
+  if (!actor.VideoChannel) {
+    logger.warn('Playlist "attributedTo" %s is not a video channel.', playlistObject.id, { playlistObject, ...lTags(playlistObject.id) })
+    return
+  }
+
+  playlistAttributes.videoChannelId = actor.VideoChannel.id
+}
+
+async function fetchElementUrls (playlistObject: PlaylistObject) {
+  let accItems: string[] = []
+  await crawlCollectionPage<string>(playlistObject.id, items => {
+    accItems = accItems.concat(items)
+
+    return Promise.resolve()
+  })
+
+  return accItems
+}
+
+async function updatePlaylistThumbnail (playlistObject: PlaylistObject, playlist: MVideoPlaylistFull) {
+  if (playlistObject.icon) {
+    const thumbnailModel = await createPlaylistMiniatureFromUrl({ downloadUrl: playlistObject.icon.url, playlist })
+    await playlist.setAndSaveThumbnail(thumbnailModel, undefined)
+
+    return
+  }
+
+  // Playlist does not have an icon, destroy existing one
+  if (playlist.hasThumbnail()) {
+    await playlist.Thumbnail.destroy()
+    playlist.Thumbnail = null
+  }
+}
+
+async function rebuildVideoPlaylistElements (elementUrls: string[], playlist: MVideoPlaylist) {
+  const elementsToCreate = await buildElementsDBAttributes(elementUrls, playlist)
+
+  await sequelizeTypescript.transaction(async t => {
+    await VideoPlaylistElementModel.deleteAllOf(playlist.id, t)
+
+    for (const element of elementsToCreate) {
+      await VideoPlaylistElementModel.create(element, { transaction: t })
+    }
+  })
+
+  logger.info('Rebuilt playlist %s with %s elements.', playlist.url, elementsToCreate.length, lTags(playlist.uuid, playlist.url))
+
+  return undefined
+}
+
+async function buildElementsDBAttributes (elementUrls: string[], playlist: MVideoPlaylist) {
+  const elementsToCreate: FilteredModelAttributes<VideoPlaylistElementModel>[] = []
+
+  await Bluebird.map(elementUrls, async elementUrl => {
+    try {
+      const { elementObject } = await fetchRemotePlaylistElement(elementUrl)
+
+      const { video } = await getOrCreateAPVideo({ videoObject: { id: elementObject.url }, fetchType: 'only-video' })
+
+      elementsToCreate.push(playlistElementObjectToDBAttributes(elementObject, playlist, video))
+    } catch (err) {
+      logger.warn('Cannot add playlist element %s.', elementUrl, { err, ...lTags(playlist.uuid, playlist.url) })
+    }
+  }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
+
+  return elementsToCreate
+}
diff --git a/server/lib/activitypub/playlists/index.ts b/server/lib/activitypub/playlists/index.ts
new file mode 100644 (file)
index 0000000..2885830
--- /dev/null
@@ -0,0 +1,2 @@
+export * from './create-update'
+export * from './refresh'
diff --git a/server/lib/activitypub/playlists/refresh.ts b/server/lib/activitypub/playlists/refresh.ts
new file mode 100644 (file)
index 0000000..ff9e547
--- /dev/null
@@ -0,0 +1,44 @@
+import { logger, loggerTagsFactory } from '@server/helpers/logger'
+import { PeerTubeRequestError } from '@server/helpers/requests'
+import { MVideoPlaylistOwner } from '@server/types/models'
+import { HttpStatusCode } from '@shared/core-utils'
+import { createOrUpdateVideoPlaylist } from './create-update'
+import { fetchRemoteVideoPlaylist } from './shared'
+
+async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner): Promise<MVideoPlaylistOwner> {
+  if (!videoPlaylist.isOutdated()) return videoPlaylist
+
+  const lTags = loggerTagsFactory('ap', 'video-playlist', 'refresh', videoPlaylist.uuid, videoPlaylist.url)
+
+  try {
+    const { playlistObject } = await fetchRemoteVideoPlaylist(videoPlaylist.url)
+
+    if (playlistObject === undefined) {
+      logger.warn('Cannot refresh remote playlist %s: invalid body.', videoPlaylist.url, lTags())
+
+      await videoPlaylist.setAsRefreshed()
+      return videoPlaylist
+    }
+
+    const byAccount = videoPlaylist.OwnerAccount
+    await createOrUpdateVideoPlaylist(playlistObject, byAccount, playlistObject.to)
+
+    return videoPlaylist
+  } catch (err) {
+    if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) {
+      logger.info('Cannot refresh not existing playlist %s. Deleting it.', videoPlaylist.url, lTags())
+
+      await videoPlaylist.destroy()
+      return undefined
+    }
+
+    logger.warn('Cannot refresh video playlist %s.', videoPlaylist.url, { err, ...lTags() })
+
+    await videoPlaylist.setAsRefreshed()
+    return videoPlaylist
+  }
+}
+
+export {
+  refreshVideoPlaylistIfNeeded
+}
diff --git a/server/lib/activitypub/playlists/shared/index.ts b/server/lib/activitypub/playlists/shared/index.ts
new file mode 100644 (file)
index 0000000..a217f22
--- /dev/null
@@ -0,0 +1,2 @@
+export * from './object-to-model-attributes'
+export * from './url-to-object'
diff --git a/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts b/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts
new file mode 100644 (file)
index 0000000..6ec4448
--- /dev/null
@@ -0,0 +1,40 @@
+import { ACTIVITY_PUB } from '@server/initializers/constants'
+import { VideoPlaylistModel } from '@server/models/video/video-playlist'
+import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element'
+import { MAccountId, MVideoId, MVideoPlaylistId } from '@server/types/models'
+import { AttributesOnly } from '@shared/core-utils'
+import { PlaylistElementObject, PlaylistObject, VideoPlaylistPrivacy } from '@shared/models'
+
+function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount: MAccountId, to: string[]) {
+  const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
+    ? VideoPlaylistPrivacy.PUBLIC
+    : VideoPlaylistPrivacy.UNLISTED
+
+  return {
+    name: playlistObject.name,
+    description: playlistObject.content,
+    privacy,
+    url: playlistObject.id,
+    uuid: playlistObject.uuid,
+    ownerAccountId: byAccount.id,
+    videoChannelId: null,
+    createdAt: new Date(playlistObject.published),
+    updatedAt: new Date(playlistObject.updated)
+  } as AttributesOnly<VideoPlaylistModel>
+}
+
+function playlistElementObjectToDBAttributes (elementObject: PlaylistElementObject, videoPlaylist: MVideoPlaylistId, video: MVideoId) {
+  return {
+    position: elementObject.position,
+    url: elementObject.id,
+    startTimestamp: elementObject.startTimestamp || null,
+    stopTimestamp: elementObject.stopTimestamp || null,
+    videoPlaylistId: videoPlaylist.id,
+    videoId: video.id
+  } as AttributesOnly<VideoPlaylistElementModel>
+}
+
+export {
+  playlistObjectToDBAttributes,
+  playlistElementObjectToDBAttributes
+}
diff --git a/server/lib/activitypub/playlists/shared/url-to-object.ts b/server/lib/activitypub/playlists/shared/url-to-object.ts
new file mode 100644 (file)
index 0000000..ec8c012
--- /dev/null
@@ -0,0 +1,47 @@
+import { isArray } from 'lodash'
+import { checkUrlsSameHost } from '@server/helpers/activitypub'
+import { isPlaylistElementObjectValid, isPlaylistObjectValid } from '@server/helpers/custom-validators/activitypub/playlist'
+import { logger, loggerTagsFactory } from '@server/helpers/logger'
+import { doJSONRequest } from '@server/helpers/requests'
+import { PlaylistElementObject, PlaylistObject } from '@shared/models'
+
+async function fetchRemoteVideoPlaylist (playlistUrl: string): Promise<{ statusCode: number, playlistObject: PlaylistObject }> {
+  const lTags = loggerTagsFactory('ap', 'video-playlist', playlistUrl)
+
+  logger.info('Fetching remote playlist %s.', playlistUrl, lTags())
+
+  const { body, statusCode } = await doJSONRequest<any>(playlistUrl, { activityPub: true })
+
+  if (isPlaylistObjectValid(body) === false || checkUrlsSameHost(body.id, playlistUrl) !== true) {
+    logger.debug('Remote video playlist JSON is not valid.', { body, ...lTags() })
+    return { statusCode, playlistObject: undefined }
+  }
+
+  if (!isArray(body.to)) {
+    logger.debug('Remote video playlist JSON does not have a valid audience.', { body, ...lTags() })
+    return { statusCode, playlistObject: undefined }
+  }
+
+  return { statusCode, playlistObject: body }
+}
+
+async function fetchRemotePlaylistElement (elementUrl: string): Promise<{ statusCode: number, elementObject: PlaylistElementObject }> {
+  const lTags = loggerTagsFactory('ap', 'video-playlist', 'element', elementUrl)
+
+  logger.debug('Fetching remote playlist element %s.', elementUrl, lTags())
+
+  const { body, statusCode } = await doJSONRequest<PlaylistElementObject>(elementUrl, { activityPub: true })
+
+  if (!isPlaylistElementObjectValid(body)) throw new Error(`Invalid body in fetch playlist element ${elementUrl}`)
+
+  if (checkUrlsSameHost(body.id, elementUrl) !== true) {
+    throw new Error(`Playlist element url ${elementUrl} host is different from the AP object id ${body.id}`)
+  }
+
+  return { statusCode, elementObject: body }
+}
+
+export {
+  fetchRemoteVideoPlaylist,
+  fetchRemotePlaylistElement
+}
index ef5a3100e8cd52271b82637d0a79586ced4e69d2..6b7f5aae8f57e96f23cdbe7c363266b62889668a 100644 (file)
@@ -1,3 +1,4 @@
+import { isBlockedByServerOrAccount } from '@server/lib/blocklist'
 import { isRedundancyAccepted } from '@server/lib/redundancy'
 import { ActivityCreate, CacheFileObject, VideoObject } from '../../../../shared'
 import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
@@ -9,11 +10,10 @@ import { APProcessorOptions } from '../../../types/activitypub-processor.model'
 import { MActorSignature, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../../types/models'
 import { Notifier } from '../../notifier'
 import { createOrUpdateCacheFile } from '../cache-file'
-import { createOrUpdateVideoPlaylist } from '../playlist'
+import { createOrUpdateVideoPlaylist } from '../playlists'
 import { forwardVideoRelatedActivity } from '../send/utils'
 import { resolveThread } from '../video-comments'
 import { getOrCreateAPVideo } from '../videos'
-import { isBlockedByServerOrAccount } from '@server/lib/blocklist'
 
 async function processCreateActivity (options: APProcessorOptions<ActivityCreate>) {
   const { activity, byActor } = options
index be3f6acac85d5f35ee6b6f09e3b052c09e0dd7f0..d2b63c9011a4a3feecb117c775b812736f66dde9 100644 (file)
@@ -15,7 +15,7 @@ import { APProcessorOptions } from '../../../types/activitypub-processor.model'
 import { MActorSignature } from '../../../types/models'
 import { getImageInfoIfExists, updateActorImageInstance, updateActorInstance } from '../actor'
 import { createOrUpdateCacheFile } from '../cache-file'
-import { createOrUpdateVideoPlaylist } from '../playlist'
+import { createOrUpdateVideoPlaylist } from '../playlists'
 import { forwardVideoRelatedActivity } from '../send/utils'
 import { APVideoUpdater, getOrCreateAPVideo } from '../videos'
 
index c22fa0893916625ba489d65c3388f38cb73f2d95..327955dd26089c795aa3cb47a6480ccaf9f55c01 100644 (file)
@@ -40,23 +40,7 @@ async function changeVideoChannelShare (
 async function addVideoShares (shareUrls: string[], video: MVideoId) {
   await Bluebird.map(shareUrls, async shareUrl => {
     try {
-      const { body } = await doJSONRequest<any>(shareUrl, { activityPub: true })
-      if (!body || !body.actor) throw new Error('Body or body actor is invalid')
-
-      const actorUrl = getAPId(body.actor)
-      if (checkUrlsSameHost(shareUrl, actorUrl) !== true) {
-        throw new Error(`Actor url ${actorUrl} has not the same host than the share url ${shareUrl}`)
-      }
-
-      const actor = await getOrCreateActorAndServerAndModel(actorUrl)
-
-      const entry = {
-        actorId: actor.id,
-        videoId: video.id,
-        url: shareUrl
-      }
-
-      await VideoShareModel.upsert(entry)
+      await addVideoShare(shareUrl, video)
     } catch (err) {
       logger.warn('Cannot add share %s.', shareUrl, { err })
     }
@@ -71,6 +55,26 @@ export {
 
 // ---------------------------------------------------------------------------
 
+async function addVideoShare (shareUrl: string, video: MVideoId) {
+  const { body } = await doJSONRequest<any>(shareUrl, { activityPub: true })
+  if (!body || !body.actor) throw new Error('Body or body actor is invalid')
+
+  const actorUrl = getAPId(body.actor)
+  if (checkUrlsSameHost(shareUrl, actorUrl) !== true) {
+    throw new Error(`Actor url ${actorUrl} has not the same host than the share url ${shareUrl}`)
+  }
+
+  const actor = await getOrCreateActorAndServerAndModel(actorUrl)
+
+  const entry = {
+    actorId: actor.id,
+    videoId: video.id,
+    url: shareUrl
+  }
+
+  await VideoShareModel.upsert(entry)
+}
+
 async function shareByServer (video: MVideo, t: Transaction) {
   const serverActor = await getServerActor()
 
index 722147b69e15a4cea081c7f7b52ca17fc6e86ffb..760da719d4ac29752ee13477eea5d4588b2af77f 100644 (file)
@@ -29,10 +29,11 @@ async function addVideoComments (commentUrls: string[]) {
 
 async function resolveThread (params: ResolveThreadParams): ResolveThreadResult {
   const { url, isVideo } = params
+
   if (params.commentCreated === undefined) params.commentCreated = false
   if (params.comments === undefined) params.comments = []
 
-  // If it is not a video, or if we don't know if it's a video
+  // If it is not a video, or if we don't know if it's a video, try to get the thread from DB
   if (isVideo === false || isVideo === undefined) {
     const result = await resolveCommentFromDB(params)
     if (result) return result
@@ -42,7 +43,7 @@ async function resolveThread (params: ResolveThreadParams): ResolveThreadResult
     // If it is a video, or if we don't know if it's a video
     if (isVideo === true || isVideo === undefined) {
       // Keep await so we catch the exception
-      return await tryResolveThreadFromVideo(params)
+      return await tryToResolveThreadFromVideo(params)
     }
   } catch (err) {
     logger.debug('Cannot resolve thread from video %s, maybe because it was not a video', url, { err })
@@ -62,28 +63,26 @@ async function resolveCommentFromDB (params: ResolveThreadParams) {
   const { url, comments, commentCreated } = params
 
   const commentFromDatabase = await VideoCommentModel.loadByUrlAndPopulateReplyAndVideoUrlAndAccount(url)
-  if (commentFromDatabase) {
-    let parentComments = comments.concat([ commentFromDatabase ])
+  if (!commentFromDatabase) return undefined
 
-    // Speed up things and resolve directly the thread
-    if (commentFromDatabase.InReplyToVideoComment) {
-      const data = await VideoCommentModel.listThreadParentComments(commentFromDatabase, undefined, 'DESC')
+  let parentComments = comments.concat([ commentFromDatabase ])
 
-      parentComments = parentComments.concat(data)
-    }
+  // Speed up things and resolve directly the thread
+  if (commentFromDatabase.InReplyToVideoComment) {
+    const data = await VideoCommentModel.listThreadParentComments(commentFromDatabase, undefined, 'DESC')
 
-    return resolveThread({
-      url: commentFromDatabase.Video.url,
-      comments: parentComments,
-      isVideo: true,
-      commentCreated
-    })
+    parentComments = parentComments.concat(data)
   }
 
-  return undefined
+  return resolveThread({
+    url: commentFromDatabase.Video.url,
+    comments: parentComments,
+    isVideo: true,
+    commentCreated
+  })
 }
 
-async function tryResolveThreadFromVideo (params: ResolveThreadParams) {
+async function tryToResolveThreadFromVideo (params: ResolveThreadParams) {
   const { url, comments, commentCreated } = params
 
   // Maybe it's a reply to a video?
index f40c07fea368f90e092163baa545a7ce50c41773..091f4ec23e729e26b67377806f661716c245d2e9 100644 (file)
@@ -15,30 +15,7 @@ import { getVideoDislikeActivityPubUrlByLocalActor, getVideoLikeActivityPubUrlBy
 async function createRates (ratesUrl: string[], video: MVideo, rate: VideoRateType) {
   await Bluebird.map(ratesUrl, async rateUrl => {
     try {
-      // Fetch url
-      const { body } = await doJSONRequest<any>(rateUrl, { activityPub: true })
-      if (!body || !body.actor) throw new Error('Body or body actor is invalid')
-
-      const actorUrl = getAPId(body.actor)
-      if (checkUrlsSameHost(actorUrl, rateUrl) !== true) {
-        throw new Error(`Rate url ${rateUrl} has not the same host than actor url ${actorUrl}`)
-      }
-
-      if (checkUrlsSameHost(body.id, rateUrl) !== true) {
-        throw new Error(`Rate url ${rateUrl} host is different from the AP object id ${body.id}`)
-      }
-
-      const actor = await getOrCreateActorAndServerAndModel(actorUrl)
-
-      const entry = {
-        videoId: video.id,
-        accountId: actor.Account.id,
-        type: rate,
-        url: body.id
-      }
-
-      // Video "likes"/"dislikes" will be updated by the caller
-      await AccountVideoRateModel.upsert(entry)
+      await createRate(rateUrl, video, rate)
     } catch (err) {
       logger.warn('Cannot add rate %s.', rateUrl, { err })
     }
@@ -73,8 +50,39 @@ function getLocalRateUrl (rateType: VideoRateType, actor: MActorUrl, video: MVid
     : getVideoDislikeActivityPubUrlByLocalActor(actor, video)
 }
 
+// ---------------------------------------------------------------------------
+
 export {
   getLocalRateUrl,
   createRates,
   sendVideoRateChange
 }
+
+// ---------------------------------------------------------------------------
+
+async function createRate (rateUrl: string, video: MVideo, rate: VideoRateType) {
+  // Fetch url
+  const { body } = await doJSONRequest<any>(rateUrl, { activityPub: true })
+  if (!body || !body.actor) throw new Error('Body or body actor is invalid')
+
+  const actorUrl = getAPId(body.actor)
+  if (checkUrlsSameHost(actorUrl, rateUrl) !== true) {
+    throw new Error(`Rate url ${rateUrl} has not the same host than actor url ${actorUrl}`)
+  }
+
+  if (checkUrlsSameHost(body.id, rateUrl) !== true) {
+    throw new Error(`Rate url ${rateUrl} host is different from the AP object id ${body.id}`)
+  }
+
+  const actor = await getOrCreateActorAndServerAndModel(actorUrl)
+
+  const entry = {
+    videoId: video.id,
+    accountId: actor.Account.id,
+    type: rate,
+    url: body.id
+  }
+
+  // Video "likes"/"dislikes" will be updated by the caller
+  await AccountVideoRateModel.upsert(entry)
+}
index e210ac3efbbf118006a58b076528e060337037ca..04b25f955958a0c4cdd5423b9e5249c51786576f 100644 (file)
@@ -8,7 +8,7 @@ import { VideoCommentModel } from '../../../models/video/video-comment'
 import { VideoShareModel } from '../../../models/video/video-share'
 import { MAccountDefault, MVideoFullLight } from '../../../types/models'
 import { crawlCollectionPage } from '../../activitypub/crawl'
-import { createAccountPlaylists } from '../../activitypub/playlist'
+import { createAccountPlaylists } from '../../activitypub/playlists'
 import { processActivities } from '../../activitypub/process'
 import { addVideoShares } from '../../activitypub/share'
 import { addVideoComments } from '../../activitypub/video-comments'
index a120e4ea8d1c8f5dcf52387d6312714c93fa4607..10e6895da7df505c149f0b8c2ec69b10d16461dd 100644 (file)
@@ -1,5 +1,5 @@
 import * as Bull from 'bull'
-import { refreshVideoPlaylistIfNeeded } from '@server/lib/activitypub/playlist'
+import { refreshVideoPlaylistIfNeeded } from '@server/lib/activitypub/playlists'
 import { refreshVideoIfNeeded } from '@server/lib/activitypub/videos'
 import { RefreshPayload } from '@shared/models'
 import { logger } from '../../../helpers/logger'
index 98cea1b64f21fabfeadd580b44062f6b33b04690..1a05f8d4256d39db2ba246ed7c824038448fc2a5 100644 (file)
@@ -18,6 +18,7 @@ import {
   UpdatedAt
 } from 'sequelize-typescript'
 import { v4 as uuidv4 } from 'uuid'
+import { setAsUpdated } from '@server/helpers/database-utils'
 import { MAccountId, MChannelId } from '@server/types/models'
 import { AttributesOnly } from '@shared/core-utils'
 import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
@@ -531,9 +532,7 @@ export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlayli
   }
 
   setAsRefreshed () {
-    this.changed('updatedAt', true)
-
-    return this.save()
+    return setAsUpdated('videoPlaylist', this.id)
   }
 
   isOwned () {
index a6a20e87a01a8572fb64a507707d4d3c46ea6d6e..9e2c6b728b42e33d67ee9073b79c4a3b9d815fa2 100644 (file)
@@ -2,5 +2,9 @@ export * from './abuse-object'
 export * from './cache-file-object'
 export * from './common-objects'
 export * from './dislike-object'
+export * from './object.model'
+export * from './playlist-element-object'
+export * from './playlist-object'
+export * from './video-comment-object'
 export * from './video-torrent-object'
 export * from './view-object'