diff --git a/client/src/app/+about/about-instance/instance-stat-rules.component.html b/client/src/app/+about/about-instance/instance-stat-rules.component.html index 0f6605f9bcf..9c8f07a41a3 100644 --- a/client/src/app/+about/about-instance/instance-stat-rules.component.html +++ b/client/src/app/+about/about-instance/instance-stat-rules.component.html @@ -28,6 +28,12 @@

Our platform in figures

+
+ {{ stats().totalLocalVideoDownloads | number }} +
downloads
+ +
+
{{ stats().totalLocalVideoComments | number }}
comments
diff --git a/client/src/app/+admin/system/jobs/jobs.component.ts b/client/src/app/+admin/system/jobs/jobs.component.ts index 15b023f70ae..0f10c458e4e 100644 --- a/client/src/app/+admin/system/jobs/jobs.component.ts +++ b/client/src/app/+admin/system/jobs/jobs.component.ts @@ -81,6 +81,7 @@ export class JobsComponent implements OnInit { 'video-studio-edition', 'video-transcoding', 'video-transcription', + 'videos-downloads-stats', 'videos-views-stats' ] jobTypeItems: SelectOptionsItem[] = this.jobTypes.map(i => ({ id: i, label: i })) diff --git a/client/src/app/+my-library/my-videos/my-videos.component.html b/client/src/app/+my-library/my-videos/my-videos.component.html index fea7bb19b4c..87f7da31aae 100644 --- a/client/src/app/+my-library/my-videos/my-videos.component.html +++ b/client/src/app/+my-library/my-videos/my-videos.component.html @@ -99,6 +99,8 @@ {video.views, plural, =0 {No views} =1 {1 view} other {{{ video.views | myNumberFormatter }} views}} +
+ {video.downloads, plural, =0 {No download} =1 {1 download} other {{{ video.downloads | myNumberFormatter }} download}} @if (video.isLive) {
{video.viewers, plural, =0 {No viewers} =1 {1 viewer} other {{{ video.views | myNumberFormatter }} viewers}} diff --git a/client/src/app/+videos-publish-manage/shared-manage/common/video-edit.model.ts b/client/src/app/+videos-publish-manage/shared-manage/common/video-edit.model.ts index d938d4ae6f1..89a32965b1d 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/common/video-edit.model.ts +++ b/client/src/app/+videos-publish-manage/shared-manage/common/video-edit.model.ts @@ -120,6 +120,7 @@ type UpdateFromAPIOptions = { | 'likes' | 'aspectRatio' | 'views' + | 'downloads' | 'blacklisted' | 'blacklistedReason' | 'thumbnails' @@ -173,6 +174,7 @@ export class VideoEdit { state: VideoStateType isLive: boolean views: number + downloads: number aspectRatio: number duration: number likes: number @@ -197,6 +199,7 @@ export class VideoEdit { aspectRatio: number duration: number views: number + downloads: number likes: number blacklisted: boolean @@ -310,6 +313,7 @@ export class VideoEdit { this.common.pluginData = {} this.metadata.views = 0 + this.metadata.downloads = 0 this.metadata.likes = 0 this.metadata.ownerAccountDisplayName = options.user.account.displayName @@ -429,6 +433,7 @@ export class VideoEdit { this.metadata.state = video.state.id this.metadata.duration = video.duration this.metadata.views = video.views + this.metadata.downloads = video.downloads this.metadata.likes = video.likes this.metadata.aspectRatio = video.aspectRatio this.metadata.blacklisted = video.blacklisted @@ -1115,6 +1120,7 @@ export class VideoEdit { isLive: this.metadata.isLive, aspectRatio: this.metadata.aspectRatio, views: this.metadata.views, + downloads: this.metadata.downloads, likes: this.metadata.likes, duration: this.metadata.duration, blacklisted: this.metadata.blacklisted, diff --git a/client/src/app/+videos-publish-manage/shared-manage/stats/video-stats.component.ts b/client/src/app/+videos-publish-manage/shared-manage/stats/video-stats.component.ts index 9ef1967a468..86bdf6a05ef 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/stats/video-stats.component.ts +++ b/client/src/app/+videos-publish-manage/shared-manage/stats/video-stats.component.ts @@ -11,6 +11,7 @@ import { secondsToTime } from '@peertube/peertube-core-utils' import { HttpStatusCode, LiveVideoSession, + VideoDownloadStatsTimeserieMetric, VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, @@ -32,7 +33,7 @@ import { VideoStatsService } from './video-stats.service' const BAR_GRAPHS = [ 'countries', 'regions', 'clients', 'devices', 'operatingSystems' ] as const type BarGraphs = typeof BAR_GRAPHS[number] -type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | BarGraphs +type ActiveGraphId = VideoStatsTimeserieMetric | VideoDownloadStatsTimeserieMetric | 'retention' | BarGraphs type GeoData = { name: string, viewers: number }[] @@ -131,6 +132,11 @@ export class VideoStatsComponent implements OnInit { label: $localize`Watch time`, zoomEnabled: true }, + { + id: 'downloads', + label: $localize`Downloads`, + zoomEnabled: true + }, { id: 'countries', label: $localize`Countries`, @@ -242,7 +248,7 @@ export class VideoStatsComponent implements OnInit { } private isTimeserieGraph (graphId: ActiveGraphId) { - return graphId === 'aggregateWatchTime' || graphId === 'viewers' + return graphId === 'aggregateWatchTime' || graphId === 'viewers' || graphId === 'downloads' } private loadOverallStats () { @@ -334,6 +340,10 @@ export class VideoStatsComponent implements OnInit { value: this.numberFormatter.transform(this.videoEdit.getVideoAttributes().views), help: $localize`A view means that someone watched the video for several seconds (10 seconds by default)` }, + { + label: $localize`Downloads`, + value: this.numberFormatter.transform(this.videoEdit.getVideoAttributes().downloads), + }, { label: $localize`Likes`, value: this.numberFormatter.transform(this.videoEdit.getVideoAttributes().likes) @@ -399,6 +409,12 @@ export class VideoStatsComponent implements OnInit { endDate: this.statsEndDate, metric: 'viewers' }), + downloads: this.statsService.getTimeserieStats({ + videoId, + startDate: this.statsStartDate, + endDate: this.statsEndDate, + metric: 'downloads' + }), countries: of(this.countries), @@ -426,6 +442,7 @@ export class VideoStatsComponent implements OnInit { retention: (rawData: VideoStatsRetention) => this.buildRetentionChartOptions(rawData), aggregateWatchTime: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData), viewers: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData), + downloads: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData), countries: (rawData: GeoData) => this.buildGeoChartOptions(rawData), regions: (rawData: GeoData) => this.buildGeoChartOptions(rawData) } diff --git a/client/src/app/+videos-publish-manage/shared-manage/stats/video-stats.service.ts b/client/src/app/+videos-publish-manage/shared-manage/stats/video-stats.service.ts index 58b12d766bd..bcd00b18f0d 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/stats/video-stats.service.ts +++ b/client/src/app/+videos-publish-manage/shared-manage/stats/video-stats.service.ts @@ -4,6 +4,7 @@ import { HttpClient, HttpParams } from '@angular/common/http' import { Injectable, inject } from '@angular/core' import { RestExtractor } from '@app/core' import { + VideoDownloadStatsTimeserieMetric, VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, @@ -38,7 +39,7 @@ export class VideoStatsService { getTimeserieStats (options: { videoId: string - metric: VideoStatsTimeserieMetric + metric: VideoStatsTimeserieMetric | VideoDownloadStatsTimeserieMetric startDate?: Date endDate?: Date }) { diff --git a/client/src/app/shared/shared-main/video/video.model.ts b/client/src/app/shared/shared-main/video/video.model.ts index b7da94fb36c..6a7db68c13b 100644 --- a/client/src/app/shared/shared-main/video/video.model.ts +++ b/client/src/app/shared/shared-main/video/video.model.ts @@ -70,6 +70,8 @@ export class Video implements VideoServerModel { views: number viewers: number + downloads: number + likes: number dislikes: number @@ -178,6 +180,7 @@ export class Video implements VideoServerModel { this.viewers = hash.viewers this.likes = hash.likes this.dislikes = hash.dislikes + this.downloads = hash.downloads this.nsfw = hash.nsfw this.nsfwFlags = hash.nsfwFlags diff --git a/packages/models/src/activitypub/activity.ts b/packages/models/src/activitypub/activity.ts index e2a4d964b73..f4e599e47c1 100644 --- a/packages/models/src/activitypub/activity.ts +++ b/packages/models/src/activitypub/activity.ts @@ -44,6 +44,7 @@ export type Activity = | ActivityFlag | ActivityApproveReply | ActivityRejectReply + | ActivityDownload export type ActivityType = | 'Create' @@ -60,6 +61,7 @@ export type ActivityType = | 'Flag' | 'ApproveReply' | 'RejectReply' + | 'Download' export interface ActivityAudience { to: string[] @@ -162,3 +164,9 @@ export interface ActivityFlag extends BaseActivity { startAt?: number endAt?: number } + +export interface ActivityDownload extends BaseActivity { + type: 'Download' + actor: string + object: APObjectId +} diff --git a/packages/models/src/activitypub/context.ts b/packages/models/src/activitypub/context.ts index e85a1677c3e..a3f0e3c217c 100644 --- a/packages/models/src/activitypub/context.ts +++ b/packages/models/src/activitypub/context.ts @@ -18,3 +18,4 @@ export type ContextType = | 'ApproveReply' | 'RejectReply' | 'PlayerSettings' + | 'Download' diff --git a/packages/models/src/server/debug.model.ts b/packages/models/src/server/debug.model.ts index ffab325ef16..fd1e3d6f9ac 100644 --- a/packages/models/src/server/debug.model.ts +++ b/packages/models/src/server/debug.model.ts @@ -6,6 +6,7 @@ export interface Debug { export type SendDebugCommand = { command: | 'remove-dandling-resumable-uploads' + | 'process-video-downloads' | 'process-video-views-buffer' | 'process-video-viewers' | 'process-video-channel-sync-latest' diff --git a/packages/models/src/server/job.model.ts b/packages/models/src/server/job.model.ts index 296753396c2..46a16060a75 100644 --- a/packages/models/src/server/job.model.ts +++ b/packages/models/src/server/job.model.ts @@ -29,6 +29,7 @@ export type JobType = | 'video-redundancy' | 'video-studio-edition' | 'video-transcoding' + | 'videos-downloads-stats' | 'videos-views-stats' | 'generate-video-storyboard' | 'create-user-export' diff --git a/packages/models/src/server/server-stats.model.ts b/packages/models/src/server/server-stats.model.ts index 1f02a0d0315..f1a6caeebb7 100644 --- a/packages/models/src/server/server-stats.model.ts +++ b/packages/models/src/server/server-stats.model.ts @@ -15,6 +15,7 @@ export interface ServerStats extends ActivityPubMessagesSuccess, ActivityPubMess totalLocalVideos: number totalLocalVideoViews: number + totalLocalVideoDownloads: number totalLocalVideoComments: number totalLocalVideoFilesSize: number diff --git a/packages/models/src/videos/stats/video-stats-timeserie-metric.type.ts b/packages/models/src/videos/stats/video-stats-timeserie-metric.type.ts index fc268d083ee..3e1af1acd86 100644 --- a/packages/models/src/videos/stats/video-stats-timeserie-metric.type.ts +++ b/packages/models/src/videos/stats/video-stats-timeserie-metric.type.ts @@ -1 +1,2 @@ export type VideoStatsTimeserieMetric = 'viewers' | 'aggregateWatchTime' +export type VideoDownloadStatsTimeserieMetric = 'downloads' diff --git a/packages/models/src/videos/video.model.ts b/packages/models/src/videos/video.model.ts index c0242c06016..40e6b65a0b2 100644 --- a/packages/models/src/videos/video.model.ts +++ b/packages/models/src/videos/video.model.ts @@ -69,6 +69,8 @@ export interface Video extends Partial { views: number viewers: number + downloads: number + likes: number dislikes: number comments: number diff --git a/packages/server-commands/src/server/jobs.ts b/packages/server-commands/src/server/jobs.ts index 3938c926487..ba452313098 100644 --- a/packages/server-commands/src/server/jobs.ts +++ b/packages/server-commands/src/server/jobs.ts @@ -22,7 +22,7 @@ async function waitJobs ( const states: JobState[] = [ 'waiting', 'active' ] if (!skipDelayed) states.push('delayed') - const repeatableJobs: JobType[] = [ 'videos-views-stats', 'activitypub-cleaner' ] + const repeatableJobs: JobType[] = [ 'videos-downloads-stats', 'videos-views-stats', 'activitypub-cleaner' ] let pendingRequests: boolean function tasksBuilder () { diff --git a/packages/server-commands/src/videos/video-stats-command.ts b/packages/server-commands/src/videos/video-stats-command.ts index 25e2641d0ce..6ebba015dc8 100644 --- a/packages/server-commands/src/videos/video-stats-command.ts +++ b/packages/server-commands/src/videos/video-stats-command.ts @@ -1,6 +1,7 @@ import { pick } from '@peertube/peertube-core-utils' import { HttpStatusCode, + VideoDownloadStatsTimeserieMetric, VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, @@ -53,7 +54,7 @@ export class VideoStatsCommand extends AbstractCommand { getTimeserieStats ( options: OverrideCommandOptions & { videoId: number | string - metric: VideoStatsTimeserieMetric + metric: VideoStatsTimeserieMetric | VideoDownloadStatsTimeserieMetric startDate?: Date endDate?: Date } diff --git a/packages/tests/src/api/downloads/index.ts b/packages/tests/src/api/downloads/index.ts new file mode 100644 index 00000000000..4a279401e74 --- /dev/null +++ b/packages/tests/src/api/downloads/index.ts @@ -0,0 +1 @@ +export * from "./video-downloads-counter.js" diff --git a/packages/tests/src/api/downloads/video-downloads-counter.ts b/packages/tests/src/api/downloads/video-downloads-counter.ts new file mode 100644 index 00000000000..80de09223b0 --- /dev/null +++ b/packages/tests/src/api/downloads/video-downloads-counter.ts @@ -0,0 +1,69 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { PeerTubeServer, waitJobs } from "@peertube/peertube-server-commands" +import { + prepareDownloadsServer, + processDownloadsStats, +} from "@tests/shared/downloads.js" +import { Promise } from "bluebird" +import { expect } from "chai" + +describe("Test video downloads counters", function() { + let server: PeerTubeServer + + before(async function() { + this.timeout(120000) + + server = await prepareDownloadsServer() + + this.timeout(120000) + }) + + async function upload(): Promise < string > { + return new Promise(async (resolve) => { + const { uuid } = await server.videos.quickUpload({ name: "video" }) + await waitJobs(server) + resolve(uuid) + }) + } + + it("Should count downloads", async function() { + const videoId = await upload() + const video = await server.videos.getWithToken({ id: videoId }) + const videoFileIds = [ video.files[0].id ] + + await server.videos.generateDownload({ + videoId, + videoFileIds, + }) + await processDownloadsStats([ server ]) + + expect((await server.videos.get({ id: videoId })).downloads).to.equal(1) + }) + + it("Should return time-series for downloads stats", async function() { + const videoId = await upload() + const video = await server.videos.getWithToken({ id: videoId }) + const videoFileIds = [ video.files[0].id ] + + await server.videos.generateDownload({ + videoId, + videoFileIds, + }) + await processDownloadsStats([ server ]) + + const startDate = new Date() + startDate.setSeconds(0) + startDate.setMilliseconds(0) + + const res = await server.videoStats.getTimeserieStats({ + videoId, + metric: "downloads", + }) + const count = res.data.find( + (e) => e.date === startDate.toISOString(), + ).value + + expect(count).to.equal(1) + }) +}) diff --git a/packages/tests/src/shared/downloads.ts b/packages/tests/src/shared/downloads.ts new file mode 100644 index 00000000000..538f59de41f --- /dev/null +++ b/packages/tests/src/shared/downloads.ts @@ -0,0 +1,29 @@ +import { + createSingleServer, + PeerTubeServer, + setAccessTokensToServers, + setDefaultVideoChannel, + waitJobs, +} from "@peertube/peertube-server-commands" + +async function prepareDownloadsServer() { + const server = await createSingleServer(1, {}) + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) + + await server.config.enableMinimumTranscoding() + + return server +} + +async function processDownloadsStats(servers: PeerTubeServer[]) { + for (const server of servers) { + await server.debug.sendCommand({ + body: { command: "process-video-downloads" }, + }) + } + + await waitJobs(servers) +} + +export { prepareDownloadsServer, processDownloadsStats } diff --git a/packages/tests/src/shared/sql-command.ts b/packages/tests/src/shared/sql-command.ts index 49ba8e0de1c..6a88dc9e63f 100644 --- a/packages/tests/src/shared/sql-command.ts +++ b/packages/tests/src/shared/sql-command.ts @@ -42,8 +42,8 @@ export class SQLCommand { } async countVideoViewsOf (uuid: string) { - const query = 'SELECT SUM("videoView"."views") AS "total" FROM "videoView" ' + - `INNER JOIN "video" ON "video"."id" = "videoView"."videoId" WHERE "video"."uuid" = :uuid` + const query = 'SELECT SUM("videoStats"."views") AS "total" FROM "videoStats" ' + + `INNER JOIN "video" ON "video"."id" = "videoStats"."videoId" WHERE "video"."uuid" = :uuid` const [ { total } ] = await this.selectQuery<{ total: number }>(query, { uuid }) if (!total) return 0 diff --git a/server/core/controllers/api/server/debug.ts b/server/core/controllers/api/server/debug.ts index f843eb597ec..9dc84ff0fda 100644 --- a/server/core/controllers/api/server/debug.ts +++ b/server/core/controllers/api/server/debug.ts @@ -22,6 +22,7 @@ import { VideoViewsManager } from '@server/lib/views/video-views-manager.js' import express from 'express' import { asyncMiddleware, authenticate, ensureUserHasRight } from '../../../middlewares/index.js' import { RemoveOldViewsScheduler } from '@server/lib/schedulers/remove-old-views-scheduler.js' +import { VideoViewerStats } from '@server/lib/views/shared/video-viewer-stats.js' const debugRouter = express.Router() @@ -60,6 +61,7 @@ async function runCommand (req: express.Request, res: express.Response) { const processors: { [id in SendDebugCommand['command']]: () => Promise } = { 'remove-dandling-resumable-uploads': () => RemoveDanglingResumableUploadsScheduler.Instance.execute(), 'remove-expired-user-exports': () => RemoveExpiredUserExportsScheduler.Instance.execute(), + 'process-video-downloads': () => VideoViewerStats.save(), 'process-video-views-buffer': () => VideoViewsBufferScheduler.Instance.execute(), 'process-video-viewers': () => VideoViewsManager.Instance.processViewerStats(), 'process-update-videos-scheduler': () => UpdateVideosScheduler.Instance.execute(), diff --git a/server/core/controllers/api/videos/stats.ts b/server/core/controllers/api/videos/stats.ts index 03c0de2cca6..4249a58a96e 100644 --- a/server/core/controllers/api/videos/stats.ts +++ b/server/core/controllers/api/videos/stats.ts @@ -1,5 +1,7 @@ import { + VideoDownloadStatsTimeserieMetric, VideoStatsOverallQuery, + VideoStatsTimeserie, VideoStatsTimeserieMetric, VideoStatsTimeserieQuery, VideoStatsUserAgentQuery @@ -13,6 +15,8 @@ import { videoRetentionStatsValidator, videoTimeseriesStatsValidator } from '../../../middlewares/index.js' +import { MVideo } from '@server/types/models/index.js' +import { VideoStatsModel } from '@server/models/view/video-stats.js' const statsRouter = express.Router() @@ -88,11 +92,26 @@ async function getRetentionStats (req: express.Request, res: express.Response) { async function getTimeseriesStats (req: express.Request, res: express.Response) { const video = res.locals.videoAll - const metric = req.params.metric as VideoStatsTimeserieMetric + const metric = req.params.metric as VideoStatsTimeserieMetric|VideoDownloadStatsTimeserieMetric + + let handler: (options: { + video: MVideo + metric: VideoStatsTimeserieMetric|VideoDownloadStatsTimeserieMetric + startDate: string + endDate: string + }) => Promise < VideoStatsTimeserie > + + switch (metric) { + case "downloads": + handler = VideoStatsModel.getTimeserieStats + break + default: + handler = LocalVideoViewerModel.getTimeserieStats + } const query = req.query as VideoStatsTimeserieQuery - const stats = await LocalVideoViewerModel.getTimeserieStats({ + const stats = await handler({ video, metric, startDate: query.startDate ?? video.createdAt.toISOString(), diff --git a/server/core/helpers/activity-pub-utils.ts b/server/core/helpers/activity-pub-utils.ts index 43ab49a7d17..af0f4d43a01 100644 --- a/server/core/helpers/activity-pub-utils.ts +++ b/server/core/helpers/activity-pub-utils.ts @@ -313,7 +313,14 @@ const contextStore: { [id in ContextType]: (string | { [id: string]: string })[] }, theme: 'pt:theme' - }) + }), + + Download: buildContext({ + DownloadAction: 'sc:DownloadAction', + InteractionCounter: 'sc:InteractionCounter', + interactionType: 'sc:interactionType', + userInteractionCount: 'sc:userInteractionCount' + }), } let allContext: (string | ContextValue)[] diff --git a/server/core/helpers/custom-validators/activitypub/activity.ts b/server/core/helpers/custom-validators/activitypub/activity.ts index 4e9365d62c7..4d2b7d4fa9d 100644 --- a/server/core/helpers/custom-validators/activitypub/activity.ts +++ b/server/core/helpers/custom-validators/activitypub/activity.ts @@ -43,7 +43,8 @@ const activityCheckers: { [P in ActivityType]: (activity: Activity) => boolean } Flag: isFlagActivityValid, Dislike: isDislikeActivityValid, ApproveReply: isApproveReplyActivityValid, - RejectReply: isRejectReplyActivityValid + RejectReply: isRejectReplyActivityValid, + Download: isDownloadActivityValid } export function isActivityValid (activity: any) { @@ -148,3 +149,9 @@ export function isRejectReplyActivityValid (activity: any) { isActivityPubUrlValid(activity.object) && isActivityPubUrlValid(activity.inReplyTo) } + +export function isDownloadActivityValid (activity: any) { + return isBaseActivityValid(activity, 'Download') && + isActivityPubUrlValid(activity.actor) && + isActivityPubUrlValid(activity.object) +} diff --git a/server/core/helpers/custom-validators/video-stats.ts b/server/core/helpers/custom-validators/video-stats.ts index 7bd30c8e225..f5645350d3e 100644 --- a/server/core/helpers/custom-validators/video-stats.ts +++ b/server/core/helpers/custom-validators/video-stats.ts @@ -1,8 +1,9 @@ -import { VideoStatsTimeserieMetric } from '@peertube/peertube-models' +import { VideoDownloadStatsTimeserieMetric, VideoStatsTimeserieMetric } from '@peertube/peertube-models' -const validMetrics = new Set([ +const validMetrics = new Set([ 'viewers', - 'aggregateWatchTime' + 'aggregateWatchTime', + 'downloads' ]) function isValidStatTimeserieMetric (value: VideoStatsTimeserieMetric) { diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts index 4c1f8e3bffd..9b4c21f4908 100644 --- a/server/core/initializers/constants.ts +++ b/server/core/initializers/constants.ts @@ -60,7 +60,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js' // --------------------------------------------------------------------------- -export const LAST_MIGRATION_VERSION = 1000 +export const LAST_MIGRATION_VERSION = 1005 // --------------------------------------------------------------------------- @@ -219,6 +219,7 @@ export const JOB_ATTEMPTS: { [id in JobType]: number } = { 'video-import': 1, 'email': 5, 'actor-keys': 3, + 'videos-downloads-stats': 1, 'videos-views-stats': 1, 'activitypub-refresher': 1, 'video-redundancy': 1, @@ -248,6 +249,7 @@ export const JOB_CONCURRENCY: { [id in Exclude { + { + await utils.queryInterface.addColumn( + "video", + "downloads", { + type: Sequelize.INTEGER, + defaultValue: 0, + }, { + transaction: utils.transaction, + }, + ) + + await utils.queryInterface.renameTable("videoView", "videoStats") + + await utils.queryInterface.addColumn( + "videoStats", + "downloads", { + type: Sequelize.INTEGER, + defaultValue: 0, + }, { + transaction: utils.transaction, + }, + ) + } +} + +async function down(utils: { + transaction: Sequelize.Transaction; + queryInterface: Sequelize.QueryInterface; + sequelize: Sequelize.Sequelize; +}): Promise < void > { + await utils.queryInterface.removeColumn("video", "downloads", { + transaction: utils.transaction, + }) + + await utils.queryInterface.removeColumn("videoStats", "downloads", { + transaction: utils.transaction, + }) + + await utils.queryInterface.renameTable("videoStats", "videoView") +} + +export { up, down } diff --git a/server/core/lib/activitypub/process/process-download.ts b/server/core/lib/activitypub/process/process-download.ts new file mode 100644 index 00000000000..020fa3bf9a2 --- /dev/null +++ b/server/core/lib/activitypub/process/process-download.ts @@ -0,0 +1,30 @@ +import { ActivityView } from '@peertube/peertube-models' +import { APProcessorOptions } from '../../../types/activitypub-processor.model.js' +import { getOrCreateAPVideo } from '../videos/index.js' +import { VideoViewerStats } from '@server/lib/views/shared/video-viewer-stats.js' + +async function processDownloadActivity (options: APProcessorOptions) { + const { activity } = options + + return processCreateDownload(activity) +} + +// --------------------------------------------------------------------------- + +export { + processDownloadActivity +} + +// --------------------------------------------------------------------------- + +async function processCreateDownload (activity: ActivityView) { + const videoObject = activity.object + + const { video } = await getOrCreateAPVideo({ + videoObject, + fetchType: 'only-video-and-blacklist', + allowRefresh: false + }) + + await VideoViewerStats.add({ video }) +} diff --git a/server/core/lib/activitypub/process/process.ts b/server/core/lib/activitypub/process/process.ts index 1db0e6f06b9..30d3795a231 100644 --- a/server/core/lib/activitypub/process/process.ts +++ b/server/core/lib/activitypub/process/process.ts @@ -19,6 +19,7 @@ import { processReplyApprovalFactory } from './process-reply-approval.js' import { processUndoActivity } from './process-undo.js' import { processUpdateActivity } from './process-update.js' import { processViewActivity } from './process-view.js' +import { processDownloadActivity } from './process-download.js' const processActivity: { [ P in ActivityType ]: (options: APProcessorOptions) => Promise } = { Create: processCreateActivity, @@ -33,6 +34,7 @@ const processActivity: { [ P in ActivityType ]: (options: APProcessorOptions { + const url = getDownloadsActivityPubUrl(byActor, video) + + return buildDownloadActivity({ + url, + byActor, + video, + audience, + downloadsCount, + }) + } + + return sendVideoRelatedActivity(activityBuilder, { + byActor, + video, + transaction, + contextType: "Download", + parallelizable: true, + }) +} + +// --------------------------------------------------------------------------- + +export { sendDownload } + +// --------------------------------------------------------------------------- + +function buildDownloadActivity(options: { + url: string; + byActor: MActorAudience; + video: MVideoUrl; + downloadsCount ? : number; + audience ? : ActivityAudience; +}): ActivityDownload { + const { + url, + byActor, + downloadsCount, + video, + audience = getPublicAudience(byActor), + } = options + + const base = { + id: url, + type: "Download" as "Download", + actor: byActor.url, + object: video.url, + } + + if (downloadsCount === undefined) { + return audiencify(base, audience) + } + + return audiencify({ + ...base, + + expires: new Date( + VideoViewsManager.Instance.buildViewerExpireTime(), + ).toISOString(), + + result: { + interactionType: "DownloadAction", + type: "InteractionCounter", + userInteractionCount: downloadsCount, + }, + }, + audience, + ) +} diff --git a/server/core/lib/activitypub/url.ts b/server/core/lib/activitypub/url.ts index 76de2b4e4db..27296a63728 100644 --- a/server/core/lib/activitypub/url.ts +++ b/server/core/lib/activitypub/url.ts @@ -129,6 +129,10 @@ export function getLocalApproveReplyActivityPubUrl (video: MVideoUUID, comment: return getLocalVideoCommentActivityPubUrl(video, comment) + '/approve-reply' } +export function getDownloadsActivityPubUrl (byActor: MActorUrl, video: MVideoId) { + return byActor.url + '/downloads/videos/' + video.id +} + // --------------------------------------------------------------------------- // Try to fetch target URL diff --git a/server/core/lib/job-queue/handlers/video-download-stats.ts b/server/core/lib/job-queue/handlers/video-download-stats.ts new file mode 100644 index 00000000000..df1d816af99 --- /dev/null +++ b/server/core/lib/job-queue/handlers/video-download-stats.ts @@ -0,0 +1,5 @@ +import { VideoViewerStats } from "@server/lib/views/shared/video-viewer-stats.js" + +export async function processVideosDownloadsStats() { + await VideoViewerStats.save() +} diff --git a/server/core/lib/job-queue/handlers/video-views-stats.ts b/server/core/lib/job-queue/handlers/video-views-stats.ts index 479b47ed085..6ed7ec946ab 100644 --- a/server/core/lib/job-queue/handlers/video-views-stats.ts +++ b/server/core/lib/job-queue/handlers/video-views-stats.ts @@ -1,5 +1,5 @@ import { isTestOrDevInstance } from '@peertube/peertube-node-utils' -import { VideoViewModel } from '@server/models/view/video-view.js' +import { VideoStatsModel } from '@server/models/view/video-stats.js' import { logger } from '../../../helpers/logger.js' import { VideoModel } from '../../../models/video/video.js' import { Redis } from '../../redis.js' @@ -34,7 +34,7 @@ async function processVideosViewsStats () { continue } - await VideoViewModel.create({ + await VideoStatsModel.create({ startDate: new Date(startDate), endDate: new Date(endDate), views, diff --git a/server/core/lib/job-queue/job-queue.ts b/server/core/lib/job-queue/job-queue.ts index 6ac3753a73e..477cb666b01 100644 --- a/server/core/lib/job-queue/job-queue.ts +++ b/server/core/lib/job-queue/job-queue.ts @@ -75,6 +75,7 @@ import { processVideoStudioEdition } from './handlers/video-studio-edition.js' import { processVideoTranscoding } from './handlers/video-transcoding.js' import { processVideoTranscription } from './handlers/video-transcription.js' import { processVideosViewsStats } from './handlers/video-views-stats.js' +import { processVideosDownloadsStats } from './handlers/video-download-stats.js' export type CreateJobArgument = | { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } @@ -89,6 +90,7 @@ export type CreateJobArgument = | { type: 'transcoding-job-builder', payload: TranscodingJobBuilderPayload } | { type: 'video-import', payload: VideoImportPayload } | { type: 'activitypub-refresher', payload: RefreshPayload } + | { type: 'videos-downloads-stats', payload: {} } | { type: 'videos-views-stats', payload: {} } | { type: 'video-live-ending', payload: VideoLiveEndingPayload } | { type: 'actor-keys', payload: ActorKeysPayload } @@ -136,6 +138,7 @@ const handlers: { [id in JobType]: (job: Job) => Promise } = { 'video-redundancy': processVideoRedundancy, 'video-studio-edition': processVideoStudioEdition, 'video-transcoding': processVideoTranscoding, + 'videos-downloads-stats': processVideosDownloadsStats, 'videos-views-stats': processVideosViewsStats, 'generate-video-storyboard': processGenerateStoryboard, 'create-user-export': processCreateUserExport, @@ -173,6 +176,7 @@ const jobTypes: JobType[] = [ 'video-redundancy', 'video-studio-edition', 'video-transcription', + 'videos-downloads-stats', 'videos-views-stats', 'create-user-export', 'import-user-archive', @@ -520,6 +524,12 @@ class JobQueue { // --------------------------------------------------------------------------- private addRepeatableJobs () { + this.queues['videos-downloads-stats'].add('job', {}, { + repeat: REPEAT_JOBS['videos-downloads-stats'], + + ...this.buildJobRemovalOptions('videos-downloads-stats') + }).catch(err => logger.error('Cannot add repeatable job.', { err })) + this.queues['videos-views-stats'].add('job', {}, { repeat: REPEAT_JOBS['videos-views-stats'], diff --git a/server/core/lib/opentelemetry/metric-helpers/stats-observers-builder.ts b/server/core/lib/opentelemetry/metric-helpers/stats-observers-builder.ts index 5817b6ef3b4..ed411e94faa 100644 --- a/server/core/lib/opentelemetry/metric-helpers/stats-observers-builder.ts +++ b/server/core/lib/opentelemetry/metric-helpers/stats-observers-builder.ts @@ -81,6 +81,14 @@ export class StatsObserversBuilder { observableResult.observe(stats.totalLocalVideoViews, { viewOrigin: 'local' }) }) + this.meter.createObservableGauge('peertube_video_downloads_total', { + description: 'Total video downloads made on the instance' + }).addCallback(async observableResult => { + const stats = await this.getInstanceStats() + + observableResult.observe(stats.totalLocalVideoDownloads, { viewOrigin: 'local' }) + }) + this.meter.createObservableGauge('peertube_video_bytes_total', { description: 'Total bytes of videos' }).addCallback(async observableResult => { diff --git a/server/core/lib/redis.ts b/server/core/lib/redis.ts index 4be4a19bc37..ef2f5670d25 100644 --- a/server/core/lib/redis.ts +++ b/server/core/lib/redis.ts @@ -355,6 +355,46 @@ class Redis { ]) } + /* ************ Generic stats ************ */ + + getStats (options: { + key?: string + // Or + scope?: string, + ip?: string + videoId?: number + }) { + if (options.key) return this.getObject(options.key) + + const { key } = this.generateKeysForStats(options.scope, options.ip, options.videoId) + + return this.getObject(key) + } + + setStats (scope: string, sessionId: string, videoId: number, object: any) { + const { setKey, key } = this.generateKeysForStats(scope, sessionId, videoId) + + return Promise.all([ + this.addToSet(setKey, key), + this.setObject(key, object) + ]) + } + + getStatsKeys (scope: string) { + const { setKey } = this.generateKeysForStats(scope) + + return this.getSet(setKey) + } + + deleteStatsKey (scope: string, key: string) { + const { setKey } = this.generateKeysForStats(scope) + + return Promise.all([ + this.deleteFromSet(setKey, key), + this.deleteKey(key) + ]) + } + /* ************ Resumable uploads final responses ************ */ setUploadSession (uploadId: string) { @@ -408,6 +448,18 @@ class Redis { return { setKey: `videos-view-h${hour}`, videoKey: `video-view-${options.videoId}-h${hour}` } } + generateKeysForStats (scope: string, sessionId: string, videoId: number): { setKey: string, key: string } + generateKeysForStats (scole: string): { setKey: string } + generateKeysForStats (scope: string, sessionId?: string, videoId?: number) { + return { + setKey: `${scope}-stats-keys`, + + key: sessionId && videoId + ? `${scope}-stats-${sessionId}-${videoId}` + : undefined + } + } + private generateResetPasswordKey (userId: number) { return 'reset-password-' + userId } diff --git a/server/core/lib/schedulers/remove-old-views-scheduler.ts b/server/core/lib/schedulers/remove-old-views-scheduler.ts index 3c5a9329c8a..a0b7ec4b12f 100644 --- a/server/core/lib/schedulers/remove-old-views-scheduler.ts +++ b/server/core/lib/schedulers/remove-old-views-scheduler.ts @@ -1,5 +1,5 @@ import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer.js' -import { VideoViewModel } from '@server/models/view/video-view.js' +import { VideoStatsModel } from '@server/models/view/video-stats.js' import { logger, loggerTagsFactory } from '../../helpers/logger.js' import { CONFIG } from '../../initializers/config.js' import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants.js' @@ -29,7 +29,7 @@ export class RemoveOldViewsScheduler extends AbstractScheduler { const now = new Date() const beforeDate = new Date(now.getTime() - CONFIG.VIEWS.VIDEOS.REMOTE.MAX_AGE).toISOString() - return VideoViewModel.removeOldRemoteViews(beforeDate) + return VideoStatsModel.removeOldRemoteViews(beforeDate) } private async removeLocalViews () { @@ -40,7 +40,7 @@ export class RemoveOldViewsScheduler extends AbstractScheduler { const now = new Date() const beforeDate = new Date(now.getTime() - CONFIG.VIEWS.VIDEOS.LOCAL.MAX_AGE).toISOString() - await VideoViewModel.removeOldLocalViews(beforeDate) + await VideoStatsModel.removeOldLocalViews(beforeDate) await LocalVideoViewerModel.removeOldViews(beforeDate) } diff --git a/server/core/lib/stat-manager.ts b/server/core/lib/stat-manager.ts index 202502ef450..4989298fbce 100644 --- a/server/core/lib/stat-manager.ts +++ b/server/core/lib/stat-manager.ts @@ -47,7 +47,7 @@ class StatsManager { } async getStats () { - const { totalLocalVideos, totalLocalVideoViews, totalVideos } = await VideoModel.getStats() + const { totalLocalVideos, totalLocalVideoViews, totalLocalVideoDownloads, totalVideos } = await VideoModel.getStats() const { totalLocalVideoComments, totalVideoComments } = await VideoCommentModel.getStats() const { totalUsers, @@ -85,6 +85,7 @@ class StatsManager { totalLocalVideos, totalLocalVideoViews, + totalLocalVideoDownloads, totalLocalVideoComments, totalLocalVideoFilesSize, @@ -148,6 +149,7 @@ class StatsManager { Dislike: 0, Flag: 0, View: 0, + Download: 0, ApproveReply: 0, RejectReply: 0 } @@ -172,6 +174,7 @@ class StatsManager { totalActivityPubDislikeMessagesSuccesses: this.inboxMessages.successesPerType.Dislike, totalActivityPubFlagMessagesSuccesses: this.inboxMessages.successesPerType.Flag, totalActivityPubViewMessagesSuccesses: this.inboxMessages.successesPerType.View, + totalActivityPubDownloadMessagesSuccesses: this.inboxMessages.successesPerType.Download, totalActivityPubApproveReplyMessagesSuccesses: this.inboxMessages.successesPerType.ApproveReply, totalActivityPubRejectReplyMessagesSuccesses: this.inboxMessages.successesPerType.RejectReply, @@ -187,6 +190,7 @@ class StatsManager { totalActivityPubDislikeMessagesErrors: this.inboxMessages.errorsPerType.Dislike, totalActivityPubFlagMessagesErrors: this.inboxMessages.errorsPerType.Flag, totalActivityPubViewMessagesErrors: this.inboxMessages.errorsPerType.View, + totalActivityPubDownloadMessagesErrors: this.inboxMessages.errorsPerType.Download, totalActivityPubApproveReplyMessagesErrors: this.inboxMessages.errorsPerType.ApproveReply, totalActivityPubRejectReplyMessagesErrors: this.inboxMessages.errorsPerType.RejectReply, diff --git a/server/core/lib/video-download.ts b/server/core/lib/video-download.ts index c0ca4a698b3..f5f20df4af5 100644 --- a/server/core/lib/video-download.ts +++ b/server/core/lib/video-download.ts @@ -17,6 +17,7 @@ import { } from './object-storage/videos.js' import { VideoPathManager } from './video-path-manager.js' import { createReadStream } from 'fs' +import { VideoViewerStats } from './views/shared/video-viewer-stats.js' export class VideoDownload { static totalDownloads = 0 @@ -81,6 +82,8 @@ export class VideoDownload { logger.info(`Mux ended for video ${this.video.url}`, { inputs: this.inputsToLog(), ...lTags(this.video.uuid) }) + await VideoViewerStats.add({video: this.video}) + res() } catch (err) { const message = err?.message || '' diff --git a/server/core/lib/views/shared/video-viewer-stats.ts b/server/core/lib/views/shared/video-viewer-stats.ts index 4fb6d1bd485..6141b71a018 100644 --- a/server/core/lib/views/shared/video-viewer-stats.ts +++ b/server/core/lib/views/shared/video-viewer-stats.ts @@ -2,19 +2,27 @@ import { VideoViewEvent } from '@peertube/peertube-models' import { isTestOrDevInstance } from '@peertube/peertube-node-utils' import { GeoIP } from '@server/helpers/geo-ip.js' import { logger, loggerTagsFactory } from '@server/helpers/logger.js' +import { generateRandomString } from '@server/helpers/utils.js' import { MAX_LOCAL_VIEWER_WATCH_SECTIONS, VIEWER_SYNC_REDIS, VIEW_LIFETIME } from '@server/initializers/constants.js' import { sequelizeTypescript } from '@server/initializers/database.js' import { sendCreateWatchAction } from '@server/lib/activitypub/send/index.js' +import { sendDownload } from '@server/lib/activitypub/send/send-download.js' import { getLocalVideoViewerActivityPubUrl } from '@server/lib/activitypub/url.js' import { Redis } from '@server/lib/redis.js' +import { getServerActor } from '@server/models/application/application.js' import { VideoModel } from '@server/models/video/video.js' import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section.js' import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer.js' -import { MVideo, MVideoImmutable } from '@server/types/models/index.js' +import { VideoStatsModel } from '@server/models/view/video-stats.js' +import { MVideo, MVideoImmutable, MVideoThumbnail } from '@server/types/models/index.js' import { Transaction } from 'sequelize' const lTags = loggerTagsFactory('views') +const REDIS_SCOPES = { + DOWNLOAD: "download" +} + type LocalViewerStats = { firstUpdated: number // Date.getTime() lastUpdated: number // Date.getTime() @@ -36,6 +44,11 @@ type LocalViewerStats = { videoId: number } +type DownloadStats = { + videoId: number; + downloadedAt: number; // Date.getTime() +} + export class VideoViewerStats { private processingViewersStats = false private processingRedisWrites = false @@ -186,6 +199,105 @@ export class VideoViewerStats { this.processingViewersStats = false } + /** + * Record a video download into Redis + */ + static async add({ video }: { video: MVideoThumbnail }) { + const sessionId = await generateRandomString(32) + const videoId = video.id + + const stats: DownloadStats = { + videoId, + downloadedAt: new Date().getTime(), + } + + try { + await Redis.Instance.setStats(REDIS_SCOPES.DOWNLOAD, sessionId, videoId, stats) + + await sendDownload({ byActor: await getServerActor(), video, downloadsCount: 1 }) + } catch (err) { + logger.error("Cannot write download into redis", { + sessionId, + videoId, + stats, + err, + }) + } + } + + /** + * Aggregate video downloads from Redis into SQL database + */ + static async save() { + logger.debug("Saving download stats to DB", lTags()) + + const keys = await Redis.Instance.getStatsKeys(REDIS_SCOPES.DOWNLOAD) + if (keys.length === 0) return + + logger.debug("Processing %d video download(s)", keys.length) + + for (const key of keys) { + const stats: DownloadStats = await Redis.Instance.getStats({ key }) + + const videoId = stats.videoId + const video = await VideoModel.load(videoId) + if (!video) { + logger.debug( + "Video %d does not exist anymore, skipping videos view stats.", + videoId, + ) + try { + await Redis.Instance.deleteStatsKey(REDIS_SCOPES.DOWNLOAD, key) + } catch (err) { + logger.error("Cannot remove key %s from Redis", key) + } + continue + } + + const downloadedAt = new Date(stats.downloadedAt) + const startDate = new Date(downloadedAt.setMinutes(0, 0, 0)) + const endDate = new Date(downloadedAt.setMinutes(59, 59, 999)) + + logger.info( + "date range: %s -> %s", + startDate.toISOString(), + endDate.toISOString(), + ) + + try { + const record = await VideoStatsModel.findOne({ + where: { videoId, startDate }, + }) + if (record) { + // Increment download count for current time slice + record.downloads++ + await record.save() + } else { + // Create a new time slice for this video downloads + await VideoStatsModel.create({ + startDate: new Date(startDate), + endDate: new Date(endDate), + downloads: 1, + videoId, + }) + } + + // Increment video total download count + video.downloads++ + await video.save() + + await Redis.Instance.deleteStatsKey(REDIS_SCOPES.DOWNLOAD, key) + } catch (err) { + logger.error( + "Cannot update video views stats of video %d on range %s -> %s", + videoId, + startDate.toISOString(), + endDate.toISOString(), { err }, + ) + } + } + } + private async saveViewerStats (video: MVideo, stats: LocalViewerStats, transaction: Transaction) { if (stats.watchTime === 0) return diff --git a/server/core/models/video/formatter/video-api-format.ts b/server/core/models/video/formatter/video-api-format.ts index 86efbd4a1fc..9d5da837cda 100644 --- a/server/core/models/video/formatter/video-api-format.ts +++ b/server/core/models/video/formatter/video-api-format.ts @@ -113,6 +113,8 @@ export function videoModelToFormattedJSON (video: MVideoFormattable, options: Vi views: video.views, viewers: VideoViewsManager.Instance.getTotalViewersOf(video), + downloads: video.downloads, + likes: video.likes, dislikes: video.dislikes, diff --git a/server/core/models/video/sql/channel/video-channel-list-query-builder.ts b/server/core/models/video/sql/channel/video-channel-list-query-builder.ts index 53b022a5bcd..a658b379a69 100644 --- a/server/core/models/video/sql/channel/video-channel-list-query-builder.ts +++ b/server/core/models/video/sql/channel/video-channel-list-query-builder.ts @@ -231,12 +231,12 @@ export class VideoChannelListQueryBuilder extends AbstractListQuery { `SELECT generate_series(date_trunc('day', now()) - '${this.options.statsDaysPrior} day'::interval, ` + `date_trunc('day', now()), '1 day'::interval) AS day ` + ') ' + - 'SELECT days.day AS day, COALESCE(SUM("videoView".views), 0) AS views ' + + 'SELECT days.day AS day, COALESCE(SUM("videoStats".views), 0) AS views ' + 'FROM days ' + 'LEFT JOIN (' + - '"videoView" INNER JOIN "video" ON "videoView"."videoId" = "video"."id" ' + + '"videoStats" INNER JOIN "video" ON "videoStats"."videoId" = "video"."id" ' + 'AND "video"."channelId" = "VideoChannelModel"."id"' + - `) ON date_trunc('day', "videoView"."startDate") = date_trunc('day', days.day) ` + + `) ON date_trunc('day', "videoStats"."startDate") = date_trunc('day', days.day) ` + 'GROUP BY day ORDER BY day ' + ') t' + ') AS "viewsPerDay"' diff --git a/server/core/models/video/sql/video/shared/video-table-attributes.ts b/server/core/models/video/sql/video/shared/video-table-attributes.ts index fbeade411c2..a04de8ee579 100644 --- a/server/core/models/video/sql/video/shared/video-table-attributes.ts +++ b/server/core/models/video/sql/video/shared/video-table-attributes.ts @@ -291,6 +291,7 @@ export class VideoTableAttributes { 'support', 'duration', 'views', + 'downloads', 'likes', 'dislikes', 'remote', diff --git a/server/core/models/video/sql/video/videos-id-list-query-builder.ts b/server/core/models/video/sql/video/videos-id-list-query-builder.ts index 3da5be68a83..01135fe4987 100644 --- a/server/core/models/video/sql/video/videos-id-list-query-builder.ts +++ b/server/core/models/video/sql/video/videos-id-list-query-builder.ts @@ -808,10 +808,10 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery { private groupForTrending (trendingDays: number) { const viewsGteDate = new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays) - this.joins.push('LEFT JOIN "videoView" ON "video"."id" = "videoView"."videoId" AND "videoView"."startDate" >= :viewsGteDate') + this.joins.push('LEFT JOIN "videoStats" ON "video"."id" = "videoStats"."videoId" AND "videoStats"."startDate" >= :viewsGteDate') this.replacements.viewsGteDate = viewsGteDate - this.attributes.push('COALESCE(SUM("videoView"."views"), 0) AS "score"') + this.attributes.push('COALESCE(SUM("videoStats"."views"), 0) AS "score"') this.group = 'GROUP BY "video"."id"' } diff --git a/server/core/models/video/video.ts b/server/core/models/video/video.ts index f6d0df1f166..7fae0d4f9c2 100644 --- a/server/core/models/video/video.ts +++ b/server/core/models/video/video.ts @@ -137,7 +137,7 @@ import { } from '../shared/index.js' import { UserVideoHistoryModel } from '../user/user-video-history.js' import { UserModel } from '../user/user.js' -import { VideoViewModel } from '../view/video-view.js' +import { VideoStatsModel } from '../view/video-stats.js' import { videoModelToActivityPubObject } from './formatter/video-activity-pub-format.js' import { VideoFormattingJSONOptions, @@ -512,6 +512,13 @@ export class VideoModel extends SequelizeModel { @Column declare views: number + @AllowNull(false) + @Default(0) + @IsInt + @Min(0) + @Column + declare downloads: number + @AllowNull(false) @Default(0) @IsInt @@ -704,14 +711,14 @@ export class VideoModel extends SequelizeModel { }) declare VideoComments: Awaited[] - @HasMany(() => VideoViewModel, { + @HasMany(() => VideoStatsModel, { foreignKey: { name: 'videoId', allowNull: false }, onDelete: 'cascade' }) - declare VideoViews: Awaited[] + declare VideoViews: Awaited[] @HasMany(() => UserVideoHistoryModel, { foreignKey: { @@ -1468,6 +1475,15 @@ export class VideoModel extends SequelizeModel { // Sequelize could return null... if (!totalLocalVideoViews) totalLocalVideoViews = 0 + const totalLocalVideoDownloads = await VideoModel.sum('downloads', { + where: { + remote: false + } + }) + + // Sequelize could return null... + if (!totalLocalVideoViews) totalLocalVideoViews = 0 + const baseOptions = { start: 0, count: 0, @@ -1490,6 +1506,7 @@ export class VideoModel extends SequelizeModel { return { totalLocalVideos, totalLocalVideoViews, + totalLocalVideoDownloads, totalVideos } } @@ -1654,7 +1671,7 @@ export class VideoModel extends SequelizeModel { return { attributes: [], subQuery: false, - model: VideoViewModel, + model: VideoStatsModel, required: false, where: { startDate: { diff --git a/server/core/models/view/video-stats.ts b/server/core/models/view/video-stats.ts new file mode 100644 index 00000000000..ef49ab5822e --- /dev/null +++ b/server/core/models/view/video-stats.ts @@ -0,0 +1,163 @@ +import { MAX_SQL_DELETE_ITEMS } from "@server/initializers/constants.js" +import { literal, Op, QueryTypes } from "sequelize" +import { + AllowNull, + BelongsTo, + Column, + CreatedAt, + DataType, + Default, + ForeignKey, + Table, +} from "sequelize-typescript" +import { safeBulkDestroy, SequelizeModel } from "../shared/index.js" +import { VideoModel } from "../video/video.js" +import { + VideoDownloadStatsTimeserieMetric, + VideoStatsTimeserie, +} from "@peertube/peertube-models" +import { MVideo } from "@server/types/models/index.js" +import { buildGroupByAndBoundaries } from "@server/lib/timeserie.js" + +/** + * Aggregate views of all videos federated with our instance + * Mainly used by the trending/hot algorithms + */ + +@Table({ + tableName: "videoStats", + updatedAt: false, + indexes: [ { + fields: [ "videoId" ], + }, + { + fields: [ "startDate" ], + }, + ], +}) +export class VideoStatsModel extends SequelizeModel < VideoStatsModel > { + @CreatedAt + declare createdAt: Date + + @AllowNull(false) + @Column(DataType.DATE) + declare startDate: Date + + @AllowNull(false) + @Column(DataType.DATE) + declare endDate: Date + + @AllowNull(false) + @Default(0) + @Column + declare views: number + + @AllowNull(false) + @Default(0) + @Column + declare downloads: number + + @ForeignKey(() => VideoModel) + @Column + declare videoId: number + + @BelongsTo(() => VideoModel, { + foreignKey: { + allowNull: false, + }, + onDelete: "CASCADE", + }) + declare Video: Awaited < VideoModel > + + static removeOldRemoteViews(beforeDate: string) { + return safeBulkDestroy(() => { + return VideoStatsModel.destroy({ + where: { + startDate: { + [Op.lt]: beforeDate, + }, + videoId: { + [Op.in]: literal( + '(SELECT "id" FROM "video" WHERE "remote" IS TRUE)', + ), + }, + }, + limit: MAX_SQL_DELETE_ITEMS, + }) + }) + } + + static removeOldLocalViews(beforeDate: string) { + return safeBulkDestroy(() => { + return VideoStatsModel.destroy({ + where: { + startDate: { + [Op.lt]: beforeDate, + }, + videoId: { + [Op.in]: literal( + '(SELECT "id" FROM "video" WHERE "remote" IS FALSE)', + ), + }, + }, + limit: MAX_SQL_DELETE_ITEMS, + }) + }) + } + + static async getTimeserieStats(options: { + video: MVideo; + metric: VideoDownloadStatsTimeserieMetric; + startDate: string; + endDate: string; + }): Promise < VideoStatsTimeserie > { + const { video } = options + + const { groupInterval, startDate, endDate } = buildGroupByAndBoundaries( + options.startDate, + options.endDate, + ) + + const query = `WITH "intervals" AS ( + SELECT + "time" AS "startDate", "time" + :groupInterval::interval as "endDate" + FROM + generate_series(:startDate::timestamptz, :endDate::timestamptz, :groupInterval::interval) serie("time") + ) + SELECT + "intervals"."startDate" AS date, COALESCE("videoStats"."downloads", 0) AS value + FROM + "intervals" + LEFT JOIN "videoStats" ON "videoStats"."videoId" = :videoId + AND + "videoStats"."startDate" <= "intervals"."endDate" + AND + "videoStats"."startDate" >= "intervals"."startDate" + ORDER BY + "intervals"."startDate" + ` + + const queryOptions = { + type: QueryTypes.SELECT as QueryTypes.SELECT, + replacements: { + startDate, + endDate, + groupInterval, + videoId: video.id, + }, + } + + const rows = await VideoStatsModel.sequelize.query < any > ( + query, + queryOptions, + ) + + return { + groupInterval, + data: rows.map((r) => ({ + date: r.date, + value: parseInt(r.value), + })), + } + } +} diff --git a/server/core/models/view/video-view.ts b/server/core/models/view/video-view.ts deleted file mode 100644 index cad6326c520..00000000000 --- a/server/core/models/view/video-view.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { MAX_SQL_DELETE_ITEMS } from '@server/initializers/constants.js' -import { literal, Op } from 'sequelize' -import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Table } from 'sequelize-typescript' -import { safeBulkDestroy, SequelizeModel } from '../shared/index.js' -import { VideoModel } from '../video/video.js' - -/** - * Aggregate views of all videos federated with our instance - * Mainly used by the trending/hot algorithms - */ - -@Table({ - tableName: 'videoView', - updatedAt: false, - indexes: [ - { - fields: [ 'videoId' ] - }, - { - fields: [ 'startDate' ] - } - ] -}) -export class VideoViewModel extends SequelizeModel { - @CreatedAt - declare createdAt: Date - - @AllowNull(false) - @Column(DataType.DATE) - declare startDate: Date - - @AllowNull(false) - @Column(DataType.DATE) - declare endDate: Date - - @AllowNull(false) - @Column - declare views: number - - @ForeignKey(() => VideoModel) - @Column - declare videoId: number - - @BelongsTo(() => VideoModel, { - foreignKey: { - allowNull: false - }, - onDelete: 'CASCADE' - }) - declare Video: Awaited - - static removeOldRemoteViews (beforeDate: string) { - return safeBulkDestroy(() => { - return VideoViewModel.destroy({ - where: { - startDate: { - [Op.lt]: beforeDate - }, - videoId: { - [Op.in]: literal('(SELECT "id" FROM "video" WHERE "remote" IS TRUE)') - } - }, - limit: MAX_SQL_DELETE_ITEMS - }) - }) - } - - static removeOldLocalViews (beforeDate: string) { - return safeBulkDestroy(() => { - return VideoViewModel.destroy({ - where: { - startDate: { - [Op.lt]: beforeDate - }, - videoId: { - [Op.in]: literal('(SELECT "id" FROM "video" WHERE "remote" IS FALSE)') - } - }, - limit: MAX_SQL_DELETE_ITEMS - }) - }) - } -} diff --git a/server/core/types/models/video/video.ts b/server/core/types/models/video/video.ts index 2365e16e927..2670636785d 100644 --- a/server/core/types/models/video/video.ts +++ b/server/core/types/models/video/video.ts @@ -46,6 +46,7 @@ export type MVideo = Omit< | 'AccountVideoRates' | 'VideoComments' | 'VideoViews' + | 'VideoDownloads' | 'UserVideoHistories' | 'ScheduleVideoUpdate' | 'VideoBlacklist' diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index e4436d2eaa7..7e91c409aea 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -8664,6 +8664,7 @@ components: - video-transcoding - video-file-import - video-import + - videos-downloads-stats - videos-views-stats - activitypub-refresher - video-redundancy @@ -10683,6 +10684,9 @@ components: totalLocalVideoViews: type: number description: Total video views made on the instance + totalLocalVideoDownloads: + type: number + description: Total video downloads made on the instance totalLocalVideoComments: type: number description: Total comments made by local users @@ -11130,6 +11134,7 @@ components: - video-transcoding - email - video-import + - videos-downloads-stats - videos-views-stats - activitypub-refresher - video-redundancy