ニジカ投稿局 https://tv.nizika.tv
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

local-video-viewer.ts 12 KiB

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