From 6a765e5ce2caae944b382351ea2bf9dce9fbcbf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NOBILI?= Date: Wed, 21 Jan 2026 16:54:05 +0100 Subject: [PATCH 01/15] register job --- .../app/+admin/system/jobs/jobs.component.ts | 1 + packages/models/src/server/job.model.ts | 1 + packages/server-commands/src/server/jobs.ts | 2 +- server/core/initializers/constants.ts | 9 +++++++++ .../handlers/video-download-stats.ts | 19 +++++++++++++++++++ server/core/lib/job-queue/job-queue.ts | 10 ++++++++++ support/doc/api/openapi.yaml | 2 ++ 7 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 server/core/lib/job-queue/handlers/video-download-stats.ts diff --git a/client/src/app/+admin/system/jobs/jobs.component.ts b/client/src/app/+admin/system/jobs/jobs.component.ts index c13777c0cde..4f1718651f5 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/packages/models/src/server/job.model.ts b/packages/models/src/server/job.model.ts index 95f8775f90a..2726a33ab76 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/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/server/core/initializers/constants.ts b/server/core/initializers/constants.ts index d453826a06f..7836bbf16a2 100644 --- a/server/core/initializers/constants.ts +++ b/server/core/initializers/constants.ts @@ -217,6 +217,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, @@ -246,6 +247,7 @@ export const JOB_CONCURRENCY: { [id in Exclude 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', @@ -514,6 +518,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/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index 868b5d68984..b67b44dab90 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -8272,6 +8272,7 @@ components: - video-transcoding - video-file-import - video-import + - videos-downloads-stats - videos-views-stats - activitypub-refresher - video-redundancy @@ -10683,6 +10684,7 @@ components: - video-transcoding - email - video-import + - videos-downloads-stats - videos-views-stats - activitypub-refresher - video-redundancy From 9912f8d63a65de3ab17ae3e3c748042ec9865808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NOBILI?= Date: Tue, 3 Feb 2026 17:39:49 +0100 Subject: [PATCH 02/15] track downloads & display total count on stats page --- .../shared-manage/common/video-edit.model.ts | 6 + .../stats/video-stats.component.ts | 4 + .../shared/shared-main/video/video.model.ts | 3 + packages/models/src/videos/video.model.ts | 2 + server/core/initializers/constants.ts | 4 + server/core/initializers/database.ts | 2 + .../migrations/0985-download-stats.ts | 52 ++++++++ .../handlers/video-download-stats.ts | 20 +-- server/core/lib/redis.ts | 52 ++++++++ server/core/lib/stats/video-download.ts | 115 ++++++++++++++++++ server/core/lib/video-download.ts | 3 + server/core/models/download/video-download.ts | 41 +++++++ .../video/formatter/video-api-format.ts | 2 + .../video/shared/video-table-attributes.ts | 1 + server/core/models/video/video.ts | 17 +++ server/core/types/models/video/video.ts | 1 + 16 files changed, 308 insertions(+), 17 deletions(-) create mode 100644 server/core/initializers/migrations/0985-download-stats.ts create mode 100644 server/core/lib/stats/video-download.ts create mode 100644 server/core/models/download/video-download.ts 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 ce00fe02111..6ecc5fea37f 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 @@ -112,6 +112,7 @@ type UpdateFromAPIOptions = { | 'likes' | 'aspectRatio' | 'views' + | 'downloads' | 'blacklisted' | 'previewPath' | 'state' @@ -162,6 +163,7 @@ export class VideoEdit { state: VideoStateType isLive: boolean views: number + downloads: number aspectRatio: number duration: number likes: number @@ -185,6 +187,7 @@ export class VideoEdit { aspectRatio: number duration: number views: number + downloads: number likes: number blacklisted: boolean @@ -295,6 +298,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 @@ -412,6 +416,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 @@ -1044,6 +1049,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..974cd1cfbd3 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 @@ -334,6 +334,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) 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 346dd2cd775..2dda3da817f 100644 --- a/client/src/app/shared/shared-main/video/video.model.ts +++ b/client/src/app/shared/shared-main/video/video.model.ts @@ -68,6 +68,8 @@ export class Video implements VideoServerModel { views: number viewers: number + downloads: number + likes: number dislikes: number @@ -182,6 +184,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/videos/video.model.ts b/packages/models/src/videos/video.model.ts index d4201a2fb64..63664dcf0c5 100644 --- a/packages/models/src/videos/video.model.ts +++ b/packages/models/src/videos/video.model.ts @@ -51,6 +51,8 @@ export interface Video extends Partial { views: number viewers: number + downloads: number + likes: number dislikes: number comments: number diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts index 7836bbf16a2..370c977e7e2 100644 --- a/server/core/initializers/constants.ts +++ b/server/core/initializers/constants.ts @@ -554,6 +554,10 @@ export const VIEW_LIFETIME = { } export let VIEWER_SYNC_REDIS = 30000 // Sync viewer into redis +export const STATS_LIFETIME = { + DOWNLOADS: 60000 * 60, // 1 hour +} + export const MAX_LOCAL_VIEWER_WATCH_SECTIONS = 100 export let CONTACT_FORM_LIFETIME = 60000 * 60 // 1 hour diff --git a/server/core/initializers/database.ts b/server/core/initializers/database.ts index 9efb01731b0..b3c46b272aa 100644 --- a/server/core/initializers/database.ts +++ b/server/core/initializers/database.ts @@ -72,6 +72,7 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla import { VideoTagModel } from '../models/video/video-tag.js' import { VideoModel } from '../models/video/video.js' import { VideoViewModel } from '../models/view/video-view.js' +import { VideoDownloadModel } from '@server/models/download/video-download.js' import { CONFIG } from './config.js' pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string @@ -159,6 +160,7 @@ export async function initDatabaseModels (silent: boolean) { ScheduleVideoUpdateModel, VideoImportModel, VideoViewModel, + VideoDownloadModel, VideoRedundancyModel, UserVideoHistoryModel, VideoLiveModel, diff --git a/server/core/initializers/migrations/0985-download-stats.ts b/server/core/initializers/migrations/0985-download-stats.ts new file mode 100644 index 00000000000..92c8ccb01ca --- /dev/null +++ b/server/core/initializers/migrations/0985-download-stats.ts @@ -0,0 +1,52 @@ +import * as Sequelize from "sequelize"; + +async function up(utils: { + transaction: Sequelize.Transaction; + queryInterface: Sequelize.QueryInterface; + sequelize: Sequelize.Sequelize; +}): Promise < void > { + { + const query = ` + CREATE TABLE IF NOT EXISTS "videoDownload" ( + "id" SERIAL, + "startDate" TIMESTAMP WITH TIME ZONE NOT NULL, + "endDate" TIMESTAMP WITH TIME ZONE NOT NULL, + "downloads" INTEGER NOT NULL, + "videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + PRIMARY KEY ("id") + ); + `; + + await utils.sequelize.query(query, { + transaction: utils.transaction, + }); + + await utils.queryInterface.addColumn( + "video", + "downloads", { + type: Sequelize.NUMBER, + defaultValue: 0, + }, { + transaction: utils.transaction, + }, + ); + } +} + +async function down(utils: { + transaction: Sequelize.Transaction; + queryInterface: Sequelize.QueryInterface; + sequelize: Sequelize.Sequelize; +}): Promise < void > { + const query = `DROP TABLE "videoDownload";`; + + await utils.sequelize.query(query, { + transaction: utils.transaction, + }); + + await utils.queryInterface.removeColumn("video", "downloads", { + transaction: utils.transaction, + }); +} + +export { up, down }; diff --git a/server/core/lib/job-queue/handlers/video-download-stats.ts b/server/core/lib/job-queue/handlers/video-download-stats.ts index 110ee728751..0f0131d3844 100644 --- a/server/core/lib/job-queue/handlers/video-download-stats.ts +++ b/server/core/lib/job-queue/handlers/video-download-stats.ts @@ -1,19 +1,5 @@ -import { isTestOrDevInstance } from "@peertube/peertube-node-utils"; -import { logger } from "../../../helpers/logger.js"; +import { VideoDownloadStats } from "@server/lib/stats/video-download.js"; -async function processVideosDownloadsStats() { - const lastHour = new Date(); - - // In test mode, we run this function multiple times per hour, so we don't want the values of the previous hour - if (!isTestOrDevInstance()) lastHour.setHours(lastHour.getHours() - 1); - - const hour = lastHour.getHours(); - - logger.info("Processing videos downloads stats in job for hour %d.", hour); +export async function processVideosDownloadsStats() { + await VideoDownloadStats.save(); } - -// --------------------------------------------------------------------------- - -export { - processVideosDownloadsStats -}; diff --git a/server/core/lib/redis.ts b/server/core/lib/redis.ts index ed2f9c90344..7523335ab1e 100644 --- a/server/core/lib/redis.ts +++ b/server/core/lib/redis.ts @@ -321,6 +321,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) { @@ -374,6 +414,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/stats/video-download.ts b/server/core/lib/stats/video-download.ts new file mode 100644 index 00000000000..1fce42b8aed --- /dev/null +++ b/server/core/lib/stats/video-download.ts @@ -0,0 +1,115 @@ +import { logger, loggerTagsFactory } from "@server/helpers/logger.js"; +import { generateRandomString } from "@server/helpers/utils.js"; +import { Redis } from "@server/lib/redis.js"; +import { VideoDownloadModel } from "@server/models/download/video-download.js"; +import { VideoModel } from "@server/models/video/video.js"; +import { MVideoThumbnail } from "@server/types/models/index.js"; + +const lTags = loggerTagsFactory("downloads"); + +const redis_scope = "download"; + +type DownloadStats = { + videoId: number; + downloadedAt: number; // Date.getTime() +}; + +export class VideoDownloadStats { + + /** + * 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_scope, sessionId, videoId, stats); + } 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_scope); + 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_scope, 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 VideoDownloadModel.findOne({ + where: { videoId, startDate }, + }); + if (record) { + // Increment download count for current time slice + record.downloads++; + record.save(); + } else { + // Create a new time slice for this video downloads + await VideoDownloadModel.create({ + startDate: new Date(startDate), + endDate: new Date(endDate), + downloads: 1, + videoId, + }); + } + + // Increment video total download count + video.downloads++; + video.save(); + + await Redis.Instance.deleteStatsKey(redis_scope, key); + } catch (err) { + logger.error( + "Cannot update video views stats of video %d on range %s -> %s", + videoId, + startDate.toISOString(), + endDate.toISOString(), { err }, + ); + } + } + } +} diff --git a/server/core/lib/video-download.ts b/server/core/lib/video-download.ts index 622fc0839f8..f0878beee03 100644 --- a/server/core/lib/video-download.ts +++ b/server/core/lib/video-download.ts @@ -15,6 +15,7 @@ import { makeWebVideoFileAvailable } from './object-storage/videos.js' import { VideoPathManager } from './video-path-manager.js' +import { VideoDownloadStats } from './stats/video-download.js' export class VideoDownload { static totalDownloads = 0 @@ -67,6 +68,8 @@ export class VideoDownload { logger.info(`Mux ended for video ${this.video.url}`, { inputs: this.inputsToLog(), ...lTags(this.video.uuid) }) + VideoDownloadStats.add({video: this.video}); + res() } catch (err) { const message = err?.message || '' diff --git a/server/core/models/download/video-download.ts b/server/core/models/download/video-download.ts new file mode 100644 index 00000000000..f1ff0414ee9 --- /dev/null +++ b/server/core/models/download/video-download.ts @@ -0,0 +1,41 @@ +import { AllowNull, BelongsTo, Column, DataType, ForeignKey, Table, } from "sequelize-typescript"; +import { VideoModel } from "../video/video.js"; +import { SequelizeModel } from "../shared/sequelize-type.js"; + +@Table({ + tableName: "videoDownload", + createdAt: false, + updatedAt: false, + indexes: [{ + fields: ["videoId"], + }, + { + fields: ["startDate"], + }, + ], +}) +export class VideoDownloadModel extends SequelizeModel < VideoDownloadModel > { + @AllowNull(false) + @Column(DataType.DATE) + declare startDate: Date; + + @AllowNull(false) + @Column(DataType.DATE) + declare endDate: Date; + + @AllowNull(false) + @Column + declare downloads: number + + @ForeignKey(() => VideoModel) + @Column + declare videoId: number; + + @BelongsTo(() => VideoModel, { + foreignKey: { + allowNull: false, + }, + onDelete: "CASCADE", + }) + declare Video: Awaited < VideoModel > ; +} diff --git a/server/core/models/video/formatter/video-api-format.ts b/server/core/models/video/formatter/video-api-format.ts index 8b67e44309f..d5e855e5534 100644 --- a/server/core/models/video/formatter/video-api-format.ts +++ b/server/core/models/video/formatter/video-api-format.ts @@ -112,6 +112,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, thumbnailPath: video.getMiniatureStaticPath(), 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 cc3c156a081..28832542946 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/video.ts b/server/core/models/video/video.ts index 42469e0b277..f28082e8266 100644 --- a/server/core/models/video/video.ts +++ b/server/core/models/video/video.ts @@ -168,6 +168,7 @@ import { VideoShareModel } from './video-share.js' import { VideoSourceModel } from './video-source.js' import { VideoStreamingPlaylistModel } from './video-streaming-playlist.js' import { VideoTagModel } from './video-tag.js' +import { VideoDownloadModel } from '../download/video-download.js' const lTags = loggerTagsFactory('video') @@ -510,6 +511,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 @@ -707,6 +715,15 @@ export class VideoModel extends SequelizeModel { }) declare VideoViews: Awaited[] + @HasMany(() => VideoDownloadModel, { + foreignKey: { + name: 'videoId', + allowNull: false + }, + onDelete: 'cascade' + }) + declare VideoDownloads: Awaited[] + @HasMany(() => UserVideoHistoryModel, { foreignKey: { name: 'videoId', diff --git a/server/core/types/models/video/video.ts b/server/core/types/models/video/video.ts index 41436aad406..d444ea6c04c 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' From 060c0244ab5edb5121a3ca815a44af9b2e41b403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NOBILI?= Date: Wed, 4 Feb 2026 15:44:02 +0100 Subject: [PATCH 03/15] display download timeseries graph --- .../my-videos/my-videos.component.html | 2 + .../stats/video-stats.component.ts | 17 +++++- .../stats/video-stats.service.ts | 3 +- .../video-stats-timeserie-metric.type.ts | 1 + server/core/controllers/api/videos/stats.ts | 41 ++++++++++---- .../helpers/custom-validators/video-stats.ts | 7 +-- server/core/models/download/video-download.ts | 54 +++++++++++++++++++ 7 files changed, 108 insertions(+), 17 deletions(-) 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 3ff727be0bd..467d2c476f6 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/stats/video-stats.component.ts b/client/src/app/+videos-publish-manage/shared-manage/stats/video-stats.component.ts index 974cd1cfbd3..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 () { @@ -403,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), @@ -430,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..02664b5d320 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/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/server/core/controllers/api/videos/stats.ts b/server/core/controllers/api/videos/stats.ts index 03c0de2cca6..02954d4bea9 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 { VideoDownloadModel } from '@server/models/download/video-download.js' const statsRouter = express.Router() @@ -87,17 +91,32 @@ 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 query = req.query as VideoStatsTimeserieQuery - - const stats = await LocalVideoViewerModel.getTimeserieStats({ - video, - metric, - startDate: query.startDate ?? video.createdAt.toISOString(), - endDate: query.endDate ?? new Date().toISOString() - }) + const video = res.locals.videoAll + 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 = VideoDownloadModel.getTimeserieStats; + break; + default: + handler = LocalVideoViewerModel.getTimeserieStats; + } + + const query = req.query as VideoStatsTimeserieQuery; + + const stats = await handler({ + video, + metric, + startDate: query.startDate ?? video.createdAt.toISOString(), + endDate: query.endDate ?? new Date().toISOString(), + }); return res.json(stats) } 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/models/download/video-download.ts b/server/core/models/download/video-download.ts index f1ff0414ee9..f793e0c81ad 100644 --- a/server/core/models/download/video-download.ts +++ b/server/core/models/download/video-download.ts @@ -1,6 +1,10 @@ import { AllowNull, BelongsTo, Column, DataType, ForeignKey, Table, } from "sequelize-typescript"; import { VideoModel } from "../video/video.js"; import { SequelizeModel } from "../shared/sequelize-type.js"; +import { MVideo } from "@server/types/models/index.js"; +import { VideoDownloadStatsTimeserieMetric, VideoStatsTimeserie } from "@peertube/peertube-models"; +import { buildGroupByAndBoundaries } from "@server/lib/timeserie.js"; +import { QueryTypes } from "sequelize"; @Table({ tableName: "videoDownload", @@ -38,4 +42,54 @@ export class VideoDownloadModel extends SequelizeModel < VideoDownloadModel > { onDelete: "CASCADE", }) declare Video: Awaited < VideoModel > ; + + static async getTimeserieStats (options: { + video: MVideo + metric: VideoDownloadStatsTimeserieMetric + startDate: string + endDate: string + }): Promise { + 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("videoDownload"."downloads", 0) AS value + FROM + "intervals" + LEFT JOIN "videoDownload" ON "videoDownload"."videoId" = :videoId + AND + "videoDownload"."startDate" <= "intervals"."endDate" + AND + "videoDownload"."endDate" >= "intervals"."startDate" + ORDER BY + "intervals"."startDate" + ` + + const queryOptions = { + type: QueryTypes.SELECT as QueryTypes.SELECT, + replacements: { + startDate, + endDate, + groupInterval, + videoId: video.id + } + } + + const rows = await VideoDownloadModel.sequelize.query(query, queryOptions) + + return { + groupInterval, + data: rows.map(r => ({ + date: r.date, + value: parseInt(r.value) + })) + } + } } From 054f030921c120d92f71b515bf08ea1b2aa73b31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NOBILI?= Date: Tue, 10 Feb 2026 12:01:34 +0100 Subject: [PATCH 04/15] automated tests --- config/dev.yaml | 1 + packages/models/src/server/debug.model.ts | 1 + .../src/videos/video-stats-command.ts | 3 +- packages/tests/src/api/downloads/index.ts | 1 + .../api/downloads/video-downloads-counter.ts | 69 +++++++++++++++++++ packages/tests/src/shared/downloads.ts | 29 ++++++++ server/core/controllers/api/server/debug.ts | 2 + 7 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 packages/tests/src/api/downloads/index.ts create mode 100644 packages/tests/src/api/downloads/video-downloads-counter.ts create mode 100644 packages/tests/src/shared/downloads.ts diff --git a/config/dev.yaml b/config/dev.yaml index 2398572d0c5..b23f2be84a6 100644 --- a/config/dev.yaml +++ b/config/dev.yaml @@ -126,6 +126,7 @@ federation: views: videos: + local_buffer_update_interval: '5 seconds' remote: max_age: -1 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/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..7831fb82133 --- /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..ac13daee0c5 --- /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..ab990fa2535 --- /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/server/core/controllers/api/server/debug.ts b/server/core/controllers/api/server/debug.ts index f843eb597ec..eb22062fc70 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 { VideoDownloadStats } from '@server/lib/stats/video-download.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': () => VideoDownloadStats.save(), 'process-video-views-buffer': () => VideoViewsBufferScheduler.Instance.execute(), 'process-video-viewers': () => VideoViewsManager.Instance.processViewerStats(), 'process-update-videos-scheduler': () => UpdateVideosScheduler.Instance.execute(), From ea04cfe6e1e57bc12b0b58c94cd68155ff099e3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NOBILI?= Date: Tue, 10 Feb 2026 14:21:34 +0100 Subject: [PATCH 05/15] diff cleanup --- .../stats/video-stats.service.ts | 2 +- config/dev.yaml | 1 - server/core/controllers/api/videos/stats.ts | 52 ++--- .../migrations/0985-download-stats.ts | 56 +++--- server/core/lib/stats/video-download.ts | 177 +++++++++--------- 5 files changed, 143 insertions(+), 145 deletions(-) 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 02664b5d320..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,7 +4,7 @@ import { HttpClient, HttpParams } from '@angular/common/http' import { Injectable, inject } from '@angular/core' import { RestExtractor } from '@app/core' import { - VideoDownloadStatsTimeserieMetric, + VideoDownloadStatsTimeserieMetric, VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, diff --git a/config/dev.yaml b/config/dev.yaml index b23f2be84a6..2398572d0c5 100644 --- a/config/dev.yaml +++ b/config/dev.yaml @@ -126,7 +126,6 @@ federation: views: videos: - local_buffer_update_interval: '5 seconds' remote: max_age: -1 diff --git a/server/core/controllers/api/videos/stats.ts b/server/core/controllers/api/videos/stats.ts index 02954d4bea9..81475d117b5 100644 --- a/server/core/controllers/api/videos/stats.ts +++ b/server/core/controllers/api/videos/stats.ts @@ -91,32 +91,32 @@ 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|VideoDownloadStatsTimeserieMetric - - let handler: (options: { - video: MVideo - metric: VideoStatsTimeserieMetric|VideoDownloadStatsTimeserieMetric - startDate: string - endDate: string - }) => Promise < VideoStatsTimeserie > - - switch (metric) { - case "downloads": - handler = VideoDownloadModel.getTimeserieStats; - break; - default: - handler = LocalVideoViewerModel.getTimeserieStats; - } - - const query = req.query as VideoStatsTimeserieQuery; - - const stats = await handler({ - video, - metric, - startDate: query.startDate ?? video.createdAt.toISOString(), - endDate: query.endDate ?? new Date().toISOString(), - }); + const video = res.locals.videoAll + 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 = VideoDownloadModel.getTimeserieStats + break; + default: + handler = LocalVideoViewerModel.getTimeserieStats + } + + const query = req.query as VideoStatsTimeserieQuery + + const stats = await handler({ + video, + metric, + startDate: query.startDate ?? video.createdAt.toISOString(), + endDate: query.endDate ?? new Date().toISOString() + }) return res.json(stats) } diff --git a/server/core/initializers/migrations/0985-download-stats.ts b/server/core/initializers/migrations/0985-download-stats.ts index 92c8ccb01ca..b12079e788e 100644 --- a/server/core/initializers/migrations/0985-download-stats.ts +++ b/server/core/initializers/migrations/0985-download-stats.ts @@ -1,12 +1,12 @@ import * as Sequelize from "sequelize"; async function up(utils: { - transaction: Sequelize.Transaction; - queryInterface: Sequelize.QueryInterface; - sequelize: Sequelize.Sequelize; + transaction: Sequelize.Transaction; + queryInterface: Sequelize.QueryInterface; + sequelize: Sequelize.Sequelize; }): Promise < void > { - { - const query = ` + { + const query = ` CREATE TABLE IF NOT EXISTS "videoDownload" ( "id" SERIAL, "startDate" TIMESTAMP WITH TIME ZONE NOT NULL, @@ -17,36 +17,36 @@ async function up(utils: { ); `; - await utils.sequelize.query(query, { - transaction: utils.transaction, - }); + await utils.sequelize.query(query, { + transaction: utils.transaction, + }); - await utils.queryInterface.addColumn( - "video", - "downloads", { - type: Sequelize.NUMBER, - defaultValue: 0, - }, { - transaction: utils.transaction, - }, - ); - } + await utils.queryInterface.addColumn( + "video", + "downloads", { + type: Sequelize.NUMBER, + defaultValue: 0, + }, { + transaction: utils.transaction, + }, + ); + } } async function down(utils: { - transaction: Sequelize.Transaction; - queryInterface: Sequelize.QueryInterface; - sequelize: Sequelize.Sequelize; + transaction: Sequelize.Transaction; + queryInterface: Sequelize.QueryInterface; + sequelize: Sequelize.Sequelize; }): Promise < void > { - const query = `DROP TABLE "videoDownload";`; + const query = `DROP TABLE "videoDownload";`; - await utils.sequelize.query(query, { - transaction: utils.transaction, - }); + await utils.sequelize.query(query, { + transaction: utils.transaction, + }); - await utils.queryInterface.removeColumn("video", "downloads", { - transaction: utils.transaction, - }); + await utils.queryInterface.removeColumn("video", "downloads", { + transaction: utils.transaction, + }); } export { up, down }; diff --git a/server/core/lib/stats/video-download.ts b/server/core/lib/stats/video-download.ts index 1fce42b8aed..630505f65b1 100644 --- a/server/core/lib/stats/video-download.ts +++ b/server/core/lib/stats/video-download.ts @@ -10,106 +10,105 @@ const lTags = loggerTagsFactory("downloads"); const redis_scope = "download"; type DownloadStats = { - videoId: number; - downloadedAt: number; // Date.getTime() + videoId: number; + downloadedAt: number; // Date.getTime() }; export class VideoDownloadStats { - /** - * 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_scope, sessionId, videoId, stats); - } catch (err) { - logger.error("Cannot write download into redis", { - sessionId, - videoId, - stats, - err, - }); - } - } + * 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_scope, sessionId, videoId, stats); + } 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_scope); - 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_scope, 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 VideoDownloadModel.findOne({ - where: { videoId, startDate }, - }); - if (record) { + * 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_scope); + 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_scope, 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 VideoDownloadModel.findOne({ + where: { videoId, startDate }, + }); + if (record) { // Increment download count for current time slice - record.downloads++; - record.save(); - } else { + record.downloads++; + record.save(); + } else { // Create a new time slice for this video downloads - await VideoDownloadModel.create({ - startDate: new Date(startDate), - endDate: new Date(endDate), - downloads: 1, - videoId, - }); - } + await VideoDownloadModel.create({ + startDate: new Date(startDate), + endDate: new Date(endDate), + downloads: 1, + videoId, + }); + } // Increment video total download count video.downloads++; video.save(); - await Redis.Instance.deleteStatsKey(redis_scope, key); - } catch (err) { - logger.error( - "Cannot update video views stats of video %d on range %s -> %s", - videoId, - startDate.toISOString(), - endDate.toISOString(), { err }, - ); - } - } - } + await Redis.Instance.deleteStatsKey(redis_scope, key); + } catch (err) { + logger.error( + "Cannot update video views stats of video %d on range %s -> %s", + videoId, + startDate.toISOString(), + endDate.toISOString(), { err }, + ); + } + } + } } From 72f227aadd7d7470f30d51eadcd3570addfc5e04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NOBILI?= Date: Tue, 10 Feb 2026 15:02:22 +0100 Subject: [PATCH 06/15] add downloads to platform stats --- .../about-instance/instance-stat-rules.component.html | 6 ++++++ packages/models/src/server/server-stats.model.ts | 1 + .../metric-helpers/stats-observers-builder.ts | 8 ++++++++ server/core/lib/stat-manager.ts | 3 ++- server/core/models/video/video.ts | 10 ++++++++++ support/doc/api/openapi.yaml | 3 +++ 6 files changed, 30 insertions(+), 1 deletion(-) 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/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/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/stat-manager.ts b/server/core/lib/stat-manager.ts index 202502ef450..b3df26f384e 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, diff --git a/server/core/models/video/video.ts b/server/core/models/video/video.ts index f28082e8266..1f190656ffc 100644 --- a/server/core/models/video/video.ts +++ b/server/core/models/video/video.ts @@ -1478,6 +1478,15 @@ export class VideoModel extends SequelizeModel { // Sequelize could return null... if (!totalLocalVideoViews) totalLocalVideoViews = 0 + let totalLocalVideoDownloads = await VideoModel.sum('downloads', { + where: { + remote: false + } + }) + + // Sequelize could return null... + if (!totalLocalVideoViews) totalLocalVideoViews = 0 + const baseOptions = { start: 0, count: 0, @@ -1500,6 +1509,7 @@ export class VideoModel extends SequelizeModel { return { totalLocalVideos, totalLocalVideoViews, + totalLocalVideoDownloads, totalVideos } } diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index b67b44dab90..834105990f1 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml @@ -10237,6 +10237,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 From b805ba5e5d9dcdde0d39f1a9d5e36ae758f2725e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NOBILI?= Date: Tue, 10 Feb 2026 15:33:50 +0100 Subject: [PATCH 07/15] remove extra semicolons --- packages/tests/src/api/downloads/index.ts | 2 +- .../api/downloads/video-downloads-counter.ts | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/tests/src/api/downloads/index.ts b/packages/tests/src/api/downloads/index.ts index 7831fb82133..4a279401e74 100644 --- a/packages/tests/src/api/downloads/index.ts +++ b/packages/tests/src/api/downloads/index.ts @@ -1 +1 @@ -export * from "./video-downloads-counter.js"; +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 index ac13daee0c5..ba2a2bc7a0b 100644 --- a/packages/tests/src/api/downloads/video-downloads-counter.ts +++ b/packages/tests/src/api/downloads/video-downloads-counter.ts @@ -1,23 +1,23 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { PeerTubeServer, waitJobs } from "@peertube/peertube-server-commands"; +import { PeerTubeServer, waitJobs } from "@peertube/peertube-server-commands" import { prepareDownloadsServer, processDownloadsStats, -} from "@tests/shared/downloads.js"; -import { Promise } from "bluebird"; -import { expect } from "chai"; +} from "@tests/shared/downloads.js" +import { Promise } from "bluebird" +import { expect } from "chai" describe("Test video downloads counters", function() { - let server: PeerTubeServer; + let server: PeerTubeServer before(async function() { - this.timeout(120000); + this.timeout(120000) - server = await prepareDownloadsServer(); + server = await prepareDownloadsServer() - this.timeout(120000); - }); + this.timeout(120000) + }) async function upload(): Promise < string > { return new Promise(async (resolve) => { From 6d1066550a4f68c0adfbff8fb48f626c54f27637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NOBILI?= Date: Tue, 10 Feb 2026 16:14:34 +0100 Subject: [PATCH 08/15] fix lint issues --- .../api/downloads/video-downloads-counter.ts | 48 ++++++------ packages/tests/src/shared/downloads.ts | 18 ++--- server/core/controllers/api/videos/stats.ts | 2 +- .../migrations/0985-download-stats.ts | 16 ++-- .../handlers/video-download-stats.ts | 4 +- server/core/lib/stats/video-download.ts | 74 +++++++++---------- server/core/lib/video-download.ts | 2 +- server/core/models/download/video-download.ts | 28 +++---- server/core/models/video/video.ts | 2 +- 9 files changed, 97 insertions(+), 97 deletions(-) diff --git a/packages/tests/src/api/downloads/video-downloads-counter.ts b/packages/tests/src/api/downloads/video-downloads-counter.ts index ba2a2bc7a0b..80de09223b0 100644 --- a/packages/tests/src/api/downloads/video-downloads-counter.ts +++ b/packages/tests/src/api/downloads/video-downloads-counter.ts @@ -21,49 +21,49 @@ describe("Test video downloads counters", function() { async function upload(): Promise < string > { return new Promise(async (resolve) => { - const { uuid } = await server.videos.quickUpload({ name: "video" }); - await waitJobs(server); - resolve(uuid); - }); + 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]; + 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]); + }) + await processDownloadsStats([ server ]) - expect((await server.videos.get({ id: videoId })).downloads).to.equal(1); - }); + 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]; + 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]); + }) + await processDownloadsStats([ server ]) - const startDate = new Date(); - startDate.setSeconds(0); - startDate.setMilliseconds(0); + 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; + ).value - expect(count).to.equal(1); - }); -}); + expect(count).to.equal(1) + }) +}) diff --git a/packages/tests/src/shared/downloads.ts b/packages/tests/src/shared/downloads.ts index ab990fa2535..538f59de41f 100644 --- a/packages/tests/src/shared/downloads.ts +++ b/packages/tests/src/shared/downloads.ts @@ -4,26 +4,26 @@ import { setAccessTokensToServers, setDefaultVideoChannel, waitJobs, -} from "@peertube/peertube-server-commands"; +} from "@peertube/peertube-server-commands" async function prepareDownloadsServer() { - const server = await createSingleServer(1, {}); - await setAccessTokensToServers([server]); - await setDefaultVideoChannel([server]); + const server = await createSingleServer(1, {}) + await setAccessTokensToServers([ server ]) + await setDefaultVideoChannel([ server ]) - await server.config.enableMinimumTranscoding(); + await server.config.enableMinimumTranscoding() - return server; + return server } async function processDownloadsStats(servers: PeerTubeServer[]) { for (const server of servers) { await server.debug.sendCommand({ body: { command: "process-video-downloads" }, - }); + }) } - await waitJobs(servers); + await waitJobs(servers) } -export { prepareDownloadsServer, processDownloadsStats }; +export { prepareDownloadsServer, processDownloadsStats } diff --git a/server/core/controllers/api/videos/stats.ts b/server/core/controllers/api/videos/stats.ts index 81475d117b5..c50fd048052 100644 --- a/server/core/controllers/api/videos/stats.ts +++ b/server/core/controllers/api/videos/stats.ts @@ -104,7 +104,7 @@ async function getTimeseriesStats (req: express.Request, res: express.Response) switch (metric) { case "downloads": handler = VideoDownloadModel.getTimeserieStats - break; + break default: handler = LocalVideoViewerModel.getTimeserieStats } diff --git a/server/core/initializers/migrations/0985-download-stats.ts b/server/core/initializers/migrations/0985-download-stats.ts index b12079e788e..b1afc654968 100644 --- a/server/core/initializers/migrations/0985-download-stats.ts +++ b/server/core/initializers/migrations/0985-download-stats.ts @@ -1,4 +1,4 @@ -import * as Sequelize from "sequelize"; +import * as Sequelize from "sequelize" async function up(utils: { transaction: Sequelize.Transaction; @@ -15,11 +15,11 @@ async function up(utils: { "videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE, PRIMARY KEY ("id") ); - `; + ` await utils.sequelize.query(query, { transaction: utils.transaction, - }); + }) await utils.queryInterface.addColumn( "video", @@ -29,7 +29,7 @@ async function up(utils: { }, { transaction: utils.transaction, }, - ); + ) } } @@ -38,15 +38,15 @@ async function down(utils: { queryInterface: Sequelize.QueryInterface; sequelize: Sequelize.Sequelize; }): Promise < void > { - const query = `DROP TABLE "videoDownload";`; + const query = `DROP TABLE "videoDownload";` await utils.sequelize.query(query, { transaction: utils.transaction, - }); + }) await utils.queryInterface.removeColumn("video", "downloads", { transaction: utils.transaction, - }); + }) } -export { up, down }; +export { up, down } diff --git a/server/core/lib/job-queue/handlers/video-download-stats.ts b/server/core/lib/job-queue/handlers/video-download-stats.ts index 0f0131d3844..daddcb725bf 100644 --- a/server/core/lib/job-queue/handlers/video-download-stats.ts +++ b/server/core/lib/job-queue/handlers/video-download-stats.ts @@ -1,5 +1,5 @@ -import { VideoDownloadStats } from "@server/lib/stats/video-download.js"; +import { VideoDownloadStats } from "@server/lib/stats/video-download.js" export async function processVideosDownloadsStats() { - await VideoDownloadStats.save(); + await VideoDownloadStats.save() } diff --git a/server/core/lib/stats/video-download.ts b/server/core/lib/stats/video-download.ts index 630505f65b1..69e8d9763a2 100644 --- a/server/core/lib/stats/video-download.ts +++ b/server/core/lib/stats/video-download.ts @@ -1,41 +1,41 @@ -import { logger, loggerTagsFactory } from "@server/helpers/logger.js"; -import { generateRandomString } from "@server/helpers/utils.js"; -import { Redis } from "@server/lib/redis.js"; -import { VideoDownloadModel } from "@server/models/download/video-download.js"; -import { VideoModel } from "@server/models/video/video.js"; -import { MVideoThumbnail } from "@server/types/models/index.js"; +import { logger, loggerTagsFactory } from "@server/helpers/logger.js" +import { generateRandomString } from "@server/helpers/utils.js" +import { Redis } from "@server/lib/redis.js" +import { VideoDownloadModel } from "@server/models/download/video-download.js" +import { VideoModel } from "@server/models/video/video.js" +import { MVideoThumbnail } from "@server/types/models/index.js" -const lTags = loggerTagsFactory("downloads"); +const lTags = loggerTagsFactory("downloads") -const redis_scope = "download"; +const REDIS_SCOPE = "download" type DownloadStats = { videoId: number; downloadedAt: number; // Date.getTime() -}; +} export class VideoDownloadStats { /** * Record a video download into Redis */ static async add({ video }: { video: MVideoThumbnail }) { - const sessionId = await generateRandomString(32); - const videoId = video.id; + const sessionId = await generateRandomString(32) + const videoId = video.id const stats: DownloadStats = { videoId, downloadedAt: new Date().getTime(), - }; + } try { - await Redis.Instance.setStats(redis_scope, sessionId, videoId, stats); + await Redis.Instance.setStats(REDIS_SCOPE, sessionId, videoId, stats) } catch (err) { logger.error("Cannot write download into redis", { sessionId, videoId, stats, err, - }); + }) } } @@ -43,49 +43,49 @@ export class VideoDownloadStats { * Aggregate video downloads from Redis into SQL database */ static async save() { - logger.debug("Saving download stats to DB", lTags()); + logger.debug("Saving download stats to DB", lTags()) - const keys = await Redis.Instance.getStatsKeys(redis_scope); - if (keys.length === 0) return; + const keys = await Redis.Instance.getStatsKeys(REDIS_SCOPE) + if (keys.length === 0) return - logger.debug("Processing %d video download(s)", keys.length); + logger.debug("Processing %d video download(s)", keys.length) for (const key of keys) { - const stats: DownloadStats = await Redis.Instance.getStats({ key }); + const stats: DownloadStats = await Redis.Instance.getStats({ key }) - const videoId = stats.videoId; - const video = await VideoModel.load(videoId); + 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_scope, key); + await Redis.Instance.deleteStatsKey(REDIS_SCOPE, key) } catch (err) { - logger.error("Cannot remove key %s from Redis", key); + logger.error("Cannot remove key %s from Redis", key) } - continue; + 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)); + 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 VideoDownloadModel.findOne({ where: { videoId, startDate }, - }); + }) if (record) { // Increment download count for current time slice - record.downloads++; - record.save(); + record.downloads++ + await record.save() } else { // Create a new time slice for this video downloads await VideoDownloadModel.create({ @@ -93,21 +93,21 @@ export class VideoDownloadStats { endDate: new Date(endDate), downloads: 1, videoId, - }); + }) } // Increment video total download count - video.downloads++; - video.save(); + video.downloads++ + await video.save() - await Redis.Instance.deleteStatsKey(redis_scope, key); + await Redis.Instance.deleteStatsKey(REDIS_SCOPE, key) } catch (err) { logger.error( "Cannot update video views stats of video %d on range %s -> %s", videoId, startDate.toISOString(), endDate.toISOString(), { err }, - ); + ) } } } diff --git a/server/core/lib/video-download.ts b/server/core/lib/video-download.ts index f0878beee03..203083f2a5c 100644 --- a/server/core/lib/video-download.ts +++ b/server/core/lib/video-download.ts @@ -68,7 +68,7 @@ export class VideoDownload { logger.info(`Mux ended for video ${this.video.url}`, { inputs: this.inputsToLog(), ...lTags(this.video.uuid) }) - VideoDownloadStats.add({video: this.video}); + await VideoDownloadStats.add({video: this.video}) res() } catch (err) { diff --git a/server/core/models/download/video-download.ts b/server/core/models/download/video-download.ts index f793e0c81ad..6a9e0f5f815 100644 --- a/server/core/models/download/video-download.ts +++ b/server/core/models/download/video-download.ts @@ -1,31 +1,31 @@ -import { AllowNull, BelongsTo, Column, DataType, ForeignKey, Table, } from "sequelize-typescript"; -import { VideoModel } from "../video/video.js"; -import { SequelizeModel } from "../shared/sequelize-type.js"; -import { MVideo } from "@server/types/models/index.js"; -import { VideoDownloadStatsTimeserieMetric, VideoStatsTimeserie } from "@peertube/peertube-models"; -import { buildGroupByAndBoundaries } from "@server/lib/timeserie.js"; -import { QueryTypes } from "sequelize"; +import { AllowNull, BelongsTo, Column, DataType, ForeignKey, Table, } from "sequelize-typescript" +import { VideoModel } from "../video/video.js" +import { SequelizeModel } from "../shared/sequelize-type.js" +import { MVideo } from "@server/types/models/index.js" +import { VideoDownloadStatsTimeserieMetric, VideoStatsTimeserie } from "@peertube/peertube-models" +import { buildGroupByAndBoundaries } from "@server/lib/timeserie.js" +import { QueryTypes } from "sequelize" @Table({ tableName: "videoDownload", createdAt: false, updatedAt: false, - indexes: [{ - fields: ["videoId"], + indexes: [ { + fields: [ "videoId" ], }, { - fields: ["startDate"], + fields: [ "startDate" ], }, ], }) export class VideoDownloadModel extends SequelizeModel < VideoDownloadModel > { @AllowNull(false) @Column(DataType.DATE) - declare startDate: Date; + declare startDate: Date @AllowNull(false) @Column(DataType.DATE) - declare endDate: Date; + declare endDate: Date @AllowNull(false) @Column @@ -33,7 +33,7 @@ export class VideoDownloadModel extends SequelizeModel < VideoDownloadModel > { @ForeignKey(() => VideoModel) @Column - declare videoId: number; + declare videoId: number @BelongsTo(() => VideoModel, { foreignKey: { @@ -41,7 +41,7 @@ export class VideoDownloadModel extends SequelizeModel < VideoDownloadModel > { }, onDelete: "CASCADE", }) - declare Video: Awaited < VideoModel > ; + declare Video: Awaited < VideoModel > static async getTimeserieStats (options: { video: MVideo diff --git a/server/core/models/video/video.ts b/server/core/models/video/video.ts index 1f190656ffc..f3e6e564d42 100644 --- a/server/core/models/video/video.ts +++ b/server/core/models/video/video.ts @@ -1478,7 +1478,7 @@ export class VideoModel extends SequelizeModel { // Sequelize could return null... if (!totalLocalVideoViews) totalLocalVideoViews = 0 - let totalLocalVideoDownloads = await VideoModel.sum('downloads', { + const totalLocalVideoDownloads = await VideoModel.sum('downloads', { where: { remote: false } From 8fd248427e6cec829d12e21809c5c7e96faee3f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NOBILI?= Date: Wed, 25 Feb 2026 15:46:04 +0100 Subject: [PATCH 09/15] set DB migration target --- server/core/initializers/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts index 370c977e7e2..865a7c10f50 100644 --- a/server/core/initializers/constants.ts +++ b/server/core/initializers/constants.ts @@ -58,7 +58,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js' // --------------------------------------------------------------------------- -export const LAST_MIGRATION_VERSION = 980 +export const LAST_MIGRATION_VERSION = 985 // --------------------------------------------------------------------------- From c66b79cf1edadec06ee0621c05597f3b1b51db6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NOBILI?= Date: Fri, 27 Feb 2026 11:09:57 +0100 Subject: [PATCH 10/15] move download stats to VideoViewModel --- server/core/controllers/api/server/debug.ts | 4 +- server/core/controllers/api/videos/stats.ts | 4 +- server/core/initializers/database.ts | 2 - .../migrations/0985-download-stats.ts | 34 ++---- .../handlers/video-download-stats.ts | 4 +- server/core/lib/stats/video-download.ts | 114 ------------------ server/core/lib/video-download.ts | 4 +- .../lib/views/shared/video-viewer-stats.ts | 110 ++++++++++++++++- server/core/models/download/video-download.ts | 95 --------------- server/core/models/video/video.ts | 10 -- server/core/models/view/video-view.ts | 63 +++++++++- 11 files changed, 191 insertions(+), 253 deletions(-) delete mode 100644 server/core/lib/stats/video-download.ts delete mode 100644 server/core/models/download/video-download.ts diff --git a/server/core/controllers/api/server/debug.ts b/server/core/controllers/api/server/debug.ts index eb22062fc70..9dc84ff0fda 100644 --- a/server/core/controllers/api/server/debug.ts +++ b/server/core/controllers/api/server/debug.ts @@ -22,7 +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 { VideoDownloadStats } from '@server/lib/stats/video-download.js' +import { VideoViewerStats } from '@server/lib/views/shared/video-viewer-stats.js' const debugRouter = express.Router() @@ -61,7 +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': () => VideoDownloadStats.save(), + '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 c50fd048052..50b32abb2fd 100644 --- a/server/core/controllers/api/videos/stats.ts +++ b/server/core/controllers/api/videos/stats.ts @@ -16,7 +16,7 @@ import { videoTimeseriesStatsValidator } from '../../../middlewares/index.js' import { MVideo } from '@server/types/models/index.js' -import { VideoDownloadModel } from '@server/models/download/video-download.js' +import { VideoViewModel } from '@server/models/view/video-view.js' const statsRouter = express.Router() @@ -103,7 +103,7 @@ async function getTimeseriesStats (req: express.Request, res: express.Response) switch (metric) { case "downloads": - handler = VideoDownloadModel.getTimeserieStats + handler = VideoViewModel.getTimeserieStats break default: handler = LocalVideoViewerModel.getTimeserieStats diff --git a/server/core/initializers/database.ts b/server/core/initializers/database.ts index b3c46b272aa..9efb01731b0 100644 --- a/server/core/initializers/database.ts +++ b/server/core/initializers/database.ts @@ -72,7 +72,6 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla import { VideoTagModel } from '../models/video/video-tag.js' import { VideoModel } from '../models/video/video.js' import { VideoViewModel } from '../models/view/video-view.js' -import { VideoDownloadModel } from '@server/models/download/video-download.js' import { CONFIG } from './config.js' pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string @@ -160,7 +159,6 @@ export async function initDatabaseModels (silent: boolean) { ScheduleVideoUpdateModel, VideoImportModel, VideoViewModel, - VideoDownloadModel, VideoRedundancyModel, UserVideoHistoryModel, VideoLiveModel, diff --git a/server/core/initializers/migrations/0985-download-stats.ts b/server/core/initializers/migrations/0985-download-stats.ts index b1afc654968..7774c9e0dcf 100644 --- a/server/core/initializers/migrations/0985-download-stats.ts +++ b/server/core/initializers/migrations/0985-download-stats.ts @@ -6,25 +6,20 @@ async function up(utils: { sequelize: Sequelize.Sequelize; }): Promise < void > { { - const query = ` - CREATE TABLE IF NOT EXISTS "videoDownload" ( - "id" SERIAL, - "startDate" TIMESTAMP WITH TIME ZONE NOT NULL, - "endDate" TIMESTAMP WITH TIME ZONE NOT NULL, - "downloads" INTEGER NOT NULL, - "videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE, - PRIMARY KEY ("id") - ); - ` - - await utils.sequelize.query(query, { - transaction: utils.transaction, - }) - await utils.queryInterface.addColumn( "video", "downloads", { - type: Sequelize.NUMBER, + type: Sequelize.INTEGER, + defaultValue: 0, + }, { + transaction: utils.transaction, + }, + ) + + await utils.queryInterface.addColumn( + "videoView", + "downloads", { + type: Sequelize.INTEGER, defaultValue: 0, }, { transaction: utils.transaction, @@ -38,13 +33,10 @@ async function down(utils: { queryInterface: Sequelize.QueryInterface; sequelize: Sequelize.Sequelize; }): Promise < void > { - const query = `DROP TABLE "videoDownload";` - - await utils.sequelize.query(query, { + await utils.queryInterface.removeColumn("video", "downloads", { transaction: utils.transaction, }) - - await utils.queryInterface.removeColumn("video", "downloads", { + await utils.queryInterface.removeColumn("videoView", "downloads", { transaction: utils.transaction, }) } diff --git a/server/core/lib/job-queue/handlers/video-download-stats.ts b/server/core/lib/job-queue/handlers/video-download-stats.ts index daddcb725bf..df1d816af99 100644 --- a/server/core/lib/job-queue/handlers/video-download-stats.ts +++ b/server/core/lib/job-queue/handlers/video-download-stats.ts @@ -1,5 +1,5 @@ -import { VideoDownloadStats } from "@server/lib/stats/video-download.js" +import { VideoViewerStats } from "@server/lib/views/shared/video-viewer-stats.js" export async function processVideosDownloadsStats() { - await VideoDownloadStats.save() + await VideoViewerStats.save() } diff --git a/server/core/lib/stats/video-download.ts b/server/core/lib/stats/video-download.ts deleted file mode 100644 index 69e8d9763a2..00000000000 --- a/server/core/lib/stats/video-download.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { logger, loggerTagsFactory } from "@server/helpers/logger.js" -import { generateRandomString } from "@server/helpers/utils.js" -import { Redis } from "@server/lib/redis.js" -import { VideoDownloadModel } from "@server/models/download/video-download.js" -import { VideoModel } from "@server/models/video/video.js" -import { MVideoThumbnail } from "@server/types/models/index.js" - -const lTags = loggerTagsFactory("downloads") - -const REDIS_SCOPE = "download" - -type DownloadStats = { - videoId: number; - downloadedAt: number; // Date.getTime() -} - -export class VideoDownloadStats { - /** - * 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_SCOPE, sessionId, videoId, stats) - } 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_SCOPE) - 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_SCOPE, 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 VideoDownloadModel.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 VideoDownloadModel.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_SCOPE, key) - } catch (err) { - logger.error( - "Cannot update video views stats of video %d on range %s -> %s", - videoId, - startDate.toISOString(), - endDate.toISOString(), { err }, - ) - } - } - } -} diff --git a/server/core/lib/video-download.ts b/server/core/lib/video-download.ts index 203083f2a5c..f0f05a2814b 100644 --- a/server/core/lib/video-download.ts +++ b/server/core/lib/video-download.ts @@ -15,7 +15,7 @@ import { makeWebVideoFileAvailable } from './object-storage/videos.js' import { VideoPathManager } from './video-path-manager.js' -import { VideoDownloadStats } from './stats/video-download.js' +import { VideoViewerStats } from './views/shared/video-viewer-stats.js' export class VideoDownload { static totalDownloads = 0 @@ -68,7 +68,7 @@ export class VideoDownload { logger.info(`Mux ended for video ${this.video.url}`, { inputs: this.inputsToLog(), ...lTags(this.video.uuid) }) - await VideoDownloadStats.add({video: this.video}) + await VideoViewerStats.add({video: this.video}) res() } catch (err) { diff --git a/server/core/lib/views/shared/video-viewer-stats.ts b/server/core/lib/views/shared/video-viewer-stats.ts index 4fb6d1bd485..b40bf581f17 100644 --- a/server/core/lib/views/shared/video-viewer-stats.ts +++ b/server/core/lib/views/shared/video-viewer-stats.ts @@ -2,6 +2,7 @@ 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' @@ -10,11 +11,16 @@ import { Redis } from '@server/lib/redis.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 { VideoViewModel } from '@server/models/view/video-view.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 +42,11 @@ type LocalViewerStats = { videoId: number } +type DownloadStats = { + videoId: number; + downloadedAt: number; // Date.getTime() +} + export class VideoViewerStats { private processingViewersStats = false private processingRedisWrites = false @@ -186,6 +197,103 @@ 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) + } 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 VideoViewModel.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 VideoViewModel.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/download/video-download.ts b/server/core/models/download/video-download.ts deleted file mode 100644 index 6a9e0f5f815..00000000000 --- a/server/core/models/download/video-download.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { AllowNull, BelongsTo, Column, DataType, ForeignKey, Table, } from "sequelize-typescript" -import { VideoModel } from "../video/video.js" -import { SequelizeModel } from "../shared/sequelize-type.js" -import { MVideo } from "@server/types/models/index.js" -import { VideoDownloadStatsTimeserieMetric, VideoStatsTimeserie } from "@peertube/peertube-models" -import { buildGroupByAndBoundaries } from "@server/lib/timeserie.js" -import { QueryTypes } from "sequelize" - -@Table({ - tableName: "videoDownload", - createdAt: false, - updatedAt: false, - indexes: [ { - fields: [ "videoId" ], - }, - { - fields: [ "startDate" ], - }, - ], -}) -export class VideoDownloadModel extends SequelizeModel < VideoDownloadModel > { - @AllowNull(false) - @Column(DataType.DATE) - declare startDate: Date - - @AllowNull(false) - @Column(DataType.DATE) - declare endDate: Date - - @AllowNull(false) - @Column - declare downloads: number - - @ForeignKey(() => VideoModel) - @Column - declare videoId: number - - @BelongsTo(() => VideoModel, { - foreignKey: { - allowNull: false, - }, - onDelete: "CASCADE", - }) - declare Video: Awaited < VideoModel > - - static async getTimeserieStats (options: { - video: MVideo - metric: VideoDownloadStatsTimeserieMetric - startDate: string - endDate: string - }): Promise { - 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("videoDownload"."downloads", 0) AS value - FROM - "intervals" - LEFT JOIN "videoDownload" ON "videoDownload"."videoId" = :videoId - AND - "videoDownload"."startDate" <= "intervals"."endDate" - AND - "videoDownload"."endDate" >= "intervals"."startDate" - ORDER BY - "intervals"."startDate" - ` - - const queryOptions = { - type: QueryTypes.SELECT as QueryTypes.SELECT, - replacements: { - startDate, - endDate, - groupInterval, - videoId: video.id - } - } - - const rows = await VideoDownloadModel.sequelize.query(query, queryOptions) - - return { - groupInterval, - data: rows.map(r => ({ - date: r.date, - value: parseInt(r.value) - })) - } - } -} diff --git a/server/core/models/video/video.ts b/server/core/models/video/video.ts index f3e6e564d42..9daf84f1606 100644 --- a/server/core/models/video/video.ts +++ b/server/core/models/video/video.ts @@ -168,7 +168,6 @@ import { VideoShareModel } from './video-share.js' import { VideoSourceModel } from './video-source.js' import { VideoStreamingPlaylistModel } from './video-streaming-playlist.js' import { VideoTagModel } from './video-tag.js' -import { VideoDownloadModel } from '../download/video-download.js' const lTags = loggerTagsFactory('video') @@ -715,15 +714,6 @@ export class VideoModel extends SequelizeModel { }) declare VideoViews: Awaited[] - @HasMany(() => VideoDownloadModel, { - foreignKey: { - name: 'videoId', - allowNull: false - }, - onDelete: 'cascade' - }) - declare VideoDownloads: Awaited[] - @HasMany(() => UserVideoHistoryModel, { foreignKey: { name: 'videoId', diff --git a/server/core/models/view/video-view.ts b/server/core/models/view/video-view.ts index cad6326c520..0c71b40d19a 100644 --- a/server/core/models/view/video-view.ts +++ b/server/core/models/view/video-view.ts @@ -1,8 +1,11 @@ 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 { 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 @@ -34,9 +37,15 @@ export class VideoViewModel extends SequelizeModel { 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 @@ -80,4 +89,54 @@ export class VideoViewModel extends SequelizeModel { }) }) } + + static async getTimeserieStats(options: { + video: MVideo + metric: VideoDownloadStatsTimeserieMetric + startDate: string + endDate: string + }): Promise { + 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("videoView"."downloads", 0) AS value + FROM + "intervals" + LEFT JOIN "videoView" ON "videoView"."videoId" = :videoId + AND + "videoView"."startDate" <= "intervals"."endDate" + AND + "videoView"."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 VideoViewModel.sequelize.query(query, queryOptions) + + return { + groupInterval, + data: rows.map(r => ({ + date: r.date, + value: parseInt(r.value) + })) + } + } } From 52f8f39e37d5821bcf4598713db7b9ab6656e648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NOBILI?= Date: Thu, 5 Mar 2026 15:01:08 +0100 Subject: [PATCH 11/15] rename videoView to videoStats --- server/core/controllers/api/videos/stats.ts | 4 +- server/core/initializers/database.ts | 4 +- .../migrations/0985-download-stats.ts | 9 +- .../job-queue/handlers/video-views-stats.ts | 4 +- .../schedulers/remove-old-views-scheduler.ts | 6 +- .../lib/views/shared/video-viewer-stats.ts | 6 +- .../video-channel-list-query-builder.ts | 6 +- .../sql/video/videos-id-list-query-builder.ts | 4 +- server/core/models/video/video.ts | 8 +- server/core/models/view/video-stats.ts | 163 ++++++++++++++++++ server/core/models/view/video-view.ts | 142 --------------- 11 files changed, 191 insertions(+), 165 deletions(-) create mode 100644 server/core/models/view/video-stats.ts delete mode 100644 server/core/models/view/video-view.ts diff --git a/server/core/controllers/api/videos/stats.ts b/server/core/controllers/api/videos/stats.ts index 50b32abb2fd..4249a58a96e 100644 --- a/server/core/controllers/api/videos/stats.ts +++ b/server/core/controllers/api/videos/stats.ts @@ -16,7 +16,7 @@ import { videoTimeseriesStatsValidator } from '../../../middlewares/index.js' import { MVideo } from '@server/types/models/index.js' -import { VideoViewModel } from '@server/models/view/video-view.js' +import { VideoStatsModel } from '@server/models/view/video-stats.js' const statsRouter = express.Router() @@ -103,7 +103,7 @@ async function getTimeseriesStats (req: express.Request, res: express.Response) switch (metric) { case "downloads": - handler = VideoViewModel.getTimeserieStats + handler = VideoStatsModel.getTimeserieStats break default: handler = LocalVideoViewerModel.getTimeserieStats diff --git a/server/core/initializers/database.ts b/server/core/initializers/database.ts index 9efb01731b0..76ee7b430bf 100644 --- a/server/core/initializers/database.ts +++ b/server/core/initializers/database.ts @@ -71,7 +71,7 @@ import { VideoShareModel } from '../models/video/video-share.js' import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist.js' import { VideoTagModel } from '../models/video/video-tag.js' import { VideoModel } from '../models/video/video.js' -import { VideoViewModel } from '../models/view/video-view.js' +import { VideoStatsModel } from '../models/view/video-stats.js' import { CONFIG } from './config.js' pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string @@ -158,7 +158,7 @@ export async function initDatabaseModels (silent: boolean) { VideoCommentModel, ScheduleVideoUpdateModel, VideoImportModel, - VideoViewModel, + VideoStatsModel, VideoRedundancyModel, UserVideoHistoryModel, VideoLiveModel, diff --git a/server/core/initializers/migrations/0985-download-stats.ts b/server/core/initializers/migrations/0985-download-stats.ts index 7774c9e0dcf..e31329a72b6 100644 --- a/server/core/initializers/migrations/0985-download-stats.ts +++ b/server/core/initializers/migrations/0985-download-stats.ts @@ -16,8 +16,10 @@ async function up(utils: { }, ) + await utils.queryInterface.renameTable("videoView", "videoStats") + await utils.queryInterface.addColumn( - "videoView", + "videoStats", "downloads", { type: Sequelize.INTEGER, defaultValue: 0, @@ -36,9 +38,12 @@ async function down(utils: { await utils.queryInterface.removeColumn("video", "downloads", { transaction: utils.transaction, }) - await utils.queryInterface.removeColumn("videoView", "downloads", { + + await utils.queryInterface.removeColumn("videoStats", "downloads", { transaction: utils.transaction, }) + + await utils.queryInterface.renameTable("videoStats", "videoView") } export { up, down } 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/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/views/shared/video-viewer-stats.ts b/server/core/lib/views/shared/video-viewer-stats.ts index b40bf581f17..6edb7d1ca68 100644 --- a/server/core/lib/views/shared/video-viewer-stats.ts +++ b/server/core/lib/views/shared/video-viewer-stats.ts @@ -11,7 +11,7 @@ import { Redis } from '@server/lib/redis.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 { VideoViewModel } from '@server/models/view/video-view.js' +import { VideoStatsModel } from '@server/models/view/video-stats.js' import { MVideo, MVideoImmutable, MVideoThumbnail } from '@server/types/models/index.js' import { Transaction } from 'sequelize' @@ -261,7 +261,7 @@ export class VideoViewerStats { ) try { - const record = await VideoViewModel.findOne({ + const record = await VideoStatsModel.findOne({ where: { videoId, startDate }, }) if (record) { @@ -270,7 +270,7 @@ export class VideoViewerStats { await record.save() } else { // Create a new time slice for this video downloads - await VideoViewModel.create({ + await VideoStatsModel.create({ startDate: new Date(startDate), endDate: new Date(endDate), downloads: 1, 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/videos-id-list-query-builder.ts b/server/core/models/video/sql/video/videos-id-list-query-builder.ts index 598aba4c5b0..547b25ce774 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 @@ -777,10 +777,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 9daf84f1606..6240a2a1508 100644 --- a/server/core/models/video/video.ts +++ b/server/core/models/video/video.ts @@ -135,7 +135,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, @@ -705,14 +705,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: { @@ -1664,7 +1664,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..fa72d0d5b9b --- /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 0c71b40d19a..00000000000 --- a/server/core/models/view/video-view.ts +++ /dev/null @@ -1,142 +0,0 @@ -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: '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) - @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 - - 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 - }) - }) - } - - static async getTimeserieStats(options: { - video: MVideo - metric: VideoDownloadStatsTimeserieMetric - startDate: string - endDate: string - }): Promise { - 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("videoView"."downloads", 0) AS value - FROM - "intervals" - LEFT JOIN "videoView" ON "videoView"."videoId" = :videoId - AND - "videoView"."startDate" <= "intervals"."endDate" - AND - "videoView"."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 VideoViewModel.sequelize.query(query, queryOptions) - - return { - groupInterval, - data: rows.map(r => ({ - date: r.date, - value: parseInt(r.value) - })) - } - } -} From fc2bd4e7a8a6330dadb93bf2fa12c359d76adc1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NOBILI?= Date: Thu, 5 Mar 2026 17:24:42 +0100 Subject: [PATCH 12/15] lint fixes --- .../migrations/1005-download-stats.ts | 16 +++--- server/core/models/view/video-stats.ts | 56 +++++++++---------- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/server/core/initializers/migrations/1005-download-stats.ts b/server/core/initializers/migrations/1005-download-stats.ts index 32fa71c649d..5593836e3c0 100644 --- a/server/core/initializers/migrations/1005-download-stats.ts +++ b/server/core/initializers/migrations/1005-download-stats.ts @@ -1,4 +1,4 @@ -import * as Sequelize from "sequelize"; +import * as Sequelize from "sequelize" async function up(utils: { transaction: Sequelize.Transaction; @@ -14,9 +14,9 @@ async function up(utils: { }, { transaction: utils.transaction, }, - ); + ) - await utils.queryInterface.renameTable("videoView", "videoStats"); + await utils.queryInterface.renameTable("videoView", "videoStats") await utils.queryInterface.addColumn( "videoStats", @@ -26,7 +26,7 @@ async function up(utils: { }, { transaction: utils.transaction, }, - ); + ) } } @@ -37,13 +37,13 @@ async function down(utils: { }): 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"); + await utils.queryInterface.renameTable("videoStats", "videoView") } -export { up, down }; +export { up, down } diff --git a/server/core/models/view/video-stats.ts b/server/core/models/view/video-stats.ts index fa72d0d5b9b..ef49ab5822e 100644 --- a/server/core/models/view/video-stats.ts +++ b/server/core/models/view/video-stats.ts @@ -1,5 +1,5 @@ -import { MAX_SQL_DELETE_ITEMS } from "@server/initializers/constants.js"; -import { literal, Op, QueryTypes } from "sequelize"; +import { MAX_SQL_DELETE_ITEMS } from "@server/initializers/constants.js" +import { literal, Op, QueryTypes } from "sequelize" import { AllowNull, BelongsTo, @@ -9,15 +9,15 @@ import { Default, ForeignKey, Table, -} from "sequelize-typescript"; -import { safeBulkDestroy, SequelizeModel } from "../shared/index.js"; -import { VideoModel } from "../video/video.js"; +} 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"; +} 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 @@ -27,39 +27,39 @@ import { buildGroupByAndBoundaries } from "@server/lib/timeserie.js"; @Table({ tableName: "videoStats", updatedAt: false, - indexes: [{ - fields: ["videoId"], + indexes: [ { + fields: [ "videoId" ], }, { - fields: ["startDate"], + fields: [ "startDate" ], }, ], }) export class VideoStatsModel extends SequelizeModel < VideoStatsModel > { @CreatedAt - declare createdAt: Date; + declare createdAt: Date @AllowNull(false) @Column(DataType.DATE) - declare startDate: Date; + declare startDate: Date @AllowNull(false) @Column(DataType.DATE) - declare endDate: Date; + declare endDate: Date @AllowNull(false) @Default(0) @Column - declare views: number; + declare views: number @AllowNull(false) @Default(0) @Column - declare downloads: number; + declare downloads: number @ForeignKey(() => VideoModel) @Column - declare videoId: number; + declare videoId: number @BelongsTo(() => VideoModel, { foreignKey: { @@ -67,7 +67,7 @@ export class VideoStatsModel extends SequelizeModel < VideoStatsModel > { }, onDelete: "CASCADE", }) - declare Video: Awaited < VideoModel > ; + declare Video: Awaited < VideoModel > static removeOldRemoteViews(beforeDate: string) { return safeBulkDestroy(() => { @@ -83,8 +83,8 @@ export class VideoStatsModel extends SequelizeModel < VideoStatsModel > { }, }, limit: MAX_SQL_DELETE_ITEMS, - }); - }); + }) + }) } static removeOldLocalViews(beforeDate: string) { @@ -101,8 +101,8 @@ export class VideoStatsModel extends SequelizeModel < VideoStatsModel > { }, }, limit: MAX_SQL_DELETE_ITEMS, - }); - }); + }) + }) } static async getTimeserieStats(options: { @@ -111,12 +111,12 @@ export class VideoStatsModel extends SequelizeModel < VideoStatsModel > { startDate: string; endDate: string; }): Promise < VideoStatsTimeserie > { - const { video } = options; + const { video } = options const { groupInterval, startDate, endDate } = buildGroupByAndBoundaries( options.startDate, options.endDate, - ); + ) const query = `WITH "intervals" AS ( SELECT @@ -135,7 +135,7 @@ export class VideoStatsModel extends SequelizeModel < VideoStatsModel > { "videoStats"."startDate" >= "intervals"."startDate" ORDER BY "intervals"."startDate" - `; + ` const queryOptions = { type: QueryTypes.SELECT as QueryTypes.SELECT, @@ -145,12 +145,12 @@ export class VideoStatsModel extends SequelizeModel < VideoStatsModel > { groupInterval, videoId: video.id, }, - }; + } const rows = await VideoStatsModel.sequelize.query < any > ( query, queryOptions, - ); + ) return { groupInterval, @@ -158,6 +158,6 @@ export class VideoStatsModel extends SequelizeModel < VideoStatsModel > { date: r.date, value: parseInt(r.value), })), - }; + } } } From 10bb89d40835d2a2f6806a306e59d4f21618af36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NOBILI?= Date: Thu, 5 Mar 2026 17:55:35 +0100 Subject: [PATCH 13/15] fix failing tests --- packages/tests/src/shared/sql-command.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 549751e58ae379039784a1624de9b922cead74f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NOBILI?= Date: Wed, 11 Mar 2026 14:47:21 +0100 Subject: [PATCH 14/15] track remote downloads --- packages/models/src/activitypub/activity.ts | 8 ++ packages/models/src/activitypub/context.ts | 1 + server/core/helpers/activity-pub-utils.ts | 9 +- .../custom-validators/activitypub/activity.ts | 9 +- .../activitypub/process/process-download.ts | 30 ++++++ .../core/lib/activitypub/process/process.ts | 2 + .../lib/activitypub/send/send-download.ts | 96 +++++++++++++++++++ server/core/lib/activitypub/url.ts | 4 + server/core/lib/stat-manager.ts | 3 + .../lib/views/shared/video-viewer-stats.ts | 4 + 10 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 server/core/lib/activitypub/process/process-download.ts create mode 100644 server/core/lib/activitypub/send/send-download.ts 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/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/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/stat-manager.ts b/server/core/lib/stat-manager.ts index b3df26f384e..4989298fbce 100644 --- a/server/core/lib/stat-manager.ts +++ b/server/core/lib/stat-manager.ts @@ -149,6 +149,7 @@ class StatsManager { Dislike: 0, Flag: 0, View: 0, + Download: 0, ApproveReply: 0, RejectReply: 0 } @@ -173,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, @@ -188,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/views/shared/video-viewer-stats.ts b/server/core/lib/views/shared/video-viewer-stats.ts index 6edb7d1ca68..6141b71a018 100644 --- a/server/core/lib/views/shared/video-viewer-stats.ts +++ b/server/core/lib/views/shared/video-viewer-stats.ts @@ -6,8 +6,10 @@ 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' @@ -211,6 +213,8 @@ export class VideoViewerStats { 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, From ab802c5bfcc1d29f4ba496ec0245b4656da56f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NOBILI?= Date: Wed, 11 Mar 2026 16:13:23 +0100 Subject: [PATCH 15/15] lint fixes --- .../lib/activitypub/send/send-download.ts | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/server/core/lib/activitypub/send/send-download.ts b/server/core/lib/activitypub/send/send-download.ts index 3ecdbb1b2f8..58337034bb7 100644 --- a/server/core/lib/activitypub/send/send-download.ts +++ b/server/core/lib/activitypub/send/send-download.ts @@ -1,19 +1,19 @@ import { ActivityAudience, ActivityDownload, -} from "@peertube/peertube-models"; -import { VideoViewsManager } from "@server/lib/views/video-views-manager.js"; +} from "@peertube/peertube-models" +import { VideoViewsManager } from "@server/lib/views/video-views-manager.js" import { MActorAudience, MActorLight, MVideoImmutable, MVideoUrl, -} from "@server/types/models/index.js"; -import { Transaction } from "sequelize"; -import { logger } from "../../../helpers/logger.js"; -import { audiencify, getPublicAudience } from "../audience.js"; -import { getDownloadsActivityPubUrl } from "../url.js"; -import { sendVideoRelatedActivity } from "./shared/send-utils.js"; +} from "@server/types/models/index.js" +import { Transaction } from "sequelize" +import { logger } from "../../../helpers/logger.js" +import { audiencify, getPublicAudience } from "../audience.js" +import { getDownloadsActivityPubUrl } from "../url.js" +import { sendVideoRelatedActivity } from "./shared/send-utils.js" async function sendDownload(options: { byActor: MActorLight; @@ -21,12 +21,12 @@ async function sendDownload(options: { downloadsCount ? : number; transaction ? : Transaction; }) { - const { byActor, downloadsCount, video, transaction } = options; + const { byActor, downloadsCount, video, transaction } = options - logger.info("Creating job to send downloads of %s.", video.url); + logger.info("Creating job to send downloads of %s.", video.url) const activityBuilder = (audience: ActivityAudience) => { - const url = getDownloadsActivityPubUrl(byActor, video); + const url = getDownloadsActivityPubUrl(byActor, video) return buildDownloadActivity({ url, @@ -34,8 +34,8 @@ async function sendDownload(options: { video, audience, downloadsCount, - }); - }; + }) + } return sendVideoRelatedActivity(activityBuilder, { byActor, @@ -43,12 +43,12 @@ async function sendDownload(options: { transaction, contextType: "Download", parallelizable: true, - }); + }) } // --------------------------------------------------------------------------- -export { sendDownload }; +export { sendDownload } // --------------------------------------------------------------------------- @@ -65,17 +65,17 @@ function buildDownloadActivity(options: { downloadsCount, video, audience = getPublicAudience(byActor), - } = options; + } = 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, audience) } return audiencify({ @@ -92,5 +92,5 @@ function buildDownloadActivity(options: { }, }, audience, - ); + ) }