local-video-viewer.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import { QueryTypes } from 'sequelize'
  2. import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, HasMany, IsUUID, Model, Table } from 'sequelize-typescript'
  3. import { getActivityStreamDuration } from '@server/lib/activitypub/activity'
  4. import { buildGroupByAndBoundaries } from '@server/lib/timeserie'
  5. import { MLocalVideoViewer, MLocalVideoViewerWithWatchSections, MVideo } from '@server/types/models'
  6. import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric, WatchActionObject } from '@shared/models'
  7. import { AttributesOnly } from '@shared/typescript-utils'
  8. import { VideoModel } from '../video/video'
  9. import { LocalVideoViewerWatchSectionModel } from './local-video-viewer-watch-section'
  10. /**
  11. *
  12. * Aggregate viewers of local videos only to display statistics to video owners
  13. * A viewer is a user that watched one or multiple sections of a specific video inside a time window
  14. *
  15. */
  16. @Table({
  17. tableName: 'localVideoViewer',
  18. updatedAt: false,
  19. indexes: [
  20. {
  21. fields: [ 'videoId' ]
  22. }
  23. ]
  24. })
  25. export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVideoViewerModel>>> {
  26. @CreatedAt
  27. createdAt: Date
  28. @AllowNull(false)
  29. @Column(DataType.DATE)
  30. startDate: Date
  31. @AllowNull(false)
  32. @Column(DataType.DATE)
  33. endDate: Date
  34. @AllowNull(false)
  35. @Column
  36. watchTime: number
  37. @AllowNull(true)
  38. @Column
  39. country: string
  40. @AllowNull(false)
  41. @Default(DataType.UUIDV4)
  42. @IsUUID(4)
  43. @Column(DataType.UUID)
  44. uuid: string
  45. @AllowNull(false)
  46. @Column
  47. url: string
  48. @ForeignKey(() => VideoModel)
  49. @Column
  50. videoId: number
  51. @BelongsTo(() => VideoModel, {
  52. foreignKey: {
  53. allowNull: false
  54. },
  55. onDelete: 'CASCADE'
  56. })
  57. Video: VideoModel
  58. @HasMany(() => LocalVideoViewerWatchSectionModel, {
  59. foreignKey: {
  60. allowNull: false
  61. },
  62. onDelete: 'cascade'
  63. })
  64. WatchSections: LocalVideoViewerWatchSectionModel[]
  65. static loadByUrl (url: string): Promise<MLocalVideoViewer> {
  66. return this.findOne({
  67. where: {
  68. url
  69. }
  70. })
  71. }
  72. static loadFullById (id: number): Promise<MLocalVideoViewerWithWatchSections> {
  73. return this.findOne({
  74. include: [
  75. {
  76. model: VideoModel.unscoped(),
  77. required: true
  78. },
  79. {
  80. model: LocalVideoViewerWatchSectionModel.unscoped(),
  81. required: true
  82. }
  83. ],
  84. where: {
  85. id
  86. }
  87. })
  88. }
  89. static async getOverallStats (options: {
  90. video: MVideo
  91. startDate?: string
  92. endDate?: string
  93. }): Promise<VideoStatsOverall> {
  94. const { video, startDate, endDate } = options
  95. const queryOptions = {
  96. type: QueryTypes.SELECT as QueryTypes.SELECT,
  97. replacements: { videoId: video.id } as any
  98. }
  99. let dateWhere = ''
  100. if (startDate) {
  101. dateWhere += ' AND "localVideoViewer"."startDate" >= :startDate'
  102. queryOptions.replacements.startDate = startDate
  103. }
  104. if (endDate) {
  105. dateWhere += ' AND "localVideoViewer"."endDate" <= :endDate'
  106. queryOptions.replacements.endDate = endDate
  107. }
  108. const watchTimeQuery = `SELECT ` +
  109. `COUNT("localVideoViewer"."id") AS "totalViewers", ` +
  110. `SUM("localVideoViewer"."watchTime") AS "totalWatchTime", ` +
  111. `AVG("localVideoViewer"."watchTime") AS "averageWatchTime" ` +
  112. `FROM "localVideoViewer" ` +
  113. `INNER JOIN "video" ON "video"."id" = "localVideoViewer"."videoId" ` +
  114. `WHERE "videoId" = :videoId ${dateWhere}`
  115. const watchTimePromise = LocalVideoViewerModel.sequelize.query<any>(watchTimeQuery, queryOptions)
  116. const watchPeakQuery = `WITH "watchPeakValues" AS (
  117. SELECT "startDate" AS "dateBreakpoint", 1 AS "inc"
  118. FROM "localVideoViewer"
  119. WHERE "videoId" = :videoId ${dateWhere}
  120. UNION ALL
  121. SELECT "endDate" AS "dateBreakpoint", -1 AS "inc"
  122. FROM "localVideoViewer"
  123. WHERE "videoId" = :videoId ${dateWhere}
  124. )
  125. SELECT "dateBreakpoint", "concurrent"
  126. FROM (
  127. SELECT "dateBreakpoint", SUM(SUM("inc")) OVER (ORDER BY "dateBreakpoint") AS "concurrent"
  128. FROM "watchPeakValues"
  129. GROUP BY "dateBreakpoint"
  130. ) tmp
  131. ORDER BY "concurrent" DESC
  132. FETCH FIRST 1 ROW ONLY`
  133. const watchPeakPromise = LocalVideoViewerModel.sequelize.query<any>(watchPeakQuery, queryOptions)
  134. const countriesQuery = `SELECT country, COUNT(country) as viewers ` +
  135. `FROM "localVideoViewer" ` +
  136. `WHERE "videoId" = :videoId AND country IS NOT NULL ${dateWhere} ` +
  137. `GROUP BY country ` +
  138. `ORDER BY viewers DESC`
  139. const countriesPromise = LocalVideoViewerModel.sequelize.query<any>(countriesQuery, queryOptions)
  140. const [ rowsWatchTime, rowsWatchPeak, rowsCountries ] = await Promise.all([
  141. watchTimePromise,
  142. watchPeakPromise,
  143. countriesPromise
  144. ])
  145. const viewersPeak = rowsWatchPeak.length !== 0
  146. ? parseInt(rowsWatchPeak[0].concurrent) || 0
  147. : 0
  148. return {
  149. totalWatchTime: rowsWatchTime.length !== 0
  150. ? Math.round(rowsWatchTime[0].totalWatchTime) || 0
  151. : 0,
  152. averageWatchTime: rowsWatchTime.length !== 0
  153. ? Math.round(rowsWatchTime[0].averageWatchTime) || 0
  154. : 0,
  155. totalViewers: rowsWatchTime.length !== 0
  156. ? Math.round(rowsWatchTime[0].totalViewers) || 0
  157. : 0,
  158. viewersPeak,
  159. viewersPeakDate: rowsWatchPeak.length !== 0 && viewersPeak !== 0
  160. ? rowsWatchPeak[0].dateBreakpoint || null
  161. : null,
  162. countries: rowsCountries.map(r => ({
  163. isoCode: r.country,
  164. viewers: r.viewers
  165. }))
  166. }
  167. }
  168. static async getRetentionStats (video: MVideo): Promise<VideoStatsRetention> {
  169. const step = Math.max(Math.round(video.duration / 100), 1)
  170. const query = `WITH "total" AS (SELECT COUNT(*) AS viewers FROM "localVideoViewer" WHERE "videoId" = :videoId) ` +
  171. `SELECT serie AS "second", ` +
  172. `(COUNT("localVideoViewer".id)::float / (SELECT GREATEST("total"."viewers", 1) FROM "total")) AS "retention" ` +
  173. `FROM generate_series(0, ${video.duration}, ${step}) serie ` +
  174. `LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId ` +
  175. `AND EXISTS (` +
  176. `SELECT 1 FROM "localVideoViewerWatchSection" ` +
  177. `WHERE "localVideoViewer"."id" = "localVideoViewerWatchSection"."localVideoViewerId" ` +
  178. `AND serie >= "localVideoViewerWatchSection"."watchStart" ` +
  179. `AND serie <= "localVideoViewerWatchSection"."watchEnd"` +
  180. `)` +
  181. `GROUP BY serie ` +
  182. `ORDER BY serie ASC`
  183. const queryOptions = {
  184. type: QueryTypes.SELECT as QueryTypes.SELECT,
  185. replacements: { videoId: video.id }
  186. }
  187. const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
  188. return {
  189. data: rows.map(r => ({
  190. second: r.second,
  191. retentionPercent: parseFloat(r.retention) * 100
  192. }))
  193. }
  194. }
  195. static async getTimeserieStats (options: {
  196. video: MVideo
  197. metric: VideoStatsTimeserieMetric
  198. startDate: string
  199. endDate: string
  200. }): Promise<VideoStatsTimeserie> {
  201. const { video, metric } = options
  202. const { groupInterval, startDate, endDate } = buildGroupByAndBoundaries(options.startDate, options.endDate)
  203. const selectMetrics: { [ id in VideoStatsTimeserieMetric ]: string } = {
  204. viewers: 'COUNT("localVideoViewer"."id")',
  205. aggregateWatchTime: 'SUM("localVideoViewer"."watchTime")'
  206. }
  207. const intervalWhere: { [ id in VideoStatsTimeserieMetric ]: string } = {
  208. // Viewer is still in the interval. Overlap algorithm
  209. viewers: '"localVideoViewer"."startDate" <= "intervals"."endDate" ' +
  210. 'AND "localVideoViewer"."endDate" >= "intervals"."startDate"',
  211. // We do an aggregation, so only sum things once. Arbitrary we use the end date for that purpose
  212. aggregateWatchTime: '"localVideoViewer"."endDate" >= "intervals"."startDate" ' +
  213. 'AND "localVideoViewer"."endDate" <= "intervals"."endDate"'
  214. }
  215. const query = `WITH "intervals" AS (
  216. SELECT
  217. "time" AS "startDate", "time" + :groupInterval::interval as "endDate"
  218. FROM
  219. generate_series(:startDate::timestamptz, :endDate::timestamptz, :groupInterval::interval) serie("time")
  220. )
  221. SELECT "intervals"."startDate" as "date", COALESCE(${selectMetrics[metric]}, 0) AS value
  222. FROM
  223. intervals
  224. LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId
  225. AND ${intervalWhere[metric]}
  226. GROUP BY
  227. "intervals"."startDate"
  228. ORDER BY
  229. "intervals"."startDate"`
  230. const queryOptions = {
  231. type: QueryTypes.SELECT as QueryTypes.SELECT,
  232. replacements: {
  233. startDate,
  234. endDate,
  235. groupInterval,
  236. videoId: video.id
  237. }
  238. }
  239. const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
  240. return {
  241. groupInterval,
  242. data: rows.map(r => ({
  243. date: r.date,
  244. value: parseInt(r.value)
  245. }))
  246. }
  247. }
  248. toActivityPubObject (this: MLocalVideoViewerWithWatchSections): WatchActionObject {
  249. const location = this.country
  250. ? {
  251. location: {
  252. addressCountry: this.country
  253. }
  254. }
  255. : {}
  256. return {
  257. id: this.url,
  258. type: 'WatchAction',
  259. duration: getActivityStreamDuration(this.watchTime),
  260. startTime: this.startDate.toISOString(),
  261. endTime: this.endDate.toISOString(),
  262. object: this.Video.url,
  263. uuid: this.uuid,
  264. actionStatus: 'CompletedActionStatus',
  265. watchSections: this.WatchSections.map(w => ({
  266. startTimestamp: w.watchStart,
  267. endTimestamp: w.watchEnd
  268. })),
  269. ...location
  270. }
  271. }
  272. }