aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/lib/activitypub/videos
diff options
context:
space:
mode:
Diffstat (limited to 'server/lib/activitypub/videos')
-rw-r--r--server/lib/activitypub/videos/federate.ts36
-rw-r--r--server/lib/activitypub/videos/fetch.ts202
-rw-r--r--server/lib/activitypub/videos/index.ts3
-rw-r--r--server/lib/activitypub/videos/shared/index.ts4
-rw-r--r--server/lib/activitypub/videos/shared/object-to-model-attributes.ts234
-rw-r--r--server/lib/activitypub/videos/shared/trackers.ts43
-rw-r--r--server/lib/activitypub/videos/shared/video-create.ts167
-rw-r--r--server/lib/activitypub/videos/shared/video-sync-attributes.ts75
-rw-r--r--server/lib/activitypub/videos/update.ts293
9 files changed, 1057 insertions, 0 deletions
diff --git a/server/lib/activitypub/videos/federate.ts b/server/lib/activitypub/videos/federate.ts
new file mode 100644
index 000000000..bd0c54b0c
--- /dev/null
+++ b/server/lib/activitypub/videos/federate.ts
@@ -0,0 +1,36 @@
1import { Transaction } from 'sequelize/types'
2import { isArray } from '@server/helpers/custom-validators/misc'
3import { MVideoAP, MVideoAPWithoutCaption } from '@server/types/models'
4import { sendCreateVideo, sendUpdateVideo } from '../send'
5import { shareVideoByServerAndChannel } from '../share'
6
7async function federateVideoIfNeeded (videoArg: MVideoAPWithoutCaption, isNewVideo: boolean, transaction?: Transaction) {
8 const video = videoArg as MVideoAP
9
10 if (
11 // Check this is not a blacklisted video, or unfederated blacklisted video
12 (video.isBlacklisted() === false || (isNewVideo === false && video.VideoBlacklist.unfederated === false)) &&
13 // Check the video is public/unlisted and published
14 video.hasPrivacyForFederation() && video.hasStateForFederation()
15 ) {
16 // Fetch more attributes that we will need to serialize in AP object
17 if (isArray(video.VideoCaptions) === false) {
18 video.VideoCaptions = await video.$get('VideoCaptions', {
19 attributes: [ 'filename', 'language' ],
20 transaction
21 })
22 }
23
24 if (isNewVideo) {
25 // Now we'll add the video's meta data to our followers
26 await sendCreateVideo(video, transaction)
27 await shareVideoByServerAndChannel(video, transaction)
28 } else {
29 await sendUpdateVideo(video, transaction)
30 }
31 }
32}
33
34export {
35 federateVideoIfNeeded
36}
diff --git a/server/lib/activitypub/videos/fetch.ts b/server/lib/activitypub/videos/fetch.ts
new file mode 100644
index 000000000..fdcf4ee5c
--- /dev/null
+++ b/server/lib/activitypub/videos/fetch.ts
@@ -0,0 +1,202 @@
1import { checkUrlsSameHost, getAPId } from "@server/helpers/activitypub"
2import { sanitizeAndCheckVideoTorrentObject } from "@server/helpers/custom-validators/activitypub/videos"
3import { retryTransactionWrapper } from "@server/helpers/database-utils"
4import { logger } from "@server/helpers/logger"
5import { doJSONRequest, PeerTubeRequestError } from "@server/helpers/requests"
6import { fetchVideoByUrl, VideoFetchByUrlType } from "@server/helpers/video"
7import { REMOTE_SCHEME } from "@server/initializers/constants"
8import { ActorFollowScoreCache } from "@server/lib/files-cache"
9import { JobQueue } from "@server/lib/job-queue"
10import { VideoModel } from "@server/models/video/video"
11import { MVideoAccountLight, MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from "@server/types/models"
12import { HttpStatusCode } from "@shared/core-utils"
13import { VideoObject } from "@shared/models"
14import { getOrCreateActorAndServerAndModel } from "../actor"
15import { SyncParam, syncVideoExternalAttributes } from "./shared"
16import { createVideo } from "./shared/video-create"
17import { APVideoUpdater } from "./update"
18
19async function fetchRemoteVideo (videoUrl: string): Promise<{ statusCode: number, videoObject: VideoObject }> {
20 logger.info('Fetching remote video %s.', videoUrl)
21
22 const { statusCode, body } = await doJSONRequest<any>(videoUrl, { activityPub: true })
23
24 if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) {
25 logger.debug('Remote video JSON is not valid.', { body })
26 return { statusCode, videoObject: undefined }
27 }
28
29 return { statusCode, videoObject: body }
30}
31
32async function fetchRemoteVideoDescription (video: MVideoAccountLight) {
33 const host = video.VideoChannel.Account.Actor.Server.host
34 const path = video.getDescriptionAPIPath()
35 const url = REMOTE_SCHEME.HTTP + '://' + host + path
36
37 const { body } = await doJSONRequest<any>(url)
38 return body.description || ''
39}
40
41function getOrCreateVideoChannelFromVideoObject (videoObject: VideoObject) {
42 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
43 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
44
45 if (checkUrlsSameHost(channel.id, videoObject.id) !== true) {
46 throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${videoObject.id}`)
47 }
48
49 return getOrCreateActorAndServerAndModel(channel.id, 'all')
50}
51
52type GetVideoResult <T> = Promise<{
53 video: T
54 created: boolean
55 autoBlacklisted?: boolean
56}>
57
58type GetVideoParamAll = {
59 videoObject: { id: string } | string
60 syncParam?: SyncParam
61 fetchType?: 'all'
62 allowRefresh?: boolean
63}
64
65type GetVideoParamImmutable = {
66 videoObject: { id: string } | string
67 syncParam?: SyncParam
68 fetchType: 'only-immutable-attributes'
69 allowRefresh: false
70}
71
72type GetVideoParamOther = {
73 videoObject: { id: string } | string
74 syncParam?: SyncParam
75 fetchType?: 'all' | 'only-video'
76 allowRefresh?: boolean
77}
78
79function getOrCreateVideoAndAccountAndChannel (options: GetVideoParamAll): GetVideoResult<MVideoAccountLightBlacklistAllFiles>
80function getOrCreateVideoAndAccountAndChannel (options: GetVideoParamImmutable): GetVideoResult<MVideoImmutable>
81function getOrCreateVideoAndAccountAndChannel (
82 options: GetVideoParamOther
83): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail>
84async function getOrCreateVideoAndAccountAndChannel (
85 options: GetVideoParamAll | GetVideoParamImmutable | GetVideoParamOther
86): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail | MVideoImmutable> {
87 // Default params
88 const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
89 const fetchType = options.fetchType || 'all'
90 const allowRefresh = options.allowRefresh !== false
91
92 // Get video url
93 const videoUrl = getAPId(options.videoObject)
94 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
95
96 if (videoFromDatabase) {
97 // If allowRefresh is true, we could not call this function using 'only-immutable-attributes' fetch type
98 if (allowRefresh === true && (videoFromDatabase as MVideoThumbnail).isOutdated()) {
99 const refreshOptions = {
100 video: videoFromDatabase as MVideoThumbnail,
101 fetchedType: fetchType,
102 syncParam
103 }
104
105 if (syncParam.refreshVideo === true) {
106 videoFromDatabase = await refreshVideoIfNeeded(refreshOptions)
107 } else {
108 await JobQueue.Instance.createJobWithPromise({
109 type: 'activitypub-refresher',
110 payload: { type: 'video', url: videoFromDatabase.url }
111 })
112 }
113 }
114
115 return { video: videoFromDatabase, created: false }
116 }
117
118 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
119 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
120
121 const actor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
122 const videoChannel = actor.VideoChannel
123
124 try {
125 const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(createVideo, fetchedVideo, videoChannel, syncParam.thumbnail)
126
127 await syncVideoExternalAttributes(videoCreated, fetchedVideo, syncParam)
128
129 return { video: videoCreated, created: true, autoBlacklisted }
130 } catch (err) {
131 // Maybe a concurrent getOrCreateVideoAndAccountAndChannel call created this video
132 if (err.name === 'SequelizeUniqueConstraintError') {
133 const fallbackVideo = await fetchVideoByUrl(videoUrl, fetchType)
134 if (fallbackVideo) return { video: fallbackVideo, created: false }
135 }
136
137 throw err
138 }
139}
140
141async function refreshVideoIfNeeded (options: {
142 video: MVideoThumbnail
143 fetchedType: VideoFetchByUrlType
144 syncParam: SyncParam
145}): Promise<MVideoThumbnail> {
146 if (!options.video.isOutdated()) return options.video
147
148 // We need more attributes if the argument video was fetched with not enough joints
149 const video = options.fetchedType === 'all'
150 ? options.video as MVideoAccountLightBlacklistAllFiles
151 : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
152
153 try {
154 const { videoObject } = await fetchRemoteVideo(video.url)
155
156 if (videoObject === undefined) {
157 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
158
159 await video.setAsRefreshed()
160 return video
161 }
162
163 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
164
165 const videoUpdater = new APVideoUpdater({
166 video,
167 videoObject,
168 channel: channelActor.VideoChannel
169 })
170 await videoUpdater.update()
171
172 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
173
174 ActorFollowScoreCache.Instance.addGoodServerId(video.VideoChannel.Actor.serverId)
175
176 return video
177 } catch (err) {
178 if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) {
179 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
180
181 // Video does not exist anymore
182 await video.destroy()
183 return undefined
184 }
185
186 logger.warn('Cannot refresh video %s.', options.video.url, { err })
187
188 ActorFollowScoreCache.Instance.addBadServerId(video.VideoChannel.Actor.serverId)
189
190 // Don't refresh in loop
191 await video.setAsRefreshed()
192 return video
193 }
194}
195
196export {
197 fetchRemoteVideo,
198 fetchRemoteVideoDescription,
199 refreshVideoIfNeeded,
200 getOrCreateVideoChannelFromVideoObject,
201 getOrCreateVideoAndAccountAndChannel
202}
diff --git a/server/lib/activitypub/videos/index.ts b/server/lib/activitypub/videos/index.ts
new file mode 100644
index 000000000..0e126c85a
--- /dev/null
+++ b/server/lib/activitypub/videos/index.ts
@@ -0,0 +1,3 @@
1export * from './federate'
2export * from './fetch'
3export * from './update'
diff --git a/server/lib/activitypub/videos/shared/index.ts b/server/lib/activitypub/videos/shared/index.ts
new file mode 100644
index 000000000..4d24fbc6a
--- /dev/null
+++ b/server/lib/activitypub/videos/shared/index.ts
@@ -0,0 +1,4 @@
1export * from './object-to-model-attributes'
2export * from './trackers'
3export * from './video-create'
4export * from './video-sync-attributes'
diff --git a/server/lib/activitypub/videos/shared/object-to-model-attributes.ts b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
new file mode 100644
index 000000000..8a8105500
--- /dev/null
+++ b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
@@ -0,0 +1,234 @@
1import { maxBy, minBy } from 'lodash'
2import * as magnetUtil from 'magnet-uri'
3import { basename } from 'path'
4import { isAPVideoFileUrlMetadataObject } from '@server/helpers/custom-validators/activitypub/videos'
5import { isVideoFileInfoHashValid } from '@server/helpers/custom-validators/videos'
6import { logger } from '@server/helpers/logger'
7import { getExtFromMimetype } from '@server/helpers/video'
8import { ACTIVITY_PUB, MIMETYPES, P2P_MEDIA_LOADER_PEER_VERSION, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '@server/initializers/constants'
9import { generateTorrentFileName } from '@server/lib/video-paths'
10import { VideoFileModel } from '@server/models/video/video-file'
11import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
12import { FilteredModelAttributes } from '@server/types'
13import { MChannelId, MStreamingPlaylist, MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoId } from '@server/types/models'
14import {
15 ActivityHashTagObject,
16 ActivityMagnetUrlObject,
17 ActivityPlaylistSegmentHashesObject,
18 ActivityPlaylistUrlObject,
19 ActivityTagObject,
20 ActivityUrlObject,
21 ActivityVideoUrlObject,
22 VideoObject,
23 VideoPrivacy,
24 VideoStreamingPlaylistType
25} from '@shared/models'
26
27function getThumbnailFromIcons (videoObject: VideoObject) {
28 let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth)
29 // Fallback if there are not valid icons
30 if (validIcons.length === 0) validIcons = videoObject.icon
31
32 return minBy(validIcons, 'width')
33}
34
35function getPreviewFromIcons (videoObject: VideoObject) {
36 const validIcons = videoObject.icon.filter(i => i.width > PREVIEWS_SIZE.minWidth)
37
38 return maxBy(validIcons, 'width')
39}
40
41function getTagsFromObject (videoObject: VideoObject) {
42 return videoObject.tag
43 .filter(isAPHashTagObject)
44 .map(t => t.name)
45}
46
47function videoFileActivityUrlToDBAttributes (
48 videoOrPlaylist: MVideo | MStreamingPlaylistVideo,
49 urls: (ActivityTagObject | ActivityUrlObject)[]
50) {
51 const fileUrls = urls.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
52
53 if (fileUrls.length === 0) return []
54
55 const attributes: FilteredModelAttributes<VideoFileModel>[] = []
56 for (const fileUrl of fileUrls) {
57 // Fetch associated magnet uri
58 const magnet = urls.filter(isAPMagnetUrlObject)
59 .find(u => u.height === fileUrl.height)
60
61 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
62
63 const parsed = magnetUtil.decode(magnet.href)
64 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
65 throw new Error('Cannot parse magnet URI ' + magnet.href)
66 }
67
68 const torrentUrl = Array.isArray(parsed.xs)
69 ? parsed.xs[0]
70 : parsed.xs
71
72 // Fetch associated metadata url, if any
73 const metadata = urls.filter(isAPVideoFileUrlMetadataObject)
74 .find(u => {
75 return u.height === fileUrl.height &&
76 u.fps === fileUrl.fps &&
77 u.rel.includes(fileUrl.mediaType)
78 })
79
80 const extname = getExtFromMimetype(MIMETYPES.VIDEO.MIMETYPE_EXT, fileUrl.mediaType)
81 const resolution = fileUrl.height
82 const videoId = (videoOrPlaylist as MStreamingPlaylist).playlistUrl ? null : videoOrPlaylist.id
83 const videoStreamingPlaylistId = (videoOrPlaylist as MStreamingPlaylist).playlistUrl ? videoOrPlaylist.id : null
84
85 const attribute = {
86 extname,
87 infoHash: parsed.infoHash,
88 resolution,
89 size: fileUrl.size,
90 fps: fileUrl.fps || -1,
91 metadataUrl: metadata?.href,
92
93 // Use the name of the remote file because we don't proxify video file requests
94 filename: basename(fileUrl.href),
95 fileUrl: fileUrl.href,
96
97 torrentUrl,
98 // Use our own torrent name since we proxify torrent requests
99 torrentFilename: generateTorrentFileName(videoOrPlaylist, resolution),
100
101 // This is a video file owned by a video or by a streaming playlist
102 videoId,
103 videoStreamingPlaylistId
104 }
105
106 attributes.push(attribute)
107 }
108
109 return attributes
110}
111
112function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObject: VideoObject, videoFiles: MVideoFile[]) {
113 const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[]
114 if (playlistUrls.length === 0) return []
115
116 const attributes: (FilteredModelAttributes<VideoStreamingPlaylistModel> & { tagAPObject?: ActivityTagObject[] })[] = []
117 for (const playlistUrlObject of playlistUrls) {
118 const segmentsSha256UrlObject = playlistUrlObject.tag.find(isAPPlaylistSegmentHashesUrlObject)
119
120 let files: unknown[] = playlistUrlObject.tag.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
121
122 // FIXME: backward compatibility introduced in v2.1.0
123 if (files.length === 0) files = videoFiles
124
125 if (!segmentsSha256UrlObject) {
126 logger.warn('No segment sha256 URL found in AP playlist object.', { playlistUrl: playlistUrlObject })
127 continue
128 }
129
130 const attribute = {
131 type: VideoStreamingPlaylistType.HLS,
132 playlistUrl: playlistUrlObject.href,
133 segmentsSha256Url: segmentsSha256UrlObject.href,
134 p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrlObject.href, files),
135 p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
136 videoId: video.id,
137 tagAPObject: playlistUrlObject.tag
138 }
139
140 attributes.push(attribute)
141 }
142
143 return attributes
144}
145
146function videoActivityObjectToDBAttributes (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) {
147 const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
148 ? VideoPrivacy.PUBLIC
149 : VideoPrivacy.UNLISTED
150
151 const duration = videoObject.duration.replace(/[^\d]+/, '')
152 const language = videoObject.language?.identifier
153
154 const category = videoObject.category
155 ? parseInt(videoObject.category.identifier, 10)
156 : undefined
157
158 const licence = videoObject.licence
159 ? parseInt(videoObject.licence.identifier, 10)
160 : undefined
161
162 const description = videoObject.content || null
163 const support = videoObject.support || null
164
165 return {
166 name: videoObject.name,
167 uuid: videoObject.uuid,
168 url: videoObject.id,
169 category,
170 licence,
171 language,
172 description,
173 support,
174 nsfw: videoObject.sensitive,
175 commentsEnabled: videoObject.commentsEnabled,
176 downloadEnabled: videoObject.downloadEnabled,
177 waitTranscoding: videoObject.waitTranscoding,
178 isLive: videoObject.isLiveBroadcast,
179 state: videoObject.state,
180 channelId: videoChannel.id,
181 duration: parseInt(duration, 10),
182 createdAt: new Date(videoObject.published),
183 publishedAt: new Date(videoObject.published),
184
185 originallyPublishedAt: videoObject.originallyPublishedAt
186 ? new Date(videoObject.originallyPublishedAt)
187 : null,
188
189 updatedAt: new Date(videoObject.updated),
190 views: videoObject.views,
191 likes: 0,
192 dislikes: 0,
193 remote: true,
194 privacy
195 }
196}
197
198// ---------------------------------------------------------------------------
199
200export {
201 getThumbnailFromIcons,
202 getPreviewFromIcons,
203
204 getTagsFromObject,
205
206 videoActivityObjectToDBAttributes,
207
208 videoFileActivityUrlToDBAttributes,
209 streamingPlaylistActivityUrlToDBAttributes
210}
211
212// ---------------------------------------------------------------------------
213
214function isAPVideoUrlObject (url: any): url is ActivityVideoUrlObject {
215 const urlMediaType = url.mediaType
216
217 return MIMETYPES.VIDEO.MIMETYPE_EXT[urlMediaType] && urlMediaType.startsWith('video/')
218}
219
220function isAPStreamingPlaylistUrlObject (url: any): url is ActivityPlaylistUrlObject {
221 return url && url.mediaType === 'application/x-mpegURL'
222}
223
224function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject {
225 return tag && tag.name === 'sha256' && tag.type === 'Link' && tag.mediaType === 'application/json'
226}
227
228function isAPMagnetUrlObject (url: any): url is ActivityMagnetUrlObject {
229 return url && url.mediaType === 'application/x-bittorrent;x-scheme-handler/magnet'
230}
231
232function isAPHashTagObject (url: any): url is ActivityHashTagObject {
233 return url && url.type === 'Hashtag'
234}
diff --git a/server/lib/activitypub/videos/shared/trackers.ts b/server/lib/activitypub/videos/shared/trackers.ts
new file mode 100644
index 000000000..fcb2a5091
--- /dev/null
+++ b/server/lib/activitypub/videos/shared/trackers.ts
@@ -0,0 +1,43 @@
1import { Transaction } from 'sequelize/types'
2import { buildRemoteVideoBaseUrl } from '@server/helpers/activitypub'
3import { isAPVideoTrackerUrlObject } from '@server/helpers/custom-validators/activitypub/videos'
4import { isArray } from '@server/helpers/custom-validators/misc'
5import { REMOTE_SCHEME } from '@server/initializers/constants'
6import { TrackerModel } from '@server/models/server/tracker'
7import { MVideo, MVideoWithHost } from '@server/types/models'
8import { ActivityTrackerUrlObject, VideoObject } from '@shared/models'
9
10function getTrackerUrls (object: VideoObject, video: MVideoWithHost) {
11 let wsFound = false
12
13 const trackers = object.url.filter(u => isAPVideoTrackerUrlObject(u))
14 .map((u: ActivityTrackerUrlObject) => {
15 if (isArray(u.rel) && u.rel.includes('websocket')) wsFound = true
16
17 return u.href
18 })
19
20 if (wsFound) return trackers
21
22 return [
23 buildRemoteVideoBaseUrl(video, '/tracker/socket', REMOTE_SCHEME.WS),
24 buildRemoteVideoBaseUrl(video, '/tracker/announce')
25 ]
26}
27
28async function setVideoTrackers (options: {
29 video: MVideo
30 trackers: string[]
31 transaction?: Transaction
32}) {
33 const { video, trackers, transaction } = options
34
35 const trackerInstances = await TrackerModel.findOrCreateTrackers(trackers, transaction)
36
37 await video.$set('Trackers', trackerInstances, { transaction })
38}
39
40export {
41 getTrackerUrls,
42 setVideoTrackers
43}
diff --git a/server/lib/activitypub/videos/shared/video-create.ts b/server/lib/activitypub/videos/shared/video-create.ts
new file mode 100644
index 000000000..80cc2ab37
--- /dev/null
+++ b/server/lib/activitypub/videos/shared/video-create.ts
@@ -0,0 +1,167 @@
1import { logger } from '@server/helpers/logger'
2import { sequelizeTypescript } from '@server/initializers/database'
3import { createPlaceholderThumbnail, createVideoMiniatureFromUrl } from '@server/lib/thumbnail'
4import { setVideoTags } from '@server/lib/video'
5import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist'
6import { VideoModel } from '@server/models/video/video'
7import { VideoCaptionModel } from '@server/models/video/video-caption'
8import { VideoFileModel } from '@server/models/video/video-file'
9import { VideoLiveModel } from '@server/models/video/video-live'
10import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
11import {
12 MChannelAccountLight,
13 MStreamingPlaylistFilesVideo,
14 MThumbnail,
15 MVideoCaption,
16 MVideoFullLight,
17 MVideoThumbnail
18} from '@server/types/models'
19import { ThumbnailType, VideoObject } from '@shared/models'
20import {
21 getPreviewFromIcons,
22 getTagsFromObject,
23 getThumbnailFromIcons,
24 streamingPlaylistActivityUrlToDBAttributes,
25 videoActivityObjectToDBAttributes,
26 videoFileActivityUrlToDBAttributes
27} from './object-to-model-attributes'
28import { getTrackerUrls, setVideoTrackers } from './trackers'
29
30async function createVideo (videoObject: VideoObject, channel: MChannelAccountLight, waitThumbnail = false) {
31 logger.debug('Adding remote video %s.', videoObject.id)
32
33 const videoData = await videoActivityObjectToDBAttributes(channel, videoObject, videoObject.to)
34 const video = VideoModel.build(videoData) as MVideoThumbnail
35
36 const promiseThumbnail = createVideoMiniatureFromUrl({
37 downloadUrl: getThumbnailFromIcons(videoObject).url,
38 video,
39 type: ThumbnailType.MINIATURE
40 }).catch(err => {
41 logger.error('Cannot create miniature from url.', { err })
42 return undefined
43 })
44
45 let thumbnailModel: MThumbnail
46 if (waitThumbnail === true) {
47 thumbnailModel = await promiseThumbnail
48 }
49
50 const { autoBlacklisted, videoCreated } = await sequelizeTypescript.transaction(async t => {
51 try {
52 const sequelizeOptions = { transaction: t }
53
54 const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight
55 videoCreated.VideoChannel = channel
56
57 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
58
59 const previewIcon = getPreviewFromIcons(videoObject)
60 if (previewIcon) {
61 const previewModel = createPlaceholderThumbnail({
62 fileUrl: previewIcon.url,
63 video: videoCreated,
64 type: ThumbnailType.PREVIEW,
65 size: previewIcon
66 })
67
68 await videoCreated.addAndSaveThumbnail(previewModel, t)
69 }
70
71 // Process files
72 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject.url)
73
74 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
75 const videoFiles = await Promise.all(videoFilePromises)
76
77 const streamingPlaylistsAttributes = streamingPlaylistActivityUrlToDBAttributes(videoCreated, videoObject, videoFiles)
78 videoCreated.VideoStreamingPlaylists = []
79
80 for (const playlistAttributes of streamingPlaylistsAttributes) {
81 const playlist = await VideoStreamingPlaylistModel.create(playlistAttributes, { transaction: t }) as MStreamingPlaylistFilesVideo
82 playlist.Video = videoCreated
83
84 const playlistFiles = videoFileActivityUrlToDBAttributes(playlist, playlistAttributes.tagAPObject)
85 const videoFilePromises = playlistFiles.map(f => VideoFileModel.create(f, { transaction: t }))
86 playlist.VideoFiles = await Promise.all(videoFilePromises)
87
88 videoCreated.VideoStreamingPlaylists.push(playlist)
89 }
90
91 // Process tags
92 const tags = getTagsFromObject(videoObject)
93 await setVideoTags({ video: videoCreated, tags, transaction: t })
94
95 // Process captions
96 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
97 const caption = new VideoCaptionModel({
98 videoId: videoCreated.id,
99 filename: VideoCaptionModel.generateCaptionName(c.identifier),
100 language: c.identifier,
101 fileUrl: c.url
102 }) as MVideoCaption
103
104 return VideoCaptionModel.insertOrReplaceLanguage(caption, t)
105 })
106 await Promise.all(videoCaptionsPromises)
107
108 // Process trackers
109 {
110 const trackers = getTrackerUrls(videoObject, videoCreated)
111 await setVideoTrackers({ video: videoCreated, trackers, transaction: t })
112 }
113
114 videoCreated.VideoFiles = videoFiles
115
116 if (videoCreated.isLive) {
117 const videoLive = new VideoLiveModel({
118 streamKey: null,
119 saveReplay: videoObject.liveSaveReplay,
120 permanentLive: videoObject.permanentLive,
121 videoId: videoCreated.id
122 })
123
124 videoCreated.VideoLive = await videoLive.save({ transaction: t })
125 }
126
127 // We added a video in this channel, set it as updated
128 await channel.setAsUpdated(t)
129
130 const autoBlacklisted = await autoBlacklistVideoIfNeeded({
131 video: videoCreated,
132 user: undefined,
133 isRemote: true,
134 isNew: true,
135 transaction: t
136 })
137
138 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
139
140 return { autoBlacklisted, videoCreated }
141 } catch (err) {
142 // FIXME: Use rollback hook when https://github.com/sequelize/sequelize/pull/13038 is released
143 // Remove thumbnail
144 if (thumbnailModel) await thumbnailModel.removeThumbnail()
145
146 throw err
147 }
148 })
149
150 if (waitThumbnail === false) {
151 // Error is already caught above
152 // eslint-disable-next-line @typescript-eslint/no-floating-promises
153 promiseThumbnail.then(thumbnailModel => {
154 if (!thumbnailModel) return
155
156 thumbnailModel = videoCreated.id
157
158 return thumbnailModel.save()
159 })
160 }
161
162 return { autoBlacklisted, videoCreated }
163}
164
165export {
166 createVideo
167}
diff --git a/server/lib/activitypub/videos/shared/video-sync-attributes.ts b/server/lib/activitypub/videos/shared/video-sync-attributes.ts
new file mode 100644
index 000000000..181893c68
--- /dev/null
+++ b/server/lib/activitypub/videos/shared/video-sync-attributes.ts
@@ -0,0 +1,75 @@
1import { logger } from '@server/helpers/logger'
2import { JobQueue } from '@server/lib/job-queue'
3import { AccountVideoRateModel } from '@server/models/account/account-video-rate'
4import { VideoCommentModel } from '@server/models/video/video-comment'
5import { VideoShareModel } from '@server/models/video/video-share'
6import { MVideo } from '@server/types/models'
7import { ActivitypubHttpFetcherPayload, VideoObject } from '@shared/models'
8import { crawlCollectionPage } from '../../crawl'
9import { addVideoShares } from '../../share'
10import { addVideoComments } from '../../video-comments'
11import { createRates } from '../../video-rates'
12
13import Bluebird = require('bluebird')
14
15type SyncParam = {
16 likes: boolean
17 dislikes: boolean
18 shares: boolean
19 comments: boolean
20 thumbnail: boolean
21 refreshVideo?: boolean
22}
23
24async function syncVideoExternalAttributes (video: MVideo, fetchedVideo: VideoObject, syncParam: SyncParam) {
25 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
26
27 const jobPayloads: ActivitypubHttpFetcherPayload[] = []
28
29 if (syncParam.likes === true) {
30 const handler = items => createRates(items, video, 'like')
31 const cleaner = crawlStartDate => AccountVideoRateModel.cleanOldRatesOf(video.id, 'like' as 'like', crawlStartDate)
32
33 await crawlCollectionPage<string>(fetchedVideo.likes, handler, cleaner)
34 .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err, rootUrl: fetchedVideo.likes }))
35 } else {
36 jobPayloads.push({ uri: fetchedVideo.likes, videoId: video.id, type: 'video-likes' as 'video-likes' })
37 }
38
39 if (syncParam.dislikes === true) {
40 const handler = items => createRates(items, video, 'dislike')
41 const cleaner = crawlStartDate => AccountVideoRateModel.cleanOldRatesOf(video.id, 'dislike' as 'dislike', crawlStartDate)
42
43 await crawlCollectionPage<string>(fetchedVideo.dislikes, handler, cleaner)
44 .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err, rootUrl: fetchedVideo.dislikes }))
45 } else {
46 jobPayloads.push({ uri: fetchedVideo.dislikes, videoId: video.id, type: 'video-dislikes' as 'video-dislikes' })
47 }
48
49 if (syncParam.shares === true) {
50 const handler = items => addVideoShares(items, video)
51 const cleaner = crawlStartDate => VideoShareModel.cleanOldSharesOf(video.id, crawlStartDate)
52
53 await crawlCollectionPage<string>(fetchedVideo.shares, handler, cleaner)
54 .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err, rootUrl: fetchedVideo.shares }))
55 } else {
56 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
57 }
58
59 if (syncParam.comments === true) {
60 const handler = items => addVideoComments(items)
61 const cleaner = crawlStartDate => VideoCommentModel.cleanOldCommentsOf(video.id, crawlStartDate)
62
63 await crawlCollectionPage<string>(fetchedVideo.comments, handler, cleaner)
64 .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err, rootUrl: fetchedVideo.comments }))
65 } else {
66 jobPayloads.push({ uri: fetchedVideo.comments, videoId: video.id, type: 'video-comments' as 'video-comments' })
67 }
68
69 await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJobWithPromise({ type: 'activitypub-http-fetcher', payload }))
70}
71
72export {
73 SyncParam,
74 syncVideoExternalAttributes
75}
diff --git a/server/lib/activitypub/videos/update.ts b/server/lib/activitypub/videos/update.ts
new file mode 100644
index 000000000..444b51628
--- /dev/null
+++ b/server/lib/activitypub/videos/update.ts
@@ -0,0 +1,293 @@
1import { Transaction } from 'sequelize/types'
2import { deleteNonExistingModels, resetSequelizeInstance } from '@server/helpers/database-utils'
3import { logger } from '@server/helpers/logger'
4import { sequelizeTypescript } from '@server/initializers/database'
5import { Notifier } from '@server/lib/notifier'
6import { PeerTubeSocket } from '@server/lib/peertube-socket'
7import { createPlaceholderThumbnail, createVideoMiniatureFromUrl } from '@server/lib/thumbnail'
8import { setVideoTags } from '@server/lib/video'
9import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist'
10import { VideoCaptionModel } from '@server/models/video/video-caption'
11import { VideoFileModel } from '@server/models/video/video-file'
12import { VideoLiveModel } from '@server/models/video/video-live'
13import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
14import {
15 MChannelAccountLight,
16 MChannelDefault,
17 MStreamingPlaylistFilesVideo,
18 MThumbnail,
19 MVideoAccountLightBlacklistAllFiles,
20 MVideoCaption,
21 MVideoFile,
22 MVideoFullLight
23} from '@server/types/models'
24import { ThumbnailType, VideoObject, VideoPrivacy } from '@shared/models'
25import {
26 getPreviewFromIcons,
27 getTagsFromObject,
28 getThumbnailFromIcons,
29 getTrackerUrls,
30 setVideoTrackers,
31 streamingPlaylistActivityUrlToDBAttributes,
32 videoActivityObjectToDBAttributes,
33 videoFileActivityUrlToDBAttributes
34} from './shared'
35
36export class APVideoUpdater {
37 private readonly video: MVideoAccountLightBlacklistAllFiles
38 private readonly videoObject: VideoObject
39 private readonly channel: MChannelDefault
40 private readonly overrideTo: string[]
41
42 private readonly wasPrivateVideo: boolean
43 private readonly wasUnlistedVideo: boolean
44
45 private readonly videoFieldsSave: any
46
47 private readonly oldVideoChannel: MChannelAccountLight
48
49 constructor (options: {
50 video: MVideoAccountLightBlacklistAllFiles
51 videoObject: VideoObject
52 channel: MChannelDefault
53 overrideTo?: string[]
54 }) {
55 this.video = options.video
56 this.videoObject = options.videoObject
57 this.channel = options.channel
58 this.overrideTo = options.overrideTo
59
60 this.wasPrivateVideo = this.video.privacy === VideoPrivacy.PRIVATE
61 this.wasUnlistedVideo = this.video.privacy === VideoPrivacy.UNLISTED
62
63 this.oldVideoChannel = this.video.VideoChannel
64
65 this.videoFieldsSave = this.video.toJSON()
66 }
67
68 async update () {
69 logger.debug('Updating remote video "%s".', this.videoObject.uuid, { videoObject: this.videoObject, channel: this.channel })
70
71 try {
72 const thumbnailModel = await this.tryToGenerateThumbnail()
73
74 const videoUpdated = await sequelizeTypescript.transaction(async t => {
75 this.checkChannelUpdateOrThrow()
76
77 const videoUpdated = await this.updateVideo(t)
78
79 await this.processIcons(videoUpdated, thumbnailModel, t)
80 await this.processWebTorrentFiles(videoUpdated, t)
81 await this.processStreamingPlaylists(videoUpdated, t)
82 await this.processTags(videoUpdated, t)
83 await this.processTrackers(videoUpdated, t)
84 await this.processCaptions(videoUpdated, t)
85 await this.processLive(videoUpdated, t)
86
87 return videoUpdated
88 })
89
90 await autoBlacklistVideoIfNeeded({
91 video: videoUpdated,
92 user: undefined,
93 isRemote: true,
94 isNew: false,
95 transaction: undefined
96 })
97
98 // Notify our users?
99 if (this.wasPrivateVideo || this.wasUnlistedVideo) {
100 Notifier.Instance.notifyOnNewVideoIfNeeded(videoUpdated)
101 }
102
103 if (videoUpdated.isLive) {
104 PeerTubeSocket.Instance.sendVideoLiveNewState(videoUpdated)
105 PeerTubeSocket.Instance.sendVideoViewsUpdate(videoUpdated)
106 }
107
108 logger.info('Remote video with uuid %s updated', this.videoObject.uuid)
109
110 return videoUpdated
111 } catch (err) {
112 this.catchUpdateError(err)
113 }
114 }
115
116 private tryToGenerateThumbnail (): Promise<MThumbnail> {
117 return createVideoMiniatureFromUrl({
118 downloadUrl: getThumbnailFromIcons(this.videoObject).url,
119 video: this.video,
120 type: ThumbnailType.MINIATURE
121 }).catch(err => {
122 logger.warn('Cannot generate thumbnail of %s.', this.videoObject.id, { err })
123
124 return undefined
125 })
126 }
127
128 // Check we can update the channel: we trust the remote server
129 private checkChannelUpdateOrThrow () {
130 if (!this.oldVideoChannel.Actor.serverId || !this.channel.Actor.serverId) {
131 throw new Error('Cannot check old channel/new channel validity because `serverId` is null')
132 }
133
134 if (this.oldVideoChannel.Actor.serverId !== this.channel.Actor.serverId) {
135 throw new Error(`New channel ${this.channel.Actor.url} is not on the same server than new channel ${this.oldVideoChannel.Actor.url}`)
136 }
137 }
138
139 private updateVideo (transaction: Transaction) {
140 const to = this.overrideTo || this.videoObject.to
141 const videoData = videoActivityObjectToDBAttributes(this.channel, this.videoObject, to)
142 this.video.name = videoData.name
143 this.video.uuid = videoData.uuid
144 this.video.url = videoData.url
145 this.video.category = videoData.category
146 this.video.licence = videoData.licence
147 this.video.language = videoData.language
148 this.video.description = videoData.description
149 this.video.support = videoData.support
150 this.video.nsfw = videoData.nsfw
151 this.video.commentsEnabled = videoData.commentsEnabled
152 this.video.downloadEnabled = videoData.downloadEnabled
153 this.video.waitTranscoding = videoData.waitTranscoding
154 this.video.state = videoData.state
155 this.video.duration = videoData.duration
156 this.video.createdAt = videoData.createdAt
157 this.video.publishedAt = videoData.publishedAt
158 this.video.originallyPublishedAt = videoData.originallyPublishedAt
159 this.video.privacy = videoData.privacy
160 this.video.channelId = videoData.channelId
161 this.video.views = videoData.views
162 this.video.isLive = videoData.isLive
163
164 // Ensures we update the updated video attribute
165 this.video.changed('updatedAt', true)
166
167 return this.video.save({ transaction }) as Promise<MVideoFullLight>
168 }
169
170 private async processIcons (videoUpdated: MVideoFullLight, thumbnailModel: MThumbnail, t: Transaction) {
171 if (thumbnailModel) await videoUpdated.addAndSaveThumbnail(thumbnailModel, t)
172
173 // Don't fetch the preview that could be big, create a placeholder instead
174 const previewIcon = getPreviewFromIcons(this.videoObject)
175 if (videoUpdated.getPreview() && previewIcon) {
176 const previewModel = createPlaceholderThumbnail({
177 fileUrl: previewIcon.url,
178 video: videoUpdated,
179 type: ThumbnailType.PREVIEW,
180 size: previewIcon
181 })
182 await videoUpdated.addAndSaveThumbnail(previewModel, t)
183 }
184 }
185
186 private async processWebTorrentFiles (videoUpdated: MVideoFullLight, t: Transaction) {
187 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoUpdated, this.videoObject.url)
188 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
189
190 // Remove video files that do not exist anymore
191 const destroyTasks = deleteNonExistingModels(videoUpdated.VideoFiles, newVideoFiles, t)
192 await Promise.all(destroyTasks)
193
194 // Update or add other one
195 const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'video', t))
196 videoUpdated.VideoFiles = await Promise.all(upsertTasks)
197 }
198
199 private async processStreamingPlaylists (videoUpdated: MVideoFullLight, t: Transaction) {
200 const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(videoUpdated, this.videoObject, videoUpdated.VideoFiles)
201 const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
202
203 // Remove video playlists that do not exist anymore
204 const destroyTasks = deleteNonExistingModels(videoUpdated.VideoStreamingPlaylists, newStreamingPlaylists, t)
205 await Promise.all(destroyTasks)
206
207 let oldStreamingPlaylistFiles: MVideoFile[] = []
208 for (const videoStreamingPlaylist of videoUpdated.VideoStreamingPlaylists) {
209 oldStreamingPlaylistFiles = oldStreamingPlaylistFiles.concat(videoStreamingPlaylist.VideoFiles)
210 }
211
212 videoUpdated.VideoStreamingPlaylists = []
213
214 for (const playlistAttributes of streamingPlaylistAttributes) {
215 const streamingPlaylistModel = await VideoStreamingPlaylistModel.upsert(playlistAttributes, { returning: true, transaction: t })
216 .then(([ streamingPlaylist ]) => streamingPlaylist as MStreamingPlaylistFilesVideo)
217 streamingPlaylistModel.Video = videoUpdated
218
219 const newVideoFiles: MVideoFile[] = videoFileActivityUrlToDBAttributes(streamingPlaylistModel, playlistAttributes.tagAPObject)
220 .map(a => new VideoFileModel(a))
221 const destroyTasks = deleteNonExistingModels(oldStreamingPlaylistFiles, newVideoFiles, t)
222 await Promise.all(destroyTasks)
223
224 // Update or add other one
225 const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'streaming-playlist', t))
226 streamingPlaylistModel.VideoFiles = await Promise.all(upsertTasks)
227
228 videoUpdated.VideoStreamingPlaylists.push(streamingPlaylistModel)
229 }
230 }
231
232 private async processTags (videoUpdated: MVideoFullLight, t: Transaction) {
233 const tags = getTagsFromObject(this.videoObject)
234 await setVideoTags({ video: videoUpdated, tags, transaction: t })
235 }
236
237 private async processTrackers (videoUpdated: MVideoFullLight, t: Transaction) {
238 const trackers = getTrackerUrls(this.videoObject, videoUpdated)
239 await setVideoTrackers({ video: videoUpdated, trackers, transaction: t })
240 }
241
242 private async processCaptions (videoUpdated: MVideoFullLight, t: Transaction) {
243 // Update captions
244 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(videoUpdated.id, t)
245
246 const videoCaptionsPromises = this.videoObject.subtitleLanguage.map(c => {
247 const caption = new VideoCaptionModel({
248 videoId: videoUpdated.id,
249 filename: VideoCaptionModel.generateCaptionName(c.identifier),
250 language: c.identifier,
251 fileUrl: c.url
252 }) as MVideoCaption
253
254 return VideoCaptionModel.insertOrReplaceLanguage(caption, t)
255 })
256
257 await Promise.all(videoCaptionsPromises)
258 }
259
260 private async processLive (videoUpdated: MVideoFullLight, t: Transaction) {
261 // Create or update existing live
262 if (this.video.isLive) {
263 const [ videoLive ] = await VideoLiveModel.upsert({
264 saveReplay: this.videoObject.liveSaveReplay,
265 permanentLive: this.videoObject.permanentLive,
266 videoId: this.video.id
267 }, { transaction: t, returning: true })
268
269 videoUpdated.VideoLive = videoLive
270 return
271 }
272
273 // Delete existing live if it exists
274 await VideoLiveModel.destroy({
275 where: {
276 videoId: this.video.id
277 },
278 transaction: t
279 })
280
281 videoUpdated.VideoLive = null
282 }
283
284 private catchUpdateError (err: Error) {
285 if (this.video !== undefined && this.videoFieldsSave !== undefined) {
286 resetSequelizeInstance(this.video, this.videoFieldsSave)
287 }
288
289 // This is just a debug because we will retry the insert
290 logger.debug('Cannot update the remote video.', { err })
291 throw err
292 }
293}