diff options
Diffstat (limited to 'server/lib/activitypub/playlist.ts')
-rw-r--r-- | server/lib/activitypub/playlist.ts | 162 |
1 files changed, 162 insertions, 0 deletions
diff --git a/server/lib/activitypub/playlist.ts b/server/lib/activitypub/playlist.ts new file mode 100644 index 000000000..c9b428c92 --- /dev/null +++ b/server/lib/activitypub/playlist.ts | |||
@@ -0,0 +1,162 @@ | |||
1 | import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object' | ||
2 | import { crawlCollectionPage } from './crawl' | ||
3 | import { ACTIVITY_PUB, CONFIG, CRAWL_REQUEST_CONCURRENCY, sequelizeTypescript, THUMBNAILS_SIZE } from '../../initializers' | ||
4 | import { AccountModel } from '../../models/account/account' | ||
5 | import { isArray } from '../../helpers/custom-validators/misc' | ||
6 | import { getOrCreateActorAndServerAndModel } from './actor' | ||
7 | import { logger } from '../../helpers/logger' | ||
8 | import { VideoPlaylistModel } from '../../models/video/video-playlist' | ||
9 | import { doRequest, downloadImage } from '../../helpers/requests' | ||
10 | import { checkUrlsSameHost } from '../../helpers/activitypub' | ||
11 | import * as Bluebird from 'bluebird' | ||
12 | import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object' | ||
13 | import { getOrCreateVideoAndAccountAndChannel } from './videos' | ||
14 | import { isPlaylistElementObjectValid, isPlaylistObjectValid } from '../../helpers/custom-validators/activitypub/playlist' | ||
15 | import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element' | ||
16 | import { VideoModel } from '../../models/video/video' | ||
17 | import { FilteredModelAttributes } from 'sequelize-typescript/lib/models/Model' | ||
18 | import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model' | ||
19 | import { ActivityIconObject } from '../../../shared/models/activitypub/objects' | ||
20 | |||
21 | function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount: AccountModel, to: string[]) { | ||
22 | const privacy = to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ? VideoPlaylistPrivacy.PUBLIC : VideoPlaylistPrivacy.UNLISTED | ||
23 | |||
24 | return { | ||
25 | name: playlistObject.name, | ||
26 | description: playlistObject.content, | ||
27 | privacy, | ||
28 | url: playlistObject.id, | ||
29 | uuid: playlistObject.uuid, | ||
30 | ownerAccountId: byAccount.id, | ||
31 | videoChannelId: null | ||
32 | } | ||
33 | } | ||
34 | |||
35 | function playlistElementObjectToDBAttributes (elementObject: PlaylistElementObject, videoPlaylist: VideoPlaylistModel, video: VideoModel) { | ||
36 | return { | ||
37 | position: elementObject.position, | ||
38 | url: elementObject.id, | ||
39 | startTimestamp: elementObject.startTimestamp || null, | ||
40 | stopTimestamp: elementObject.stopTimestamp || null, | ||
41 | videoPlaylistId: videoPlaylist.id, | ||
42 | videoId: video.id | ||
43 | } | ||
44 | } | ||
45 | |||
46 | async function createAccountPlaylists (playlistUrls: string[], account: AccountModel) { | ||
47 | await Bluebird.map(playlistUrls, async playlistUrl => { | ||
48 | try { | ||
49 | const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl) | ||
50 | if (exists === true) return | ||
51 | |||
52 | // Fetch url | ||
53 | const { body } = await doRequest<PlaylistObject>({ | ||
54 | uri: playlistUrl, | ||
55 | json: true, | ||
56 | activityPub: true | ||
57 | }) | ||
58 | |||
59 | if (!isPlaylistObjectValid(body)) { | ||
60 | throw new Error(`Invalid playlist object when fetch account playlists: ${JSON.stringify(body)}`) | ||
61 | } | ||
62 | |||
63 | if (!isArray(body.to)) { | ||
64 | throw new Error('Playlist does not have an audience.') | ||
65 | } | ||
66 | |||
67 | return createOrUpdateVideoPlaylist(body, account, body.to) | ||
68 | } catch (err) { | ||
69 | logger.warn('Cannot add playlist element %s.', playlistUrl, { err }) | ||
70 | } | ||
71 | }, { concurrency: CRAWL_REQUEST_CONCURRENCY }) | ||
72 | } | ||
73 | |||
74 | async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAccount: AccountModel, to: string[]) { | ||
75 | const playlistAttributes = playlistObjectToDBAttributes(playlistObject, byAccount, to) | ||
76 | |||
77 | if (isArray(playlistObject.attributedTo) && playlistObject.attributedTo.length === 1) { | ||
78 | const actor = await getOrCreateActorAndServerAndModel(playlistObject.attributedTo[0]) | ||
79 | |||
80 | if (actor.VideoChannel) { | ||
81 | playlistAttributes.videoChannelId = actor.VideoChannel.id | ||
82 | } else { | ||
83 | logger.warn('Attributed to of video playlist %s is not a video channel.', playlistObject.id, { playlistObject }) | ||
84 | } | ||
85 | } | ||
86 | |||
87 | const [ playlist ] = await VideoPlaylistModel.upsert<VideoPlaylistModel>(playlistAttributes, { returning: true }) | ||
88 | |||
89 | let accItems: string[] = [] | ||
90 | await crawlCollectionPage<string>(playlistObject.id, items => { | ||
91 | accItems = accItems.concat(items) | ||
92 | |||
93 | return Promise.resolve() | ||
94 | }) | ||
95 | |||
96 | // Empty playlists generally do not have a miniature, so skip it | ||
97 | if (accItems.length !== 0) { | ||
98 | try { | ||
99 | await generateThumbnailFromUrl(playlist, playlistObject.icon) | ||
100 | } catch (err) { | ||
101 | logger.warn('Cannot generate thumbnail of %s.', playlistObject.id, { err }) | ||
102 | } | ||
103 | } | ||
104 | |||
105 | return resetVideoPlaylistElements(accItems, playlist) | ||
106 | } | ||
107 | |||
108 | // --------------------------------------------------------------------------- | ||
109 | |||
110 | export { | ||
111 | createAccountPlaylists, | ||
112 | playlistObjectToDBAttributes, | ||
113 | playlistElementObjectToDBAttributes, | ||
114 | createOrUpdateVideoPlaylist | ||
115 | } | ||
116 | |||
117 | // --------------------------------------------------------------------------- | ||
118 | |||
119 | async function resetVideoPlaylistElements (elementUrls: string[], playlist: VideoPlaylistModel) { | ||
120 | const elementsToCreate: FilteredModelAttributes<VideoPlaylistElementModel>[] = [] | ||
121 | |||
122 | await Bluebird.map(elementUrls, async elementUrl => { | ||
123 | try { | ||
124 | // Fetch url | ||
125 | const { body } = await doRequest<PlaylistElementObject>({ | ||
126 | uri: elementUrl, | ||
127 | json: true, | ||
128 | activityPub: true | ||
129 | }) | ||
130 | |||
131 | if (!isPlaylistElementObjectValid(body)) throw new Error(`Invalid body in video get playlist element ${elementUrl}`) | ||
132 | |||
133 | if (checkUrlsSameHost(body.id, elementUrl) !== true) { | ||
134 | throw new Error(`Playlist element url ${elementUrl} host is different from the AP object id ${body.id}`) | ||
135 | } | ||
136 | |||
137 | const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: { id: body.url }, fetchType: 'only-video' }) | ||
138 | |||
139 | elementsToCreate.push(playlistElementObjectToDBAttributes(body, playlist, video)) | ||
140 | } catch (err) { | ||
141 | logger.warn('Cannot add playlist element %s.', elementUrl, { err }) | ||
142 | } | ||
143 | }, { concurrency: CRAWL_REQUEST_CONCURRENCY }) | ||
144 | |||
145 | await sequelizeTypescript.transaction(async t => { | ||
146 | await VideoPlaylistElementModel.deleteAllOf(playlist.id, t) | ||
147 | |||
148 | for (const element of elementsToCreate) { | ||
149 | await VideoPlaylistElementModel.create(element, { transaction: t }) | ||
150 | } | ||
151 | }) | ||
152 | |||
153 | logger.info('Reset playlist %s with %s elements.', playlist.url, elementsToCreate.length) | ||
154 | |||
155 | return undefined | ||
156 | } | ||
157 | |||
158 | function generateThumbnailFromUrl (playlist: VideoPlaylistModel, icon: ActivityIconObject) { | ||
159 | const thumbnailName = playlist.getThumbnailName() | ||
160 | |||
161 | return downloadImage(icon.url, CONFIG.STORAGE.THUMBNAILS_DIR, thumbnailName, THUMBNAILS_SIZE) | ||
162 | } | ||