diff options
Diffstat (limited to 'server/lib/activitypub/playlists')
6 files changed, 281 insertions, 0 deletions
diff --git a/server/lib/activitypub/playlists/create-update.ts b/server/lib/activitypub/playlists/create-update.ts new file mode 100644 index 000000000..886b1f288 --- /dev/null +++ b/server/lib/activitypub/playlists/create-update.ts | |||
@@ -0,0 +1,146 @@ | |||
1 | import { isArray } from '@server/helpers/custom-validators/misc' | ||
2 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
3 | import { CRAWL_REQUEST_CONCURRENCY } from '@server/initializers/constants' | ||
4 | import { sequelizeTypescript } from '@server/initializers/database' | ||
5 | import { createPlaylistMiniatureFromUrl } from '@server/lib/thumbnail' | ||
6 | import { VideoPlaylistModel } from '@server/models/video/video-playlist' | ||
7 | import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element' | ||
8 | import { FilteredModelAttributes } from '@server/types' | ||
9 | import { MAccountDefault, MAccountId, MVideoPlaylist, MVideoPlaylistFull } from '@server/types/models' | ||
10 | import { AttributesOnly } from '@shared/core-utils' | ||
11 | import { PlaylistObject } from '@shared/models' | ||
12 | import { getOrCreateActorAndServerAndModel } from '../actor' | ||
13 | import { crawlCollectionPage } from '../crawl' | ||
14 | import { getOrCreateAPVideo } from '../videos' | ||
15 | import { | ||
16 | fetchRemotePlaylistElement, | ||
17 | fetchRemoteVideoPlaylist, | ||
18 | playlistElementObjectToDBAttributes, | ||
19 | playlistObjectToDBAttributes | ||
20 | } from './shared' | ||
21 | |||
22 | import Bluebird = require('bluebird') | ||
23 | |||
24 | const lTags = loggerTagsFactory('ap', 'video-playlist') | ||
25 | |||
26 | async function createAccountPlaylists (playlistUrls: string[], account: MAccountDefault) { | ||
27 | await Bluebird.map(playlistUrls, async playlistUrl => { | ||
28 | try { | ||
29 | const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl) | ||
30 | if (exists === true) return | ||
31 | |||
32 | const { playlistObject } = await fetchRemoteVideoPlaylist(playlistUrl) | ||
33 | |||
34 | if (playlistObject === undefined) { | ||
35 | throw new Error(`Cannot refresh remote playlist ${playlistUrl}: invalid body.`) | ||
36 | } | ||
37 | |||
38 | return createOrUpdateVideoPlaylist(playlistObject, account, playlistObject.to) | ||
39 | } catch (err) { | ||
40 | logger.warn('Cannot add playlist element %s.', playlistUrl, { err, ...lTags(playlistUrl) }) | ||
41 | } | ||
42 | }, { concurrency: CRAWL_REQUEST_CONCURRENCY }) | ||
43 | } | ||
44 | |||
45 | async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAccount: MAccountId, to: string[]) { | ||
46 | const playlistAttributes = playlistObjectToDBAttributes(playlistObject, byAccount, to) | ||
47 | |||
48 | await setVideoChannelIfNeeded(playlistObject, playlistAttributes) | ||
49 | |||
50 | const [ upsertPlaylist ] = await VideoPlaylistModel.upsert<MVideoPlaylist>(playlistAttributes, { returning: true }) | ||
51 | |||
52 | const playlistElementUrls = await fetchElementUrls(playlistObject) | ||
53 | |||
54 | // Refetch playlist from DB since elements fetching could be long in time | ||
55 | const playlist = await VideoPlaylistModel.loadWithAccountAndChannel(upsertPlaylist.id, null) | ||
56 | |||
57 | try { | ||
58 | await updatePlaylistThumbnail(playlistObject, playlist) | ||
59 | } catch (err) { | ||
60 | logger.warn('Cannot update thumbnail of %s.', playlistObject.id, { err, ...lTags(playlistObject.id, playlist.uuid, playlist.url) }) | ||
61 | } | ||
62 | |||
63 | return rebuildVideoPlaylistElements(playlistElementUrls, playlist) | ||
64 | } | ||
65 | |||
66 | // --------------------------------------------------------------------------- | ||
67 | |||
68 | export { | ||
69 | createAccountPlaylists, | ||
70 | createOrUpdateVideoPlaylist | ||
71 | } | ||
72 | |||
73 | // --------------------------------------------------------------------------- | ||
74 | |||
75 | async function setVideoChannelIfNeeded (playlistObject: PlaylistObject, playlistAttributes: AttributesOnly<VideoPlaylistModel>) { | ||
76 | if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) return | ||
77 | |||
78 | const actor = await getOrCreateActorAndServerAndModel(playlistObject.attributedTo[0]) | ||
79 | |||
80 | if (!actor.VideoChannel) { | ||
81 | logger.warn('Playlist "attributedTo" %s is not a video channel.', playlistObject.id, { playlistObject, ...lTags(playlistObject.id) }) | ||
82 | return | ||
83 | } | ||
84 | |||
85 | playlistAttributes.videoChannelId = actor.VideoChannel.id | ||
86 | } | ||
87 | |||
88 | async function fetchElementUrls (playlistObject: PlaylistObject) { | ||
89 | let accItems: string[] = [] | ||
90 | await crawlCollectionPage<string>(playlistObject.id, items => { | ||
91 | accItems = accItems.concat(items) | ||
92 | |||
93 | return Promise.resolve() | ||
94 | }) | ||
95 | |||
96 | return accItems | ||
97 | } | ||
98 | |||
99 | async function updatePlaylistThumbnail (playlistObject: PlaylistObject, playlist: MVideoPlaylistFull) { | ||
100 | if (playlistObject.icon) { | ||
101 | const thumbnailModel = await createPlaylistMiniatureFromUrl({ downloadUrl: playlistObject.icon.url, playlist }) | ||
102 | await playlist.setAndSaveThumbnail(thumbnailModel, undefined) | ||
103 | |||
104 | return | ||
105 | } | ||
106 | |||
107 | // Playlist does not have an icon, destroy existing one | ||
108 | if (playlist.hasThumbnail()) { | ||
109 | await playlist.Thumbnail.destroy() | ||
110 | playlist.Thumbnail = null | ||
111 | } | ||
112 | } | ||
113 | |||
114 | async function rebuildVideoPlaylistElements (elementUrls: string[], playlist: MVideoPlaylist) { | ||
115 | const elementsToCreate = await buildElementsDBAttributes(elementUrls, playlist) | ||
116 | |||
117 | await sequelizeTypescript.transaction(async t => { | ||
118 | await VideoPlaylistElementModel.deleteAllOf(playlist.id, t) | ||
119 | |||
120 | for (const element of elementsToCreate) { | ||
121 | await VideoPlaylistElementModel.create(element, { transaction: t }) | ||
122 | } | ||
123 | }) | ||
124 | |||
125 | logger.info('Rebuilt playlist %s with %s elements.', playlist.url, elementsToCreate.length, lTags(playlist.uuid, playlist.url)) | ||
126 | |||
127 | return undefined | ||
128 | } | ||
129 | |||
130 | async function buildElementsDBAttributes (elementUrls: string[], playlist: MVideoPlaylist) { | ||
131 | const elementsToCreate: FilteredModelAttributes<VideoPlaylistElementModel>[] = [] | ||
132 | |||
133 | await Bluebird.map(elementUrls, async elementUrl => { | ||
134 | try { | ||
135 | const { elementObject } = await fetchRemotePlaylistElement(elementUrl) | ||
136 | |||
137 | const { video } = await getOrCreateAPVideo({ videoObject: { id: elementObject.url }, fetchType: 'only-video' }) | ||
138 | |||
139 | elementsToCreate.push(playlistElementObjectToDBAttributes(elementObject, playlist, video)) | ||
140 | } catch (err) { | ||
141 | logger.warn('Cannot add playlist element %s.', elementUrl, { err, ...lTags(playlist.uuid, playlist.url) }) | ||
142 | } | ||
143 | }, { concurrency: CRAWL_REQUEST_CONCURRENCY }) | ||
144 | |||
145 | return elementsToCreate | ||
146 | } | ||
diff --git a/server/lib/activitypub/playlists/index.ts b/server/lib/activitypub/playlists/index.ts new file mode 100644 index 000000000..2885830b4 --- /dev/null +++ b/server/lib/activitypub/playlists/index.ts | |||
@@ -0,0 +1,2 @@ | |||
1 | export * from './create-update' | ||
2 | export * from './refresh' | ||
diff --git a/server/lib/activitypub/playlists/refresh.ts b/server/lib/activitypub/playlists/refresh.ts new file mode 100644 index 000000000..ff9e5471a --- /dev/null +++ b/server/lib/activitypub/playlists/refresh.ts | |||
@@ -0,0 +1,44 @@ | |||
1 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
2 | import { PeerTubeRequestError } from '@server/helpers/requests' | ||
3 | import { MVideoPlaylistOwner } from '@server/types/models' | ||
4 | import { HttpStatusCode } from '@shared/core-utils' | ||
5 | import { createOrUpdateVideoPlaylist } from './create-update' | ||
6 | import { fetchRemoteVideoPlaylist } from './shared' | ||
7 | |||
8 | async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner): Promise<MVideoPlaylistOwner> { | ||
9 | if (!videoPlaylist.isOutdated()) return videoPlaylist | ||
10 | |||
11 | const lTags = loggerTagsFactory('ap', 'video-playlist', 'refresh', videoPlaylist.uuid, videoPlaylist.url) | ||
12 | |||
13 | try { | ||
14 | const { playlistObject } = await fetchRemoteVideoPlaylist(videoPlaylist.url) | ||
15 | |||
16 | if (playlistObject === undefined) { | ||
17 | logger.warn('Cannot refresh remote playlist %s: invalid body.', videoPlaylist.url, lTags()) | ||
18 | |||
19 | await videoPlaylist.setAsRefreshed() | ||
20 | return videoPlaylist | ||
21 | } | ||
22 | |||
23 | const byAccount = videoPlaylist.OwnerAccount | ||
24 | await createOrUpdateVideoPlaylist(playlistObject, byAccount, playlistObject.to) | ||
25 | |||
26 | return videoPlaylist | ||
27 | } catch (err) { | ||
28 | if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) { | ||
29 | logger.info('Cannot refresh not existing playlist %s. Deleting it.', videoPlaylist.url, lTags()) | ||
30 | |||
31 | await videoPlaylist.destroy() | ||
32 | return undefined | ||
33 | } | ||
34 | |||
35 | logger.warn('Cannot refresh video playlist %s.', videoPlaylist.url, { err, ...lTags() }) | ||
36 | |||
37 | await videoPlaylist.setAsRefreshed() | ||
38 | return videoPlaylist | ||
39 | } | ||
40 | } | ||
41 | |||
42 | export { | ||
43 | refreshVideoPlaylistIfNeeded | ||
44 | } | ||
diff --git a/server/lib/activitypub/playlists/shared/index.ts b/server/lib/activitypub/playlists/shared/index.ts new file mode 100644 index 000000000..a217f2291 --- /dev/null +++ b/server/lib/activitypub/playlists/shared/index.ts | |||
@@ -0,0 +1,2 @@ | |||
1 | export * from './object-to-model-attributes' | ||
2 | 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 index 000000000..6ec44485e --- /dev/null +++ b/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts | |||
@@ -0,0 +1,40 @@ | |||
1 | import { ACTIVITY_PUB } from '@server/initializers/constants' | ||
2 | import { VideoPlaylistModel } from '@server/models/video/video-playlist' | ||
3 | import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element' | ||
4 | import { MAccountId, MVideoId, MVideoPlaylistId } from '@server/types/models' | ||
5 | import { AttributesOnly } from '@shared/core-utils' | ||
6 | import { PlaylistElementObject, PlaylistObject, VideoPlaylistPrivacy } from '@shared/models' | ||
7 | |||
8 | function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount: MAccountId, to: string[]) { | ||
9 | const privacy = to.includes(ACTIVITY_PUB.PUBLIC) | ||
10 | ? VideoPlaylistPrivacy.PUBLIC | ||
11 | : VideoPlaylistPrivacy.UNLISTED | ||
12 | |||
13 | return { | ||
14 | name: playlistObject.name, | ||
15 | description: playlistObject.content, | ||
16 | privacy, | ||
17 | url: playlistObject.id, | ||
18 | uuid: playlistObject.uuid, | ||
19 | ownerAccountId: byAccount.id, | ||
20 | videoChannelId: null, | ||
21 | createdAt: new Date(playlistObject.published), | ||
22 | updatedAt: new Date(playlistObject.updated) | ||
23 | } as AttributesOnly<VideoPlaylistModel> | ||
24 | } | ||
25 | |||
26 | function playlistElementObjectToDBAttributes (elementObject: PlaylistElementObject, videoPlaylist: MVideoPlaylistId, video: MVideoId) { | ||
27 | return { | ||
28 | position: elementObject.position, | ||
29 | url: elementObject.id, | ||
30 | startTimestamp: elementObject.startTimestamp || null, | ||
31 | stopTimestamp: elementObject.stopTimestamp || null, | ||
32 | videoPlaylistId: videoPlaylist.id, | ||
33 | videoId: video.id | ||
34 | } as AttributesOnly<VideoPlaylistElementModel> | ||
35 | } | ||
36 | |||
37 | export { | ||
38 | playlistObjectToDBAttributes, | ||
39 | playlistElementObjectToDBAttributes | ||
40 | } | ||
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 index 000000000..ec8c01255 --- /dev/null +++ b/server/lib/activitypub/playlists/shared/url-to-object.ts | |||
@@ -0,0 +1,47 @@ | |||
1 | import { isArray } from 'lodash' | ||
2 | import { checkUrlsSameHost } from '@server/helpers/activitypub' | ||
3 | import { isPlaylistElementObjectValid, isPlaylistObjectValid } from '@server/helpers/custom-validators/activitypub/playlist' | ||
4 | import { logger, loggerTagsFactory } from '@server/helpers/logger' | ||
5 | import { doJSONRequest } from '@server/helpers/requests' | ||
6 | import { PlaylistElementObject, PlaylistObject } from '@shared/models' | ||
7 | |||
8 | async function fetchRemoteVideoPlaylist (playlistUrl: string): Promise<{ statusCode: number, playlistObject: PlaylistObject }> { | ||
9 | const lTags = loggerTagsFactory('ap', 'video-playlist', playlistUrl) | ||
10 | |||
11 | logger.info('Fetching remote playlist %s.', playlistUrl, lTags()) | ||
12 | |||
13 | const { body, statusCode } = await doJSONRequest<any>(playlistUrl, { activityPub: true }) | ||
14 | |||
15 | if (isPlaylistObjectValid(body) === false || checkUrlsSameHost(body.id, playlistUrl) !== true) { | ||
16 | logger.debug('Remote video playlist JSON is not valid.', { body, ...lTags() }) | ||
17 | return { statusCode, playlistObject: undefined } | ||
18 | } | ||
19 | |||
20 | if (!isArray(body.to)) { | ||
21 | logger.debug('Remote video playlist JSON does not have a valid audience.', { body, ...lTags() }) | ||
22 | return { statusCode, playlistObject: undefined } | ||
23 | } | ||
24 | |||
25 | return { statusCode, playlistObject: body } | ||
26 | } | ||
27 | |||
28 | async function fetchRemotePlaylistElement (elementUrl: string): Promise<{ statusCode: number, elementObject: PlaylistElementObject }> { | ||
29 | const lTags = loggerTagsFactory('ap', 'video-playlist', 'element', elementUrl) | ||
30 | |||
31 | logger.debug('Fetching remote playlist element %s.', elementUrl, lTags()) | ||
32 | |||
33 | const { body, statusCode } = await doJSONRequest<PlaylistElementObject>(elementUrl, { activityPub: true }) | ||
34 | |||
35 | if (!isPlaylistElementObjectValid(body)) throw new Error(`Invalid body in fetch playlist element ${elementUrl}`) | ||
36 | |||
37 | if (checkUrlsSameHost(body.id, elementUrl) !== true) { | ||
38 | throw new Error(`Playlist element url ${elementUrl} host is different from the AP object id ${body.id}`) | ||
39 | } | ||
40 | |||
41 | return { statusCode, elementObject: body } | ||
42 | } | ||
43 | |||
44 | export { | ||
45 | fetchRemoteVideoPlaylist, | ||
46 | fetchRemotePlaylistElement | ||
47 | } | ||