diff options
author | Chocobozzz <me@florianbigard.com> | 2022-03-24 13:36:47 +0100 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2022-04-15 09:49:35 +0200 |
commit | b211106695bb82f6c32e53306081b5262c3d109d (patch) | |
tree | fa187de1c33b0956665f5362e29af6b0f6d8bb57 /server/models | |
parent | 69d48ee30c9d47cddf0c3c047dc99a99dcb6e894 (diff) | |
download | PeerTube-b211106695bb82f6c32e53306081b5262c3d109d.tar.gz PeerTube-b211106695bb82f6c32e53306081b5262c3d109d.tar.zst PeerTube-b211106695bb82f6c32e53306081b5262c3d109d.zip |
Support video views/viewers stats in server
* Add "currentTime" and "event" body params to view endpoint
* Merge watching and view endpoints
* Introduce WatchAction AP activity
* Add tables to store viewer information of local videos
* Add endpoints to fetch video views/viewers stats of local videos
* Refactor views/viewers handlers
* Support "views" and "viewers" counters for both VOD and live videos
Diffstat (limited to 'server/models')
-rw-r--r-- | server/models/video/formatter/video-format-utils.ts | 31 | ||||
-rw-r--r-- | server/models/video/video.ts | 2 | ||||
-rw-r--r-- | server/models/view/local-video-viewer-watch-section.ts | 63 | ||||
-rw-r--r-- | server/models/view/local-video-viewer.ts | 274 | ||||
-rw-r--r-- | server/models/view/video-view.ts (renamed from server/models/video/video-view.ts) | 2 |
5 files changed, 355 insertions, 17 deletions
diff --git a/server/models/video/formatter/video-format-utils.ts b/server/models/video/formatter/video-format-utils.ts index 611edf0b9..6222107d7 100644 --- a/server/models/video/formatter/video-format-utils.ts +++ b/server/models/video/formatter/video-format-utils.ts | |||
@@ -1,11 +1,19 @@ | |||
1 | import { generateMagnetUri } from '@server/helpers/webtorrent' | 1 | import { generateMagnetUri } from '@server/helpers/webtorrent' |
2 | import { getActivityStreamDuration } from '@server/lib/activitypub/activity' | ||
2 | import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls' | 3 | import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls' |
3 | import { VideoViews } from '@server/lib/video-views' | 4 | import { VideoViewsManager } from '@server/lib/views/video-views-manager' |
4 | import { uuidToShort } from '@shared/extra-utils' | 5 | import { uuidToShort } from '@shared/extra-utils' |
5 | import { VideoFile, VideosCommonQueryAfterSanitize } from '@shared/models' | 6 | import { |
6 | import { ActivityTagObject, ActivityUrlObject, VideoObject } from '../../../../shared/models/activitypub/objects' | 7 | ActivityTagObject, |
7 | import { Video, VideoDetails, VideoInclude } from '../../../../shared/models/videos' | 8 | ActivityUrlObject, |
8 | import { VideoStreamingPlaylist } from '../../../../shared/models/videos/video-streaming-playlist.model' | 9 | Video, |
10 | VideoDetails, | ||
11 | VideoFile, | ||
12 | VideoInclude, | ||
13 | VideoObject, | ||
14 | VideosCommonQueryAfterSanitize, | ||
15 | VideoStreamingPlaylist | ||
16 | } from '@shared/models' | ||
9 | import { isArray } from '../../../helpers/custom-validators/misc' | 17 | import { isArray } from '../../../helpers/custom-validators/misc' |
10 | import { | 18 | import { |
11 | MIMETYPES, | 19 | MIMETYPES, |
@@ -97,7 +105,10 @@ function videoModelToFormattedJSON (video: MVideoFormattable, options: VideoForm | |||
97 | 105 | ||
98 | isLocal: video.isOwned(), | 106 | isLocal: video.isOwned(), |
99 | duration: video.duration, | 107 | duration: video.duration, |
108 | |||
100 | views: video.views, | 109 | views: video.views, |
110 | viewers: VideoViewsManager.Instance.getViewers(video), | ||
111 | |||
101 | likes: video.likes, | 112 | likes: video.likes, |
102 | dislikes: video.dislikes, | 113 | dislikes: video.dislikes, |
103 | thumbnailPath: video.getMiniatureStaticPath(), | 114 | thumbnailPath: video.getMiniatureStaticPath(), |
@@ -121,10 +132,6 @@ function videoModelToFormattedJSON (video: MVideoFormattable, options: VideoForm | |||
121 | pluginData: (video as any).pluginData | 132 | pluginData: (video as any).pluginData |
122 | } | 133 | } |
123 | 134 | ||
124 | if (video.isLive) { | ||
125 | videoObject.viewers = VideoViews.Instance.getViewers(video) | ||
126 | } | ||
127 | |||
128 | const add = options.additionalAttributes | 135 | const add = options.additionalAttributes |
129 | if (add?.state === true) { | 136 | if (add?.state === true) { |
130 | videoObject.state = { | 137 | videoObject.state = { |
@@ -459,11 +466,6 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject { | |||
459 | } | 466 | } |
460 | } | 467 | } |
461 | 468 | ||
462 | function getActivityStreamDuration (duration: number) { | ||
463 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration | ||
464 | return 'PT' + duration + 'S' | ||
465 | } | ||
466 | |||
467 | function getCategoryLabel (id: number) { | 469 | function getCategoryLabel (id: number) { |
468 | return VIDEO_CATEGORIES[id] || 'Misc' | 470 | return VIDEO_CATEGORIES[id] || 'Misc' |
469 | } | 471 | } |
@@ -489,7 +491,6 @@ export { | |||
489 | videoModelToFormattedDetailsJSON, | 491 | videoModelToFormattedDetailsJSON, |
490 | videoFilesModelToFormattedJSON, | 492 | videoFilesModelToFormattedJSON, |
491 | videoModelToActivityPubObject, | 493 | videoModelToActivityPubObject, |
492 | getActivityStreamDuration, | ||
493 | 494 | ||
494 | guessAdditionalAttributesFromQuery, | 495 | guessAdditionalAttributesFromQuery, |
495 | 496 | ||
diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 8bad2a01e..13d81561a 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts | |||
@@ -106,6 +106,7 @@ import { setAsUpdated } from '../shared' | |||
106 | import { UserModel } from '../user/user' | 106 | import { UserModel } from '../user/user' |
107 | import { UserVideoHistoryModel } from '../user/user-video-history' | 107 | import { UserVideoHistoryModel } from '../user/user-video-history' |
108 | import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils' | 108 | import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils' |
109 | import { VideoViewModel } from '../view/video-view' | ||
109 | import { | 110 | import { |
110 | videoFilesModelToFormattedJSON, | 111 | videoFilesModelToFormattedJSON, |
111 | VideoFormattingJSONOptions, | 112 | VideoFormattingJSONOptions, |
@@ -135,7 +136,6 @@ import { VideoPlaylistElementModel } from './video-playlist-element' | |||
135 | import { VideoShareModel } from './video-share' | 136 | import { VideoShareModel } from './video-share' |
136 | import { VideoStreamingPlaylistModel } from './video-streaming-playlist' | 137 | import { VideoStreamingPlaylistModel } from './video-streaming-playlist' |
137 | import { VideoTagModel } from './video-tag' | 138 | import { VideoTagModel } from './video-tag' |
138 | import { VideoViewModel } from './video-view' | ||
139 | 139 | ||
140 | export enum ScopeNames { | 140 | export enum ScopeNames { |
141 | FOR_API = 'FOR_API', | 141 | FOR_API = 'FOR_API', |
diff --git a/server/models/view/local-video-viewer-watch-section.ts b/server/models/view/local-video-viewer-watch-section.ts new file mode 100644 index 000000000..e29bb7847 --- /dev/null +++ b/server/models/view/local-video-viewer-watch-section.ts | |||
@@ -0,0 +1,63 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table } from 'sequelize-typescript' | ||
3 | import { MLocalVideoViewerWatchSection } from '@server/types/models' | ||
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { LocalVideoViewerModel } from './local-video-viewer' | ||
6 | |||
7 | @Table({ | ||
8 | tableName: 'localVideoViewerWatchSection', | ||
9 | updatedAt: false, | ||
10 | indexes: [ | ||
11 | { | ||
12 | fields: [ 'localVideoViewerId' ] | ||
13 | } | ||
14 | ] | ||
15 | }) | ||
16 | export class LocalVideoViewerWatchSectionModel extends Model<Partial<AttributesOnly<LocalVideoViewerWatchSectionModel>>> { | ||
17 | @CreatedAt | ||
18 | createdAt: Date | ||
19 | |||
20 | @AllowNull(false) | ||
21 | @Column | ||
22 | watchStart: number | ||
23 | |||
24 | @AllowNull(false) | ||
25 | @Column | ||
26 | watchEnd: number | ||
27 | |||
28 | @ForeignKey(() => LocalVideoViewerModel) | ||
29 | @Column | ||
30 | localVideoViewerId: number | ||
31 | |||
32 | @BelongsTo(() => LocalVideoViewerModel, { | ||
33 | foreignKey: { | ||
34 | allowNull: false | ||
35 | }, | ||
36 | onDelete: 'CASCADE' | ||
37 | }) | ||
38 | LocalVideoViewer: LocalVideoViewerModel | ||
39 | |||
40 | static async bulkCreateSections (options: { | ||
41 | localVideoViewerId: number | ||
42 | watchSections: { | ||
43 | start: number | ||
44 | end: number | ||
45 | }[] | ||
46 | transaction?: Transaction | ||
47 | }) { | ||
48 | const { localVideoViewerId, watchSections, transaction } = options | ||
49 | const models: MLocalVideoViewerWatchSection[] = [] | ||
50 | |||
51 | for (const section of watchSections) { | ||
52 | const model = await this.create({ | ||
53 | watchStart: section.start, | ||
54 | watchEnd: section.end, | ||
55 | localVideoViewerId | ||
56 | }, { transaction }) | ||
57 | |||
58 | models.push(model) | ||
59 | } | ||
60 | |||
61 | return models | ||
62 | } | ||
63 | } | ||
diff --git a/server/models/view/local-video-viewer.ts b/server/models/view/local-video-viewer.ts new file mode 100644 index 000000000..6f8de53cd --- /dev/null +++ b/server/models/view/local-video-viewer.ts | |||
@@ -0,0 +1,274 @@ | |||
1 | import { QueryTypes } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, HasMany, IsUUID, Model, Table } from 'sequelize-typescript' | ||
3 | import { STATS_TIMESERIE } from '@server/initializers/constants' | ||
4 | import { getActivityStreamDuration } from '@server/lib/activitypub/activity' | ||
5 | import { MLocalVideoViewer, MLocalVideoViewerWithWatchSections, MVideo } from '@server/types/models' | ||
6 | import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric, WatchActionObject } from '@shared/models' | ||
7 | import { AttributesOnly } from '@shared/typescript-utils' | ||
8 | import { VideoModel } from '../video/video' | ||
9 | import { LocalVideoViewerWatchSectionModel } from './local-video-viewer-watch-section' | ||
10 | |||
11 | @Table({ | ||
12 | tableName: 'localVideoViewer', | ||
13 | updatedAt: false, | ||
14 | indexes: [ | ||
15 | { | ||
16 | fields: [ 'videoId' ] | ||
17 | } | ||
18 | ] | ||
19 | }) | ||
20 | export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVideoViewerModel>>> { | ||
21 | @CreatedAt | ||
22 | createdAt: Date | ||
23 | |||
24 | @AllowNull(false) | ||
25 | @Column(DataType.DATE) | ||
26 | startDate: Date | ||
27 | |||
28 | @AllowNull(false) | ||
29 | @Column(DataType.DATE) | ||
30 | endDate: Date | ||
31 | |||
32 | @AllowNull(false) | ||
33 | @Column | ||
34 | watchTime: number | ||
35 | |||
36 | @AllowNull(true) | ||
37 | @Column | ||
38 | country: string | ||
39 | |||
40 | @AllowNull(false) | ||
41 | @Default(DataType.UUIDV4) | ||
42 | @IsUUID(4) | ||
43 | @Column(DataType.UUID) | ||
44 | uuid: string | ||
45 | |||
46 | @AllowNull(false) | ||
47 | @Column | ||
48 | url: string | ||
49 | |||
50 | @ForeignKey(() => VideoModel) | ||
51 | @Column | ||
52 | videoId: number | ||
53 | |||
54 | @BelongsTo(() => VideoModel, { | ||
55 | foreignKey: { | ||
56 | allowNull: false | ||
57 | }, | ||
58 | onDelete: 'CASCADE' | ||
59 | }) | ||
60 | Video: VideoModel | ||
61 | |||
62 | @HasMany(() => LocalVideoViewerWatchSectionModel, { | ||
63 | foreignKey: { | ||
64 | allowNull: false | ||
65 | }, | ||
66 | onDelete: 'cascade' | ||
67 | }) | ||
68 | WatchSections: LocalVideoViewerWatchSectionModel[] | ||
69 | |||
70 | static loadByUrl (url: string): Promise<MLocalVideoViewer> { | ||
71 | return this.findOne({ | ||
72 | where: { | ||
73 | url | ||
74 | } | ||
75 | }) | ||
76 | } | ||
77 | |||
78 | static loadFullById (id: number): Promise<MLocalVideoViewerWithWatchSections> { | ||
79 | return this.findOne({ | ||
80 | include: [ | ||
81 | { | ||
82 | model: VideoModel.unscoped(), | ||
83 | required: true | ||
84 | }, | ||
85 | { | ||
86 | model: LocalVideoViewerWatchSectionModel.unscoped(), | ||
87 | required: true | ||
88 | } | ||
89 | ], | ||
90 | where: { | ||
91 | id | ||
92 | } | ||
93 | }) | ||
94 | } | ||
95 | |||
96 | static async getOverallStats (video: MVideo): Promise<VideoStatsOverall> { | ||
97 | const options = { | ||
98 | type: QueryTypes.SELECT as QueryTypes.SELECT, | ||
99 | replacements: { videoId: video.id } | ||
100 | } | ||
101 | |||
102 | const watchTimeQuery = `SELECT ` + | ||
103 | `SUM("localVideoViewer"."watchTime") AS "totalWatchTime", ` + | ||
104 | `AVG("localVideoViewer"."watchTime") AS "averageWatchTime" ` + | ||
105 | `FROM "localVideoViewer" ` + | ||
106 | `INNER JOIN "video" ON "video"."id" = "localVideoViewer"."videoId" ` + | ||
107 | `WHERE "videoId" = :videoId` | ||
108 | |||
109 | const watchTimePromise = LocalVideoViewerModel.sequelize.query<any>(watchTimeQuery, options) | ||
110 | |||
111 | const watchPeakQuery = `WITH "watchPeakValues" AS ( | ||
112 | SELECT "startDate" AS "dateBreakpoint", 1 AS "inc" | ||
113 | FROM "localVideoViewer" | ||
114 | WHERE "videoId" = :videoId | ||
115 | UNION ALL | ||
116 | SELECT "endDate" AS "dateBreakpoint", -1 AS "inc" | ||
117 | FROM "localVideoViewer" | ||
118 | WHERE "videoId" = :videoId | ||
119 | ) | ||
120 | SELECT "dateBreakpoint", "concurrent" | ||
121 | FROM ( | ||
122 | SELECT "dateBreakpoint", SUM(SUM("inc")) OVER (ORDER BY "dateBreakpoint") AS "concurrent" | ||
123 | FROM "watchPeakValues" | ||
124 | GROUP BY "dateBreakpoint" | ||
125 | ) tmp | ||
126 | ORDER BY "concurrent" DESC | ||
127 | FETCH FIRST 1 ROW ONLY` | ||
128 | const watchPeakPromise = LocalVideoViewerModel.sequelize.query<any>(watchPeakQuery, options) | ||
129 | |||
130 | const commentsQuery = `SELECT COUNT(*) AS comments FROM "videoComment" WHERE "videoId" = :videoId` | ||
131 | const commentsPromise = LocalVideoViewerModel.sequelize.query<any>(commentsQuery, options) | ||
132 | |||
133 | const countriesQuery = `SELECT country, COUNT(country) as viewers ` + | ||
134 | `FROM "localVideoViewer" ` + | ||
135 | `WHERE "videoId" = :videoId AND country IS NOT NULL ` + | ||
136 | `GROUP BY country ` + | ||
137 | `ORDER BY viewers DESC` | ||
138 | const countriesPromise = LocalVideoViewerModel.sequelize.query<any>(countriesQuery, options) | ||
139 | |||
140 | const [ rowsWatchTime, rowsWatchPeak, rowsComment, rowsCountries ] = await Promise.all([ | ||
141 | watchTimePromise, | ||
142 | watchPeakPromise, | ||
143 | commentsPromise, | ||
144 | countriesPromise | ||
145 | ]) | ||
146 | |||
147 | return { | ||
148 | totalWatchTime: rowsWatchTime.length !== 0 | ||
149 | ? Math.round(rowsWatchTime[0].totalWatchTime) || 0 | ||
150 | : 0, | ||
151 | averageWatchTime: rowsWatchTime.length !== 0 | ||
152 | ? Math.round(rowsWatchTime[0].averageWatchTime) || 0 | ||
153 | : 0, | ||
154 | |||
155 | viewersPeak: rowsWatchPeak.length !== 0 | ||
156 | ? parseInt(rowsWatchPeak[0].concurrent) || 0 | ||
157 | : 0, | ||
158 | viewersPeakDate: rowsWatchPeak.length !== 0 | ||
159 | ? rowsWatchPeak[0].dateBreakpoint || null | ||
160 | : null, | ||
161 | |||
162 | views: video.views, | ||
163 | likes: video.likes, | ||
164 | dislikes: video.dislikes, | ||
165 | |||
166 | comments: rowsComment.length !== 0 | ||
167 | ? parseInt(rowsComment[0].comments) || 0 | ||
168 | : 0, | ||
169 | |||
170 | countries: rowsCountries.map(r => ({ | ||
171 | isoCode: r.country, | ||
172 | viewers: r.viewers | ||
173 | })) | ||
174 | } | ||
175 | } | ||
176 | |||
177 | static async getRetentionStats (video: MVideo): Promise<VideoStatsRetention> { | ||
178 | const step = Math.max(Math.round(video.duration / 100), 1) | ||
179 | |||
180 | const query = `WITH "total" AS (SELECT COUNT(*) AS viewers FROM "localVideoViewer" WHERE "videoId" = :videoId) ` + | ||
181 | `SELECT serie AS "second", ` + | ||
182 | `(COUNT("localVideoViewer".id)::float / (SELECT GREATEST("total"."viewers", 1) FROM "total")) AS "retention" ` + | ||
183 | `FROM generate_series(0, ${video.duration}, ${step}) serie ` + | ||
184 | `LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId ` + | ||
185 | `AND EXISTS (` + | ||
186 | `SELECT 1 FROM "localVideoViewerWatchSection" ` + | ||
187 | `WHERE "localVideoViewer"."id" = "localVideoViewerWatchSection"."localVideoViewerId" ` + | ||
188 | `AND serie >= "localVideoViewerWatchSection"."watchStart" ` + | ||
189 | `AND serie <= "localVideoViewerWatchSection"."watchEnd"` + | ||
190 | `)` + | ||
191 | `GROUP BY serie ` + | ||
192 | `ORDER BY serie ASC` | ||
193 | |||
194 | const queryOptions = { | ||
195 | type: QueryTypes.SELECT as QueryTypes.SELECT, | ||
196 | replacements: { videoId: video.id } | ||
197 | } | ||
198 | |||
199 | const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions) | ||
200 | |||
201 | return { | ||
202 | data: rows.map(r => ({ | ||
203 | second: r.second, | ||
204 | retentionPercent: parseFloat(r.retention) * 100 | ||
205 | })) | ||
206 | } | ||
207 | } | ||
208 | |||
209 | static async getTimeserieStats (options: { | ||
210 | video: MVideo | ||
211 | metric: VideoStatsTimeserieMetric | ||
212 | }): Promise<VideoStatsTimeserie> { | ||
213 | const { video, metric } = options | ||
214 | |||
215 | const selectMetrics: { [ id in VideoStatsTimeserieMetric ]: string } = { | ||
216 | viewers: 'COUNT("localVideoViewer"."id")', | ||
217 | aggregateWatchTime: 'SUM("localVideoViewer"."watchTime")' | ||
218 | } | ||
219 | |||
220 | const query = `WITH days AS ( ` + | ||
221 | `SELECT (current_date::timestamp - (serie || ' days')::interval)::timestamptz AS day | ||
222 | FROM generate_series(0, ${STATS_TIMESERIE.MAX_DAYS - 1}) serie` + | ||
223 | `) ` + | ||
224 | `SELECT days.day AS date, COALESCE(${selectMetrics[metric]}, 0) AS value ` + | ||
225 | `FROM days ` + | ||
226 | `LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId ` + | ||
227 | `AND date_trunc('day', "localVideoViewer"."startDate") = date_trunc('day', days.day) ` + | ||
228 | `GROUP BY day ` + | ||
229 | `ORDER BY day ` | ||
230 | |||
231 | const queryOptions = { | ||
232 | type: QueryTypes.SELECT as QueryTypes.SELECT, | ||
233 | replacements: { videoId: video.id } | ||
234 | } | ||
235 | |||
236 | const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions) | ||
237 | |||
238 | return { | ||
239 | data: rows.map(r => ({ | ||
240 | date: r.date, | ||
241 | value: parseInt(r.value) | ||
242 | })) | ||
243 | } | ||
244 | } | ||
245 | |||
246 | toActivityPubObject (this: MLocalVideoViewerWithWatchSections): WatchActionObject { | ||
247 | const location = this.country | ||
248 | ? { | ||
249 | location: { | ||
250 | addressCountry: this.country | ||
251 | } | ||
252 | } | ||
253 | : {} | ||
254 | |||
255 | return { | ||
256 | id: this.url, | ||
257 | type: 'WatchAction', | ||
258 | duration: getActivityStreamDuration(this.watchTime), | ||
259 | startTime: this.startDate.toISOString(), | ||
260 | endTime: this.endDate.toISOString(), | ||
261 | |||
262 | object: this.Video.url, | ||
263 | uuid: this.uuid, | ||
264 | actionStatus: 'CompletedActionStatus', | ||
265 | |||
266 | watchSections: this.WatchSections.map(w => ({ | ||
267 | startTimestamp: w.watchStart, | ||
268 | endTimestamp: w.watchEnd | ||
269 | })), | ||
270 | |||
271 | ...location | ||
272 | } | ||
273 | } | ||
274 | } | ||
diff --git a/server/models/video/video-view.ts b/server/models/view/video-view.ts index d72df100f..df462e631 100644 --- a/server/models/video/video-view.ts +++ b/server/models/view/video-view.ts | |||
@@ -1,7 +1,7 @@ | |||
1 | import { literal, Op } from 'sequelize' | 1 | import { literal, Op } from 'sequelize' |
2 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table } from 'sequelize-typescript' | 2 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table } from 'sequelize-typescript' |
3 | import { AttributesOnly } from '@shared/typescript-utils' | 3 | import { AttributesOnly } from '@shared/typescript-utils' |
4 | import { VideoModel } from './video' | 4 | import { VideoModel } from '../video/video' |
5 | 5 | ||
6 | @Table({ | 6 | @Table({ |
7 | tableName: 'videoView', | 7 | tableName: 'videoView', |