]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/lib/activitypub/playlist.ts
Shared utils -> extra-utils
[github/Chocobozzz/PeerTube.git] / server / lib / activitypub / playlist.ts
CommitLineData
418d092a
C
1import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
2import { crawlCollectionPage } from './crawl'
74dc3bca 3import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY, THUMBNAILS_SIZE } from '../../initializers/constants'
418d092a
C
4import { AccountModel } from '../../models/account/account'
5import { isArray } from '../../helpers/custom-validators/misc'
6import { getOrCreateActorAndServerAndModel } from './actor'
7import { logger } from '../../helpers/logger'
8import { VideoPlaylistModel } from '../../models/video/video-playlist'
9import { doRequest, downloadImage } from '../../helpers/requests'
10import { checkUrlsSameHost } from '../../helpers/activitypub'
11import * as Bluebird from 'bluebird'
12import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object'
13import { getOrCreateVideoAndAccountAndChannel } from './videos'
14import { isPlaylistElementObjectValid, isPlaylistObjectValid } from '../../helpers/custom-validators/activitypub/playlist'
15import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
16import { VideoModel } from '../../models/video/video'
17import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model'
18import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
19import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
6dd9de95 20import { CONFIG } from '../../initializers/config'
74dc3bca 21import { sequelizeTypescript } from '../../initializers/database'
418d092a
C
22
23function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount: AccountModel, to: string[]) {
24 const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPlaylistPrivacy.PUBLIC : VideoPlaylistPrivacy.UNLISTED
25
26 return {
27 name: playlistObject.name,
28 description: playlistObject.content,
29 privacy,
30 url: playlistObject.id,
31 uuid: playlistObject.uuid,
32 ownerAccountId: byAccount.id,
df0b219d
C
33 videoChannelId: null,
34 createdAt: new Date(playlistObject.published),
35 updatedAt: new Date(playlistObject.updated)
418d092a
C
36 }
37}
38
39function playlistElementObjectToDBAttributes (elementObject: PlaylistElementObject, videoPlaylist: VideoPlaylistModel, video: VideoModel) {
40 return {
41 position: elementObject.position,
42 url: elementObject.id,
43 startTimestamp: elementObject.startTimestamp || null,
44 stopTimestamp: elementObject.stopTimestamp || null,
45 videoPlaylistId: videoPlaylist.id,
46 videoId: video.id
47 }
48}
49
50async function createAccountPlaylists (playlistUrls: string[], account: AccountModel) {
51 await Bluebird.map(playlistUrls, async playlistUrl => {
52 try {
53 const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl)
54 if (exists === true) return
55
56 // Fetch url
57 const { body } = await doRequest<PlaylistObject>({
58 uri: playlistUrl,
59 json: true,
60 activityPub: true
61 })
62
63 if (!isPlaylistObjectValid(body)) {
64 throw new Error(`Invalid playlist object when fetch account playlists: ${JSON.stringify(body)}`)
65 }
66
67 if (!isArray(body.to)) {
68 throw new Error('Playlist does not have an audience.')
69 }
70
71 return createOrUpdateVideoPlaylist(body, account, body.to)
72 } catch (err) {
73 logger.warn('Cannot add playlist element %s.', playlistUrl, { err })
74 }
75 }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
76}
77
78async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAccount: AccountModel, to: string[]) {
79 const playlistAttributes = playlistObjectToDBAttributes(playlistObject, byAccount, to)
80
81 if (isArray(playlistObject.attributedTo) && playlistObject.attributedTo.length === 1) {
82 const actor = await getOrCreateActorAndServerAndModel(playlistObject.attributedTo[0])
83
84 if (actor.VideoChannel) {
85 playlistAttributes.videoChannelId = actor.VideoChannel.id
86 } else {
87 logger.warn('Attributed to of video playlist %s is not a video channel.', playlistObject.id, { playlistObject })
88 }
89 }
90
91 const [ playlist ] = await VideoPlaylistModel.upsert<VideoPlaylistModel>(playlistAttributes, { returning: true })
92
93 let accItems: string[] = []
94 await crawlCollectionPage<string>(playlistObject.id, items => {
95 accItems = accItems.concat(items)
96
97 return Promise.resolve()
98 })
99
9f79ade6 100 // Empty playlists generally do not have a miniature, so skip this
418d092a
C
101 if (accItems.length !== 0) {
102 try {
103 await generateThumbnailFromUrl(playlist, playlistObject.icon)
104 } catch (err) {
105 logger.warn('Cannot generate thumbnail of %s.', playlistObject.id, { err })
106 }
107 }
108
109 return resetVideoPlaylistElements(accItems, playlist)
110}
111
9f79ade6
C
112async function refreshVideoPlaylistIfNeeded (videoPlaylist: VideoPlaylistModel): Promise<VideoPlaylistModel> {
113 if (!videoPlaylist.isOutdated()) return videoPlaylist
114
115 try {
116 const { statusCode, playlistObject } = await fetchRemoteVideoPlaylist(videoPlaylist.url)
117 if (statusCode === 404) {
118 logger.info('Cannot refresh remote video playlist %s: it does not exist anymore. Deleting it.', videoPlaylist.url)
119
120 await videoPlaylist.destroy()
121 return undefined
122 }
123
124 if (playlistObject === undefined) {
125 logger.warn('Cannot refresh remote playlist %s: invalid body.', videoPlaylist.url)
126
127 await videoPlaylist.setAsRefreshed()
128 return videoPlaylist
129 }
130
131 const byAccount = videoPlaylist.OwnerAccount
132 await createOrUpdateVideoPlaylist(playlistObject, byAccount, playlistObject.to)
133
134 return videoPlaylist
135 } catch (err) {
136 logger.warn('Cannot refresh video playlist %s.', videoPlaylist.url, { err })
137
138 await videoPlaylist.setAsRefreshed()
139 return videoPlaylist
140 }
141}
142
418d092a
C
143// ---------------------------------------------------------------------------
144
145export {
146 createAccountPlaylists,
147 playlistObjectToDBAttributes,
148 playlistElementObjectToDBAttributes,
9f79ade6
C
149 createOrUpdateVideoPlaylist,
150 refreshVideoPlaylistIfNeeded
418d092a
C
151}
152
153// ---------------------------------------------------------------------------
154
155async function resetVideoPlaylistElements (elementUrls: string[], playlist: VideoPlaylistModel) {
156 const elementsToCreate: FilteredModelAttributes<VideoPlaylistElementModel>[] = []
157
158 await Bluebird.map(elementUrls, async elementUrl => {
159 try {
160 // Fetch url
161 const { body } = await doRequest<PlaylistElementObject>({
162 uri: elementUrl,
163 json: true,
164 activityPub: true
165 })
166
167 if (!isPlaylistElementObjectValid(body)) throw new Error(`Invalid body in video get playlist element ${elementUrl}`)
168
169 if (checkUrlsSameHost(body.id, elementUrl) !== true) {
170 throw new Error(`Playlist element url ${elementUrl} host is different from the AP object id ${body.id}`)
171 }
172
173 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: { id: body.url }, fetchType: 'only-video' })
174
175 elementsToCreate.push(playlistElementObjectToDBAttributes(body, playlist, video))
176 } catch (err) {
177 logger.warn('Cannot add playlist element %s.', elementUrl, { err })
178 }
179 }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
180
181 await sequelizeTypescript.transaction(async t => {
182 await VideoPlaylistElementModel.deleteAllOf(playlist.id, t)
183
184 for (const element of elementsToCreate) {
185 await VideoPlaylistElementModel.create(element, { transaction: t })
186 }
187 })
188
189 logger.info('Reset playlist %s with %s elements.', playlist.url, elementsToCreate.length)
190
191 return undefined
192}
193
194function generateThumbnailFromUrl (playlist: VideoPlaylistModel, icon: ActivityIconObject) {
195 const thumbnailName = playlist.getThumbnailName()
196
197 return downloadImage(icon.url, CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName, THUMBNAILS_SIZE)
198}
9f79ade6
C
199
200async function fetchRemoteVideoPlaylist (playlistUrl: string): Promise<{ statusCode: number, playlistObject: PlaylistObject }> {
201 const options = {
202 uri: playlistUrl,
203 method: 'GET',
204 json: true,
205 activityPub: true
206 }
207
208 logger.info('Fetching remote playlist %s.', playlistUrl)
209
210 const { response, body } = await doRequest(options)
211
212 if (isPlaylistObjectValid(body) === false || checkUrlsSameHost(body.id, playlistUrl) !== true) {
213 logger.debug('Remote video playlist JSON is not valid.', { body })
214 return { statusCode: response.statusCode, playlistObject: undefined }
215 }
216
217 return { statusCode: response.statusCode, playlistObject: body }
218}