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 @@
{{ 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
|