aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/lib/activitypub
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2022-03-24 13:36:47 +0100
committerChocobozzz <chocobozzz@cpy.re>2022-04-15 09:49:35 +0200
commitb211106695bb82f6c32e53306081b5262c3d109d (patch)
treefa187de1c33b0956665f5362e29af6b0f6d8bb57 /server/lib/activitypub
parent69d48ee30c9d47cddf0c3c047dc99a99dcb6e894 (diff)
downloadPeerTube-b211106695bb82f6c32e53306081b5262c3d109d.tar.gz
PeerTube-b211106695bb82f6c32e53306081b5262c3d109d.tar.zst
PeerTube-b211106695bb82f6c32e53306081b5262c3d109d.zip
Support video views/viewers stats in server
* Add "currentTime" and "event" body params to view endpoint * Merge watching and view endpoints * Introduce WatchAction AP activity * Add tables to store viewer information of local videos * Add endpoints to fetch video views/viewers stats of local videos * Refactor views/viewers handlers * Support "views" and "viewers" counters for both VOD and live videos
Diffstat (limited to 'server/lib/activitypub')
-rw-r--r--server/lib/activitypub/activity.ts13
-rw-r--r--server/lib/activitypub/context.ts23
-rw-r--r--server/lib/activitypub/local-video-viewer.ts42
-rw-r--r--server/lib/activitypub/process/process-create.ts21
-rw-r--r--server/lib/activitypub/process/process-view.ts4
-rw-r--r--server/lib/activitypub/send/send-create.ts17
-rw-r--r--server/lib/activitypub/send/send-view.ts51
-rw-r--r--server/lib/activitypub/url.ts6
-rw-r--r--server/lib/activitypub/videos/shared/object-to-model-attributes.ts4
9 files changed, 156 insertions, 25 deletions
diff --git a/server/lib/activitypub/activity.ts b/server/lib/activitypub/activity.ts
index cccb7b1c1..e6cec1ba7 100644
--- a/server/lib/activitypub/activity.ts
+++ b/server/lib/activitypub/activity.ts
@@ -4,6 +4,17 @@ function getAPId (object: string | { id: string }) {
4 return object.id 4 return object.id
5} 5}
6 6
7function getActivityStreamDuration (duration: number) {
8 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
9 return 'PT' + duration + 'S'
10}
11
12function getDurationFromActivityStream (duration: string) {
13 return parseInt(duration.replace(/[^\d]+/, ''))
14}
15
7export { 16export {
8 getAPId 17 getAPId,
18 getActivityStreamDuration,
19 getDurationFromActivityStream
9} 20}
diff --git a/server/lib/activitypub/context.ts b/server/lib/activitypub/context.ts
index 3bc40e2aa..b452cf9b3 100644
--- a/server/lib/activitypub/context.ts
+++ b/server/lib/activitypub/context.ts
@@ -15,7 +15,7 @@ export {
15 15
16type ContextValue = { [ id: string ]: (string | { '@type': string, '@id': string }) } 16type ContextValue = { [ id: string ]: (string | { '@type': string, '@id': string }) }
17 17
18const contextStore = { 18const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string })[] } = {
19 Video: buildContext({ 19 Video: buildContext({
20 Hashtag: 'as:Hashtag', 20 Hashtag: 'as:Hashtag',
21 uuid: 'sc:identifier', 21 uuid: 'sc:identifier',
@@ -109,7 +109,8 @@ const contextStore = {
109 stopTimestamp: { 109 stopTimestamp: {
110 '@type': 'sc:Number', 110 '@type': 'sc:Number',
111 '@id': 'pt:stopTimestamp' 111 '@id': 'pt:stopTimestamp'
112 } 112 },
113 uuid: 'sc:identifier'
113 }), 114 }),
114 115
115 CacheFile: buildContext({ 116 CacheFile: buildContext({
@@ -128,6 +129,24 @@ const contextStore = {
128 } 129 }
129 }), 130 }),
130 131
132 WatchAction: buildContext({
133 WatchAction: 'sc:WatchAction',
134 startTimestamp: {
135 '@type': 'sc:Number',
136 '@id': 'pt:startTimestamp'
137 },
138 stopTimestamp: {
139 '@type': 'sc:Number',
140 '@id': 'pt:stopTimestamp'
141 },
142 watchSection: {
143 '@type': 'sc:Number',
144 '@id': 'pt:stopTimestamp'
145 },
146 uuid: 'sc:identifier'
147 }),
148
149 Collection: buildContext(),
131 Follow: buildContext(), 150 Follow: buildContext(),
132 Reject: buildContext(), 151 Reject: buildContext(),
133 Accept: buildContext(), 152 Accept: buildContext(),
diff --git a/server/lib/activitypub/local-video-viewer.ts b/server/lib/activitypub/local-video-viewer.ts
new file mode 100644
index 000000000..738083adc
--- /dev/null
+++ b/server/lib/activitypub/local-video-viewer.ts
@@ -0,0 +1,42 @@
1import { Transaction } from 'sequelize'
2import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer'
3import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section'
4import { MVideo } from '@server/types/models'
5import { WatchActionObject } from '@shared/models'
6import { getDurationFromActivityStream } from './activity'
7
8async function createOrUpdateLocalVideoViewer (watchAction: WatchActionObject, video: MVideo, t: Transaction) {
9 const stats = await LocalVideoViewerModel.loadByUrl(watchAction.id)
10 if (stats) await stats.destroy({ transaction: t })
11
12 const localVideoViewer = await LocalVideoViewerModel.create({
13 url: watchAction.id,
14 uuid: watchAction.uuid,
15
16 watchTime: getDurationFromActivityStream(watchAction.duration),
17
18 startDate: new Date(watchAction.startTime),
19 endDate: new Date(watchAction.endTime),
20
21 country: watchAction.location
22 ? watchAction.location.addressCountry
23 : null,
24
25 videoId: video.id
26 })
27
28 await LocalVideoViewerWatchSectionModel.bulkCreateSections({
29 localVideoViewerId: localVideoViewer.id,
30
31 watchSections: watchAction.watchSections.map(s => ({
32 start: s.startTimestamp,
33 end: s.endTimestamp
34 }))
35 })
36}
37
38// ---------------------------------------------------------------------------
39
40export {
41 createOrUpdateLocalVideoViewer
42}
diff --git a/server/lib/activitypub/process/process-create.ts b/server/lib/activitypub/process/process-create.ts
index b5b1a0feb..3e7931bb2 100644
--- a/server/lib/activitypub/process/process-create.ts
+++ b/server/lib/activitypub/process/process-create.ts
@@ -1,6 +1,7 @@
1import { isBlockedByServerOrAccount } from '@server/lib/blocklist' 1import { isBlockedByServerOrAccount } from '@server/lib/blocklist'
2import { isRedundancyAccepted } from '@server/lib/redundancy' 2import { isRedundancyAccepted } from '@server/lib/redundancy'
3import { ActivityCreate, CacheFileObject, PlaylistObject, VideoCommentObject, VideoObject } from '@shared/models' 3import { VideoModel } from '@server/models/video/video'
4import { ActivityCreate, CacheFileObject, PlaylistObject, VideoCommentObject, VideoObject, WatchActionObject } from '@shared/models'
4import { retryTransactionWrapper } from '../../../helpers/database-utils' 5import { retryTransactionWrapper } from '../../../helpers/database-utils'
5import { logger } from '../../../helpers/logger' 6import { logger } from '../../../helpers/logger'
6import { sequelizeTypescript } from '../../../initializers/database' 7import { sequelizeTypescript } from '../../../initializers/database'
@@ -8,6 +9,7 @@ import { APProcessorOptions } from '../../../types/activitypub-processor.model'
8import { MActorSignature, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../../types/models' 9import { MActorSignature, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../../types/models'
9import { Notifier } from '../../notifier' 10import { Notifier } from '../../notifier'
10import { createOrUpdateCacheFile } from '../cache-file' 11import { createOrUpdateCacheFile } from '../cache-file'
12import { createOrUpdateLocalVideoViewer } from '../local-video-viewer'
11import { createOrUpdateVideoPlaylist } from '../playlists' 13import { createOrUpdateVideoPlaylist } from '../playlists'
12import { forwardVideoRelatedActivity } from '../send/shared/send-utils' 14import { forwardVideoRelatedActivity } from '../send/shared/send-utils'
13import { resolveThread } from '../video-comments' 15import { resolveThread } from '../video-comments'
@@ -32,6 +34,10 @@ async function processCreateActivity (options: APProcessorOptions<ActivityCreate
32 return retryTransactionWrapper(processCreateVideoComment, activity, byActor, notify) 34 return retryTransactionWrapper(processCreateVideoComment, activity, byActor, notify)
33 } 35 }
34 36
37 if (activityType === 'WatchAction') {
38 return retryTransactionWrapper(processCreateWatchAction, activity)
39 }
40
35 if (activityType === 'CacheFile') { 41 if (activityType === 'CacheFile') {
36 return retryTransactionWrapper(processCreateCacheFile, activity, byActor) 42 return retryTransactionWrapper(processCreateCacheFile, activity, byActor)
37 } 43 }
@@ -81,6 +87,19 @@ async function processCreateCacheFile (activity: ActivityCreate, byActor: MActor
81 } 87 }
82} 88}
83 89
90async function processCreateWatchAction (activity: ActivityCreate) {
91 const watchAction = activity.object as WatchActionObject
92
93 if (watchAction.actionStatus !== 'CompletedActionStatus') return
94
95 const video = await VideoModel.loadByUrl(watchAction.object)
96 if (video.remote) return
97
98 await sequelizeTypescript.transaction(async t => {
99 return createOrUpdateLocalVideoViewer(watchAction, video, t)
100 })
101}
102
84async function processCreateVideoComment (activity: ActivityCreate, byActor: MActorSignature, notify: boolean) { 103async function processCreateVideoComment (activity: ActivityCreate, byActor: MActorSignature, notify: boolean) {
85 const commentObject = activity.object as VideoCommentObject 104 const commentObject = activity.object as VideoCommentObject
86 const byAccount = byActor.Account 105 const byAccount = byActor.Account
diff --git a/server/lib/activitypub/process/process-view.ts b/server/lib/activitypub/process/process-view.ts
index c59940164..bad079843 100644
--- a/server/lib/activitypub/process/process-view.ts
+++ b/server/lib/activitypub/process/process-view.ts
@@ -1,4 +1,4 @@
1import { VideoViews } from '@server/lib/video-views' 1import { VideoViewsManager } from '@server/lib/views/video-views-manager'
2import { ActivityView } from '../../../../shared/models/activitypub' 2import { ActivityView } from '../../../../shared/models/activitypub'
3import { APProcessorOptions } from '../../../types/activitypub-processor.model' 3import { APProcessorOptions } from '../../../types/activitypub-processor.model'
4import { MActorSignature } from '../../../types/models' 4import { MActorSignature } from '../../../types/models'
@@ -32,7 +32,7 @@ async function processCreateView (activity: ActivityView, byActor: MActorSignatu
32 ? new Date(activity.expires) 32 ? new Date(activity.expires)
33 : undefined 33 : undefined
34 34
35 await VideoViews.Instance.processView({ video, ip: null, viewerExpires }) 35 await VideoViewsManager.Instance.processRemoteView({ video, viewerExpires })
36 36
37 if (video.isOwned()) { 37 if (video.isOwned()) {
38 // Forward the view but don't resend the activity to the sender 38 // Forward the view but don't resend the activity to the sender
diff --git a/server/lib/activitypub/send/send-create.ts b/server/lib/activitypub/send/send-create.ts
index 5d8763495..7c3a6bdd0 100644
--- a/server/lib/activitypub/send/send-create.ts
+++ b/server/lib/activitypub/send/send-create.ts
@@ -6,6 +6,7 @@ import { VideoCommentModel } from '../../../models/video/video-comment'
6import { 6import {
7 MActorLight, 7 MActorLight,
8 MCommentOwnerVideo, 8 MCommentOwnerVideo,
9 MLocalVideoViewerWithWatchSections,
9 MVideoAccountLight, 10 MVideoAccountLight,
10 MVideoAP, 11 MVideoAP,
11 MVideoPlaylistFull, 12 MVideoPlaylistFull,
@@ -19,6 +20,7 @@ import {
19 getActorsInvolvedInVideo, 20 getActorsInvolvedInVideo,
20 getAudienceFromFollowersOf, 21 getAudienceFromFollowersOf,
21 getVideoCommentAudience, 22 getVideoCommentAudience,
23 sendVideoActivityToOrigin,
22 sendVideoRelatedActivity, 24 sendVideoRelatedActivity,
23 unicastTo 25 unicastTo
24} from './shared' 26} from './shared'
@@ -61,6 +63,18 @@ async function sendCreateCacheFile (
61 }) 63 })
62} 64}
63 65
66async function sendCreateWatchAction (stats: MLocalVideoViewerWithWatchSections, transaction: Transaction) {
67 logger.info('Creating job to send create watch action %s.', stats.url, lTags(stats.uuid))
68
69 const byActor = await getServerActor()
70
71 const activityBuilder = (audience: ActivityAudience) => {
72 return buildCreateActivity(stats.url, byActor, stats.toActivityPubObject(), audience)
73 }
74
75 return sendVideoActivityToOrigin(activityBuilder, { byActor, video: stats.Video, transaction, contextType: 'WatchAction' })
76}
77
64async function sendCreateVideoPlaylist (playlist: MVideoPlaylistFull, transaction: Transaction) { 78async function sendCreateVideoPlaylist (playlist: MVideoPlaylistFull, transaction: Transaction) {
65 if (playlist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined 79 if (playlist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined
66 80
@@ -175,7 +189,8 @@ export {
175 buildCreateActivity, 189 buildCreateActivity,
176 sendCreateVideoComment, 190 sendCreateVideoComment,
177 sendCreateVideoPlaylist, 191 sendCreateVideoPlaylist,
178 sendCreateCacheFile 192 sendCreateCacheFile,
193 sendCreateWatchAction
179} 194}
180 195
181// --------------------------------------------------------------------------- 196// ---------------------------------------------------------------------------
diff --git a/server/lib/activitypub/send/send-view.ts b/server/lib/activitypub/send/send-view.ts
index 1f97307b9..1088bf258 100644
--- a/server/lib/activitypub/send/send-view.ts
+++ b/server/lib/activitypub/send/send-view.ts
@@ -1,27 +1,49 @@
1import { Transaction } from 'sequelize' 1import { Transaction } from 'sequelize'
2import { VideoViews } from '@server/lib/video-views' 2import { VideoViewsManager } from '@server/lib/views/video-views-manager'
3import { MActorAudience, MVideoImmutable, MVideoUrl } from '@server/types/models' 3import { MActorAudience, MActorLight, MVideoImmutable, MVideoUrl } from '@server/types/models'
4import { ActivityAudience, ActivityView } from '@shared/models' 4import { ActivityAudience, ActivityView } from '@shared/models'
5import { logger } from '../../../helpers/logger' 5import { logger } from '../../../helpers/logger'
6import { ActorModel } from '../../../models/actor/actor'
7import { audiencify, getAudience } from '../audience' 6import { audiencify, getAudience } from '../audience'
8import { getLocalVideoViewActivityPubUrl } from '../url' 7import { getLocalVideoViewActivityPubUrl } from '../url'
9import { sendVideoRelatedActivity } from './shared/send-utils' 8import { sendVideoRelatedActivity } from './shared/send-utils'
10 9
11async function sendView (byActor: ActorModel, video: MVideoImmutable, t: Transaction) { 10type ViewType = 'view' | 'viewer'
12 logger.info('Creating job to send view of %s.', video.url) 11
12async function sendView (options: {
13 byActor: MActorLight
14 type: ViewType
15 video: MVideoImmutable
16 transaction?: Transaction
17}) {
18 const { byActor, type, video, transaction } = options
19
20 logger.info('Creating job to send %s of %s.', type, video.url)
13 21
14 const activityBuilder = (audience: ActivityAudience) => { 22 const activityBuilder = (audience: ActivityAudience) => {
15 const url = getLocalVideoViewActivityPubUrl(byActor, video) 23 const url = getLocalVideoViewActivityPubUrl(byActor, video)
16 24
17 return buildViewActivity(url, byActor, video, audience) 25 return buildViewActivity({ url, byActor, video, audience, type })
18 } 26 }
19 27
20 return sendVideoRelatedActivity(activityBuilder, { byActor, video, transaction: t, contextType: 'View' }) 28 return sendVideoRelatedActivity(activityBuilder, { byActor, video, transaction, contextType: 'View' })
21} 29}
22 30
23function buildViewActivity (url: string, byActor: MActorAudience, video: MVideoUrl, audience?: ActivityAudience): ActivityView { 31// ---------------------------------------------------------------------------
24 if (!audience) audience = getAudience(byActor) 32
33export {
34 sendView
35}
36
37// ---------------------------------------------------------------------------
38
39function buildViewActivity (options: {
40 url: string
41 byActor: MActorAudience
42 video: MVideoUrl
43 type: ViewType
44 audience?: ActivityAudience
45}): ActivityView {
46 const { url, byActor, type, video, audience = getAudience(byActor) } = options
25 47
26 return audiencify( 48 return audiencify(
27 { 49 {
@@ -29,14 +51,11 @@ function buildViewActivity (url: string, byActor: MActorAudience, video: MVideoU
29 type: 'View' as 'View', 51 type: 'View' as 'View',
30 actor: byActor.url, 52 actor: byActor.url,
31 object: video.url, 53 object: video.url,
32 expires: new Date(VideoViews.Instance.buildViewerExpireTime()).toISOString() 54
55 expires: type === 'viewer'
56 ? new Date(VideoViewsManager.Instance.buildViewerExpireTime()).toISOString()
57 : undefined
33 }, 58 },
34 audience 59 audience
35 ) 60 )
36} 61}
37
38// ---------------------------------------------------------------------------
39
40export {
41 sendView
42}
diff --git a/server/lib/activitypub/url.ts b/server/lib/activitypub/url.ts
index 50be4fac9..8443fef4c 100644
--- a/server/lib/activitypub/url.ts
+++ b/server/lib/activitypub/url.ts
@@ -7,6 +7,7 @@ import {
7 MActorId, 7 MActorId,
8 MActorUrl, 8 MActorUrl,
9 MCommentId, 9 MCommentId,
10 MLocalVideoViewer,
10 MVideoId, 11 MVideoId,
11 MVideoPlaylistElement, 12 MVideoPlaylistElement,
12 MVideoUrl, 13 MVideoUrl,
@@ -59,6 +60,10 @@ function getLocalVideoViewActivityPubUrl (byActor: MActorUrl, video: MVideoId) {
59 return byActor.url + '/views/videos/' + video.id + '/' + new Date().toISOString() 60 return byActor.url + '/views/videos/' + video.id + '/' + new Date().toISOString()
60} 61}
61 62
63function getLocalVideoViewerActivityPubUrl (stats: MLocalVideoViewer) {
64 return WEBSERVER.URL + '/videos/local-viewer/' + stats.uuid
65}
66
62function getVideoLikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) { 67function getVideoLikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) {
63 return byActor.url + '/likes/' + video.id 68 return byActor.url + '/likes/' + video.id
64} 69}
@@ -167,6 +172,7 @@ export {
167 getLocalVideoCommentsActivityPubUrl, 172 getLocalVideoCommentsActivityPubUrl,
168 getLocalVideoLikesActivityPubUrl, 173 getLocalVideoLikesActivityPubUrl,
169 getLocalVideoDislikesActivityPubUrl, 174 getLocalVideoDislikesActivityPubUrl,
175 getLocalVideoViewerActivityPubUrl,
170 176
171 getAbuseTargetUrl, 177 getAbuseTargetUrl,
172 checkUrlsSameHost, 178 checkUrlsSameHost,
diff --git a/server/lib/activitypub/videos/shared/object-to-model-attributes.ts b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
index c97217669..f02b9cba6 100644
--- a/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
+++ b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
@@ -24,6 +24,7 @@ import {
24 VideoPrivacy, 24 VideoPrivacy,
25 VideoStreamingPlaylistType 25 VideoStreamingPlaylistType
26} from '@shared/models' 26} from '@shared/models'
27import { getDurationFromActivityStream } from '../../activity'
27 28
28function getThumbnailFromIcons (videoObject: VideoObject) { 29function getThumbnailFromIcons (videoObject: VideoObject) {
29 let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth) 30 let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth)
@@ -170,7 +171,6 @@ function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: Vi
170 ? VideoPrivacy.PUBLIC 171 ? VideoPrivacy.PUBLIC
171 : VideoPrivacy.UNLISTED 172 : VideoPrivacy.UNLISTED
172 173
173 const duration = videoObject.duration.replace(/[^\d]+/, '')
174 const language = videoObject.language?.identifier 174 const language = videoObject.language?.identifier
175 175
176 const category = videoObject.category 176 const category = videoObject.category
@@ -200,7 +200,7 @@ function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: Vi
200 isLive: videoObject.isLiveBroadcast, 200 isLive: videoObject.isLiveBroadcast,
201 state: videoObject.state, 201 state: videoObject.state,
202 channelId: videoChannel.id, 202 channelId: videoChannel.id,
203 duration: parseInt(duration, 10), 203 duration: getDurationFromActivityStream(videoObject.duration),
204 createdAt: new Date(videoObject.published), 204 createdAt: new Date(videoObject.published),
205 publishedAt: new Date(videoObject.published), 205 publishedAt: new Date(videoObject.published),
206 206