aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models
diff options
context:
space:
mode:
Diffstat (limited to 'server/models')
-rw-r--r--server/models/video/formatter/video-format-utils.ts31
-rw-r--r--server/models/video/video.ts2
-rw-r--r--server/models/view/local-video-viewer-watch-section.ts63
-rw-r--r--server/models/view/local-video-viewer.ts274
-rw-r--r--server/models/view/video-view.ts (renamed from server/models/video/video-view.ts)2
5 files changed, 355 insertions, 17 deletions
diff --git a/server/models/video/formatter/video-format-utils.ts b/server/models/video/formatter/video-format-utils.ts
index 611edf0b9..6222107d7 100644
--- a/server/models/video/formatter/video-format-utils.ts
+++ b/server/models/video/formatter/video-format-utils.ts
@@ -1,11 +1,19 @@
1import { generateMagnetUri } from '@server/helpers/webtorrent' 1import { generateMagnetUri } from '@server/helpers/webtorrent'
2import { getActivityStreamDuration } from '@server/lib/activitypub/activity'
2import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls' 3import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls'
3import { VideoViews } from '@server/lib/video-views' 4import { VideoViewsManager } from '@server/lib/views/video-views-manager'
4import { uuidToShort } from '@shared/extra-utils' 5import { uuidToShort } from '@shared/extra-utils'
5import { VideoFile, VideosCommonQueryAfterSanitize } from '@shared/models' 6import {
6import { ActivityTagObject, ActivityUrlObject, VideoObject } from '../../../../shared/models/activitypub/objects' 7 ActivityTagObject,
7import { Video, VideoDetails, VideoInclude } from '../../../../shared/models/videos' 8 ActivityUrlObject,
8import { VideoStreamingPlaylist } from '../../../../shared/models/videos/video-streaming-playlist.model' 9 Video,
10 VideoDetails,
11 VideoFile,
12 VideoInclude,
13 VideoObject,
14 VideosCommonQueryAfterSanitize,
15 VideoStreamingPlaylist
16} from '@shared/models'
9import { isArray } from '../../../helpers/custom-validators/misc' 17import { isArray } from '../../../helpers/custom-validators/misc'
10import { 18import {
11 MIMETYPES, 19 MIMETYPES,
@@ -97,7 +105,10 @@ function videoModelToFormattedJSON (video: MVideoFormattable, options: VideoForm
97 105
98 isLocal: video.isOwned(), 106 isLocal: video.isOwned(),
99 duration: video.duration, 107 duration: video.duration,
108
100 views: video.views, 109 views: video.views,
110 viewers: VideoViewsManager.Instance.getViewers(video),
111
101 likes: video.likes, 112 likes: video.likes,
102 dislikes: video.dislikes, 113 dislikes: video.dislikes,
103 thumbnailPath: video.getMiniatureStaticPath(), 114 thumbnailPath: video.getMiniatureStaticPath(),
@@ -121,10 +132,6 @@ function videoModelToFormattedJSON (video: MVideoFormattable, options: VideoForm
121 pluginData: (video as any).pluginData 132 pluginData: (video as any).pluginData
122 } 133 }
123 134
124 if (video.isLive) {
125 videoObject.viewers = VideoViews.Instance.getViewers(video)
126 }
127
128 const add = options.additionalAttributes 135 const add = options.additionalAttributes
129 if (add?.state === true) { 136 if (add?.state === true) {
130 videoObject.state = { 137 videoObject.state = {
@@ -459,11 +466,6 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
459 } 466 }
460} 467}
461 468
462function getActivityStreamDuration (duration: number) {
463 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
464 return 'PT' + duration + 'S'
465}
466
467function getCategoryLabel (id: number) { 469function getCategoryLabel (id: number) {
468 return VIDEO_CATEGORIES[id] || 'Misc' 470 return VIDEO_CATEGORIES[id] || 'Misc'
469} 471}
@@ -489,7 +491,6 @@ export {
489 videoModelToFormattedDetailsJSON, 491 videoModelToFormattedDetailsJSON,
490 videoFilesModelToFormattedJSON, 492 videoFilesModelToFormattedJSON,
491 videoModelToActivityPubObject, 493 videoModelToActivityPubObject,
492 getActivityStreamDuration,
493 494
494 guessAdditionalAttributesFromQuery, 495 guessAdditionalAttributesFromQuery,
495 496
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 8bad2a01e..13d81561a 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -106,6 +106,7 @@ import { setAsUpdated } from '../shared'
106import { UserModel } from '../user/user' 106import { UserModel } from '../user/user'
107import { UserVideoHistoryModel } from '../user/user-video-history' 107import { UserVideoHistoryModel } from '../user/user-video-history'
108import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils' 108import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
109import { VideoViewModel } from '../view/video-view'
109import { 110import {
110 videoFilesModelToFormattedJSON, 111 videoFilesModelToFormattedJSON,
111 VideoFormattingJSONOptions, 112 VideoFormattingJSONOptions,
@@ -135,7 +136,6 @@ import { VideoPlaylistElementModel } from './video-playlist-element'
135import { VideoShareModel } from './video-share' 136import { VideoShareModel } from './video-share'
136import { VideoStreamingPlaylistModel } from './video-streaming-playlist' 137import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
137import { VideoTagModel } from './video-tag' 138import { VideoTagModel } from './video-tag'
138import { VideoViewModel } from './video-view'
139 139
140export enum ScopeNames { 140export enum ScopeNames {
141 FOR_API = 'FOR_API', 141 FOR_API = 'FOR_API',
diff --git a/server/models/view/local-video-viewer-watch-section.ts b/server/models/view/local-video-viewer-watch-section.ts
new file mode 100644
index 000000000..e29bb7847
--- /dev/null
+++ b/server/models/view/local-video-viewer-watch-section.ts
@@ -0,0 +1,63 @@
1import { Transaction } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table } from 'sequelize-typescript'
3import { MLocalVideoViewerWatchSection } from '@server/types/models'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { LocalVideoViewerModel } from './local-video-viewer'
6
7@Table({
8 tableName: 'localVideoViewerWatchSection',
9 updatedAt: false,
10 indexes: [
11 {
12 fields: [ 'localVideoViewerId' ]
13 }
14 ]
15})
16export class LocalVideoViewerWatchSectionModel extends Model<Partial<AttributesOnly<LocalVideoViewerWatchSectionModel>>> {
17 @CreatedAt
18 createdAt: Date
19
20 @AllowNull(false)
21 @Column
22 watchStart: number
23
24 @AllowNull(false)
25 @Column
26 watchEnd: number
27
28 @ForeignKey(() => LocalVideoViewerModel)
29 @Column
30 localVideoViewerId: number
31
32 @BelongsTo(() => LocalVideoViewerModel, {
33 foreignKey: {
34 allowNull: false
35 },
36 onDelete: 'CASCADE'
37 })
38 LocalVideoViewer: LocalVideoViewerModel
39
40 static async bulkCreateSections (options: {
41 localVideoViewerId: number
42 watchSections: {
43 start: number
44 end: number
45 }[]
46 transaction?: Transaction
47 }) {
48 const { localVideoViewerId, watchSections, transaction } = options
49 const models: MLocalVideoViewerWatchSection[] = []
50
51 for (const section of watchSections) {
52 const model = await this.create({
53 watchStart: section.start,
54 watchEnd: section.end,
55 localVideoViewerId
56 }, { transaction })
57
58 models.push(model)
59 }
60
61 return models
62 }
63}
diff --git a/server/models/view/local-video-viewer.ts b/server/models/view/local-video-viewer.ts
new file mode 100644
index 000000000..6f8de53cd
--- /dev/null
+++ b/server/models/view/local-video-viewer.ts
@@ -0,0 +1,274 @@
1import { QueryTypes } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, HasMany, IsUUID, Model, Table } from 'sequelize-typescript'
3import { STATS_TIMESERIE } from '@server/initializers/constants'
4import { getActivityStreamDuration } from '@server/lib/activitypub/activity'
5import { MLocalVideoViewer, MLocalVideoViewerWithWatchSections, MVideo } from '@server/types/models'
6import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric, WatchActionObject } from '@shared/models'
7import { AttributesOnly } from '@shared/typescript-utils'
8import { VideoModel } from '../video/video'
9import { LocalVideoViewerWatchSectionModel } from './local-video-viewer-watch-section'
10
11@Table({
12 tableName: 'localVideoViewer',
13 updatedAt: false,
14 indexes: [
15 {
16 fields: [ 'videoId' ]
17 }
18 ]
19})
20export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVideoViewerModel>>> {
21 @CreatedAt
22 createdAt: Date
23
24 @AllowNull(false)
25 @Column(DataType.DATE)
26 startDate: Date
27
28 @AllowNull(false)
29 @Column(DataType.DATE)
30 endDate: Date
31
32 @AllowNull(false)
33 @Column
34 watchTime: number
35
36 @AllowNull(true)
37 @Column
38 country: string
39
40 @AllowNull(false)
41 @Default(DataType.UUIDV4)
42 @IsUUID(4)
43 @Column(DataType.UUID)
44 uuid: string
45
46 @AllowNull(false)
47 @Column
48 url: string
49
50 @ForeignKey(() => VideoModel)
51 @Column
52 videoId: number
53
54 @BelongsTo(() => VideoModel, {
55 foreignKey: {
56 allowNull: false
57 },
58 onDelete: 'CASCADE'
59 })
60 Video: VideoModel
61
62 @HasMany(() => LocalVideoViewerWatchSectionModel, {
63 foreignKey: {
64 allowNull: false
65 },
66 onDelete: 'cascade'
67 })
68 WatchSections: LocalVideoViewerWatchSectionModel[]
69
70 static loadByUrl (url: string): Promise<MLocalVideoViewer> {
71 return this.findOne({
72 where: {
73 url
74 }
75 })
76 }
77
78 static loadFullById (id: number): Promise<MLocalVideoViewerWithWatchSections> {
79 return this.findOne({
80 include: [
81 {
82 model: VideoModel.unscoped(),
83 required: true
84 },
85 {
86 model: LocalVideoViewerWatchSectionModel.unscoped(),
87 required: true
88 }
89 ],
90 where: {
91 id
92 }
93 })
94 }
95
96 static async getOverallStats (video: MVideo): Promise<VideoStatsOverall> {
97 const options = {
98 type: QueryTypes.SELECT as QueryTypes.SELECT,
99 replacements: { videoId: video.id }
100 }
101
102 const watchTimeQuery = `SELECT ` +
103 `SUM("localVideoViewer"."watchTime") AS "totalWatchTime", ` +
104 `AVG("localVideoViewer"."watchTime") AS "averageWatchTime" ` +
105 `FROM "localVideoViewer" ` +
106 `INNER JOIN "video" ON "video"."id" = "localVideoViewer"."videoId" ` +
107 `WHERE "videoId" = :videoId`
108
109 const watchTimePromise = LocalVideoViewerModel.sequelize.query<any>(watchTimeQuery, options)
110
111 const watchPeakQuery = `WITH "watchPeakValues" AS (
112 SELECT "startDate" AS "dateBreakpoint", 1 AS "inc"
113 FROM "localVideoViewer"
114 WHERE "videoId" = :videoId
115 UNION ALL
116 SELECT "endDate" AS "dateBreakpoint", -1 AS "inc"
117 FROM "localVideoViewer"
118 WHERE "videoId" = :videoId
119 )
120 SELECT "dateBreakpoint", "concurrent"
121 FROM (
122 SELECT "dateBreakpoint", SUM(SUM("inc")) OVER (ORDER BY "dateBreakpoint") AS "concurrent"
123 FROM "watchPeakValues"
124 GROUP BY "dateBreakpoint"
125 ) tmp
126 ORDER BY "concurrent" DESC
127 FETCH FIRST 1 ROW ONLY`
128 const watchPeakPromise = LocalVideoViewerModel.sequelize.query<any>(watchPeakQuery, options)
129
130 const commentsQuery = `SELECT COUNT(*) AS comments FROM "videoComment" WHERE "videoId" = :videoId`
131 const commentsPromise = LocalVideoViewerModel.sequelize.query<any>(commentsQuery, options)
132
133 const countriesQuery = `SELECT country, COUNT(country) as viewers ` +
134 `FROM "localVideoViewer" ` +
135 `WHERE "videoId" = :videoId AND country IS NOT NULL ` +
136 `GROUP BY country ` +
137 `ORDER BY viewers DESC`
138 const countriesPromise = LocalVideoViewerModel.sequelize.query<any>(countriesQuery, options)
139
140 const [ rowsWatchTime, rowsWatchPeak, rowsComment, rowsCountries ] = await Promise.all([
141 watchTimePromise,
142 watchPeakPromise,
143 commentsPromise,
144 countriesPromise
145 ])
146
147 return {
148 totalWatchTime: rowsWatchTime.length !== 0
149 ? Math.round(rowsWatchTime[0].totalWatchTime) || 0
150 : 0,
151 averageWatchTime: rowsWatchTime.length !== 0
152 ? Math.round(rowsWatchTime[0].averageWatchTime) || 0
153 : 0,
154
155 viewersPeak: rowsWatchPeak.length !== 0
156 ? parseInt(rowsWatchPeak[0].concurrent) || 0
157 : 0,
158 viewersPeakDate: rowsWatchPeak.length !== 0
159 ? rowsWatchPeak[0].dateBreakpoint || null
160 : null,
161
162 views: video.views,
163 likes: video.likes,
164 dislikes: video.dislikes,
165
166 comments: rowsComment.length !== 0
167 ? parseInt(rowsComment[0].comments) || 0
168 : 0,
169
170 countries: rowsCountries.map(r => ({
171 isoCode: r.country,
172 viewers: r.viewers
173 }))
174 }
175 }
176
177 static async getRetentionStats (video: MVideo): Promise<VideoStatsRetention> {
178 const step = Math.max(Math.round(video.duration / 100), 1)
179
180 const query = `WITH "total" AS (SELECT COUNT(*) AS viewers FROM "localVideoViewer" WHERE "videoId" = :videoId) ` +
181 `SELECT serie AS "second", ` +
182 `(COUNT("localVideoViewer".id)::float / (SELECT GREATEST("total"."viewers", 1) FROM "total")) AS "retention" ` +
183 `FROM generate_series(0, ${video.duration}, ${step}) serie ` +
184 `LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId ` +
185 `AND EXISTS (` +
186 `SELECT 1 FROM "localVideoViewerWatchSection" ` +
187 `WHERE "localVideoViewer"."id" = "localVideoViewerWatchSection"."localVideoViewerId" ` +
188 `AND serie >= "localVideoViewerWatchSection"."watchStart" ` +
189 `AND serie <= "localVideoViewerWatchSection"."watchEnd"` +
190 `)` +
191 `GROUP BY serie ` +
192 `ORDER BY serie ASC`
193
194 const queryOptions = {
195 type: QueryTypes.SELECT as QueryTypes.SELECT,
196 replacements: { videoId: video.id }
197 }
198
199 const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
200
201 return {
202 data: rows.map(r => ({
203 second: r.second,
204 retentionPercent: parseFloat(r.retention) * 100
205 }))
206 }
207 }
208
209 static async getTimeserieStats (options: {
210 video: MVideo
211 metric: VideoStatsTimeserieMetric
212 }): Promise<VideoStatsTimeserie> {
213 const { video, metric } = options
214
215 const selectMetrics: { [ id in VideoStatsTimeserieMetric ]: string } = {
216 viewers: 'COUNT("localVideoViewer"."id")',
217 aggregateWatchTime: 'SUM("localVideoViewer"."watchTime")'
218 }
219
220 const query = `WITH days AS ( ` +
221 `SELECT (current_date::timestamp - (serie || ' days')::interval)::timestamptz AS day
222 FROM generate_series(0, ${STATS_TIMESERIE.MAX_DAYS - 1}) serie` +
223 `) ` +
224 `SELECT days.day AS date, COALESCE(${selectMetrics[metric]}, 0) AS value ` +
225 `FROM days ` +
226 `LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId ` +
227 `AND date_trunc('day', "localVideoViewer"."startDate") = date_trunc('day', days.day) ` +
228 `GROUP BY day ` +
229 `ORDER BY day `
230
231 const queryOptions = {
232 type: QueryTypes.SELECT as QueryTypes.SELECT,
233 replacements: { videoId: video.id }
234 }
235
236 const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
237
238 return {
239 data: rows.map(r => ({
240 date: r.date,
241 value: parseInt(r.value)
242 }))
243 }
244 }
245
246 toActivityPubObject (this: MLocalVideoViewerWithWatchSections): WatchActionObject {
247 const location = this.country
248 ? {
249 location: {
250 addressCountry: this.country
251 }
252 }
253 : {}
254
255 return {
256 id: this.url,
257 type: 'WatchAction',
258 duration: getActivityStreamDuration(this.watchTime),
259 startTime: this.startDate.toISOString(),
260 endTime: this.endDate.toISOString(),
261
262 object: this.Video.url,
263 uuid: this.uuid,
264 actionStatus: 'CompletedActionStatus',
265
266 watchSections: this.WatchSections.map(w => ({
267 startTimestamp: w.watchStart,
268 endTimestamp: w.watchEnd
269 })),
270
271 ...location
272 }
273 }
274}
diff --git a/server/models/video/video-view.ts b/server/models/view/video-view.ts
index d72df100f..df462e631 100644
--- a/server/models/video/video-view.ts
+++ b/server/models/view/video-view.ts
@@ -1,7 +1,7 @@
1import { literal, Op } from 'sequelize' 1import { literal, Op } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table } from 'sequelize-typescript' 2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table } from 'sequelize-typescript'
3import { AttributesOnly } from '@shared/typescript-utils' 3import { AttributesOnly } from '@shared/typescript-utils'
4import { VideoModel } from './video' 4import { VideoModel } from '../video/video'
5 5
6@Table({ 6@Table({
7 tableName: 'videoView', 7 tableName: 'videoView',