aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models/video
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2023-07-31 14:34:36 +0200
committerChocobozzz <me@florianbigard.com>2023-08-11 15:02:33 +0200
commit3a4992633ee62d5edfbb484d9c6bcb3cf158489d (patch)
treee4510b39bdac9c318fdb4b47018d08f15368b8f0 /server/models/video
parent04d1da5621d25d59bd5fa1543b725c497bf5d9a8 (diff)
downloadPeerTube-3a4992633ee62d5edfbb484d9c6bcb3cf158489d.tar.gz
PeerTube-3a4992633ee62d5edfbb484d9c6bcb3cf158489d.tar.zst
PeerTube-3a4992633ee62d5edfbb484d9c6bcb3cf158489d.zip
Migrate server to ESM
Sorry for the very big commit that may lead to git log issues and merge conflicts, but it's a major step forward: * Server can be faster at startup because imports() are async and we can easily lazy import big modules * Angular doesn't seem to support ES import (with .js extension), so we had to correctly organize peertube into a monorepo: * Use yarn workspace feature * Use typescript reference projects for dependencies * Shared projects have been moved into "packages", each one is now a node module (with a dedicated package.json/tsconfig.json) * server/tools have been moved into apps/ and is now a dedicated app bundled and published on NPM so users don't have to build peertube cli tools manually * server/tests have been moved into packages/ so we don't compile them every time we want to run the server * Use isolatedModule option: * Had to move from const enum to const (https://www.typescriptlang.org/docs/handbook/enums.html#objects-vs-enums) * Had to explictely specify "type" imports when used in decorators * Prefer tsx (that uses esbuild under the hood) instead of ts-node to load typescript files (tests with mocha or scripts): * To reduce test complexity as esbuild doesn't support decorator metadata, we only test server files that do not import server models * We still build tests files into js files for a faster CI * Remove unmaintained peertube CLI import script * Removed some barrels to speed up execution (less imports)
Diffstat (limited to 'server/models/video')
-rw-r--r--server/models/video/formatter/index.ts2
-rw-r--r--server/models/video/formatter/shared/index.ts1
-rw-r--r--server/models/video/formatter/shared/video-format-utils.ts7
-rw-r--r--server/models/video/formatter/video-activity-pub-format.ts296
-rw-r--r--server/models/video/formatter/video-api-format.ts305
-rw-r--r--server/models/video/schedule-video-update.ts95
-rw-r--r--server/models/video/sql/comment/video-comment-list-query-builder.ts400
-rw-r--r--server/models/video/sql/comment/video-comment-table-attributes.ts43
-rw-r--r--server/models/video/sql/video/index.ts3
-rw-r--r--server/models/video/sql/video/shared/abstract-video-query-builder.ts340
-rw-r--r--server/models/video/sql/video/shared/video-file-query-builder.ts75
-rw-r--r--server/models/video/sql/video/shared/video-model-builder.ts408
-rw-r--r--server/models/video/sql/video/shared/video-table-attributes.ts273
-rw-r--r--server/models/video/sql/video/video-model-get-query-builder.ts189
-rw-r--r--server/models/video/sql/video/videos-id-list-query-builder.ts728
-rw-r--r--server/models/video/sql/video/videos-model-list-query-builder.ts103
-rw-r--r--server/models/video/storyboard.ts169
-rw-r--r--server/models/video/tag.ts86
-rw-r--r--server/models/video/thumbnail.ts208
-rw-r--r--server/models/video/video-blacklist.ts134
-rw-r--r--server/models/video/video-caption.ts247
-rw-r--r--server/models/video/video-change-ownership.ts137
-rw-r--r--server/models/video/video-channel-sync.ts176
-rw-r--r--server/models/video/video-channel.ts860
-rw-r--r--server/models/video/video-comment.ts683
-rw-r--r--server/models/video/video-file.ts635
-rw-r--r--server/models/video/video-import.ts267
-rw-r--r--server/models/video/video-job-info.ts121
-rw-r--r--server/models/video/video-live-replay-setting.ts42
-rw-r--r--server/models/video/video-live-session.ts217
-rw-r--r--server/models/video/video-live.ts184
-rw-r--r--server/models/video/video-password.ts137
-rw-r--r--server/models/video/video-playlist-element.ts370
-rw-r--r--server/models/video/video-playlist.ts725
-rw-r--r--server/models/video/video-share.ts216
-rw-r--r--server/models/video/video-source.ts56
-rw-r--r--server/models/video/video-streaming-playlist.ts328
-rw-r--r--server/models/video/video-tag.ts31
-rw-r--r--server/models/video/video.ts2047
39 files changed, 0 insertions, 11344 deletions
diff --git a/server/models/video/formatter/index.ts b/server/models/video/formatter/index.ts
deleted file mode 100644
index 77b406559..000000000
--- a/server/models/video/formatter/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
1export * from './video-activity-pub-format'
2export * from './video-api-format'
diff --git a/server/models/video/formatter/shared/index.ts b/server/models/video/formatter/shared/index.ts
deleted file mode 100644
index d558fa7d6..000000000
--- a/server/models/video/formatter/shared/index.ts
+++ /dev/null
@@ -1 +0,0 @@
1export * from './video-format-utils'
diff --git a/server/models/video/formatter/shared/video-format-utils.ts b/server/models/video/formatter/shared/video-format-utils.ts
deleted file mode 100644
index df3bbdf1c..000000000
--- a/server/models/video/formatter/shared/video-format-utils.ts
+++ /dev/null
@@ -1,7 +0,0 @@
1import { MVideoFile } from '@server/types/models'
2
3export function sortByResolutionDesc (fileA: MVideoFile, fileB: MVideoFile) {
4 if (fileA.resolution < fileB.resolution) return 1
5 if (fileA.resolution === fileB.resolution) return 0
6 return -1
7}
diff --git a/server/models/video/formatter/video-activity-pub-format.ts b/server/models/video/formatter/video-activity-pub-format.ts
deleted file mode 100644
index 694c66c33..000000000
--- a/server/models/video/formatter/video-activity-pub-format.ts
+++ /dev/null
@@ -1,296 +0,0 @@
1import { isArray } from '@server/helpers/custom-validators/misc'
2import { generateMagnetUri } from '@server/helpers/webtorrent'
3import { getActivityStreamDuration } from '@server/lib/activitypub/activity'
4import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls'
5import {
6 ActivityIconObject,
7 ActivityPlaylistUrlObject,
8 ActivityPubStoryboard,
9 ActivityTagObject,
10 ActivityTrackerUrlObject,
11 ActivityUrlObject,
12 VideoObject
13} from '@shared/models'
14import { MIMETYPES, WEBSERVER } from '../../../initializers/constants'
15import {
16 getLocalVideoCommentsActivityPubUrl,
17 getLocalVideoDislikesActivityPubUrl,
18 getLocalVideoLikesActivityPubUrl,
19 getLocalVideoSharesActivityPubUrl
20} from '../../../lib/activitypub/url'
21import { MStreamingPlaylistFiles, MUserId, MVideo, MVideoAP, MVideoFile } from '../../../types/models'
22import { VideoCaptionModel } from '../video-caption'
23import { sortByResolutionDesc } from './shared'
24import { getCategoryLabel, getLanguageLabel, getLicenceLabel } from './video-api-format'
25
26export function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
27 const language = video.language
28 ? { identifier: video.language, name: getLanguageLabel(video.language) }
29 : undefined
30
31 const category = video.category
32 ? { identifier: video.category + '', name: getCategoryLabel(video.category) }
33 : undefined
34
35 const licence = video.licence
36 ? { identifier: video.licence + '', name: getLicenceLabel(video.licence) }
37 : undefined
38
39 const url: ActivityUrlObject[] = [
40 // HTML url should be the first element in the array so Mastodon correctly displays the embed
41 {
42 type: 'Link',
43 mediaType: 'text/html',
44 href: WEBSERVER.URL + '/videos/watch/' + video.uuid
45 } as ActivityUrlObject,
46
47 ...buildVideoFileUrls({ video, files: video.VideoFiles }),
48
49 ...buildStreamingPlaylistUrls(video),
50
51 ...buildTrackerUrls(video)
52 ]
53
54 return {
55 type: 'Video' as 'Video',
56 id: video.url,
57 name: video.name,
58 duration: getActivityStreamDuration(video.duration),
59 uuid: video.uuid,
60 category,
61 licence,
62 language,
63 views: video.views,
64 sensitive: video.nsfw,
65 waitTranscoding: video.waitTranscoding,
66
67 state: video.state,
68 commentsEnabled: video.commentsEnabled,
69 downloadEnabled: video.downloadEnabled,
70 published: video.publishedAt.toISOString(),
71
72 originallyPublishedAt: video.originallyPublishedAt
73 ? video.originallyPublishedAt.toISOString()
74 : null,
75
76 updated: video.updatedAt.toISOString(),
77
78 uploadDate: video.inputFileUpdatedAt?.toISOString(),
79
80 tag: buildTags(video),
81
82 mediaType: 'text/markdown',
83 content: video.description,
84 support: video.support,
85
86 subtitleLanguage: buildSubtitleLanguage(video),
87
88 icon: buildIcon(video),
89
90 preview: buildPreviewAPAttribute(video),
91
92 url,
93
94 likes: getLocalVideoLikesActivityPubUrl(video),
95 dislikes: getLocalVideoDislikesActivityPubUrl(video),
96 shares: getLocalVideoSharesActivityPubUrl(video),
97 comments: getLocalVideoCommentsActivityPubUrl(video),
98
99 attributedTo: [
100 {
101 type: 'Person',
102 id: video.VideoChannel.Account.Actor.url
103 },
104 {
105 type: 'Group',
106 id: video.VideoChannel.Actor.url
107 }
108 ],
109
110 ...buildLiveAPAttributes(video)
111 }
112}
113
114// ---------------------------------------------------------------------------
115// Private
116// ---------------------------------------------------------------------------
117
118function buildLiveAPAttributes (video: MVideoAP) {
119 if (!video.isLive) {
120 return {
121 isLiveBroadcast: false,
122 liveSaveReplay: null,
123 permanentLive: null,
124 latencyMode: null
125 }
126 }
127
128 return {
129 isLiveBroadcast: true,
130 liveSaveReplay: video.VideoLive.saveReplay,
131 permanentLive: video.VideoLive.permanentLive,
132 latencyMode: video.VideoLive.latencyMode
133 }
134}
135
136function buildPreviewAPAttribute (video: MVideoAP): ActivityPubStoryboard[] {
137 if (!video.Storyboard) return undefined
138
139 const storyboard = video.Storyboard
140
141 return [
142 {
143 type: 'Image',
144 rel: [ 'storyboard' ],
145 url: [
146 {
147 mediaType: 'image/jpeg',
148
149 href: storyboard.getOriginFileUrl(video),
150
151 width: storyboard.totalWidth,
152 height: storyboard.totalHeight,
153
154 tileWidth: storyboard.spriteWidth,
155 tileHeight: storyboard.spriteHeight,
156 tileDuration: getActivityStreamDuration(storyboard.spriteDuration)
157 }
158 ]
159 }
160 ]
161}
162
163function buildVideoFileUrls (options: {
164 video: MVideo
165 files: MVideoFile[]
166 user?: MUserId
167}): ActivityUrlObject[] {
168 const { video, files } = options
169
170 if (!isArray(files)) return []
171
172 const urls: ActivityUrlObject[] = []
173
174 const trackerUrls = video.getTrackerUrls()
175 const sortedFiles = files
176 .filter(f => !f.isLive())
177 .sort(sortByResolutionDesc)
178
179 for (const file of sortedFiles) {
180 urls.push({
181 type: 'Link',
182 mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] as any,
183 href: file.getFileUrl(video),
184 height: file.resolution,
185 size: file.size,
186 fps: file.fps
187 })
188
189 urls.push({
190 type: 'Link',
191 rel: [ 'metadata', MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] ],
192 mediaType: 'application/json' as 'application/json',
193 href: getLocalVideoFileMetadataUrl(video, file),
194 height: file.resolution,
195 fps: file.fps
196 })
197
198 if (file.hasTorrent()) {
199 urls.push({
200 type: 'Link',
201 mediaType: 'application/x-bittorrent' as 'application/x-bittorrent',
202 href: file.getTorrentUrl(),
203 height: file.resolution
204 })
205
206 urls.push({
207 type: 'Link',
208 mediaType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
209 href: generateMagnetUri(video, file, trackerUrls),
210 height: file.resolution
211 })
212 }
213 }
214
215 return urls
216}
217
218// ---------------------------------------------------------------------------
219
220function buildStreamingPlaylistUrls (video: MVideoAP): ActivityPlaylistUrlObject[] {
221 if (!isArray(video.VideoStreamingPlaylists)) return []
222
223 return video.VideoStreamingPlaylists
224 .map(playlist => ({
225 type: 'Link',
226 mediaType: 'application/x-mpegURL' as 'application/x-mpegURL',
227 href: playlist.getMasterPlaylistUrl(video),
228 tag: buildStreamingPlaylistTags(video, playlist)
229 }))
230}
231
232function buildStreamingPlaylistTags (video: MVideoAP, playlist: MStreamingPlaylistFiles) {
233 return [
234 ...playlist.p2pMediaLoaderInfohashes.map(i => ({ type: 'Infohash' as 'Infohash', name: i })),
235
236 {
237 type: 'Link',
238 name: 'sha256',
239 mediaType: 'application/json' as 'application/json',
240 href: playlist.getSha256SegmentsUrl(video)
241 },
242
243 ...buildVideoFileUrls({ video, files: playlist.VideoFiles })
244 ] as ActivityTagObject[]
245}
246
247// ---------------------------------------------------------------------------
248
249function buildTrackerUrls (video: MVideoAP): ActivityTrackerUrlObject[] {
250 return video.getTrackerUrls()
251 .map(trackerUrl => {
252 const rel2 = trackerUrl.startsWith('http')
253 ? 'http'
254 : 'websocket'
255
256 return {
257 type: 'Link',
258 name: `tracker-${rel2}`,
259 rel: [ 'tracker', rel2 ],
260 href: trackerUrl
261 }
262 })
263}
264
265// ---------------------------------------------------------------------------
266
267function buildTags (video: MVideoAP) {
268 if (!isArray(video.Tags)) return []
269
270 return video.Tags.map(t => ({
271 type: 'Hashtag' as 'Hashtag',
272 name: t.name
273 }))
274}
275
276function buildIcon (video: MVideoAP): ActivityIconObject[] {
277 return [ video.getMiniature(), video.getPreview() ]
278 .map(i => ({
279 type: 'Image',
280 url: i.getOriginFileUrl(video),
281 mediaType: 'image/jpeg',
282 width: i.width,
283 height: i.height
284 }))
285}
286
287function buildSubtitleLanguage (video: MVideoAP) {
288 if (!isArray(video.VideoCaptions)) return []
289
290 return video.VideoCaptions
291 .map(caption => ({
292 identifier: caption.language,
293 name: VideoCaptionModel.getLanguageLabel(caption.language),
294 url: caption.getFileUrl(video)
295 }))
296}
diff --git a/server/models/video/formatter/video-api-format.ts b/server/models/video/formatter/video-api-format.ts
deleted file mode 100644
index 7a58f5d3c..000000000
--- a/server/models/video/formatter/video-api-format.ts
+++ /dev/null
@@ -1,305 +0,0 @@
1import { generateMagnetUri } from '@server/helpers/webtorrent'
2import { tracer } from '@server/lib/opentelemetry/tracing'
3import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls'
4import { VideoViewsManager } from '@server/lib/views/video-views-manager'
5import { uuidToShort } from '@shared/extra-utils'
6import {
7 Video,
8 VideoAdditionalAttributes,
9 VideoDetails,
10 VideoFile,
11 VideoInclude,
12 VideosCommonQueryAfterSanitize,
13 VideoStreamingPlaylist
14} from '@shared/models'
15import { isArray } from '../../../helpers/custom-validators/misc'
16import { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES, VIDEO_STATES } from '../../../initializers/constants'
17import { MServer, MStreamingPlaylistRedundanciesOpt, MVideoFormattable, MVideoFormattableDetails } from '../../../types/models'
18import { MVideoFileRedundanciesOpt } from '../../../types/models/video/video-file'
19import { sortByResolutionDesc } from './shared'
20
21export type VideoFormattingJSONOptions = {
22 completeDescription?: boolean
23
24 additionalAttributes?: {
25 state?: boolean
26 waitTranscoding?: boolean
27 scheduledUpdate?: boolean
28 blacklistInfo?: boolean
29 files?: boolean
30 blockedOwner?: boolean
31 }
32}
33
34export function guessAdditionalAttributesFromQuery (query: VideosCommonQueryAfterSanitize): VideoFormattingJSONOptions {
35 if (!query?.include) return {}
36
37 return {
38 additionalAttributes: {
39 state: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
40 waitTranscoding: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
41 scheduledUpdate: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE),
42 blacklistInfo: !!(query.include & VideoInclude.BLACKLISTED),
43 files: !!(query.include & VideoInclude.FILES),
44 blockedOwner: !!(query.include & VideoInclude.BLOCKED_OWNER)
45 }
46 }
47}
48
49// ---------------------------------------------------------------------------
50
51export function videoModelToFormattedJSON (video: MVideoFormattable, options: VideoFormattingJSONOptions = {}): Video {
52 const span = tracer.startSpan('peertube.VideoModel.toFormattedJSON')
53
54 const userHistory = isArray(video.UserVideoHistories)
55 ? video.UserVideoHistories[0]
56 : undefined
57
58 const videoObject: Video = {
59 id: video.id,
60 uuid: video.uuid,
61 shortUUID: uuidToShort(video.uuid),
62
63 url: video.url,
64
65 name: video.name,
66 category: {
67 id: video.category,
68 label: getCategoryLabel(video.category)
69 },
70 licence: {
71 id: video.licence,
72 label: getLicenceLabel(video.licence)
73 },
74 language: {
75 id: video.language,
76 label: getLanguageLabel(video.language)
77 },
78 privacy: {
79 id: video.privacy,
80 label: getPrivacyLabel(video.privacy)
81 },
82 nsfw: video.nsfw,
83
84 truncatedDescription: video.getTruncatedDescription(),
85 description: options && options.completeDescription === true
86 ? video.description
87 : video.getTruncatedDescription(),
88
89 isLocal: video.isOwned(),
90 duration: video.duration,
91
92 views: video.views,
93 viewers: VideoViewsManager.Instance.getViewers(video),
94
95 likes: video.likes,
96 dislikes: video.dislikes,
97 thumbnailPath: video.getMiniatureStaticPath(),
98 previewPath: video.getPreviewStaticPath(),
99 embedPath: video.getEmbedStaticPath(),
100 createdAt: video.createdAt,
101 updatedAt: video.updatedAt,
102 publishedAt: video.publishedAt,
103 originallyPublishedAt: video.originallyPublishedAt,
104
105 isLive: video.isLive,
106
107 account: video.VideoChannel.Account.toFormattedSummaryJSON(),
108 channel: video.VideoChannel.toFormattedSummaryJSON(),
109
110 userHistory: userHistory
111 ? { currentTime: userHistory.currentTime }
112 : undefined,
113
114 // Can be added by external plugins
115 pluginData: (video as any).pluginData,
116
117 ...buildAdditionalAttributes(video, options)
118 }
119
120 span.end()
121
122 return videoObject
123}
124
125export function videoModelToFormattedDetailsJSON (video: MVideoFormattableDetails): VideoDetails {
126 const span = tracer.startSpan('peertube.VideoModel.toFormattedDetailsJSON')
127
128 const videoJSON = video.toFormattedJSON({
129 completeDescription: true,
130 additionalAttributes: {
131 scheduledUpdate: true,
132 blacklistInfo: true,
133 files: true
134 }
135 }) as Video & Required<Pick<Video, 'files' | 'streamingPlaylists' | 'scheduledUpdate' | 'blacklisted' | 'blacklistedReason'>>
136
137 const tags = video.Tags
138 ? video.Tags.map(t => t.name)
139 : []
140
141 const detailsJSON = {
142 ...videoJSON,
143
144 support: video.support,
145 descriptionPath: video.getDescriptionAPIPath(),
146 channel: video.VideoChannel.toFormattedJSON(),
147 account: video.VideoChannel.Account.toFormattedJSON(),
148 tags,
149 commentsEnabled: video.commentsEnabled,
150 downloadEnabled: video.downloadEnabled,
151 waitTranscoding: video.waitTranscoding,
152 inputFileUpdatedAt: video.inputFileUpdatedAt,
153 state: {
154 id: video.state,
155 label: getStateLabel(video.state)
156 },
157
158 trackerUrls: video.getTrackerUrls()
159 }
160
161 span.end()
162
163 return detailsJSON
164}
165
166export function streamingPlaylistsModelToFormattedJSON (
167 video: MVideoFormattable,
168 playlists: MStreamingPlaylistRedundanciesOpt[]
169): VideoStreamingPlaylist[] {
170 if (isArray(playlists) === false) return []
171
172 return playlists
173 .map(playlist => ({
174 id: playlist.id,
175 type: playlist.type,
176
177 playlistUrl: playlist.getMasterPlaylistUrl(video),
178 segmentsSha256Url: playlist.getSha256SegmentsUrl(video),
179
180 redundancies: isArray(playlist.RedundancyVideos)
181 ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl }))
182 : [],
183
184 files: videoFilesModelToFormattedJSON(video, playlist.VideoFiles)
185 }))
186}
187
188export function videoFilesModelToFormattedJSON (
189 video: MVideoFormattable,
190 videoFiles: MVideoFileRedundanciesOpt[],
191 options: {
192 includeMagnet?: boolean // default true
193 } = {}
194): VideoFile[] {
195 const { includeMagnet = true } = options
196
197 if (isArray(videoFiles) === false) return []
198
199 const trackerUrls = includeMagnet
200 ? video.getTrackerUrls()
201 : []
202
203 return videoFiles
204 .filter(f => !f.isLive())
205 .sort(sortByResolutionDesc)
206 .map(videoFile => {
207 return {
208 id: videoFile.id,
209
210 resolution: {
211 id: videoFile.resolution,
212 label: videoFile.resolution === 0
213 ? 'Audio'
214 : `${videoFile.resolution}p`
215 },
216
217 magnetUri: includeMagnet && videoFile.hasTorrent()
218 ? generateMagnetUri(video, videoFile, trackerUrls)
219 : undefined,
220
221 size: videoFile.size,
222 fps: videoFile.fps,
223
224 torrentUrl: videoFile.getTorrentUrl(),
225 torrentDownloadUrl: videoFile.getTorrentDownloadUrl(),
226
227 fileUrl: videoFile.getFileUrl(video),
228 fileDownloadUrl: videoFile.getFileDownloadUrl(video),
229
230 metadataUrl: videoFile.metadataUrl ?? getLocalVideoFileMetadataUrl(video, videoFile)
231 }
232 })
233}
234
235// ---------------------------------------------------------------------------
236
237export function getCategoryLabel (id: number) {
238 return VIDEO_CATEGORIES[id] || 'Unknown'
239}
240
241export function getLicenceLabel (id: number) {
242 return VIDEO_LICENCES[id] || 'Unknown'
243}
244
245export function getLanguageLabel (id: string) {
246 return VIDEO_LANGUAGES[id] || 'Unknown'
247}
248
249export function getPrivacyLabel (id: number) {
250 return VIDEO_PRIVACIES[id] || 'Unknown'
251}
252
253export function getStateLabel (id: number) {
254 return VIDEO_STATES[id] || 'Unknown'
255}
256
257// ---------------------------------------------------------------------------
258// Private
259// ---------------------------------------------------------------------------
260
261function buildAdditionalAttributes (video: MVideoFormattable, options: VideoFormattingJSONOptions) {
262 const add = options.additionalAttributes
263
264 const result: Partial<VideoAdditionalAttributes> = {}
265
266 if (add?.state === true) {
267 result.state = {
268 id: video.state,
269 label: getStateLabel(video.state)
270 }
271 }
272
273 if (add?.waitTranscoding === true) {
274 result.waitTranscoding = video.waitTranscoding
275 }
276
277 if (add?.scheduledUpdate === true && video.ScheduleVideoUpdate) {
278 result.scheduledUpdate = {
279 updateAt: video.ScheduleVideoUpdate.updateAt,
280 privacy: video.ScheduleVideoUpdate.privacy || undefined
281 }
282 }
283
284 if (add?.blacklistInfo === true) {
285 result.blacklisted = !!video.VideoBlacklist
286 result.blacklistedReason =
287 video.VideoBlacklist
288 ? video.VideoBlacklist.reason
289 : null
290 }
291
292 if (add?.blockedOwner === true) {
293 result.blockedOwner = video.VideoChannel.Account.isBlocked()
294
295 const server = video.VideoChannel.Account.Actor.Server as MServer
296 result.blockedServer = !!(server?.isBlocked())
297 }
298
299 if (add?.files === true) {
300 result.streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists)
301 result.files = videoFilesModelToFormattedJSON(video, video.VideoFiles)
302 }
303
304 return result
305}
diff --git a/server/models/video/schedule-video-update.ts b/server/models/video/schedule-video-update.ts
deleted file mode 100644
index b3cf26966..000000000
--- a/server/models/video/schedule-video-update.ts
+++ /dev/null
@@ -1,95 +0,0 @@
1import { Op, Transaction } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { MScheduleVideoUpdateFormattable, MScheduleVideoUpdate } from '@server/types/models'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { VideoPrivacy } from '../../../shared/models/videos'
6import { VideoModel } from './video'
7
8@Table({
9 tableName: 'scheduleVideoUpdate',
10 indexes: [
11 {
12 fields: [ 'videoId' ],
13 unique: true
14 },
15 {
16 fields: [ 'updateAt' ]
17 }
18 ]
19})
20export class ScheduleVideoUpdateModel extends Model<Partial<AttributesOnly<ScheduleVideoUpdateModel>>> {
21
22 @AllowNull(false)
23 @Default(null)
24 @Column
25 updateAt: Date
26
27 @AllowNull(true)
28 @Default(null)
29 @Column
30 privacy: VideoPrivacy.PUBLIC | VideoPrivacy.UNLISTED | VideoPrivacy.INTERNAL
31
32 @CreatedAt
33 createdAt: Date
34
35 @UpdatedAt
36 updatedAt: Date
37
38 @ForeignKey(() => VideoModel)
39 @Column
40 videoId: number
41
42 @BelongsTo(() => VideoModel, {
43 foreignKey: {
44 allowNull: false
45 },
46 onDelete: 'cascade'
47 })
48 Video: VideoModel
49
50 static areVideosToUpdate () {
51 const query = {
52 logging: false,
53 attributes: [ 'id' ],
54 where: {
55 updateAt: {
56 [Op.lte]: new Date()
57 }
58 }
59 }
60
61 return ScheduleVideoUpdateModel.findOne(query)
62 .then(res => !!res)
63 }
64
65 static listVideosToUpdate (transaction?: Transaction) {
66 const query = {
67 where: {
68 updateAt: {
69 [Op.lte]: new Date()
70 }
71 },
72 transaction
73 }
74
75 return ScheduleVideoUpdateModel.findAll<MScheduleVideoUpdate>(query)
76 }
77
78 static deleteByVideoId (videoId: number, t: Transaction) {
79 const query = {
80 where: {
81 videoId
82 },
83 transaction: t
84 }
85
86 return ScheduleVideoUpdateModel.destroy(query)
87 }
88
89 toFormattedJSON (this: MScheduleVideoUpdateFormattable) {
90 return {
91 updateAt: this.updateAt,
92 privacy: this.privacy || undefined
93 }
94 }
95}
diff --git a/server/models/video/sql/comment/video-comment-list-query-builder.ts b/server/models/video/sql/comment/video-comment-list-query-builder.ts
deleted file mode 100644
index a7eed22a1..000000000
--- a/server/models/video/sql/comment/video-comment-list-query-builder.ts
+++ /dev/null
@@ -1,400 +0,0 @@
1import { Model, Sequelize, Transaction } from 'sequelize'
2import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
3import { ActorImageType, VideoPrivacy } from '@shared/models'
4import { createSafeIn, getSort, parseRowCountResult } from '../../../shared'
5import { VideoCommentTableAttributes } from './video-comment-table-attributes'
6
7export interface ListVideoCommentsOptions {
8 selectType: 'api' | 'feed' | 'comment-only'
9
10 start?: number
11 count?: number
12 sort?: string
13
14 videoId?: number
15 threadId?: number
16 accountId?: number
17 videoChannelId?: number
18
19 blockerAccountIds?: number[]
20
21 isThread?: boolean
22 notDeleted?: boolean
23 isLocal?: boolean
24 onLocalVideo?: boolean
25 onPublicVideo?: boolean
26 videoAccountOwnerId?: boolean
27
28 search?: string
29 searchAccount?: string
30 searchVideo?: string
31
32 includeReplyCounters?: boolean
33
34 transaction?: Transaction
35}
36
37export class VideoCommentListQueryBuilder extends AbstractRunQuery {
38 private readonly tableAttributes = new VideoCommentTableAttributes()
39
40 private innerQuery: string
41
42 private select = ''
43 private joins = ''
44
45 private innerSelect = ''
46 private innerJoins = ''
47 private innerLateralJoins = ''
48 private innerWhere = ''
49
50 private readonly built = {
51 cte: false,
52 accountJoin: false,
53 videoJoin: false,
54 videoChannelJoin: false,
55 avatarJoin: false
56 }
57
58 constructor (
59 protected readonly sequelize: Sequelize,
60 private readonly options: ListVideoCommentsOptions
61 ) {
62 super(sequelize)
63
64 if (this.options.includeReplyCounters && !this.options.videoId) {
65 throw new Error('Cannot include reply counters without videoId')
66 }
67 }
68
69 async listComments <T extends Model> () {
70 this.buildListQuery()
71
72 const results = await this.runQuery({ nest: true, transaction: this.options.transaction })
73 const modelBuilder = new ModelBuilder<T>(this.sequelize)
74
75 return modelBuilder.createModels(results, 'VideoComment')
76 }
77
78 async countComments () {
79 this.buildCountQuery()
80
81 const result = await this.runQuery({ transaction: this.options.transaction })
82
83 return parseRowCountResult(result)
84 }
85
86 // ---------------------------------------------------------------------------
87
88 private buildListQuery () {
89 this.buildInnerListQuery()
90 this.buildListSelect()
91
92 this.query = `${this.select} ` +
93 `FROM (${this.innerQuery}) AS "VideoCommentModel" ` +
94 `${this.joins} ` +
95 `${this.getOrder()}`
96 }
97
98 private buildInnerListQuery () {
99 this.buildWhere()
100 this.buildInnerListSelect()
101
102 this.innerQuery = `${this.innerSelect} ` +
103 `FROM "videoComment" AS "VideoCommentModel" ` +
104 `${this.innerJoins} ` +
105 `${this.innerLateralJoins} ` +
106 `${this.innerWhere} ` +
107 `${this.getOrder()} ` +
108 `${this.getInnerLimit()}`
109 }
110
111 // ---------------------------------------------------------------------------
112
113 private buildCountQuery () {
114 this.buildWhere()
115
116 this.query = `SELECT COUNT(*) AS "total" ` +
117 `FROM "videoComment" AS "VideoCommentModel" ` +
118 `${this.innerJoins} ` +
119 `${this.innerWhere}`
120 }
121
122 // ---------------------------------------------------------------------------
123
124 private buildWhere () {
125 let where: string[] = []
126
127 if (this.options.videoId) {
128 this.replacements.videoId = this.options.videoId
129
130 where.push('"VideoCommentModel"."videoId" = :videoId')
131 }
132
133 if (this.options.threadId) {
134 this.replacements.threadId = this.options.threadId
135
136 where.push('("VideoCommentModel"."id" = :threadId OR "VideoCommentModel"."originCommentId" = :threadId)')
137 }
138
139 if (this.options.accountId) {
140 this.replacements.accountId = this.options.accountId
141
142 where.push('"VideoCommentModel"."accountId" = :accountId')
143 }
144
145 if (this.options.videoChannelId) {
146 this.buildVideoChannelJoin()
147
148 this.replacements.videoChannelId = this.options.videoChannelId
149
150 where.push('"Account->VideoChannel"."id" = :videoChannelId')
151 }
152
153 if (this.options.blockerAccountIds) {
154 this.buildVideoChannelJoin()
155
156 where = where.concat(this.getBlockWhere('VideoCommentModel', 'Video->VideoChannel'))
157 }
158
159 if (this.options.isThread === true) {
160 where.push('"VideoCommentModel"."inReplyToCommentId" IS NULL')
161 }
162
163 if (this.options.notDeleted === true) {
164 where.push('"VideoCommentModel"."deletedAt" IS NULL')
165 }
166
167 if (this.options.isLocal === true) {
168 this.buildAccountJoin()
169
170 where.push('"Account->Actor"."serverId" IS NULL')
171 } else if (this.options.isLocal === false) {
172 this.buildAccountJoin()
173
174 where.push('"Account->Actor"."serverId" IS NOT NULL')
175 }
176
177 if (this.options.onLocalVideo === true) {
178 this.buildVideoJoin()
179
180 where.push('"Video"."remote" IS FALSE')
181 } else if (this.options.onLocalVideo === false) {
182 this.buildVideoJoin()
183
184 where.push('"Video"."remote" IS TRUE')
185 }
186
187 if (this.options.onPublicVideo === true) {
188 this.buildVideoJoin()
189
190 where.push(`"Video"."privacy" = ${VideoPrivacy.PUBLIC}`)
191 }
192
193 if (this.options.videoAccountOwnerId) {
194 this.buildVideoChannelJoin()
195
196 this.replacements.videoAccountOwnerId = this.options.videoAccountOwnerId
197
198 where.push(`"Video->VideoChannel"."accountId" = :videoAccountOwnerId`)
199 }
200
201 if (this.options.search) {
202 this.buildVideoJoin()
203 this.buildAccountJoin()
204
205 const escapedLikeSearch = this.sequelize.escape('%' + this.options.search + '%')
206
207 where.push(
208 `(` +
209 `"VideoCommentModel"."text" ILIKE ${escapedLikeSearch} OR ` +
210 `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
211 `"Account"."name" ILIKE ${escapedLikeSearch} OR ` +
212 `"Video"."name" ILIKE ${escapedLikeSearch} ` +
213 `)`
214 )
215 }
216
217 if (this.options.searchAccount) {
218 this.buildAccountJoin()
219
220 const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchAccount + '%')
221
222 where.push(
223 `(` +
224 `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
225 `"Account"."name" ILIKE ${escapedLikeSearch} ` +
226 `)`
227 )
228 }
229
230 if (this.options.searchVideo) {
231 this.buildVideoJoin()
232
233 const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchVideo + '%')
234
235 where.push(`"Video"."name" ILIKE ${escapedLikeSearch}`)
236 }
237
238 if (where.length !== 0) {
239 this.innerWhere = `WHERE ${where.join(' AND ')}`
240 }
241 }
242
243 private buildAccountJoin () {
244 if (this.built.accountJoin) return
245
246 this.innerJoins += ' LEFT JOIN "account" "Account" ON "Account"."id" = "VideoCommentModel"."accountId" ' +
247 'LEFT JOIN "actor" "Account->Actor" ON "Account->Actor"."id" = "Account"."actorId" ' +
248 'LEFT JOIN "server" "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id" '
249
250 this.built.accountJoin = true
251 }
252
253 private buildVideoJoin () {
254 if (this.built.videoJoin) return
255
256 this.innerJoins += ' LEFT JOIN "video" "Video" ON "Video"."id" = "VideoCommentModel"."videoId" '
257
258 this.built.videoJoin = true
259 }
260
261 private buildVideoChannelJoin () {
262 if (this.built.videoChannelJoin) return
263
264 this.buildVideoJoin()
265
266 this.innerJoins += ' LEFT JOIN "videoChannel" "Video->VideoChannel" ON "Video"."channelId" = "Video->VideoChannel"."id" '
267
268 this.built.videoChannelJoin = true
269 }
270
271 private buildAvatarsJoin () {
272 if (this.options.selectType !== 'api' && this.options.selectType !== 'feed') return ''
273 if (this.built.avatarJoin) return
274
275 this.joins += `LEFT JOIN "actorImage" "Account->Actor->Avatars" ` +
276 `ON "VideoCommentModel"."Account.Actor.id" = "Account->Actor->Avatars"."actorId" ` +
277 `AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
278
279 this.built.avatarJoin = true
280 }
281
282 // ---------------------------------------------------------------------------
283
284 private buildListSelect () {
285 const toSelect = [ '"VideoCommentModel".*' ]
286
287 if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
288 this.buildAvatarsJoin()
289
290 toSelect.push(this.tableAttributes.getAvatarAttributes())
291 }
292
293 this.select = this.buildSelect(toSelect)
294 }
295
296 private buildInnerListSelect () {
297 let toSelect = [ this.tableAttributes.getVideoCommentAttributes() ]
298
299 if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
300 this.buildAccountJoin()
301 this.buildVideoJoin()
302
303 toSelect = toSelect.concat([
304 this.tableAttributes.getVideoAttributes(),
305 this.tableAttributes.getAccountAttributes(),
306 this.tableAttributes.getActorAttributes(),
307 this.tableAttributes.getServerAttributes()
308 ])
309 }
310
311 if (this.options.includeReplyCounters === true) {
312 this.buildTotalRepliesSelect()
313 this.buildAuthorTotalRepliesSelect()
314
315 toSelect.push('"totalRepliesFromVideoAuthor"."count" AS "totalRepliesFromVideoAuthor"')
316 toSelect.push('"totalReplies"."count" AS "totalReplies"')
317 }
318
319 this.innerSelect = this.buildSelect(toSelect)
320 }
321
322 // ---------------------------------------------------------------------------
323
324 private getBlockWhere (commentTableName: string, channelTableName: string) {
325 const where: string[] = []
326
327 const blockerIdsString = createSafeIn(
328 this.sequelize,
329 this.options.blockerAccountIds,
330 [ `"${channelTableName}"."accountId"` ]
331 )
332
333 where.push(
334 `NOT EXISTS (` +
335 `SELECT 1 FROM "accountBlocklist" ` +
336 `WHERE "targetAccountId" = "${commentTableName}"."accountId" ` +
337 `AND "accountId" IN (${blockerIdsString})` +
338 `)`
339 )
340
341 where.push(
342 `NOT EXISTS (` +
343 `SELECT 1 FROM "account" ` +
344 `INNER JOIN "actor" ON account."actorId" = actor.id ` +
345 `INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` +
346 `WHERE "account"."id" = "${commentTableName}"."accountId" ` +
347 `AND "serverBlocklist"."accountId" IN (${blockerIdsString})` +
348 `)`
349 )
350
351 return where
352 }
353
354 // ---------------------------------------------------------------------------
355
356 private buildTotalRepliesSelect () {
357 const blockWhereString = this.getBlockWhere('replies', 'videoChannel').join(' AND ')
358
359 // Help the planner by providing videoId that should filter out many comments
360 this.replacements.videoId = this.options.videoId
361
362 this.innerLateralJoins += `LEFT JOIN LATERAL (` +
363 `SELECT COUNT("replies"."id") AS "count" FROM "videoComment" AS "replies" ` +
364 `INNER JOIN "video" ON "video"."id" = "replies"."videoId" AND "replies"."videoId" = :videoId ` +
365 `LEFT JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id" ` +
366 `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ` +
367 `AND "deletedAt" IS NULL ` +
368 `AND ${blockWhereString} ` +
369 `) "totalReplies" ON TRUE `
370 }
371
372 private buildAuthorTotalRepliesSelect () {
373 // Help the planner by providing videoId that should filter out many comments
374 this.replacements.videoId = this.options.videoId
375
376 this.innerLateralJoins += `LEFT JOIN LATERAL (` +
377 `SELECT COUNT("replies"."id") AS "count" FROM "videoComment" AS "replies" ` +
378 `INNER JOIN "video" ON "video"."id" = "replies"."videoId" AND "replies"."videoId" = :videoId ` +
379 `INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ` +
380 `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" AND "replies"."accountId" = "videoChannel"."accountId"` +
381 `) "totalRepliesFromVideoAuthor" ON TRUE `
382 }
383
384 private getOrder () {
385 if (!this.options.sort) return ''
386
387 const orders = getSort(this.options.sort)
388
389 return 'ORDER BY ' + orders.map(o => `"${o[0]}" ${o[1]}`).join(', ')
390 }
391
392 private getInnerLimit () {
393 if (!this.options.count) return ''
394
395 this.replacements.limit = this.options.count
396 this.replacements.offset = this.options.start || 0
397
398 return `LIMIT :limit OFFSET :offset `
399 }
400}
diff --git a/server/models/video/sql/comment/video-comment-table-attributes.ts b/server/models/video/sql/comment/video-comment-table-attributes.ts
deleted file mode 100644
index 87f8750c1..000000000
--- a/server/models/video/sql/comment/video-comment-table-attributes.ts
+++ /dev/null
@@ -1,43 +0,0 @@
1import { Memoize } from '@server/helpers/memoize'
2import { AccountModel } from '@server/models/account/account'
3import { ActorModel } from '@server/models/actor/actor'
4import { ActorImageModel } from '@server/models/actor/actor-image'
5import { ServerModel } from '@server/models/server/server'
6import { VideoCommentModel } from '../../video-comment'
7
8export class VideoCommentTableAttributes {
9
10 @Memoize()
11 getVideoCommentAttributes () {
12 return VideoCommentModel.getSQLAttributes('VideoCommentModel').join(', ')
13 }
14
15 @Memoize()
16 getAccountAttributes () {
17 return AccountModel.getSQLAttributes('Account', 'Account.').join(', ')
18 }
19
20 @Memoize()
21 getVideoAttributes () {
22 return [
23 `"Video"."id" AS "Video.id"`,
24 `"Video"."uuid" AS "Video.uuid"`,
25 `"Video"."name" AS "Video.name"`
26 ].join(', ')
27 }
28
29 @Memoize()
30 getActorAttributes () {
31 return ActorModel.getSQLAPIAttributes('Account->Actor', `Account.Actor.`).join(', ')
32 }
33
34 @Memoize()
35 getServerAttributes () {
36 return ServerModel.getSQLAttributes('Account->Actor->Server', `Account.Actor.Server.`).join(', ')
37 }
38
39 @Memoize()
40 getAvatarAttributes () {
41 return ActorImageModel.getSQLAttributes('Account->Actor->Avatars', 'Account.Actor.Avatars.').join(', ')
42 }
43}
diff --git a/server/models/video/sql/video/index.ts b/server/models/video/sql/video/index.ts
deleted file mode 100644
index e9132d5e1..000000000
--- a/server/models/video/sql/video/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
1export * from './video-model-get-query-builder'
2export * from './videos-id-list-query-builder'
3export * from './videos-model-list-query-builder'
diff --git a/server/models/video/sql/video/shared/abstract-video-query-builder.ts b/server/models/video/sql/video/shared/abstract-video-query-builder.ts
deleted file mode 100644
index 56a00aa0c..000000000
--- a/server/models/video/sql/video/shared/abstract-video-query-builder.ts
+++ /dev/null
@@ -1,340 +0,0 @@
1import { Sequelize } from 'sequelize'
2import validator from 'validator'
3import { MUserAccountId } from '@server/types/models'
4import { ActorImageType } from '@shared/models'
5import { AbstractRunQuery } from '../../../../shared/abstract-run-query'
6import { createSafeIn } from '../../../../shared'
7import { VideoTableAttributes } from './video-table-attributes'
8
9/**
10 *
11 * Abstract builder to create SQL query and fetch video models
12 *
13 */
14
15export class AbstractVideoQueryBuilder extends AbstractRunQuery {
16 protected attributes: { [key: string]: string } = {}
17
18 protected joins = ''
19 protected where: string
20
21 protected tables: VideoTableAttributes
22
23 constructor (
24 protected readonly sequelize: Sequelize,
25 protected readonly mode: 'list' | 'get'
26 ) {
27 super(sequelize)
28
29 this.tables = new VideoTableAttributes(this.mode)
30 }
31
32 protected buildSelect () {
33 return 'SELECT ' + Object.keys(this.attributes).map(key => {
34 const value = this.attributes[key]
35 if (value) return `${key} AS ${value}`
36
37 return key
38 }).join(', ')
39 }
40
41 protected includeChannels () {
42 this.addJoin('INNER JOIN "videoChannel" AS "VideoChannel" ON "video"."channelId" = "VideoChannel"."id"')
43 this.addJoin('INNER JOIN "actor" AS "VideoChannel->Actor" ON "VideoChannel"."actorId" = "VideoChannel->Actor"."id"')
44
45 this.addJoin(
46 'LEFT OUTER JOIN "server" AS "VideoChannel->Actor->Server" ON "VideoChannel->Actor"."serverId" = "VideoChannel->Actor->Server"."id"'
47 )
48
49 this.addJoin(
50 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Actor->Avatars" ' +
51 'ON "VideoChannel->Actor"."id" = "VideoChannel->Actor->Avatars"."actorId" ' +
52 `AND "VideoChannel->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
53 )
54
55 this.attributes = {
56 ...this.attributes,
57
58 ...this.buildAttributesObject('VideoChannel', this.tables.getChannelAttributes()),
59 ...this.buildActorInclude('VideoChannel->Actor'),
60 ...this.buildAvatarInclude('VideoChannel->Actor->Avatars'),
61 ...this.buildServerInclude('VideoChannel->Actor->Server')
62 }
63 }
64
65 protected includeAccounts () {
66 this.addJoin('INNER JOIN "account" AS "VideoChannel->Account" ON "VideoChannel"."accountId" = "VideoChannel->Account"."id"')
67 this.addJoin(
68 'INNER JOIN "actor" AS "VideoChannel->Account->Actor" ON "VideoChannel->Account"."actorId" = "VideoChannel->Account->Actor"."id"'
69 )
70
71 this.addJoin(
72 'LEFT OUTER JOIN "server" AS "VideoChannel->Account->Actor->Server" ' +
73 'ON "VideoChannel->Account->Actor"."serverId" = "VideoChannel->Account->Actor->Server"."id"'
74 )
75
76 this.addJoin(
77 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Account->Actor->Avatars" ' +
78 'ON "VideoChannel->Account"."actorId"= "VideoChannel->Account->Actor->Avatars"."actorId" ' +
79 `AND "VideoChannel->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
80 )
81
82 this.attributes = {
83 ...this.attributes,
84
85 ...this.buildAttributesObject('VideoChannel->Account', this.tables.getAccountAttributes()),
86 ...this.buildActorInclude('VideoChannel->Account->Actor'),
87 ...this.buildAvatarInclude('VideoChannel->Account->Actor->Avatars'),
88 ...this.buildServerInclude('VideoChannel->Account->Actor->Server')
89 }
90 }
91
92 protected includeOwnerUser () {
93 this.addJoin('INNER JOIN "videoChannel" AS "VideoChannel" ON "video"."channelId" = "VideoChannel"."id"')
94 this.addJoin('INNER JOIN "account" AS "VideoChannel->Account" ON "VideoChannel"."accountId" = "VideoChannel->Account"."id"')
95
96 this.attributes = {
97 ...this.attributes,
98
99 ...this.buildAttributesObject('VideoChannel', this.tables.getChannelAttributes()),
100 ...this.buildAttributesObject('VideoChannel->Account', this.tables.getUserAccountAttributes())
101 }
102 }
103
104 protected includeThumbnails () {
105 this.addJoin('LEFT OUTER JOIN "thumbnail" AS "Thumbnails" ON "video"."id" = "Thumbnails"."videoId"')
106
107 this.attributes = {
108 ...this.attributes,
109
110 ...this.buildAttributesObject('Thumbnails', this.tables.getThumbnailAttributes())
111 }
112 }
113
114 protected includeWebVideoFiles () {
115 this.addJoin('LEFT JOIN "videoFile" AS "VideoFiles" ON "VideoFiles"."videoId" = "video"."id"')
116
117 this.attributes = {
118 ...this.attributes,
119
120 ...this.buildAttributesObject('VideoFiles', this.tables.getFileAttributes())
121 }
122 }
123
124 protected includeStreamingPlaylistFiles () {
125 this.addJoin(
126 'LEFT JOIN "videoStreamingPlaylist" AS "VideoStreamingPlaylists" ON "VideoStreamingPlaylists"."videoId" = "video"."id"'
127 )
128
129 this.addJoin(
130 'LEFT JOIN "videoFile" AS "VideoStreamingPlaylists->VideoFiles" ' +
131 'ON "VideoStreamingPlaylists->VideoFiles"."videoStreamingPlaylistId" = "VideoStreamingPlaylists"."id"'
132 )
133
134 this.attributes = {
135 ...this.attributes,
136
137 ...this.buildAttributesObject('VideoStreamingPlaylists', this.tables.getStreamingPlaylistAttributes()),
138 ...this.buildAttributesObject('VideoStreamingPlaylists->VideoFiles', this.tables.getFileAttributes())
139 }
140 }
141
142 protected includeUserHistory (userId: number) {
143 this.addJoin(
144 'LEFT OUTER JOIN "userVideoHistory" ' +
145 'ON "video"."id" = "userVideoHistory"."videoId" AND "userVideoHistory"."userId" = :userVideoHistoryId'
146 )
147
148 this.replacements.userVideoHistoryId = userId
149
150 this.attributes = {
151 ...this.attributes,
152
153 ...this.buildAttributesObject('userVideoHistory', this.tables.getUserHistoryAttributes())
154 }
155 }
156
157 protected includePlaylist (playlistId: number) {
158 this.addJoin(
159 'INNER JOIN "videoPlaylistElement" as "VideoPlaylistElement" ON "videoPlaylistElement"."videoId" = "video"."id" ' +
160 'AND "VideoPlaylistElement"."videoPlaylistId" = :videoPlaylistId'
161 )
162
163 this.replacements.videoPlaylistId = playlistId
164
165 this.attributes = {
166 ...this.attributes,
167
168 ...this.buildAttributesObject('VideoPlaylistElement', this.tables.getPlaylistAttributes())
169 }
170 }
171
172 protected includeTags () {
173 this.addJoin(
174 'LEFT OUTER JOIN (' +
175 '"videoTag" AS "Tags->VideoTagModel" INNER JOIN "tag" AS "Tags" ON "Tags"."id" = "Tags->VideoTagModel"."tagId"' +
176 ') ' +
177 'ON "video"."id" = "Tags->VideoTagModel"."videoId"'
178 )
179
180 this.attributes = {
181 ...this.attributes,
182
183 ...this.buildAttributesObject('Tags', this.tables.getTagAttributes()),
184 ...this.buildAttributesObject('Tags->VideoTagModel', this.tables.getVideoTagAttributes())
185 }
186 }
187
188 protected includeBlacklisted () {
189 this.addJoin(
190 'LEFT OUTER JOIN "videoBlacklist" AS "VideoBlacklist" ON "video"."id" = "VideoBlacklist"."videoId"'
191 )
192
193 this.attributes = {
194 ...this.attributes,
195
196 ...this.buildAttributesObject('VideoBlacklist', this.tables.getBlacklistedAttributes())
197 }
198 }
199
200 protected includeBlockedOwnerAndServer (serverAccountId: number, user?: MUserAccountId) {
201 const blockerIds = [ serverAccountId ]
202 if (user) blockerIds.push(user.Account.id)
203
204 const inClause = createSafeIn(this.sequelize, blockerIds)
205
206 this.addJoin(
207 'LEFT JOIN "accountBlocklist" AS "VideoChannel->Account->AccountBlocklist" ' +
208 'ON "VideoChannel->Account"."id" = "VideoChannel->Account->AccountBlocklist"."targetAccountId" ' +
209 'AND "VideoChannel->Account->AccountBlocklist"."accountId" IN (' + inClause + ')'
210 )
211
212 this.addJoin(
213 'LEFT JOIN "serverBlocklist" AS "VideoChannel->Account->Actor->Server->ServerBlocklist" ' +
214 'ON "VideoChannel->Account->Actor->Server->ServerBlocklist"."targetServerId" = "VideoChannel->Account->Actor"."serverId" ' +
215 'AND "VideoChannel->Account->Actor->Server->ServerBlocklist"."accountId" IN (' + inClause + ') '
216 )
217
218 this.attributes = {
219 ...this.attributes,
220
221 ...this.buildAttributesObject('VideoChannel->Account->AccountBlocklist', this.tables.getBlocklistAttributes()),
222 ...this.buildAttributesObject('VideoChannel->Account->Actor->Server->ServerBlocklist', this.tables.getBlocklistAttributes())
223 }
224 }
225
226 protected includeScheduleUpdate () {
227 this.addJoin(
228 'LEFT OUTER JOIN "scheduleVideoUpdate" AS "ScheduleVideoUpdate" ON "video"."id" = "ScheduleVideoUpdate"."videoId"'
229 )
230
231 this.attributes = {
232 ...this.attributes,
233
234 ...this.buildAttributesObject('ScheduleVideoUpdate', this.tables.getScheduleUpdateAttributes())
235 }
236 }
237
238 protected includeLive () {
239 this.addJoin(
240 'LEFT OUTER JOIN "videoLive" AS "VideoLive" ON "video"."id" = "VideoLive"."videoId"'
241 )
242
243 this.attributes = {
244 ...this.attributes,
245
246 ...this.buildAttributesObject('VideoLive', this.tables.getLiveAttributes())
247 }
248 }
249
250 protected includeTrackers () {
251 this.addJoin(
252 'LEFT OUTER JOIN (' +
253 '"videoTracker" AS "Trackers->VideoTrackerModel" ' +
254 'INNER JOIN "tracker" AS "Trackers" ON "Trackers"."id" = "Trackers->VideoTrackerModel"."trackerId"' +
255 ') ON "video"."id" = "Trackers->VideoTrackerModel"."videoId"'
256 )
257
258 this.attributes = {
259 ...this.attributes,
260
261 ...this.buildAttributesObject('Trackers', this.tables.getTrackerAttributes()),
262 ...this.buildAttributesObject('Trackers->VideoTrackerModel', this.tables.getVideoTrackerAttributes())
263 }
264 }
265
266 protected includeWebVideoRedundancies () {
267 this.addJoin(
268 'LEFT OUTER JOIN "videoRedundancy" AS "VideoFiles->RedundancyVideos" ON ' +
269 '"VideoFiles"."id" = "VideoFiles->RedundancyVideos"."videoFileId"'
270 )
271
272 this.attributes = {
273 ...this.attributes,
274
275 ...this.buildAttributesObject('VideoFiles->RedundancyVideos', this.tables.getRedundancyAttributes())
276 }
277 }
278
279 protected includeStreamingPlaylistRedundancies () {
280 this.addJoin(
281 'LEFT OUTER JOIN "videoRedundancy" AS "VideoStreamingPlaylists->RedundancyVideos" ' +
282 'ON "VideoStreamingPlaylists"."id" = "VideoStreamingPlaylists->RedundancyVideos"."videoStreamingPlaylistId"'
283 )
284
285 this.attributes = {
286 ...this.attributes,
287
288 ...this.buildAttributesObject('VideoStreamingPlaylists->RedundancyVideos', this.tables.getRedundancyAttributes())
289 }
290 }
291
292 protected buildActorInclude (prefixKey: string) {
293 return this.buildAttributesObject(prefixKey, this.tables.getActorAttributes())
294 }
295
296 protected buildAvatarInclude (prefixKey: string) {
297 return this.buildAttributesObject(prefixKey, this.tables.getAvatarAttributes())
298 }
299
300 protected buildServerInclude (prefixKey: string) {
301 return this.buildAttributesObject(prefixKey, this.tables.getServerAttributes())
302 }
303
304 protected buildAttributesObject (prefixKey: string, attributeKeys: string[]) {
305 const result: { [id: string]: string } = {}
306
307 const prefixValue = prefixKey.replace(/->/g, '.')
308
309 for (const attribute of attributeKeys) {
310 result[`"${prefixKey}"."${attribute}"`] = `"${prefixValue}.${attribute}"`
311 }
312
313 return result
314 }
315
316 protected whereId (options: { ids?: number[], id?: string | number, url?: string }) {
317 if (options.ids) {
318 this.where = `WHERE "video"."id" IN (${createSafeIn(this.sequelize, options.ids)})`
319 return
320 }
321
322 if (options.url) {
323 this.where = 'WHERE "video"."url" = :videoUrl'
324 this.replacements.videoUrl = options.url
325 return
326 }
327
328 if (validator.isInt('' + options.id)) {
329 this.where = 'WHERE "video".id = :videoId'
330 } else {
331 this.where = 'WHERE uuid = :videoId'
332 }
333
334 this.replacements.videoId = options.id
335 }
336
337 protected addJoin (join: string) {
338 this.joins += join + ' '
339 }
340}
diff --git a/server/models/video/sql/video/shared/video-file-query-builder.ts b/server/models/video/sql/video/shared/video-file-query-builder.ts
deleted file mode 100644
index 196b72b43..000000000
--- a/server/models/video/sql/video/shared/video-file-query-builder.ts
+++ /dev/null
@@ -1,75 +0,0 @@
1import { Sequelize, Transaction } from 'sequelize'
2import { AbstractVideoQueryBuilder } from './abstract-video-query-builder'
3
4export type FileQueryOptions = {
5 id?: string | number
6 url?: string
7
8 includeRedundancy: boolean
9
10 transaction?: Transaction
11
12 logging?: boolean
13}
14
15/**
16 *
17 * Fetch files (web videos and streaming playlist) according to a video
18 *
19 */
20
21export class VideoFileQueryBuilder extends AbstractVideoQueryBuilder {
22 protected attributes: { [key: string]: string }
23
24 constructor (protected readonly sequelize: Sequelize) {
25 super(sequelize, 'get')
26 }
27
28 queryWebVideos (options: FileQueryOptions) {
29 this.buildWebVideoFilesQuery(options)
30
31 return this.runQuery(options)
32 }
33
34 queryStreamingPlaylistVideos (options: FileQueryOptions) {
35 this.buildVideoStreamingPlaylistFilesQuery(options)
36
37 return this.runQuery(options)
38 }
39
40 private buildWebVideoFilesQuery (options: FileQueryOptions) {
41 this.attributes = {
42 '"video"."id"': ''
43 }
44
45 this.includeWebVideoFiles()
46
47 if (options.includeRedundancy) {
48 this.includeWebVideoRedundancies()
49 }
50
51 this.whereId(options)
52
53 this.query = this.buildQuery()
54 }
55
56 private buildVideoStreamingPlaylistFilesQuery (options: FileQueryOptions) {
57 this.attributes = {
58 '"video"."id"': ''
59 }
60
61 this.includeStreamingPlaylistFiles()
62
63 if (options.includeRedundancy) {
64 this.includeStreamingPlaylistRedundancies()
65 }
66
67 this.whereId(options)
68
69 this.query = this.buildQuery()
70 }
71
72 private buildQuery () {
73 return `${this.buildSelect()} FROM "video" ${this.joins} ${this.where}`
74 }
75}
diff --git a/server/models/video/sql/video/shared/video-model-builder.ts b/server/models/video/sql/video/shared/video-model-builder.ts
deleted file mode 100644
index 740aa842f..000000000
--- a/server/models/video/sql/video/shared/video-model-builder.ts
+++ /dev/null
@@ -1,408 +0,0 @@
1
2import { AccountModel } from '@server/models/account/account'
3import { AccountBlocklistModel } from '@server/models/account/account-blocklist'
4import { ActorModel } from '@server/models/actor/actor'
5import { ActorImageModel } from '@server/models/actor/actor-image'
6import { VideoRedundancyModel } from '@server/models/redundancy/video-redundancy'
7import { ServerModel } from '@server/models/server/server'
8import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
9import { TrackerModel } from '@server/models/server/tracker'
10import { UserVideoHistoryModel } from '@server/models/user/user-video-history'
11import { VideoInclude } from '@shared/models'
12import { ScheduleVideoUpdateModel } from '../../../schedule-video-update'
13import { TagModel } from '../../../tag'
14import { ThumbnailModel } from '../../../thumbnail'
15import { VideoModel } from '../../../video'
16import { VideoBlacklistModel } from '../../../video-blacklist'
17import { VideoChannelModel } from '../../../video-channel'
18import { VideoFileModel } from '../../../video-file'
19import { VideoLiveModel } from '../../../video-live'
20import { VideoStreamingPlaylistModel } from '../../../video-streaming-playlist'
21import { VideoTableAttributes } from './video-table-attributes'
22
23type SQLRow = { [id: string]: string | number }
24
25/**
26 *
27 * Build video models from SQL rows
28 *
29 */
30
31export class VideoModelBuilder {
32 private videosMemo: { [ id: number ]: VideoModel }
33 private videoStreamingPlaylistMemo: { [ id: number ]: VideoStreamingPlaylistModel }
34 private videoFileMemo: { [ id: number ]: VideoFileModel }
35
36 private thumbnailsDone: Set<any>
37 private actorImagesDone: Set<any>
38 private historyDone: Set<any>
39 private blacklistDone: Set<any>
40 private accountBlocklistDone: Set<any>
41 private serverBlocklistDone: Set<any>
42 private liveDone: Set<any>
43 private redundancyDone: Set<any>
44 private scheduleVideoUpdateDone: Set<any>
45
46 private trackersDone: Set<string>
47 private tagsDone: Set<string>
48
49 private videos: VideoModel[]
50
51 private readonly buildOpts = { raw: true, isNewRecord: false }
52
53 constructor (
54 private readonly mode: 'get' | 'list',
55 private readonly tables: VideoTableAttributes
56 ) {
57
58 }
59
60 buildVideosFromRows (options: {
61 rows: SQLRow[]
62 include?: VideoInclude
63 rowsWebVideoFiles?: SQLRow[]
64 rowsStreamingPlaylist?: SQLRow[]
65 }) {
66 const { rows, rowsWebVideoFiles, rowsStreamingPlaylist, include } = options
67
68 this.reinit()
69
70 for (const row of rows) {
71 this.buildVideoAndAccount(row)
72
73 const videoModel = this.videosMemo[row.id as number]
74
75 this.setUserHistory(row, videoModel)
76 this.addThumbnail(row, videoModel)
77
78 const channelActor = videoModel.VideoChannel?.Actor
79 if (channelActor) {
80 this.addActorAvatar(row, 'VideoChannel.Actor', channelActor)
81 }
82
83 const accountActor = videoModel.VideoChannel?.Account?.Actor
84 if (accountActor) {
85 this.addActorAvatar(row, 'VideoChannel.Account.Actor', accountActor)
86 }
87
88 if (!rowsWebVideoFiles) {
89 this.addWebVideoFile(row, videoModel)
90 }
91
92 if (!rowsStreamingPlaylist) {
93 this.addStreamingPlaylist(row, videoModel)
94 this.addStreamingPlaylistFile(row)
95 }
96
97 if (this.mode === 'get') {
98 this.addTag(row, videoModel)
99 this.addTracker(row, videoModel)
100 this.setBlacklisted(row, videoModel)
101 this.setScheduleVideoUpdate(row, videoModel)
102 this.setLive(row, videoModel)
103 } else {
104 if (include & VideoInclude.BLACKLISTED) {
105 this.setBlacklisted(row, videoModel)
106 }
107
108 if (include & VideoInclude.BLOCKED_OWNER) {
109 this.setBlockedOwner(row, videoModel)
110 this.setBlockedServer(row, videoModel)
111 }
112 }
113 }
114
115 this.grabSeparateWebVideoFiles(rowsWebVideoFiles)
116 this.grabSeparateStreamingPlaylistFiles(rowsStreamingPlaylist)
117
118 return this.videos
119 }
120
121 private reinit () {
122 this.videosMemo = {}
123 this.videoStreamingPlaylistMemo = {}
124 this.videoFileMemo = {}
125
126 this.thumbnailsDone = new Set()
127 this.actorImagesDone = new Set()
128 this.historyDone = new Set()
129 this.blacklistDone = new Set()
130 this.liveDone = new Set()
131 this.redundancyDone = new Set()
132 this.scheduleVideoUpdateDone = new Set()
133
134 this.accountBlocklistDone = new Set()
135 this.serverBlocklistDone = new Set()
136
137 this.trackersDone = new Set()
138 this.tagsDone = new Set()
139
140 this.videos = []
141 }
142
143 private grabSeparateWebVideoFiles (rowsWebVideoFiles?: SQLRow[]) {
144 if (!rowsWebVideoFiles) return
145
146 for (const row of rowsWebVideoFiles) {
147 const id = row['VideoFiles.id']
148 if (!id) continue
149
150 const videoModel = this.videosMemo[row.id]
151 this.addWebVideoFile(row, videoModel)
152 this.addRedundancy(row, 'VideoFiles', this.videoFileMemo[id])
153 }
154 }
155
156 private grabSeparateStreamingPlaylistFiles (rowsStreamingPlaylist?: SQLRow[]) {
157 if (!rowsStreamingPlaylist) return
158
159 for (const row of rowsStreamingPlaylist) {
160 const id = row['VideoStreamingPlaylists.id']
161 if (!id) continue
162
163 const videoModel = this.videosMemo[row.id]
164
165 this.addStreamingPlaylist(row, videoModel)
166 this.addStreamingPlaylistFile(row)
167 this.addRedundancy(row, 'VideoStreamingPlaylists', this.videoStreamingPlaylistMemo[id])
168 }
169 }
170
171 private buildVideoAndAccount (row: SQLRow) {
172 if (this.videosMemo[row.id]) return
173
174 const videoModel = new VideoModel(this.grab(row, this.tables.getVideoAttributes(), ''), this.buildOpts)
175
176 videoModel.UserVideoHistories = []
177 videoModel.Thumbnails = []
178 videoModel.VideoFiles = []
179 videoModel.VideoStreamingPlaylists = []
180 videoModel.Tags = []
181 videoModel.Trackers = []
182
183 this.buildAccount(row, videoModel)
184
185 this.videosMemo[row.id] = videoModel
186
187 // Keep rows order
188 this.videos.push(videoModel)
189 }
190
191 private buildAccount (row: SQLRow, videoModel: VideoModel) {
192 const id = row['VideoChannel.Account.id']
193 if (!id) return
194
195 const channelModel = new VideoChannelModel(this.grab(row, this.tables.getChannelAttributes(), 'VideoChannel'), this.buildOpts)
196 channelModel.Actor = this.buildActor(row, 'VideoChannel')
197
198 const accountModel = new AccountModel(this.grab(row, this.tables.getAccountAttributes(), 'VideoChannel.Account'), this.buildOpts)
199 accountModel.Actor = this.buildActor(row, 'VideoChannel.Account')
200
201 accountModel.BlockedBy = []
202
203 channelModel.Account = accountModel
204
205 videoModel.VideoChannel = channelModel
206 }
207
208 private buildActor (row: SQLRow, prefix: string) {
209 const actorPrefix = `${prefix}.Actor`
210 const serverPrefix = `${actorPrefix}.Server`
211
212 const serverModel = row[`${serverPrefix}.id`] !== null
213 ? new ServerModel(this.grab(row, this.tables.getServerAttributes(), serverPrefix), this.buildOpts)
214 : null
215
216 if (serverModel) serverModel.BlockedBy = []
217
218 const actorModel = new ActorModel(this.grab(row, this.tables.getActorAttributes(), actorPrefix), this.buildOpts)
219 actorModel.Server = serverModel
220 actorModel.Avatars = []
221
222 return actorModel
223 }
224
225 private setUserHistory (row: SQLRow, videoModel: VideoModel) {
226 const id = row['userVideoHistory.id']
227 if (!id || this.historyDone.has(id)) return
228
229 const attributes = this.grab(row, this.tables.getUserHistoryAttributes(), 'userVideoHistory')
230 const historyModel = new UserVideoHistoryModel(attributes, this.buildOpts)
231 videoModel.UserVideoHistories.push(historyModel)
232
233 this.historyDone.add(id)
234 }
235
236 private addActorAvatar (row: SQLRow, actorPrefix: string, actor: ActorModel) {
237 const avatarPrefix = `${actorPrefix}.Avatars`
238 const id = row[`${avatarPrefix}.id`]
239 const key = `${row.id}${id}`
240
241 if (!id || this.actorImagesDone.has(key)) return
242
243 const attributes = this.grab(row, this.tables.getAvatarAttributes(), avatarPrefix)
244 const avatarModel = new ActorImageModel(attributes, this.buildOpts)
245 actor.Avatars.push(avatarModel)
246
247 this.actorImagesDone.add(key)
248 }
249
250 private addThumbnail (row: SQLRow, videoModel: VideoModel) {
251 const id = row['Thumbnails.id']
252 if (!id || this.thumbnailsDone.has(id)) return
253
254 const attributes = this.grab(row, this.tables.getThumbnailAttributes(), 'Thumbnails')
255 const thumbnailModel = new ThumbnailModel(attributes, this.buildOpts)
256 videoModel.Thumbnails.push(thumbnailModel)
257
258 this.thumbnailsDone.add(id)
259 }
260
261 private addWebVideoFile (row: SQLRow, videoModel: VideoModel) {
262 const id = row['VideoFiles.id']
263 if (!id || this.videoFileMemo[id]) return
264
265 const attributes = this.grab(row, this.tables.getFileAttributes(), 'VideoFiles')
266 const videoFileModel = new VideoFileModel(attributes, this.buildOpts)
267 videoModel.VideoFiles.push(videoFileModel)
268
269 this.videoFileMemo[id] = videoFileModel
270 }
271
272 private addStreamingPlaylist (row: SQLRow, videoModel: VideoModel) {
273 const id = row['VideoStreamingPlaylists.id']
274 if (!id || this.videoStreamingPlaylistMemo[id]) return
275
276 const attributes = this.grab(row, this.tables.getStreamingPlaylistAttributes(), 'VideoStreamingPlaylists')
277 const streamingPlaylist = new VideoStreamingPlaylistModel(attributes, this.buildOpts)
278 streamingPlaylist.VideoFiles = []
279
280 videoModel.VideoStreamingPlaylists.push(streamingPlaylist)
281
282 this.videoStreamingPlaylistMemo[id] = streamingPlaylist
283 }
284
285 private addStreamingPlaylistFile (row: SQLRow) {
286 const id = row['VideoStreamingPlaylists.VideoFiles.id']
287 if (!id || this.videoFileMemo[id]) return
288
289 const streamingPlaylist = this.videoStreamingPlaylistMemo[row['VideoStreamingPlaylists.id']]
290
291 const attributes = this.grab(row, this.tables.getFileAttributes(), 'VideoStreamingPlaylists.VideoFiles')
292 const videoFileModel = new VideoFileModel(attributes, this.buildOpts)
293 streamingPlaylist.VideoFiles.push(videoFileModel)
294
295 this.videoFileMemo[id] = videoFileModel
296 }
297
298 private addRedundancy (row: SQLRow, prefix: string, to: VideoFileModel | VideoStreamingPlaylistModel) {
299 if (!to.RedundancyVideos) to.RedundancyVideos = []
300
301 const redundancyPrefix = `${prefix}.RedundancyVideos`
302 const id = row[`${redundancyPrefix}.id`]
303
304 if (!id || this.redundancyDone.has(id)) return
305
306 const attributes = this.grab(row, this.tables.getRedundancyAttributes(), redundancyPrefix)
307 const redundancyModel = new VideoRedundancyModel(attributes, this.buildOpts)
308 to.RedundancyVideos.push(redundancyModel)
309
310 this.redundancyDone.add(id)
311 }
312
313 private addTag (row: SQLRow, videoModel: VideoModel) {
314 if (!row['Tags.name']) return
315
316 const key = `${row['Tags.VideoTagModel.videoId']}-${row['Tags.VideoTagModel.tagId']}`
317 if (this.tagsDone.has(key)) return
318
319 const attributes = this.grab(row, this.tables.getTagAttributes(), 'Tags')
320 const tagModel = new TagModel(attributes, this.buildOpts)
321 videoModel.Tags.push(tagModel)
322
323 this.tagsDone.add(key)
324 }
325
326 private addTracker (row: SQLRow, videoModel: VideoModel) {
327 if (!row['Trackers.id']) return
328
329 const key = `${row['Trackers.VideoTrackerModel.videoId']}-${row['Trackers.VideoTrackerModel.trackerId']}`
330 if (this.trackersDone.has(key)) return
331
332 const attributes = this.grab(row, this.tables.getTrackerAttributes(), 'Trackers')
333 const trackerModel = new TrackerModel(attributes, this.buildOpts)
334 videoModel.Trackers.push(trackerModel)
335
336 this.trackersDone.add(key)
337 }
338
339 private setBlacklisted (row: SQLRow, videoModel: VideoModel) {
340 const id = row['VideoBlacklist.id']
341 if (!id || this.blacklistDone.has(id)) return
342
343 const attributes = this.grab(row, this.tables.getBlacklistedAttributes(), 'VideoBlacklist')
344 videoModel.VideoBlacklist = new VideoBlacklistModel(attributes, this.buildOpts)
345
346 this.blacklistDone.add(id)
347 }
348
349 private setBlockedOwner (row: SQLRow, videoModel: VideoModel) {
350 const id = row['VideoChannel.Account.AccountBlocklist.id']
351 if (!id) return
352
353 const key = `${videoModel.id}-${id}`
354 if (this.accountBlocklistDone.has(key)) return
355
356 const attributes = this.grab(row, this.tables.getBlocklistAttributes(), 'VideoChannel.Account.AccountBlocklist')
357 videoModel.VideoChannel.Account.BlockedBy.push(new AccountBlocklistModel(attributes, this.buildOpts))
358
359 this.accountBlocklistDone.add(key)
360 }
361
362 private setBlockedServer (row: SQLRow, videoModel: VideoModel) {
363 const id = row['VideoChannel.Account.Actor.Server.ServerBlocklist.id']
364 if (!id || this.serverBlocklistDone.has(id)) return
365
366 const key = `${videoModel.id}-${id}`
367 if (this.serverBlocklistDone.has(key)) return
368
369 const attributes = this.grab(row, this.tables.getBlocklistAttributes(), 'VideoChannel.Account.Actor.Server.ServerBlocklist')
370 videoModel.VideoChannel.Account.Actor.Server.BlockedBy.push(new ServerBlocklistModel(attributes, this.buildOpts))
371
372 this.serverBlocklistDone.add(key)
373 }
374
375 private setScheduleVideoUpdate (row: SQLRow, videoModel: VideoModel) {
376 const id = row['ScheduleVideoUpdate.id']
377 if (!id || this.scheduleVideoUpdateDone.has(id)) return
378
379 const attributes = this.grab(row, this.tables.getScheduleUpdateAttributes(), 'ScheduleVideoUpdate')
380 videoModel.ScheduleVideoUpdate = new ScheduleVideoUpdateModel(attributes, this.buildOpts)
381
382 this.scheduleVideoUpdateDone.add(id)
383 }
384
385 private setLive (row: SQLRow, videoModel: VideoModel) {
386 const id = row['VideoLive.id']
387 if (!id || this.liveDone.has(id)) return
388
389 const attributes = this.grab(row, this.tables.getLiveAttributes(), 'VideoLive')
390 videoModel.VideoLive = new VideoLiveModel(attributes, this.buildOpts)
391
392 this.liveDone.add(id)
393 }
394
395 private grab (row: SQLRow, attributes: string[], prefix: string) {
396 const result: { [ id: string ]: string | number } = {}
397
398 for (const a of attributes) {
399 const key = prefix
400 ? prefix + '.' + a
401 : a
402
403 result[a] = row[key]
404 }
405
406 return result
407 }
408}
diff --git a/server/models/video/sql/video/shared/video-table-attributes.ts b/server/models/video/sql/video/shared/video-table-attributes.ts
deleted file mode 100644
index ef625c57b..000000000
--- a/server/models/video/sql/video/shared/video-table-attributes.ts
+++ /dev/null
@@ -1,273 +0,0 @@
1
2/**
3 *
4 * Class to build video attributes/join names we want to fetch from the database
5 *
6 */
7export class VideoTableAttributes {
8
9 constructor (private readonly mode: 'get' | 'list') {
10
11 }
12
13 getChannelAttributesForUser () {
14 return [ 'id', 'accountId' ]
15 }
16
17 getChannelAttributes () {
18 let attributeKeys = [
19 'id',
20 'name',
21 'description',
22 'actorId'
23 ]
24
25 if (this.mode === 'get') {
26 attributeKeys = attributeKeys.concat([
27 'support',
28 'createdAt',
29 'updatedAt'
30 ])
31 }
32
33 return attributeKeys
34 }
35
36 getUserAccountAttributes () {
37 return [ 'id', 'userId' ]
38 }
39
40 getAccountAttributes () {
41 let attributeKeys = [ 'id', 'name', 'actorId' ]
42
43 if (this.mode === 'get') {
44 attributeKeys = attributeKeys.concat([
45 'description',
46 'userId',
47 'createdAt',
48 'updatedAt'
49 ])
50 }
51
52 return attributeKeys
53 }
54
55 getThumbnailAttributes () {
56 let attributeKeys = [ 'id', 'type', 'filename' ]
57
58 if (this.mode === 'get') {
59 attributeKeys = attributeKeys.concat([
60 'height',
61 'width',
62 'fileUrl',
63 'onDisk',
64 'automaticallyGenerated',
65 'videoId',
66 'videoPlaylistId',
67 'createdAt',
68 'updatedAt'
69 ])
70 }
71
72 return attributeKeys
73 }
74
75 getFileAttributes () {
76 return [
77 'id',
78 'createdAt',
79 'updatedAt',
80 'resolution',
81 'size',
82 'extname',
83 'filename',
84 'fileUrl',
85 'torrentFilename',
86 'torrentUrl',
87 'infoHash',
88 'fps',
89 'metadataUrl',
90 'videoStreamingPlaylistId',
91 'videoId',
92 'storage'
93 ]
94 }
95
96 getStreamingPlaylistAttributes () {
97 return [
98 'id',
99 'playlistUrl',
100 'playlistFilename',
101 'type',
102 'p2pMediaLoaderInfohashes',
103 'p2pMediaLoaderPeerVersion',
104 'segmentsSha256Filename',
105 'segmentsSha256Url',
106 'videoId',
107 'createdAt',
108 'updatedAt',
109 'storage'
110 ]
111 }
112
113 getUserHistoryAttributes () {
114 return [ 'id', 'currentTime' ]
115 }
116
117 getPlaylistAttributes () {
118 return [
119 'createdAt',
120 'updatedAt',
121 'url',
122 'position',
123 'startTimestamp',
124 'stopTimestamp',
125 'videoPlaylistId'
126 ]
127 }
128
129 getTagAttributes () {
130 return [ 'id', 'name' ]
131 }
132
133 getVideoTagAttributes () {
134 return [ 'videoId', 'tagId', 'createdAt', 'updatedAt' ]
135 }
136
137 getBlacklistedAttributes () {
138 return [ 'id', 'reason', 'unfederated' ]
139 }
140
141 getBlocklistAttributes () {
142 return [ 'id' ]
143 }
144
145 getScheduleUpdateAttributes () {
146 return [
147 'id',
148 'updateAt',
149 'privacy',
150 'videoId',
151 'createdAt',
152 'updatedAt'
153 ]
154 }
155
156 getLiveAttributes () {
157 return [
158 'id',
159 'streamKey',
160 'saveReplay',
161 'permanentLive',
162 'latencyMode',
163 'videoId',
164 'replaySettingId',
165 'createdAt',
166 'updatedAt'
167 ]
168 }
169
170 getTrackerAttributes () {
171 return [ 'id', 'url' ]
172 }
173
174 getVideoTrackerAttributes () {
175 return [
176 'videoId',
177 'trackerId',
178 'createdAt',
179 'updatedAt'
180 ]
181 }
182
183 getRedundancyAttributes () {
184 return [ 'id', 'fileUrl' ]
185 }
186
187 getActorAttributes () {
188 let attributeKeys = [
189 'id',
190 'preferredUsername',
191 'url',
192 'serverId'
193 ]
194
195 if (this.mode === 'get') {
196 attributeKeys = attributeKeys.concat([
197 'type',
198 'followersCount',
199 'followingCount',
200 'inboxUrl',
201 'outboxUrl',
202 'sharedInboxUrl',
203 'followersUrl',
204 'followingUrl',
205 'remoteCreatedAt',
206 'createdAt',
207 'updatedAt'
208 ])
209 }
210
211 return attributeKeys
212 }
213
214 getAvatarAttributes () {
215 let attributeKeys = [
216 'id',
217 'width',
218 'filename',
219 'type',
220 'fileUrl',
221 'onDisk',
222 'createdAt',
223 'updatedAt'
224 ]
225
226 if (this.mode === 'get') {
227 attributeKeys = attributeKeys.concat([
228 'height',
229 'width',
230 'type'
231 ])
232 }
233
234 return attributeKeys
235 }
236
237 getServerAttributes () {
238 return [ 'id', 'host' ]
239 }
240
241 getVideoAttributes () {
242 return [
243 'id',
244 'uuid',
245 'name',
246 'category',
247 'licence',
248 'language',
249 'privacy',
250 'nsfw',
251 'description',
252 'support',
253 'duration',
254 'views',
255 'likes',
256 'dislikes',
257 'remote',
258 'isLive',
259 'url',
260 'commentsEnabled',
261 'downloadEnabled',
262 'waitTranscoding',
263 'state',
264 'publishedAt',
265 'originallyPublishedAt',
266 'inputFileUpdatedAt',
267 'channelId',
268 'createdAt',
269 'updatedAt',
270 'moveJobsRunning'
271 ]
272 }
273}
diff --git a/server/models/video/sql/video/video-model-get-query-builder.ts b/server/models/video/sql/video/video-model-get-query-builder.ts
deleted file mode 100644
index 3f43d4d92..000000000
--- a/server/models/video/sql/video/video-model-get-query-builder.ts
+++ /dev/null
@@ -1,189 +0,0 @@
1import { Sequelize, Transaction } from 'sequelize'
2import { pick } from '@shared/core-utils'
3import { AbstractVideoQueryBuilder } from './shared/abstract-video-query-builder'
4import { VideoFileQueryBuilder } from './shared/video-file-query-builder'
5import { VideoModelBuilder } from './shared/video-model-builder'
6import { VideoTableAttributes } from './shared/video-table-attributes'
7
8/**
9 *
10 * Build a GET SQL query, fetch rows and create the video model
11 *
12 */
13
14export type GetType =
15 'api' |
16 'full' |
17 'account-blacklist-files' |
18 'all-files' |
19 'thumbnails' |
20 'thumbnails-blacklist' |
21 'id' |
22 'blacklist-rights'
23
24export type BuildVideoGetQueryOptions = {
25 id?: number | string
26 url?: string
27
28 type: GetType
29
30 userId?: number
31 transaction?: Transaction
32
33 logging?: boolean
34}
35
36export class VideoModelGetQueryBuilder {
37 videoQueryBuilder: VideosModelGetQuerySubBuilder
38 webVideoFilesQueryBuilder: VideoFileQueryBuilder
39 streamingPlaylistFilesQueryBuilder: VideoFileQueryBuilder
40
41 private readonly videoModelBuilder: VideoModelBuilder
42
43 private static readonly videoFilesInclude = new Set<GetType>([ 'api', 'full', 'account-blacklist-files', 'all-files' ])
44
45 constructor (protected readonly sequelize: Sequelize) {
46 this.videoQueryBuilder = new VideosModelGetQuerySubBuilder(sequelize)
47 this.webVideoFilesQueryBuilder = new VideoFileQueryBuilder(sequelize)
48 this.streamingPlaylistFilesQueryBuilder = new VideoFileQueryBuilder(sequelize)
49
50 this.videoModelBuilder = new VideoModelBuilder('get', new VideoTableAttributes('get'))
51 }
52
53 async queryVideo (options: BuildVideoGetQueryOptions) {
54 const fileQueryOptions = {
55 ...pick(options, [ 'id', 'url', 'transaction', 'logging' ]),
56
57 includeRedundancy: this.shouldIncludeRedundancies(options)
58 }
59
60 const [ videoRows, webVideoFilesRows, streamingPlaylistFilesRows ] = await Promise.all([
61 this.videoQueryBuilder.queryVideos(options),
62
63 VideoModelGetQueryBuilder.videoFilesInclude.has(options.type)
64 ? this.webVideoFilesQueryBuilder.queryWebVideos(fileQueryOptions)
65 : Promise.resolve(undefined),
66
67 VideoModelGetQueryBuilder.videoFilesInclude.has(options.type)
68 ? this.streamingPlaylistFilesQueryBuilder.queryStreamingPlaylistVideos(fileQueryOptions)
69 : Promise.resolve(undefined)
70 ])
71
72 const videos = this.videoModelBuilder.buildVideosFromRows({
73 rows: videoRows,
74 rowsWebVideoFiles: webVideoFilesRows,
75 rowsStreamingPlaylist: streamingPlaylistFilesRows
76 })
77
78 if (videos.length > 1) {
79 throw new Error('Video results is more than 1')
80 }
81
82 if (videos.length === 0) return null
83
84 return videos[0]
85 }
86
87 private shouldIncludeRedundancies (options: BuildVideoGetQueryOptions) {
88 return options.type === 'api'
89 }
90}
91
92export class VideosModelGetQuerySubBuilder extends AbstractVideoQueryBuilder {
93 protected attributes: { [key: string]: string }
94
95 protected webVideoFilesQuery: string
96 protected streamingPlaylistFilesQuery: string
97
98 private static readonly trackersInclude = new Set<GetType>([ 'api' ])
99 private static readonly liveInclude = new Set<GetType>([ 'api', 'full' ])
100 private static readonly scheduleUpdateInclude = new Set<GetType>([ 'api', 'full' ])
101 private static readonly tagsInclude = new Set<GetType>([ 'api', 'full' ])
102 private static readonly userHistoryInclude = new Set<GetType>([ 'api', 'full' ])
103 private static readonly accountInclude = new Set<GetType>([ 'api', 'full', 'account-blacklist-files' ])
104 private static readonly ownerUserInclude = new Set<GetType>([ 'blacklist-rights' ])
105
106 private static readonly blacklistedInclude = new Set<GetType>([
107 'api',
108 'full',
109 'account-blacklist-files',
110 'thumbnails-blacklist',
111 'blacklist-rights'
112 ])
113
114 private static readonly thumbnailsInclude = new Set<GetType>([
115 'api',
116 'full',
117 'account-blacklist-files',
118 'all-files',
119 'thumbnails',
120 'thumbnails-blacklist'
121 ])
122
123 constructor (protected readonly sequelize: Sequelize) {
124 super(sequelize, 'get')
125 }
126
127 queryVideos (options: BuildVideoGetQueryOptions) {
128 this.buildMainGetQuery(options)
129
130 return this.runQuery(options)
131 }
132
133 private buildMainGetQuery (options: BuildVideoGetQueryOptions) {
134 this.attributes = {
135 '"video".*': ''
136 }
137
138 if (VideosModelGetQuerySubBuilder.thumbnailsInclude.has(options.type)) {
139 this.includeThumbnails()
140 }
141
142 if (VideosModelGetQuerySubBuilder.blacklistedInclude.has(options.type)) {
143 this.includeBlacklisted()
144 }
145
146 if (VideosModelGetQuerySubBuilder.accountInclude.has(options.type)) {
147 this.includeChannels()
148 this.includeAccounts()
149 }
150
151 if (VideosModelGetQuerySubBuilder.tagsInclude.has(options.type)) {
152 this.includeTags()
153 }
154
155 if (VideosModelGetQuerySubBuilder.scheduleUpdateInclude.has(options.type)) {
156 this.includeScheduleUpdate()
157 }
158
159 if (VideosModelGetQuerySubBuilder.liveInclude.has(options.type)) {
160 this.includeLive()
161 }
162
163 if (options.userId && VideosModelGetQuerySubBuilder.userHistoryInclude.has(options.type)) {
164 this.includeUserHistory(options.userId)
165 }
166
167 if (VideosModelGetQuerySubBuilder.ownerUserInclude.has(options.type)) {
168 this.includeOwnerUser()
169 }
170
171 if (VideosModelGetQuerySubBuilder.trackersInclude.has(options.type)) {
172 this.includeTrackers()
173 }
174
175 this.whereId(options)
176
177 this.query = this.buildQuery(options)
178 }
179
180 private buildQuery (options: BuildVideoGetQueryOptions) {
181 const order = VideosModelGetQuerySubBuilder.tagsInclude.has(options.type)
182 ? 'ORDER BY "Tags"."name" ASC'
183 : ''
184
185 const from = `SELECT * FROM "video" ${this.where} LIMIT 1`
186
187 return `${this.buildSelect()} FROM (${from}) AS "video" ${this.joins} ${order}`
188 }
189}
diff --git a/server/models/video/sql/video/videos-id-list-query-builder.ts b/server/models/video/sql/video/videos-id-list-query-builder.ts
deleted file mode 100644
index 7f2376102..000000000
--- a/server/models/video/sql/video/videos-id-list-query-builder.ts
+++ /dev/null
@@ -1,728 +0,0 @@
1import { Sequelize, Transaction } from 'sequelize'
2import validator from 'validator'
3import { exists } from '@server/helpers/custom-validators/misc'
4import { WEBSERVER } from '@server/initializers/constants'
5import { buildSortDirectionAndField } from '@server/models/shared'
6import { MUserAccountId, MUserId } from '@server/types/models'
7import { forceNumber } from '@shared/core-utils'
8import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models'
9import { createSafeIn, parseRowCountResult } from '../../../shared'
10import { AbstractRunQuery } from '../../../shared/abstract-run-query'
11
12/**
13 *
14 * Build videos list SQL query to fetch rows
15 *
16 */
17
18export type DisplayOnlyForFollowerOptions = {
19 actorId: number
20 orLocalVideos: boolean
21}
22
23export type BuildVideosListQueryOptions = {
24 attributes?: string[]
25
26 serverAccountIdForBlock: number
27
28 displayOnlyForFollower: DisplayOnlyForFollowerOptions
29
30 count: number
31 start: number
32 sort: string
33
34 nsfw?: boolean
35 host?: string
36 isLive?: boolean
37 isLocal?: boolean
38 include?: VideoInclude
39
40 categoryOneOf?: number[]
41 licenceOneOf?: number[]
42 languageOneOf?: string[]
43 tagsOneOf?: string[]
44 tagsAllOf?: string[]
45 privacyOneOf?: VideoPrivacy[]
46
47 uuids?: string[]
48
49 hasFiles?: boolean
50 hasHLSFiles?: boolean
51
52 hasWebVideoFiles?: boolean
53 hasWebtorrentFiles?: boolean // TODO: Remove in v7
54
55 accountId?: number
56 videoChannelId?: number
57
58 videoPlaylistId?: number
59
60 trendingAlgorithm?: string // best, hot, or any other algorithm implemented
61 trendingDays?: number
62
63 user?: MUserAccountId
64 historyOfUser?: MUserId
65
66 startDate?: string // ISO 8601
67 endDate?: string // ISO 8601
68 originallyPublishedStartDate?: string
69 originallyPublishedEndDate?: string
70
71 durationMin?: number // seconds
72 durationMax?: number // seconds
73
74 search?: string
75
76 isCount?: boolean
77
78 group?: string
79 having?: string
80
81 transaction?: Transaction
82 logging?: boolean
83
84 excludeAlreadyWatched?: boolean
85}
86
87export class VideosIdListQueryBuilder extends AbstractRunQuery {
88 protected replacements: any = {}
89
90 private attributes: string[]
91 private joins: string[] = []
92
93 private readonly and: string[] = []
94
95 private readonly cte: string[] = []
96
97 private group = ''
98 private having = ''
99
100 private sort = ''
101 private limit = ''
102 private offset = ''
103
104 constructor (protected readonly sequelize: Sequelize) {
105 super(sequelize)
106 }
107
108 queryVideoIds (options: BuildVideosListQueryOptions) {
109 this.buildIdsListQuery(options)
110
111 return this.runQuery()
112 }
113
114 countVideoIds (countOptions: BuildVideosListQueryOptions): Promise<number> {
115 this.buildIdsListQuery(countOptions)
116
117 return this.runQuery().then(rows => parseRowCountResult(rows))
118 }
119
120 getQuery (options: BuildVideosListQueryOptions) {
121 this.buildIdsListQuery(options)
122
123 return { query: this.query, sort: this.sort, replacements: this.replacements }
124 }
125
126 private buildIdsListQuery (options: BuildVideosListQueryOptions) {
127 this.attributes = options.attributes || [ '"video"."id"' ]
128
129 if (options.group) this.group = options.group
130 if (options.having) this.having = options.having
131
132 this.joins = this.joins.concat([
133 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId"',
134 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId"',
135 'INNER JOIN "actor" "accountActor" ON "account"."actorId" = "accountActor"."id"'
136 ])
137
138 if (!(options.include & VideoInclude.BLACKLISTED)) {
139 this.whereNotBlacklisted()
140 }
141
142 if (options.serverAccountIdForBlock && !(options.include & VideoInclude.BLOCKED_OWNER)) {
143 this.whereNotBlocked(options.serverAccountIdForBlock, options.user)
144 }
145
146 // Only list published videos
147 if (!(options.include & VideoInclude.NOT_PUBLISHED_STATE)) {
148 this.whereStateAvailable()
149 }
150
151 if (options.videoPlaylistId) {
152 this.joinPlaylist(options.videoPlaylistId)
153 }
154
155 if (exists(options.isLocal)) {
156 this.whereLocal(options.isLocal)
157 }
158
159 if (options.host) {
160 this.whereHost(options.host)
161 }
162
163 if (options.accountId) {
164 this.whereAccountId(options.accountId)
165 }
166
167 if (options.videoChannelId) {
168 this.whereChannelId(options.videoChannelId)
169 }
170
171 if (options.displayOnlyForFollower) {
172 this.whereFollowerActorId(options.displayOnlyForFollower)
173 }
174
175 if (options.hasFiles === true) {
176 this.whereFileExists()
177 }
178
179 if (exists(options.hasWebtorrentFiles)) {
180 this.whereWebVideoFileExists(options.hasWebtorrentFiles)
181 } else if (exists(options.hasWebVideoFiles)) {
182 this.whereWebVideoFileExists(options.hasWebVideoFiles)
183 }
184
185 if (exists(options.hasHLSFiles)) {
186 this.whereHLSFileExists(options.hasHLSFiles)
187 }
188
189 if (options.tagsOneOf) {
190 this.whereTagsOneOf(options.tagsOneOf)
191 }
192
193 if (options.tagsAllOf) {
194 this.whereTagsAllOf(options.tagsAllOf)
195 }
196
197 if (options.privacyOneOf) {
198 this.wherePrivacyOneOf(options.privacyOneOf)
199 } else {
200 // Only list videos with the appropriate privacy
201 this.wherePrivacyAvailable(options.user)
202 }
203
204 if (options.uuids) {
205 this.whereUUIDs(options.uuids)
206 }
207
208 if (options.nsfw === true) {
209 this.whereNSFW()
210 } else if (options.nsfw === false) {
211 this.whereSFW()
212 }
213
214 if (options.isLive === true) {
215 this.whereLive()
216 } else if (options.isLive === false) {
217 this.whereVOD()
218 }
219
220 if (options.categoryOneOf) {
221 this.whereCategoryOneOf(options.categoryOneOf)
222 }
223
224 if (options.licenceOneOf) {
225 this.whereLicenceOneOf(options.licenceOneOf)
226 }
227
228 if (options.languageOneOf) {
229 this.whereLanguageOneOf(options.languageOneOf)
230 }
231
232 // We don't exclude results in this so if we do a count we don't need to add this complex clause
233 if (options.isCount !== true) {
234 if (options.trendingDays) {
235 this.groupForTrending(options.trendingDays)
236 } else if ([ 'best', 'hot' ].includes(options.trendingAlgorithm)) {
237 this.groupForHotOrBest(options.trendingAlgorithm, options.user)
238 }
239 }
240
241 if (options.historyOfUser) {
242 this.joinHistory(options.historyOfUser.id)
243 }
244
245 if (options.startDate) {
246 this.whereStartDate(options.startDate)
247 }
248
249 if (options.endDate) {
250 this.whereEndDate(options.endDate)
251 }
252
253 if (options.originallyPublishedStartDate) {
254 this.whereOriginallyPublishedStartDate(options.originallyPublishedStartDate)
255 }
256
257 if (options.originallyPublishedEndDate) {
258 this.whereOriginallyPublishedEndDate(options.originallyPublishedEndDate)
259 }
260
261 if (options.durationMin) {
262 this.whereDurationMin(options.durationMin)
263 }
264
265 if (options.durationMax) {
266 this.whereDurationMax(options.durationMax)
267 }
268
269 if (options.excludeAlreadyWatched) {
270 if (exists(options.user.id)) {
271 this.whereExcludeAlreadyWatched(options.user.id)
272 } else {
273 throw new Error('Cannot use excludeAlreadyWatched parameter when auth token is not provided')
274 }
275 }
276
277 this.whereSearch(options.search)
278
279 if (options.isCount === true) {
280 this.setCountAttribute()
281 } else {
282 if (exists(options.sort)) {
283 this.setSort(options.sort)
284 }
285
286 if (exists(options.count)) {
287 this.setLimit(options.count)
288 }
289
290 if (exists(options.start)) {
291 this.setOffset(options.start)
292 }
293 }
294
295 const cteString = this.cte.length !== 0
296 ? `WITH ${this.cte.join(', ')} `
297 : ''
298
299 this.query = cteString +
300 'SELECT ' + this.attributes.join(', ') + ' ' +
301 'FROM "video" ' + this.joins.join(' ') + ' ' +
302 'WHERE ' + this.and.join(' AND ') + ' ' +
303 this.group + ' ' +
304 this.having + ' ' +
305 this.sort + ' ' +
306 this.limit + ' ' +
307 this.offset
308 }
309
310 private setCountAttribute () {
311 this.attributes = [ 'COUNT(*) as "total"' ]
312 }
313
314 private joinHistory (userId: number) {
315 this.joins.push('INNER JOIN "userVideoHistory" ON "video"."id" = "userVideoHistory"."videoId"')
316
317 this.and.push('"userVideoHistory"."userId" = :historyOfUser')
318
319 this.replacements.historyOfUser = userId
320 }
321
322 private joinPlaylist (playlistId: number) {
323 this.joins.push(
324 'INNER JOIN "videoPlaylistElement" "video"."id" = "videoPlaylistElement"."videoId" ' +
325 'AND "videoPlaylistElement"."videoPlaylistId" = :videoPlaylistId'
326 )
327
328 this.replacements.videoPlaylistId = playlistId
329 }
330
331 private whereStateAvailable () {
332 this.and.push(
333 `("video"."state" = ${VideoState.PUBLISHED} OR ` +
334 `("video"."state" = ${VideoState.TO_TRANSCODE} AND "video"."waitTranscoding" IS false))`
335 )
336 }
337
338 private wherePrivacyAvailable (user?: MUserAccountId) {
339 if (user) {
340 this.and.push(
341 `("video"."privacy" = ${VideoPrivacy.PUBLIC} OR "video"."privacy" = ${VideoPrivacy.INTERNAL})`
342 )
343 } else { // Or only public videos
344 this.and.push(
345 `"video"."privacy" = ${VideoPrivacy.PUBLIC}`
346 )
347 }
348 }
349
350 private whereLocal (isLocal: boolean) {
351 const isRemote = isLocal ? 'FALSE' : 'TRUE'
352
353 this.and.push('"video"."remote" IS ' + isRemote)
354 }
355
356 private whereHost (host: string) {
357 // Local instance
358 if (host === WEBSERVER.HOST) {
359 this.and.push('"accountActor"."serverId" IS NULL')
360 return
361 }
362
363 this.joins.push('INNER JOIN "server" ON "server"."id" = "accountActor"."serverId"')
364
365 this.and.push('"server"."host" = :host')
366 this.replacements.host = host
367 }
368
369 private whereAccountId (accountId: number) {
370 this.and.push('"account"."id" = :accountId')
371 this.replacements.accountId = accountId
372 }
373
374 private whereChannelId (channelId: number) {
375 this.and.push('"videoChannel"."id" = :videoChannelId')
376 this.replacements.videoChannelId = channelId
377 }
378
379 private whereFollowerActorId (options: { actorId: number, orLocalVideos: boolean }) {
380 let query =
381 '(' +
382 ' EXISTS (' + // Videos shared by actors we follow
383 ' SELECT 1 FROM "videoShare" ' +
384 ' INNER JOIN "actorFollow" "actorFollowShare" ON "actorFollowShare"."targetActorId" = "videoShare"."actorId" ' +
385 ' AND "actorFollowShare"."actorId" = :followerActorId AND "actorFollowShare"."state" = \'accepted\' ' +
386 ' WHERE "videoShare"."videoId" = "video"."id"' +
387 ' )' +
388 ' OR' +
389 ' EXISTS (' + // Videos published by channels or accounts we follow
390 ' SELECT 1 from "actorFollow" ' +
391 ' WHERE ("actorFollow"."targetActorId" = "account"."actorId" OR "actorFollow"."targetActorId" = "videoChannel"."actorId") ' +
392 ' AND "actorFollow"."actorId" = :followerActorId ' +
393 ' AND "actorFollow"."state" = \'accepted\'' +
394 ' )'
395
396 if (options.orLocalVideos) {
397 query += ' OR "video"."remote" IS FALSE'
398 }
399
400 query += ')'
401
402 this.and.push(query)
403 this.replacements.followerActorId = options.actorId
404 }
405
406 private whereFileExists () {
407 this.and.push(`(${this.buildWebVideoFileExistsQuery(true)} OR ${this.buildHLSFileExistsQuery(true)})`)
408 }
409
410 private whereWebVideoFileExists (exists: boolean) {
411 this.and.push(this.buildWebVideoFileExistsQuery(exists))
412 }
413
414 private whereHLSFileExists (exists: boolean) {
415 this.and.push(this.buildHLSFileExistsQuery(exists))
416 }
417
418 private buildWebVideoFileExistsQuery (exists: boolean) {
419 const prefix = exists ? '' : 'NOT '
420
421 return prefix + 'EXISTS (SELECT 1 FROM "videoFile" WHERE "videoFile"."videoId" = "video"."id")'
422 }
423
424 private buildHLSFileExistsQuery (exists: boolean) {
425 const prefix = exists ? '' : 'NOT '
426
427 return prefix + 'EXISTS (' +
428 ' SELECT 1 FROM "videoStreamingPlaylist" ' +
429 ' INNER JOIN "videoFile" ON "videoFile"."videoStreamingPlaylistId" = "videoStreamingPlaylist"."id" ' +
430 ' WHERE "videoStreamingPlaylist"."videoId" = "video"."id"' +
431 ')'
432 }
433
434 private whereTagsOneOf (tagsOneOf: string[]) {
435 const tagsOneOfLower = tagsOneOf.map(t => t.toLowerCase())
436
437 this.and.push(
438 'EXISTS (' +
439 ' SELECT 1 FROM "videoTag" ' +
440 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
441 ' WHERE lower("tag"."name") IN (' + createSafeIn(this.sequelize, tagsOneOfLower) + ') ' +
442 ' AND "video"."id" = "videoTag"."videoId"' +
443 ')'
444 )
445 }
446
447 private whereTagsAllOf (tagsAllOf: string[]) {
448 const tagsAllOfLower = tagsAllOf.map(t => t.toLowerCase())
449
450 this.and.push(
451 'EXISTS (' +
452 ' SELECT 1 FROM "videoTag" ' +
453 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
454 ' WHERE lower("tag"."name") IN (' + createSafeIn(this.sequelize, tagsAllOfLower) + ') ' +
455 ' AND "video"."id" = "videoTag"."videoId" ' +
456 ' GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + tagsAllOfLower.length +
457 ')'
458 )
459 }
460
461 private wherePrivacyOneOf (privacyOneOf: VideoPrivacy[]) {
462 this.and.push('"video"."privacy" IN (:privacyOneOf)')
463 this.replacements.privacyOneOf = privacyOneOf
464 }
465
466 private whereUUIDs (uuids: string[]) {
467 this.and.push('"video"."uuid" IN (' + createSafeIn(this.sequelize, uuids) + ')')
468 }
469
470 private whereCategoryOneOf (categoryOneOf: number[]) {
471 this.and.push('"video"."category" IN (:categoryOneOf)')
472 this.replacements.categoryOneOf = categoryOneOf
473 }
474
475 private whereLicenceOneOf (licenceOneOf: number[]) {
476 this.and.push('"video"."licence" IN (:licenceOneOf)')
477 this.replacements.licenceOneOf = licenceOneOf
478 }
479
480 private whereLanguageOneOf (languageOneOf: string[]) {
481 const languages = languageOneOf.filter(l => l && l !== '_unknown')
482 const languagesQueryParts: string[] = []
483
484 if (languages.length !== 0) {
485 languagesQueryParts.push('"video"."language" IN (:languageOneOf)')
486 this.replacements.languageOneOf = languages
487
488 languagesQueryParts.push(
489 'EXISTS (' +
490 ' SELECT 1 FROM "videoCaption" WHERE "videoCaption"."language" ' +
491 ' IN (' + createSafeIn(this.sequelize, languages) + ') AND ' +
492 ' "videoCaption"."videoId" = "video"."id"' +
493 ')'
494 )
495 }
496
497 if (languageOneOf.includes('_unknown')) {
498 languagesQueryParts.push('"video"."language" IS NULL')
499 }
500
501 if (languagesQueryParts.length !== 0) {
502 this.and.push('(' + languagesQueryParts.join(' OR ') + ')')
503 }
504 }
505
506 private whereNSFW () {
507 this.and.push('"video"."nsfw" IS TRUE')
508 }
509
510 private whereSFW () {
511 this.and.push('"video"."nsfw" IS FALSE')
512 }
513
514 private whereLive () {
515 this.and.push('"video"."isLive" IS TRUE')
516 }
517
518 private whereVOD () {
519 this.and.push('"video"."isLive" IS FALSE')
520 }
521
522 private whereNotBlocked (serverAccountId: number, user?: MUserAccountId) {
523 const blockerIds = [ serverAccountId ]
524 if (user) blockerIds.push(user.Account.id)
525
526 const inClause = createSafeIn(this.sequelize, blockerIds)
527
528 this.and.push(
529 'NOT EXISTS (' +
530 ' SELECT 1 FROM "accountBlocklist" ' +
531 ' WHERE "accountBlocklist"."accountId" IN (' + inClause + ') ' +
532 ' AND "accountBlocklist"."targetAccountId" = "account"."id" ' +
533 ')' +
534 'AND NOT EXISTS (' +
535 ' SELECT 1 FROM "serverBlocklist" WHERE "serverBlocklist"."accountId" IN (' + inClause + ') ' +
536 ' AND "serverBlocklist"."targetServerId" = "accountActor"."serverId"' +
537 ')'
538 )
539 }
540
541 private whereSearch (search?: string) {
542 if (!search) {
543 this.attributes.push('0 as similarity')
544 return
545 }
546
547 const escapedSearch = this.sequelize.escape(search)
548 const escapedLikeSearch = this.sequelize.escape('%' + search + '%')
549
550 this.cte.push(
551 '"trigramSearch" AS (' +
552 ' SELECT "video"."id", ' +
553 ` similarity(lower(immutable_unaccent("video"."name")), lower(immutable_unaccent(${escapedSearch}))) as similarity ` +
554 ' FROM "video" ' +
555 ' WHERE lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
556 ' lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
557 ')'
558 )
559
560 this.joins.push('LEFT JOIN "trigramSearch" ON "video"."id" = "trigramSearch"."id"')
561
562 let base = '(' +
563 ' "trigramSearch"."id" IS NOT NULL OR ' +
564 ' EXISTS (' +
565 ' SELECT 1 FROM "videoTag" ' +
566 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
567 ` WHERE lower("tag"."name") = lower(${escapedSearch}) ` +
568 ' AND "video"."id" = "videoTag"."videoId"' +
569 ' )'
570
571 if (validator.isUUID(search)) {
572 base += ` OR "video"."uuid" = ${escapedSearch}`
573 }
574
575 base += ')'
576
577 this.and.push(base)
578 this.attributes.push(`COALESCE("trigramSearch"."similarity", 0) as similarity`)
579 }
580
581 private whereNotBlacklisted () {
582 this.and.push('"video"."id" NOT IN (SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")')
583 }
584
585 private whereStartDate (startDate: string) {
586 this.and.push('"video"."publishedAt" >= :startDate')
587 this.replacements.startDate = startDate
588 }
589
590 private whereEndDate (endDate: string) {
591 this.and.push('"video"."publishedAt" <= :endDate')
592 this.replacements.endDate = endDate
593 }
594
595 private whereOriginallyPublishedStartDate (startDate: string) {
596 this.and.push('"video"."originallyPublishedAt" >= :originallyPublishedStartDate')
597 this.replacements.originallyPublishedStartDate = startDate
598 }
599
600 private whereOriginallyPublishedEndDate (endDate: string) {
601 this.and.push('"video"."originallyPublishedAt" <= :originallyPublishedEndDate')
602 this.replacements.originallyPublishedEndDate = endDate
603 }
604
605 private whereDurationMin (durationMin: number) {
606 this.and.push('"video"."duration" >= :durationMin')
607 this.replacements.durationMin = durationMin
608 }
609
610 private whereDurationMax (durationMax: number) {
611 this.and.push('"video"."duration" <= :durationMax')
612 this.replacements.durationMax = durationMax
613 }
614
615 private whereExcludeAlreadyWatched (userId: number) {
616 this.and.push(
617 'NOT EXISTS (' +
618 ' SELECT 1' +
619 ' FROM "userVideoHistory"' +
620 ' WHERE "video"."id" = "userVideoHistory"."videoId"' +
621 ' AND "userVideoHistory"."userId" = :excludeAlreadyWatchedUserId' +
622 ')'
623 )
624 this.replacements.excludeAlreadyWatchedUserId = userId
625 }
626
627 private groupForTrending (trendingDays: number) {
628 const viewsGteDate = new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
629
630 this.joins.push('LEFT JOIN "videoView" ON "video"."id" = "videoView"."videoId" AND "videoView"."startDate" >= :viewsGteDate')
631 this.replacements.viewsGteDate = viewsGteDate
632
633 this.attributes.push('COALESCE(SUM("videoView"."views"), 0) AS "score"')
634
635 this.group = 'GROUP BY "video"."id"'
636 }
637
638 private groupForHotOrBest (trendingAlgorithm: string, user?: MUserAccountId) {
639 /**
640 * "Hotness" is a measure based on absolute view/comment/like/dislike numbers,
641 * with fixed weights only applied to their log values.
642 *
643 * This algorithm gives little chance for an old video to have a good score,
644 * for which recent spikes in interactions could be a sign of "hotness" and
645 * justify a better score. However there are multiple ways to achieve that
646 * goal, which is left for later. Yes, this is a TODO :)
647 *
648 * notes:
649 * - weights and base score are in number of half-days.
650 * - all comments are counted, regardless of being written by the video author or not
651 * see https://github.com/reddit-archive/reddit/blob/master/r2/r2/lib/db/_sorts.pyx#L47-L58
652 * - we have less interactions than on reddit, so multiply weights by an arbitrary factor
653 */
654 const weights = {
655 like: 3 * 50,
656 dislike: -3 * 50,
657 view: Math.floor((1 / 3) * 50),
658 comment: 2 * 50, // a comment takes more time than a like to do, but can be done multiple times
659 history: -2 * 50
660 }
661
662 this.joins.push('LEFT JOIN "videoComment" ON "video"."id" = "videoComment"."videoId"')
663
664 let attribute =
665 `LOG(GREATEST(1, "video"."likes" - 1)) * ${weights.like} ` + // likes (+)
666 `+ LOG(GREATEST(1, "video"."dislikes" - 1)) * ${weights.dislike} ` + // dislikes (-)
667 `+ LOG("video"."views" + 1) * ${weights.view} ` + // views (+)
668 `+ LOG(GREATEST(1, COUNT(DISTINCT "videoComment"."id"))) * ${weights.comment} ` + // comments (+)
669 '+ (SELECT (EXTRACT(epoch FROM "video"."publishedAt") - 1446156582) / 47000) ' // base score (in number of half-days)
670
671 if (trendingAlgorithm === 'best' && user) {
672 this.joins.push(
673 'LEFT JOIN "userVideoHistory" ON "video"."id" = "userVideoHistory"."videoId" AND "userVideoHistory"."userId" = :bestUser'
674 )
675 this.replacements.bestUser = user.id
676
677 attribute += `+ POWER(COUNT(DISTINCT "userVideoHistory"."id"), 2.0) * ${weights.history} `
678 }
679
680 attribute += 'AS "score"'
681 this.attributes.push(attribute)
682
683 this.group = 'GROUP BY "video"."id"'
684 }
685
686 private setSort (sort: string) {
687 if (sort === '-originallyPublishedAt' || sort === 'originallyPublishedAt') {
688 this.attributes.push('COALESCE("video"."originallyPublishedAt", "video"."publishedAt") AS "publishedAtForOrder"')
689 }
690
691 this.sort = this.buildOrder(sort)
692 }
693
694 private buildOrder (value: string) {
695 const { direction, field } = buildSortDirectionAndField(value)
696 if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field)
697
698 if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
699
700 if ([ 'trending', 'hot', 'best' ].includes(field.toLowerCase())) { // Sort by aggregation
701 return `ORDER BY "score" ${direction}, "video"."views" ${direction}`
702 }
703
704 let firstSort: string
705
706 if (field.toLowerCase() === 'match') { // Search
707 firstSort = '"similarity"'
708 } else if (field === 'originallyPublishedAt') {
709 firstSort = '"publishedAtForOrder"'
710 } else if (field.includes('.')) {
711 firstSort = field
712 } else {
713 firstSort = `"video"."${field}"`
714 }
715
716 return `ORDER BY ${firstSort} ${direction}, "video"."id" ASC`
717 }
718
719 private setLimit (countArg: number) {
720 const count = forceNumber(countArg)
721 this.limit = `LIMIT ${count}`
722 }
723
724 private setOffset (startArg: number) {
725 const start = forceNumber(startArg)
726 this.offset = `OFFSET ${start}`
727 }
728}
diff --git a/server/models/video/sql/video/videos-model-list-query-builder.ts b/server/models/video/sql/video/videos-model-list-query-builder.ts
deleted file mode 100644
index b73dc28cd..000000000
--- a/server/models/video/sql/video/videos-model-list-query-builder.ts
+++ /dev/null
@@ -1,103 +0,0 @@
1import { Sequelize } from 'sequelize'
2import { pick } from '@shared/core-utils'
3import { VideoInclude } from '@shared/models'
4import { AbstractVideoQueryBuilder } from './shared/abstract-video-query-builder'
5import { VideoFileQueryBuilder } from './shared/video-file-query-builder'
6import { VideoModelBuilder } from './shared/video-model-builder'
7import { BuildVideosListQueryOptions, VideosIdListQueryBuilder } from './videos-id-list-query-builder'
8
9/**
10 *
11 * Build videos list SQL query and create video models
12 *
13 */
14
15export class VideosModelListQueryBuilder extends AbstractVideoQueryBuilder {
16 protected attributes: { [key: string]: string }
17
18 private innerQuery: string
19 private innerSort: string
20
21 webVideoFilesQueryBuilder: VideoFileQueryBuilder
22 streamingPlaylistFilesQueryBuilder: VideoFileQueryBuilder
23
24 private readonly videoModelBuilder: VideoModelBuilder
25
26 constructor (protected readonly sequelize: Sequelize) {
27 super(sequelize, 'list')
28
29 this.videoModelBuilder = new VideoModelBuilder(this.mode, this.tables)
30 this.webVideoFilesQueryBuilder = new VideoFileQueryBuilder(sequelize)
31 this.streamingPlaylistFilesQueryBuilder = new VideoFileQueryBuilder(sequelize)
32 }
33
34 async queryVideos (options: BuildVideosListQueryOptions) {
35 this.buildInnerQuery(options)
36 this.buildMainQuery(options)
37
38 const rows = await this.runQuery()
39
40 if (options.include & VideoInclude.FILES) {
41 const videoIds = Array.from(new Set(rows.map(r => r.id)))
42
43 if (videoIds.length !== 0) {
44 const fileQueryOptions = {
45 ...pick(options, [ 'transaction', 'logging' ]),
46
47 ids: videoIds,
48 includeRedundancy: false
49 }
50
51 const [ rowsWebVideoFiles, rowsStreamingPlaylist ] = await Promise.all([
52 this.webVideoFilesQueryBuilder.queryWebVideos(fileQueryOptions),
53 this.streamingPlaylistFilesQueryBuilder.queryStreamingPlaylistVideos(fileQueryOptions)
54 ])
55
56 return this.videoModelBuilder.buildVideosFromRows({ rows, include: options.include, rowsStreamingPlaylist, rowsWebVideoFiles })
57 }
58 }
59
60 return this.videoModelBuilder.buildVideosFromRows({ rows, include: options.include })
61 }
62
63 private buildInnerQuery (options: BuildVideosListQueryOptions) {
64 const idsQueryBuilder = new VideosIdListQueryBuilder(this.sequelize)
65 const { query, sort, replacements } = idsQueryBuilder.getQuery(options)
66
67 this.replacements = replacements
68 this.innerQuery = query
69 this.innerSort = sort
70 }
71
72 private buildMainQuery (options: BuildVideosListQueryOptions) {
73 this.attributes = {
74 '"video".*': ''
75 }
76
77 this.addJoin('INNER JOIN "video" ON "tmp"."id" = "video"."id"')
78
79 this.includeChannels()
80 this.includeAccounts()
81 this.includeThumbnails()
82
83 if (options.user) {
84 this.includeUserHistory(options.user.id)
85 }
86
87 if (options.videoPlaylistId) {
88 this.includePlaylist(options.videoPlaylistId)
89 }
90
91 if (options.include & VideoInclude.BLACKLISTED) {
92 this.includeBlacklisted()
93 }
94
95 if (options.include & VideoInclude.BLOCKED_OWNER) {
96 this.includeBlockedOwnerAndServer(options.serverAccountIdForBlock, options.user)
97 }
98
99 const select = this.buildSelect()
100
101 this.query = `${select} FROM (${this.innerQuery}) AS "tmp" ${this.joins} ${this.innerSort}`
102 }
103}
diff --git a/server/models/video/storyboard.ts b/server/models/video/storyboard.ts
deleted file mode 100644
index 1c3c6d850..000000000
--- a/server/models/video/storyboard.ts
+++ /dev/null
@@ -1,169 +0,0 @@
1import { remove } from 'fs-extra'
2import { join } from 'path'
3import { AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
4import { CONFIG } from '@server/initializers/config'
5import { MStoryboard, MStoryboardVideo, MVideo } from '@server/types/models'
6import { Storyboard } from '@shared/models'
7import { AttributesOnly } from '@shared/typescript-utils'
8import { logger } from '../../helpers/logger'
9import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
10import { VideoModel } from './video'
11import { Transaction } from 'sequelize'
12
13@Table({
14 tableName: 'storyboard',
15 indexes: [
16 {
17 fields: [ 'videoId' ],
18 unique: true
19 },
20 {
21 fields: [ 'filename' ],
22 unique: true
23 }
24 ]
25})
26export class StoryboardModel extends Model<Partial<AttributesOnly<StoryboardModel>>> {
27
28 @AllowNull(false)
29 @Column
30 filename: string
31
32 @AllowNull(false)
33 @Column
34 totalHeight: number
35
36 @AllowNull(false)
37 @Column
38 totalWidth: number
39
40 @AllowNull(false)
41 @Column
42 spriteHeight: number
43
44 @AllowNull(false)
45 @Column
46 spriteWidth: number
47
48 @AllowNull(false)
49 @Column
50 spriteDuration: number
51
52 @AllowNull(true)
53 @Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max))
54 fileUrl: string
55
56 @ForeignKey(() => VideoModel)
57 @Column
58 videoId: number
59
60 @BelongsTo(() => VideoModel, {
61 foreignKey: {
62 allowNull: false
63 },
64 onDelete: 'CASCADE'
65 })
66 Video: VideoModel
67
68 @CreatedAt
69 createdAt: Date
70
71 @UpdatedAt
72 updatedAt: Date
73
74 @AfterDestroy
75 static removeInstanceFile (instance: StoryboardModel) {
76 logger.info('Removing storyboard file %s.', instance.filename)
77
78 // Don't block the transaction
79 instance.removeFile()
80 .catch(err => logger.error('Cannot remove storyboard file %s.', instance.filename, { err }))
81 }
82
83 static loadByVideo (videoId: number, transaction?: Transaction): Promise<MStoryboard> {
84 const query = {
85 where: {
86 videoId
87 },
88 transaction
89 }
90
91 return StoryboardModel.findOne(query)
92 }
93
94 static loadByFilename (filename: string): Promise<MStoryboard> {
95 const query = {
96 where: {
97 filename
98 }
99 }
100
101 return StoryboardModel.findOne(query)
102 }
103
104 static loadWithVideoByFilename (filename: string): Promise<MStoryboardVideo> {
105 const query = {
106 where: {
107 filename
108 },
109 include: [
110 {
111 model: VideoModel.unscoped(),
112 required: true
113 }
114 ]
115 }
116
117 return StoryboardModel.findOne(query)
118 }
119
120 // ---------------------------------------------------------------------------
121
122 static async listStoryboardsOf (video: MVideo): Promise<MStoryboardVideo[]> {
123 const query = {
124 where: {
125 videoId: video.id
126 }
127 }
128
129 const storyboards = await StoryboardModel.findAll<MStoryboard>(query)
130
131 return storyboards.map(s => Object.assign(s, { Video: video }))
132 }
133
134 // ---------------------------------------------------------------------------
135
136 getOriginFileUrl (video: MVideo) {
137 if (video.isOwned()) {
138 return WEBSERVER.URL + this.getLocalStaticPath()
139 }
140
141 return this.fileUrl
142 }
143
144 getLocalStaticPath () {
145 return LAZY_STATIC_PATHS.STORYBOARDS + this.filename
146 }
147
148 getPath () {
149 return join(CONFIG.STORAGE.STORYBOARDS_DIR, this.filename)
150 }
151
152 removeFile () {
153 return remove(this.getPath())
154 }
155
156 toFormattedJSON (this: MStoryboardVideo): Storyboard {
157 return {
158 storyboardPath: this.getLocalStaticPath(),
159
160 totalHeight: this.totalHeight,
161 totalWidth: this.totalWidth,
162
163 spriteWidth: this.spriteWidth,
164 spriteHeight: this.spriteHeight,
165
166 spriteDuration: this.spriteDuration
167 }
168 }
169}
diff --git a/server/models/video/tag.ts b/server/models/video/tag.ts
deleted file mode 100644
index cebde3755..000000000
--- a/server/models/video/tag.ts
+++ /dev/null
@@ -1,86 +0,0 @@
1import { col, fn, QueryTypes, Transaction } from 'sequelize'
2import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { MTag } from '@server/types/models'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { VideoPrivacy, VideoState } from '../../../shared/models/videos'
6import { isVideoTagValid } from '../../helpers/custom-validators/videos'
7import { throwIfNotValid } from '../shared'
8import { VideoModel } from './video'
9import { VideoTagModel } from './video-tag'
10
11@Table({
12 tableName: 'tag',
13 timestamps: false,
14 indexes: [
15 {
16 fields: [ 'name' ],
17 unique: true
18 },
19 {
20 name: 'tag_lower_name',
21 fields: [ fn('lower', col('name')) ]
22 }
23 ]
24})
25export class TagModel extends Model<Partial<AttributesOnly<TagModel>>> {
26
27 @AllowNull(false)
28 @Is('VideoTag', value => throwIfNotValid(value, isVideoTagValid, 'tag'))
29 @Column
30 name: string
31
32 @CreatedAt
33 createdAt: Date
34
35 @UpdatedAt
36 updatedAt: Date
37
38 @BelongsToMany(() => VideoModel, {
39 foreignKey: 'tagId',
40 through: () => VideoTagModel,
41 onDelete: 'CASCADE'
42 })
43 Videos: VideoModel[]
44
45 static findOrCreateTags (tags: string[], transaction: Transaction): Promise<MTag[]> {
46 if (tags === null) return Promise.resolve([])
47
48 const uniqueTags = new Set(tags)
49
50 const tasks = Array.from(uniqueTags).map(tag => {
51 const query = {
52 where: {
53 name: tag
54 },
55 defaults: {
56 name: tag
57 },
58 transaction
59 }
60
61 return TagModel.findOrCreate<MTag>(query)
62 .then(([ tagInstance ]) => tagInstance)
63 })
64
65 return Promise.all(tasks)
66 }
67
68 // threshold corresponds to how many video the field should have to be returned
69 static getRandomSamples (threshold: number, count: number): Promise<string[]> {
70 const query = 'SELECT tag.name FROM tag ' +
71 'INNER JOIN "videoTag" ON "videoTag"."tagId" = tag.id ' +
72 'INNER JOIN video ON video.id = "videoTag"."videoId" ' +
73 'WHERE video.privacy = $videoPrivacy AND video.state = $videoState ' +
74 'GROUP BY tag.name HAVING COUNT(tag.name) >= $threshold ' +
75 'ORDER BY random() ' +
76 'LIMIT $count'
77
78 const options = {
79 bind: { threshold, count, videoPrivacy: VideoPrivacy.PUBLIC, videoState: VideoState.PUBLISHED },
80 type: QueryTypes.SELECT as QueryTypes.SELECT
81 }
82
83 return TagModel.sequelize.query<{ name: string }>(query, options)
84 .then(data => data.map(d => d.name))
85 }
86}
diff --git a/server/models/video/thumbnail.ts b/server/models/video/thumbnail.ts
deleted file mode 100644
index 1722acdb4..000000000
--- a/server/models/video/thumbnail.ts
+++ /dev/null
@@ -1,208 +0,0 @@
1import { remove } from 'fs-extra'
2import { join } from 'path'
3import {
4 AfterDestroy,
5 AllowNull,
6 BeforeCreate,
7 BeforeUpdate,
8 BelongsTo,
9 Column,
10 CreatedAt,
11 DataType,
12 Default,
13 ForeignKey,
14 Model,
15 Table,
16 UpdatedAt
17} from 'sequelize-typescript'
18import { afterCommitIfTransaction } from '@server/helpers/database-utils'
19import { MThumbnail, MThumbnailVideo, MVideo } from '@server/types/models'
20import { AttributesOnly } from '@shared/typescript-utils'
21import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
22import { logger } from '../../helpers/logger'
23import { CONFIG } from '../../initializers/config'
24import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
25import { VideoModel } from './video'
26import { VideoPlaylistModel } from './video-playlist'
27
28@Table({
29 tableName: 'thumbnail',
30 indexes: [
31 {
32 fields: [ 'videoId' ]
33 },
34 {
35 fields: [ 'videoPlaylistId' ],
36 unique: true
37 },
38 {
39 fields: [ 'filename', 'type' ],
40 unique: true
41 }
42 ]
43})
44export class ThumbnailModel extends Model<Partial<AttributesOnly<ThumbnailModel>>> {
45
46 @AllowNull(false)
47 @Column
48 filename: string
49
50 @AllowNull(true)
51 @Default(null)
52 @Column
53 height: number
54
55 @AllowNull(true)
56 @Default(null)
57 @Column
58 width: number
59
60 @AllowNull(false)
61 @Column
62 type: ThumbnailType
63
64 @AllowNull(true)
65 @Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max))
66 fileUrl: string
67
68 @AllowNull(true)
69 @Column
70 automaticallyGenerated: boolean
71
72 @AllowNull(false)
73 @Column
74 onDisk: boolean
75
76 @ForeignKey(() => VideoModel)
77 @Column
78 videoId: number
79
80 @BelongsTo(() => VideoModel, {
81 foreignKey: {
82 allowNull: true
83 },
84 onDelete: 'CASCADE'
85 })
86 Video: VideoModel
87
88 @ForeignKey(() => VideoPlaylistModel)
89 @Column
90 videoPlaylistId: number
91
92 @BelongsTo(() => VideoPlaylistModel, {
93 foreignKey: {
94 allowNull: true
95 },
96 onDelete: 'CASCADE'
97 })
98 VideoPlaylist: VideoPlaylistModel
99
100 @CreatedAt
101 createdAt: Date
102
103 @UpdatedAt
104 updatedAt: Date
105
106 // If this thumbnail replaced existing one, track the old name
107 previousThumbnailFilename: string
108
109 private static readonly types: { [ id in ThumbnailType ]: { label: string, directory: string, staticPath: string } } = {
110 [ThumbnailType.MINIATURE]: {
111 label: 'miniature',
112 directory: CONFIG.STORAGE.THUMBNAILS_DIR,
113 staticPath: LAZY_STATIC_PATHS.THUMBNAILS
114 },
115 [ThumbnailType.PREVIEW]: {
116 label: 'preview',
117 directory: CONFIG.STORAGE.PREVIEWS_DIR,
118 staticPath: LAZY_STATIC_PATHS.PREVIEWS
119 }
120 }
121
122 @BeforeCreate
123 @BeforeUpdate
124 static removeOldFile (instance: ThumbnailModel, options) {
125 return afterCommitIfTransaction(options.transaction, () => instance.removePreviousFilenameIfNeeded())
126 }
127
128 @AfterDestroy
129 static removeFiles (instance: ThumbnailModel) {
130 logger.info('Removing %s file %s.', ThumbnailModel.types[instance.type].label, instance.filename)
131
132 // Don't block the transaction
133 instance.removeThumbnail()
134 .catch(err => logger.error('Cannot remove thumbnail file %s.', instance.filename, { err }))
135 }
136
137 static loadByFilename (filename: string, thumbnailType: ThumbnailType): Promise<MThumbnail> {
138 const query = {
139 where: {
140 filename,
141 type: thumbnailType
142 }
143 }
144
145 return ThumbnailModel.findOne(query)
146 }
147
148 static loadWithVideoByFilename (filename: string, thumbnailType: ThumbnailType): Promise<MThumbnailVideo> {
149 const query = {
150 where: {
151 filename,
152 type: thumbnailType
153 },
154 include: [
155 {
156 model: VideoModel.unscoped(),
157 required: true
158 }
159 ]
160 }
161
162 return ThumbnailModel.findOne(query)
163 }
164
165 static buildPath (type: ThumbnailType, filename: string) {
166 const directory = ThumbnailModel.types[type].directory
167
168 return join(directory, filename)
169 }
170
171 getOriginFileUrl (video: MVideo) {
172 const staticPath = ThumbnailModel.types[this.type].staticPath + this.filename
173
174 if (video.isOwned()) return WEBSERVER.URL + staticPath
175
176 return this.fileUrl
177 }
178
179 getLocalStaticPath () {
180 return ThumbnailModel.types[this.type].staticPath + this.filename
181 }
182
183 getPath () {
184 return ThumbnailModel.buildPath(this.type, this.filename)
185 }
186
187 getPreviousPath () {
188 return ThumbnailModel.buildPath(this.type, this.previousThumbnailFilename)
189 }
190
191 removeThumbnail () {
192 return remove(this.getPath())
193 }
194
195 removePreviousFilenameIfNeeded () {
196 if (!this.previousThumbnailFilename) return
197
198 const previousPath = this.getPreviousPath()
199 remove(previousPath)
200 .catch(err => logger.error('Cannot remove previous thumbnail file %s.', previousPath, { err }))
201
202 this.previousThumbnailFilename = undefined
203 }
204
205 isOwned () {
206 return !this.fileUrl
207 }
208}
diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts
deleted file mode 100644
index 9247d0e2b..000000000
--- a/server/models/video/video-blacklist.ts
+++ /dev/null
@@ -1,134 +0,0 @@
1import { FindOptions } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { MVideoBlacklist, MVideoBlacklistFormattable } from '@server/types/models'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos'
6import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist'
7import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
8import { getBlacklistSort, searchAttribute, throwIfNotValid } from '../shared'
9import { ThumbnailModel } from './thumbnail'
10import { VideoModel } from './video'
11import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
12
13@Table({
14 tableName: 'videoBlacklist',
15 indexes: [
16 {
17 fields: [ 'videoId' ],
18 unique: true
19 }
20 ]
21})
22export class VideoBlacklistModel extends Model<Partial<AttributesOnly<VideoBlacklistModel>>> {
23
24 @AllowNull(true)
25 @Is('VideoBlacklistReason', value => throwIfNotValid(value, isVideoBlacklistReasonValid, 'reason', true))
26 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_BLACKLIST.REASON.max))
27 reason: string
28
29 @AllowNull(false)
30 @Column
31 unfederated: boolean
32
33 @AllowNull(false)
34 @Default(null)
35 @Is('VideoBlacklistType', value => throwIfNotValid(value, isVideoBlacklistTypeValid, 'type'))
36 @Column
37 type: VideoBlacklistType
38
39 @CreatedAt
40 createdAt: Date
41
42 @UpdatedAt
43 updatedAt: Date
44
45 @ForeignKey(() => VideoModel)
46 @Column
47 videoId: number
48
49 @BelongsTo(() => VideoModel, {
50 foreignKey: {
51 allowNull: false
52 },
53 onDelete: 'cascade'
54 })
55 Video: VideoModel
56
57 static listForApi (parameters: {
58 start: number
59 count: number
60 sort: string
61 search?: string
62 type?: VideoBlacklistType
63 }) {
64 const { start, count, sort, search, type } = parameters
65
66 function buildBaseQuery (): FindOptions {
67 return {
68 offset: start,
69 limit: count,
70 order: getBlacklistSort(sort)
71 }
72 }
73
74 const countQuery = buildBaseQuery()
75
76 const findQuery = buildBaseQuery()
77 findQuery.include = [
78 {
79 model: VideoModel,
80 required: true,
81 where: searchAttribute(search, 'name'),
82 include: [
83 {
84 model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }),
85 required: true
86 },
87 {
88 model: ThumbnailModel,
89 attributes: [ 'type', 'filename' ],
90 required: false
91 }
92 ]
93 }
94 ]
95
96 if (type) {
97 countQuery.where = { type }
98 findQuery.where = { type }
99 }
100
101 return Promise.all([
102 VideoBlacklistModel.count(countQuery),
103 VideoBlacklistModel.findAll(findQuery)
104 ]).then(([ count, rows ]) => {
105 return {
106 data: rows,
107 total: count
108 }
109 })
110 }
111
112 static loadByVideoId (id: number): Promise<MVideoBlacklist> {
113 const query = {
114 where: {
115 videoId: id
116 }
117 }
118
119 return VideoBlacklistModel.findOne(query)
120 }
121
122 toFormattedJSON (this: MVideoBlacklistFormattable): VideoBlacklist {
123 return {
124 id: this.id,
125 createdAt: this.createdAt,
126 updatedAt: this.updatedAt,
127 reason: this.reason,
128 unfederated: this.unfederated,
129 type: this.type,
130
131 video: this.Video.toFormattedJSON()
132 }
133 }
134}
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts
deleted file mode 100644
index dd4cefd65..000000000
--- a/server/models/video/video-caption.ts
+++ /dev/null
@@ -1,247 +0,0 @@
1import { remove } from 'fs-extra'
2import { join } from 'path'
3import { Op, OrderItem, Transaction } from 'sequelize'
4import {
5 AllowNull,
6 BeforeDestroy,
7 BelongsTo,
8 Column,
9 CreatedAt,
10 DataType,
11 ForeignKey,
12 Is,
13 Model,
14 Scopes,
15 Table,
16 UpdatedAt
17} from 'sequelize-typescript'
18import { MVideo, MVideoCaption, MVideoCaptionFormattable, MVideoCaptionLanguageUrl, MVideoCaptionVideo } from '@server/types/models'
19import { buildUUID } from '@shared/extra-utils'
20import { AttributesOnly } from '@shared/typescript-utils'
21import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model'
22import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions'
23import { logger } from '../../helpers/logger'
24import { CONFIG } from '../../initializers/config'
25import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants'
26import { buildWhereIdOrUUID, throwIfNotValid } from '../shared'
27import { VideoModel } from './video'
28
29export enum ScopeNames {
30 WITH_VIDEO_UUID_AND_REMOTE = 'WITH_VIDEO_UUID_AND_REMOTE'
31}
32
33@Scopes(() => ({
34 [ScopeNames.WITH_VIDEO_UUID_AND_REMOTE]: {
35 include: [
36 {
37 attributes: [ 'id', 'uuid', 'remote' ],
38 model: VideoModel.unscoped(),
39 required: true
40 }
41 ]
42 }
43}))
44
45@Table({
46 tableName: 'videoCaption',
47 indexes: [
48 {
49 fields: [ 'filename' ],
50 unique: true
51 },
52 {
53 fields: [ 'videoId' ]
54 },
55 {
56 fields: [ 'videoId', 'language' ],
57 unique: true
58 }
59 ]
60})
61export class VideoCaptionModel extends Model<Partial<AttributesOnly<VideoCaptionModel>>> {
62 @CreatedAt
63 createdAt: Date
64
65 @UpdatedAt
66 updatedAt: Date
67
68 @AllowNull(false)
69 @Is('VideoCaptionLanguage', value => throwIfNotValid(value, isVideoCaptionLanguageValid, 'language'))
70 @Column
71 language: string
72
73 @AllowNull(false)
74 @Column
75 filename: string
76
77 @AllowNull(true)
78 @Column(DataType.STRING(CONSTRAINTS_FIELDS.COMMONS.URL.max))
79 fileUrl: string
80
81 @ForeignKey(() => VideoModel)
82 @Column
83 videoId: number
84
85 @BelongsTo(() => VideoModel, {
86 foreignKey: {
87 allowNull: false
88 },
89 onDelete: 'CASCADE'
90 })
91 Video: VideoModel
92
93 @BeforeDestroy
94 static async removeFiles (instance: VideoCaptionModel, options) {
95 if (!instance.Video) {
96 instance.Video = await instance.$get('Video', { transaction: options.transaction })
97 }
98
99 if (instance.isOwned()) {
100 logger.info('Removing caption %s.', instance.filename)
101
102 try {
103 await instance.removeCaptionFile()
104 } catch (err) {
105 logger.error('Cannot remove caption file %s.', instance.filename)
106 }
107 }
108
109 return undefined
110 }
111
112 static loadByVideoIdAndLanguage (videoId: string | number, language: string, transaction?: Transaction): Promise<MVideoCaptionVideo> {
113 const videoInclude = {
114 model: VideoModel.unscoped(),
115 attributes: [ 'id', 'remote', 'uuid' ],
116 where: buildWhereIdOrUUID(videoId)
117 }
118
119 const query = {
120 where: {
121 language
122 },
123 include: [
124 videoInclude
125 ],
126 transaction
127 }
128
129 return VideoCaptionModel.findOne(query)
130 }
131
132 static loadWithVideoByFilename (filename: string): Promise<MVideoCaptionVideo> {
133 const query = {
134 where: {
135 filename
136 },
137 include: [
138 {
139 model: VideoModel.unscoped(),
140 attributes: [ 'id', 'remote', 'uuid' ]
141 }
142 ]
143 }
144
145 return VideoCaptionModel.findOne(query)
146 }
147
148 static async insertOrReplaceLanguage (caption: MVideoCaption, transaction: Transaction) {
149 const existing = await VideoCaptionModel.loadByVideoIdAndLanguage(caption.videoId, caption.language, transaction)
150
151 // Delete existing file
152 if (existing) await existing.destroy({ transaction })
153
154 return caption.save({ transaction })
155 }
156
157 static listVideoCaptions (videoId: number, transaction?: Transaction): Promise<MVideoCaptionVideo[]> {
158 const query = {
159 order: [ [ 'language', 'ASC' ] ] as OrderItem[],
160 where: {
161 videoId
162 },
163 transaction
164 }
165
166 return VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll(query)
167 }
168
169 static async listCaptionsOfMultipleVideos (videoIds: number[], transaction?: Transaction) {
170 const query = {
171 order: [ [ 'language', 'ASC' ] ] as OrderItem[],
172 where: {
173 videoId: {
174 [Op.in]: videoIds
175 }
176 },
177 transaction
178 }
179
180 const captions = await VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll<MVideoCaptionVideo>(query)
181 const result: { [ id: number ]: MVideoCaptionVideo[] } = {}
182
183 for (const id of videoIds) {
184 result[id] = []
185 }
186
187 for (const caption of captions) {
188 result[caption.videoId].push(caption)
189 }
190
191 return result
192 }
193
194 static getLanguageLabel (language: string) {
195 return VIDEO_LANGUAGES[language] || 'Unknown'
196 }
197
198 static deleteAllCaptionsOfRemoteVideo (videoId: number, transaction: Transaction) {
199 const query = {
200 where: {
201 videoId
202 },
203 transaction
204 }
205
206 return VideoCaptionModel.destroy(query)
207 }
208
209 static generateCaptionName (language: string) {
210 return `${buildUUID()}-${language}.vtt`
211 }
212
213 isOwned () {
214 return this.Video.remote === false
215 }
216
217 toFormattedJSON (this: MVideoCaptionFormattable): VideoCaption {
218 return {
219 language: {
220 id: this.language,
221 label: VideoCaptionModel.getLanguageLabel(this.language)
222 },
223 captionPath: this.getCaptionStaticPath(),
224 updatedAt: this.updatedAt.toISOString()
225 }
226 }
227
228 getCaptionStaticPath (this: MVideoCaptionLanguageUrl) {
229 return join(LAZY_STATIC_PATHS.VIDEO_CAPTIONS, this.filename)
230 }
231
232 removeCaptionFile (this: MVideoCaption) {
233 return remove(CONFIG.STORAGE.CAPTIONS_DIR + this.filename)
234 }
235
236 getFileUrl (this: MVideoCaptionLanguageUrl, video: MVideo) {
237 if (video.isOwned()) return WEBSERVER.URL + this.getCaptionStaticPath()
238
239 return this.fileUrl
240 }
241
242 isEqual (this: MVideoCaption, other: MVideoCaption) {
243 if (this.fileUrl) return this.fileUrl === other.fileUrl
244
245 return this.filename === other.filename
246 }
247}
diff --git a/server/models/video/video-change-ownership.ts b/server/models/video/video-change-ownership.ts
deleted file mode 100644
index 26f072f4f..000000000
--- a/server/models/video/video-change-ownership.ts
+++ /dev/null
@@ -1,137 +0,0 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
2import { MVideoChangeOwnershipFormattable, MVideoChangeOwnershipFull } from '@server/types/models/video/video-change-ownership'
3import { AttributesOnly } from '@shared/typescript-utils'
4import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos'
5import { AccountModel } from '../account/account'
6import { getSort } from '../shared'
7import { ScopeNames as VideoScopeNames, VideoModel } from './video'
8
9enum ScopeNames {
10 WITH_ACCOUNTS = 'WITH_ACCOUNTS',
11 WITH_VIDEO = 'WITH_VIDEO'
12}
13
14@Table({
15 tableName: 'videoChangeOwnership',
16 indexes: [
17 {
18 fields: [ 'videoId' ]
19 },
20 {
21 fields: [ 'initiatorAccountId' ]
22 },
23 {
24 fields: [ 'nextOwnerAccountId' ]
25 }
26 ]
27})
28@Scopes(() => ({
29 [ScopeNames.WITH_ACCOUNTS]: {
30 include: [
31 {
32 model: AccountModel,
33 as: 'Initiator',
34 required: true
35 },
36 {
37 model: AccountModel,
38 as: 'NextOwner',
39 required: true
40 }
41 ]
42 },
43 [ScopeNames.WITH_VIDEO]: {
44 include: [
45 {
46 model: VideoModel.scope([
47 VideoScopeNames.WITH_THUMBNAILS,
48 VideoScopeNames.WITH_WEB_VIDEO_FILES,
49 VideoScopeNames.WITH_STREAMING_PLAYLISTS,
50 VideoScopeNames.WITH_ACCOUNT_DETAILS
51 ]),
52 required: true
53 }
54 ]
55 }
56}))
57export class VideoChangeOwnershipModel extends Model<Partial<AttributesOnly<VideoChangeOwnershipModel>>> {
58 @CreatedAt
59 createdAt: Date
60
61 @UpdatedAt
62 updatedAt: Date
63
64 @AllowNull(false)
65 @Column
66 status: VideoChangeOwnershipStatus
67
68 @ForeignKey(() => AccountModel)
69 @Column
70 initiatorAccountId: number
71
72 @BelongsTo(() => AccountModel, {
73 foreignKey: {
74 name: 'initiatorAccountId',
75 allowNull: false
76 },
77 onDelete: 'cascade'
78 })
79 Initiator: AccountModel
80
81 @ForeignKey(() => AccountModel)
82 @Column
83 nextOwnerAccountId: number
84
85 @BelongsTo(() => AccountModel, {
86 foreignKey: {
87 name: 'nextOwnerAccountId',
88 allowNull: false
89 },
90 onDelete: 'cascade'
91 })
92 NextOwner: AccountModel
93
94 @ForeignKey(() => VideoModel)
95 @Column
96 videoId: number
97
98 @BelongsTo(() => VideoModel, {
99 foreignKey: {
100 allowNull: false
101 },
102 onDelete: 'cascade'
103 })
104 Video: VideoModel
105
106 static listForApi (nextOwnerId: number, start: number, count: number, sort: string) {
107 const query = {
108 offset: start,
109 limit: count,
110 order: getSort(sort),
111 where: {
112 nextOwnerAccountId: nextOwnerId
113 }
114 }
115
116 return Promise.all([
117 VideoChangeOwnershipModel.scope(ScopeNames.WITH_ACCOUNTS).count(query),
118 VideoChangeOwnershipModel.scope([ ScopeNames.WITH_ACCOUNTS, ScopeNames.WITH_VIDEO ]).findAll<MVideoChangeOwnershipFull>(query)
119 ]).then(([ count, rows ]) => ({ total: count, data: rows }))
120 }
121
122 static load (id: number): Promise<MVideoChangeOwnershipFull> {
123 return VideoChangeOwnershipModel.scope([ ScopeNames.WITH_ACCOUNTS, ScopeNames.WITH_VIDEO ])
124 .findByPk(id)
125 }
126
127 toFormattedJSON (this: MVideoChangeOwnershipFormattable): VideoChangeOwnership {
128 return {
129 id: this.id,
130 status: this.status,
131 initiatorAccount: this.Initiator.toFormattedJSON(),
132 nextOwnerAccount: this.NextOwner.toFormattedJSON(),
133 video: this.Video.toFormattedJSON(),
134 createdAt: this.createdAt
135 }
136 }
137}
diff --git a/server/models/video/video-channel-sync.ts b/server/models/video/video-channel-sync.ts
deleted file mode 100644
index a4cbf51f5..000000000
--- a/server/models/video/video-channel-sync.ts
+++ /dev/null
@@ -1,176 +0,0 @@
1import { Op } from 'sequelize'
2import {
3 AllowNull,
4 BelongsTo,
5 Column,
6 CreatedAt,
7 DataType,
8 Default,
9 DefaultScope,
10 ForeignKey,
11 Is,
12 Model,
13 Table,
14 UpdatedAt
15} from 'sequelize-typescript'
16import { isUrlValid } from '@server/helpers/custom-validators/activitypub/misc'
17import { isVideoChannelSyncStateValid } from '@server/helpers/custom-validators/video-channel-syncs'
18import { CONSTRAINTS_FIELDS, VIDEO_CHANNEL_SYNC_STATE } from '@server/initializers/constants'
19import { MChannelSync, MChannelSyncChannel, MChannelSyncFormattable } from '@server/types/models'
20import { VideoChannelSync, VideoChannelSyncState } from '@shared/models'
21import { AttributesOnly } from '@shared/typescript-utils'
22import { AccountModel } from '../account/account'
23import { UserModel } from '../user/user'
24import { getChannelSyncSort, throwIfNotValid } from '../shared'
25import { VideoChannelModel } from './video-channel'
26
27@DefaultScope(() => ({
28 include: [
29 {
30 model: VideoChannelModel, // Default scope includes avatar and server
31 required: true
32 }
33 ]
34}))
35@Table({
36 tableName: 'videoChannelSync',
37 indexes: [
38 {
39 fields: [ 'videoChannelId' ]
40 }
41 ]
42})
43export class VideoChannelSyncModel extends Model<Partial<AttributesOnly<VideoChannelSyncModel>>> {
44
45 @AllowNull(false)
46 @Default(null)
47 @Is('VideoChannelExternalChannelUrl', value => throwIfNotValid(value, isUrlValid, 'externalChannelUrl', true))
48 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNEL_SYNCS.EXTERNAL_CHANNEL_URL.max))
49 externalChannelUrl: string
50
51 @CreatedAt
52 createdAt: Date
53
54 @UpdatedAt
55 updatedAt: Date
56
57 @ForeignKey(() => VideoChannelModel)
58 @Column
59 videoChannelId: number
60
61 @BelongsTo(() => VideoChannelModel, {
62 foreignKey: {
63 allowNull: false
64 },
65 onDelete: 'cascade'
66 })
67 VideoChannel: VideoChannelModel
68
69 @AllowNull(false)
70 @Default(VideoChannelSyncState.WAITING_FIRST_RUN)
71 @Is('VideoChannelSyncState', value => throwIfNotValid(value, isVideoChannelSyncStateValid, 'state'))
72 @Column
73 state: VideoChannelSyncState
74
75 @AllowNull(true)
76 @Column(DataType.DATE)
77 lastSyncAt: Date
78
79 static listByAccountForAPI (options: {
80 accountId: number
81 start: number
82 count: number
83 sort: string
84 }) {
85 const getQuery = (forCount: boolean) => {
86 const videoChannelModel = forCount
87 ? VideoChannelModel.unscoped()
88 : VideoChannelModel
89
90 return {
91 offset: options.start,
92 limit: options.count,
93 order: getChannelSyncSort(options.sort),
94 include: [
95 {
96 model: videoChannelModel,
97 required: true,
98 where: {
99 accountId: options.accountId
100 }
101 }
102 ]
103 }
104 }
105
106 return Promise.all([
107 VideoChannelSyncModel.unscoped().count(getQuery(true)),
108 VideoChannelSyncModel.unscoped().findAll(getQuery(false))
109 ]).then(([ total, data ]) => ({ total, data }))
110 }
111
112 static countByAccount (accountId: number) {
113 const query = {
114 include: [
115 {
116 model: VideoChannelModel.unscoped(),
117 required: true,
118 where: {
119 accountId
120 }
121 }
122 ]
123 }
124
125 return VideoChannelSyncModel.unscoped().count(query)
126 }
127
128 static loadWithChannel (id: number): Promise<MChannelSyncChannel> {
129 return VideoChannelSyncModel.findByPk(id)
130 }
131
132 static async listSyncs (): Promise<MChannelSync[]> {
133 const query = {
134 include: [
135 {
136 model: VideoChannelModel.unscoped(),
137 required: true,
138 include: [
139 {
140 model: AccountModel.unscoped(),
141 required: true,
142 include: [ {
143 attributes: [],
144 model: UserModel.unscoped(),
145 required: true,
146 where: {
147 videoQuota: {
148 [Op.ne]: 0
149 },
150 videoQuotaDaily: {
151 [Op.ne]: 0
152 }
153 }
154 } ]
155 }
156 ]
157 }
158 ]
159 }
160 return VideoChannelSyncModel.unscoped().findAll(query)
161 }
162
163 toFormattedJSON (this: MChannelSyncFormattable): VideoChannelSync {
164 return {
165 id: this.id,
166 state: {
167 id: this.state,
168 label: VIDEO_CHANNEL_SYNC_STATE[this.state]
169 },
170 externalChannelUrl: this.externalChannelUrl,
171 createdAt: this.createdAt.toISOString(),
172 channel: this.VideoChannel.toFormattedSummaryJSON(),
173 lastSyncAt: this.lastSyncAt?.toISOString()
174 }
175 }
176}
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
deleted file mode 100644
index 2c38850d7..000000000
--- a/server/models/video/video-channel.ts
+++ /dev/null
@@ -1,860 +0,0 @@
1import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
2import {
3 AfterCreate,
4 AfterDestroy,
5 AfterUpdate,
6 AllowNull,
7 BeforeDestroy,
8 BelongsTo,
9 Column,
10 CreatedAt,
11 DataType,
12 Default,
13 DefaultScope,
14 ForeignKey,
15 HasMany,
16 Is,
17 Model,
18 Scopes,
19 Sequelize,
20 Table,
21 UpdatedAt
22} from 'sequelize-typescript'
23import { CONFIG } from '@server/initializers/config'
24import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
25import { MAccountHost } from '@server/types/models'
26import { forceNumber, pick } from '@shared/core-utils'
27import { AttributesOnly } from '@shared/typescript-utils'
28import { ActivityPubActor } from '../../../shared/models/activitypub'
29import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
30import {
31 isVideoChannelDescriptionValid,
32 isVideoChannelDisplayNameValid,
33 isVideoChannelSupportValid
34} from '../../helpers/custom-validators/video-channels'
35import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
36import { sendDeleteActor } from '../../lib/activitypub/send'
37import {
38 MChannel,
39 MChannelActor,
40 MChannelAP,
41 MChannelBannerAccountDefault,
42 MChannelFormattable,
43 MChannelHost,
44 MChannelSummaryFormattable
45} from '../../types/models/video'
46import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
47import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
48import { ActorFollowModel } from '../actor/actor-follow'
49import { ActorImageModel } from '../actor/actor-image'
50import { ServerModel } from '../server/server'
51import {
52 buildServerIdsFollowedBy,
53 buildTrigramSearchIndex,
54 createSimilarityAttribute,
55 getSort,
56 setAsUpdated,
57 throwIfNotValid
58} from '../shared'
59import { VideoModel } from './video'
60import { VideoPlaylistModel } from './video-playlist'
61
62export enum ScopeNames {
63 FOR_API = 'FOR_API',
64 SUMMARY = 'SUMMARY',
65 WITH_ACCOUNT = 'WITH_ACCOUNT',
66 WITH_ACTOR = 'WITH_ACTOR',
67 WITH_ACTOR_BANNER = 'WITH_ACTOR_BANNER',
68 WITH_VIDEOS = 'WITH_VIDEOS',
69 WITH_STATS = 'WITH_STATS'
70}
71
72type AvailableForListOptions = {
73 actorId: number
74 search?: string
75 host?: string
76 handles?: string[]
77 forCount?: boolean
78}
79
80type AvailableWithStatsOptions = {
81 daysPrior: number
82}
83
84export type SummaryOptions = {
85 actorRequired?: boolean // Default: true
86 withAccount?: boolean // Default: false
87 withAccountBlockerIds?: number[]
88}
89
90@DefaultScope(() => ({
91 include: [
92 {
93 model: ActorModel,
94 required: true
95 }
96 ]
97}))
98@Scopes(() => ({
99 [ScopeNames.FOR_API]: (options: AvailableForListOptions) => {
100 // Only list local channels OR channels that are on an instance followed by actorId
101 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
102
103 const whereActorAnd: WhereOptions[] = [
104 {
105 [Op.or]: [
106 {
107 serverId: null
108 },
109 {
110 serverId: {
111 [Op.in]: Sequelize.literal(inQueryInstanceFollow)
112 }
113 }
114 ]
115 }
116 ]
117
118 let serverRequired = false
119 let whereServer: WhereOptions
120
121 if (options.host && options.host !== WEBSERVER.HOST) {
122 serverRequired = true
123 whereServer = { host: options.host }
124 }
125
126 if (options.host === WEBSERVER.HOST) {
127 whereActorAnd.push({
128 serverId: null
129 })
130 }
131
132 if (Array.isArray(options.handles) && options.handles.length !== 0) {
133 const or: string[] = []
134
135 for (const handle of options.handles || []) {
136 const [ preferredUsername, host ] = handle.split('@')
137
138 const sanitizedPreferredUsername = VideoChannelModel.sequelize.escape(preferredUsername.toLowerCase())
139 const sanitizedHost = VideoChannelModel.sequelize.escape(host)
140
141 if (!host || host === WEBSERVER.HOST) {
142 or.push(`(LOWER("preferredUsername") = ${sanitizedPreferredUsername} AND "serverId" IS NULL)`)
143 } else {
144 or.push(
145 `(` +
146 `LOWER("preferredUsername") = ${sanitizedPreferredUsername} ` +
147 `AND "host" = ${sanitizedHost}` +
148 `)`
149 )
150 }
151 }
152
153 whereActorAnd.push({
154 id: {
155 [Op.in]: literal(`(SELECT "actor".id FROM actor LEFT JOIN server on server.id = actor."serverId" WHERE ${or.join(' OR ')})`)
156 }
157 })
158 }
159
160 const channelActorInclude: Includeable[] = []
161 const accountActorInclude: Includeable[] = []
162
163 if (options.forCount !== true) {
164 accountActorInclude.push({
165 model: ServerModel,
166 required: false
167 })
168
169 accountActorInclude.push({
170 model: ActorImageModel,
171 as: 'Avatars',
172 required: false
173 })
174
175 channelActorInclude.push({
176 model: ActorImageModel,
177 as: 'Avatars',
178 required: false
179 })
180
181 channelActorInclude.push({
182 model: ActorImageModel,
183 as: 'Banners',
184 required: false
185 })
186 }
187
188 if (options.forCount !== true || serverRequired) {
189 channelActorInclude.push({
190 model: ServerModel,
191 duplicating: false,
192 required: serverRequired,
193 where: whereServer
194 })
195 }
196
197 return {
198 include: [
199 {
200 attributes: {
201 exclude: unusedActorAttributesForAPI
202 },
203 model: ActorModel.unscoped(),
204 where: {
205 [Op.and]: whereActorAnd
206 },
207 include: channelActorInclude
208 },
209 {
210 model: AccountModel.unscoped(),
211 required: true,
212 include: [
213 {
214 attributes: {
215 exclude: unusedActorAttributesForAPI
216 },
217 model: ActorModel.unscoped(),
218 required: true,
219 include: accountActorInclude
220 }
221 ]
222 }
223 ]
224 }
225 },
226 [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => {
227 const include: Includeable[] = [
228 {
229 attributes: [ 'id', 'preferredUsername', 'url', 'serverId' ],
230 model: ActorModel.unscoped(),
231 required: options.actorRequired ?? true,
232 include: [
233 {
234 attributes: [ 'host' ],
235 model: ServerModel.unscoped(),
236 required: false
237 },
238 {
239 model: ActorImageModel,
240 as: 'Avatars',
241 required: false
242 }
243 ]
244 }
245 ]
246
247 const base: FindOptions = {
248 attributes: [ 'id', 'name', 'description', 'actorId' ]
249 }
250
251 if (options.withAccount === true) {
252 include.push({
253 model: AccountModel.scope({
254 method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ]
255 }),
256 required: true
257 })
258 }
259
260 base.include = include
261
262 return base
263 },
264 [ScopeNames.WITH_ACCOUNT]: {
265 include: [
266 {
267 model: AccountModel,
268 required: true
269 }
270 ]
271 },
272 [ScopeNames.WITH_ACTOR]: {
273 include: [
274 ActorModel
275 ]
276 },
277 [ScopeNames.WITH_ACTOR_BANNER]: {
278 include: [
279 {
280 model: ActorModel,
281 include: [
282 {
283 model: ActorImageModel,
284 required: false,
285 as: 'Banners'
286 }
287 ]
288 }
289 ]
290 },
291 [ScopeNames.WITH_VIDEOS]: {
292 include: [
293 VideoModel
294 ]
295 },
296 [ScopeNames.WITH_STATS]: (options: AvailableWithStatsOptions = { daysPrior: 30 }) => {
297 const daysPrior = forceNumber(options.daysPrior)
298
299 return {
300 attributes: {
301 include: [
302 [
303 literal('(SELECT COUNT(*) FROM "video" WHERE "channelId" = "VideoChannelModel"."id")'),
304 'videosCount'
305 ],
306 [
307 literal(
308 '(' +
309 `SELECT string_agg(concat_ws('|', t.day, t.views), ',') ` +
310 'FROM ( ' +
311 'WITH ' +
312 'days AS ( ' +
313 `SELECT generate_series(date_trunc('day', now()) - '${daysPrior} day'::interval, ` +
314 `date_trunc('day', now()), '1 day'::interval) AS day ` +
315 ') ' +
316 'SELECT days.day AS day, COALESCE(SUM("videoView".views), 0) AS views ' +
317 'FROM days ' +
318 'LEFT JOIN (' +
319 '"videoView" INNER JOIN "video" ON "videoView"."videoId" = "video"."id" ' +
320 'AND "video"."channelId" = "VideoChannelModel"."id"' +
321 `) ON date_trunc('day', "videoView"."startDate") = date_trunc('day', days.day) ` +
322 'GROUP BY day ' +
323 'ORDER BY day ' +
324 ') t' +
325 ')'
326 ),
327 'viewsPerDay'
328 ],
329 [
330 literal(
331 '(' +
332 'SELECT COALESCE(SUM("video".views), 0) AS totalViews ' +
333 'FROM "video" ' +
334 'WHERE "video"."channelId" = "VideoChannelModel"."id"' +
335 ')'
336 ),
337 'totalViews'
338 ]
339 ]
340 }
341 }
342 }
343}))
344@Table({
345 tableName: 'videoChannel',
346 indexes: [
347 buildTrigramSearchIndex('video_channel_name_trigram', 'name'),
348
349 {
350 fields: [ 'accountId' ]
351 },
352 {
353 fields: [ 'actorId' ]
354 }
355 ]
356})
357export class VideoChannelModel extends Model<Partial<AttributesOnly<VideoChannelModel>>> {
358
359 @AllowNull(false)
360 @Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'name'))
361 @Column
362 name: string
363
364 @AllowNull(true)
365 @Default(null)
366 @Is('VideoChannelDescription', value => throwIfNotValid(value, isVideoChannelDescriptionValid, 'description', true))
367 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.DESCRIPTION.max))
368 description: string
369
370 @AllowNull(true)
371 @Default(null)
372 @Is('VideoChannelSupport', value => throwIfNotValid(value, isVideoChannelSupportValid, 'support', true))
373 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.SUPPORT.max))
374 support: string
375
376 @CreatedAt
377 createdAt: Date
378
379 @UpdatedAt
380 updatedAt: Date
381
382 @ForeignKey(() => ActorModel)
383 @Column
384 actorId: number
385
386 @BelongsTo(() => ActorModel, {
387 foreignKey: {
388 allowNull: false
389 },
390 onDelete: 'cascade'
391 })
392 Actor: ActorModel
393
394 @ForeignKey(() => AccountModel)
395 @Column
396 accountId: number
397
398 @BelongsTo(() => AccountModel, {
399 foreignKey: {
400 allowNull: false
401 }
402 })
403 Account: AccountModel
404
405 @HasMany(() => VideoModel, {
406 foreignKey: {
407 name: 'channelId',
408 allowNull: false
409 },
410 onDelete: 'CASCADE',
411 hooks: true
412 })
413 Videos: VideoModel[]
414
415 @HasMany(() => VideoPlaylistModel, {
416 foreignKey: {
417 allowNull: true
418 },
419 onDelete: 'CASCADE',
420 hooks: true
421 })
422 VideoPlaylists: VideoPlaylistModel[]
423
424 @AfterCreate
425 static notifyCreate (channel: MChannel) {
426 InternalEventEmitter.Instance.emit('channel-created', { channel })
427 }
428
429 @AfterUpdate
430 static notifyUpdate (channel: MChannel) {
431 InternalEventEmitter.Instance.emit('channel-updated', { channel })
432 }
433
434 @AfterDestroy
435 static notifyDestroy (channel: MChannel) {
436 InternalEventEmitter.Instance.emit('channel-deleted', { channel })
437 }
438
439 @BeforeDestroy
440 static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
441 if (!instance.Actor) {
442 instance.Actor = await instance.$get('Actor', { transaction: options.transaction })
443 }
444
445 await ActorFollowModel.removeFollowsOf(instance.Actor.id, options.transaction)
446
447 if (instance.Actor.isOwned()) {
448 return sendDeleteActor(instance.Actor, options.transaction)
449 }
450
451 return undefined
452 }
453
454 static countByAccount (accountId: number) {
455 const query = {
456 where: {
457 accountId
458 }
459 }
460
461 return VideoChannelModel.unscoped().count(query)
462 }
463
464 static async getStats () {
465
466 function getLocalVideoChannelStats (days?: number) {
467 const options = {
468 type: QueryTypes.SELECT as QueryTypes.SELECT,
469 raw: true
470 }
471
472 const videoJoin = days
473 ? `INNER JOIN "video" AS "Videos" ON "VideoChannelModel"."id" = "Videos"."channelId" ` +
474 `AND ("Videos"."publishedAt" > Now() - interval '${days}d')`
475 : ''
476
477 const query = `
478 SELECT COUNT(DISTINCT("VideoChannelModel"."id")) AS "count"
479 FROM "videoChannel" AS "VideoChannelModel"
480 ${videoJoin}
481 INNER JOIN "account" AS "Account" ON "VideoChannelModel"."accountId" = "Account"."id"
482 INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id"
483 AND "Account->Actor"."serverId" IS NULL`
484
485 return VideoChannelModel.sequelize.query<{ count: string }>(query, options)
486 .then(r => parseInt(r[0].count, 10))
487 }
488
489 const totalLocalVideoChannels = await getLocalVideoChannelStats()
490 const totalLocalDailyActiveVideoChannels = await getLocalVideoChannelStats(1)
491 const totalLocalWeeklyActiveVideoChannels = await getLocalVideoChannelStats(7)
492 const totalLocalMonthlyActiveVideoChannels = await getLocalVideoChannelStats(30)
493 const totalLocalHalfYearActiveVideoChannels = await getLocalVideoChannelStats(180)
494
495 return {
496 totalLocalVideoChannels,
497 totalLocalDailyActiveVideoChannels,
498 totalLocalWeeklyActiveVideoChannels,
499 totalLocalMonthlyActiveVideoChannels,
500 totalLocalHalfYearActiveVideoChannels
501 }
502 }
503
504 static listLocalsForSitemap (sort: string): Promise<MChannelActor[]> {
505 const query = {
506 attributes: [ ],
507 offset: 0,
508 order: getSort(sort),
509 include: [
510 {
511 attributes: [ 'preferredUsername', 'serverId' ],
512 model: ActorModel.unscoped(),
513 where: {
514 serverId: null
515 }
516 }
517 ]
518 }
519
520 return VideoChannelModel
521 .unscoped()
522 .findAll(query)
523 }
524
525 static listForApi (parameters: Pick<AvailableForListOptions, 'actorId'> & {
526 start: number
527 count: number
528 sort: string
529 }) {
530 const { actorId } = parameters
531
532 const query = {
533 offset: parameters.start,
534 limit: parameters.count,
535 order: getSort(parameters.sort)
536 }
537
538 const getScope = (forCount: boolean) => {
539 return { method: [ ScopeNames.FOR_API, { actorId, forCount } as AvailableForListOptions ] }
540 }
541
542 return Promise.all([
543 VideoChannelModel.scope(getScope(true)).count(),
544 VideoChannelModel.scope(getScope(false)).findAll(query)
545 ]).then(([ total, data ]) => ({ total, data }))
546 }
547
548 static searchForApi (options: Pick<AvailableForListOptions, 'actorId' | 'search' | 'host' | 'handles'> & {
549 start: number
550 count: number
551 sort: string
552 }) {
553 let attributesInclude: any[] = [ literal('0 as similarity') ]
554 let where: WhereOptions
555
556 if (options.search) {
557 const escapedSearch = VideoChannelModel.sequelize.escape(options.search)
558 const escapedLikeSearch = VideoChannelModel.sequelize.escape('%' + options.search + '%')
559 attributesInclude = [ createSimilarityAttribute('VideoChannelModel.name', options.search) ]
560
561 where = {
562 [Op.or]: [
563 Sequelize.literal(
564 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
565 ),
566 Sequelize.literal(
567 'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
568 )
569 ]
570 }
571 }
572
573 const query = {
574 attributes: {
575 include: attributesInclude
576 },
577 offset: options.start,
578 limit: options.count,
579 order: getSort(options.sort),
580 where
581 }
582
583 const getScope = (forCount: boolean) => {
584 return {
585 method: [
586 ScopeNames.FOR_API, {
587 ...pick(options, [ 'actorId', 'host', 'handles' ]),
588
589 forCount
590 } as AvailableForListOptions
591 ]
592 }
593 }
594
595 return Promise.all([
596 VideoChannelModel.scope(getScope(true)).count(query),
597 VideoChannelModel.scope(getScope(false)).findAll(query)
598 ]).then(([ total, data ]) => ({ total, data }))
599 }
600
601 static listByAccountForAPI (options: {
602 accountId: number
603 start: number
604 count: number
605 sort: string
606 withStats?: boolean
607 search?: string
608 }) {
609 const escapedSearch = VideoModel.sequelize.escape(options.search)
610 const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
611 const where = options.search
612 ? {
613 [Op.or]: [
614 Sequelize.literal(
615 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
616 ),
617 Sequelize.literal(
618 'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
619 )
620 ]
621 }
622 : null
623
624 const getQuery = (forCount: boolean) => {
625 const accountModel = forCount
626 ? AccountModel.unscoped()
627 : AccountModel
628
629 return {
630 offset: options.start,
631 limit: options.count,
632 order: getSort(options.sort),
633 include: [
634 {
635 model: accountModel,
636 where: {
637 id: options.accountId
638 },
639 required: true
640 }
641 ],
642 where
643 }
644 }
645
646 const findScopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR_BANNER ]
647
648 if (options.withStats === true) {
649 findScopes.push({
650 method: [ ScopeNames.WITH_STATS, { daysPrior: 30 } as AvailableWithStatsOptions ]
651 })
652 }
653
654 return Promise.all([
655 VideoChannelModel.unscoped().count(getQuery(true)),
656 VideoChannelModel.scope(findScopes).findAll(getQuery(false))
657 ]).then(([ total, data ]) => ({ total, data }))
658 }
659
660 static listAllByAccount (accountId: number): Promise<MChannel[]> {
661 const query = {
662 limit: CONFIG.VIDEO_CHANNELS.MAX_PER_USER,
663 include: [
664 {
665 attributes: [],
666 model: AccountModel.unscoped(),
667 where: {
668 id: accountId
669 },
670 required: true
671 }
672 ]
673 }
674
675 return VideoChannelModel.findAll(query)
676 }
677
678 static loadAndPopulateAccount (id: number, transaction?: Transaction): Promise<MChannelBannerAccountDefault> {
679 return VideoChannelModel.unscoped()
680 .scope([ ScopeNames.WITH_ACTOR_BANNER, ScopeNames.WITH_ACCOUNT ])
681 .findByPk(id, { transaction })
682 }
683
684 static loadByUrlAndPopulateAccount (url: string): Promise<MChannelBannerAccountDefault> {
685 const query = {
686 include: [
687 {
688 model: ActorModel,
689 required: true,
690 where: {
691 url
692 },
693 include: [
694 {
695 model: ActorImageModel,
696 required: false,
697 as: 'Banners'
698 }
699 ]
700 }
701 ]
702 }
703
704 return VideoChannelModel
705 .scope([ ScopeNames.WITH_ACCOUNT ])
706 .findOne(query)
707 }
708
709 static loadByNameWithHostAndPopulateAccount (nameWithHost: string) {
710 const [ name, host ] = nameWithHost.split('@')
711
712 if (!host || host === WEBSERVER.HOST) return VideoChannelModel.loadLocalByNameAndPopulateAccount(name)
713
714 return VideoChannelModel.loadByNameAndHostAndPopulateAccount(name, host)
715 }
716
717 static loadLocalByNameAndPopulateAccount (name: string): Promise<MChannelBannerAccountDefault> {
718 const query = {
719 include: [
720 {
721 model: ActorModel,
722 required: true,
723 where: {
724 [Op.and]: [
725 ActorModel.wherePreferredUsername(name, 'Actor.preferredUsername'),
726 { serverId: null }
727 ]
728 },
729 include: [
730 {
731 model: ActorImageModel,
732 required: false,
733 as: 'Banners'
734 }
735 ]
736 }
737 ]
738 }
739
740 return VideoChannelModel.unscoped()
741 .scope([ ScopeNames.WITH_ACCOUNT ])
742 .findOne(query)
743 }
744
745 static loadByNameAndHostAndPopulateAccount (name: string, host: string): Promise<MChannelBannerAccountDefault> {
746 const query = {
747 include: [
748 {
749 model: ActorModel,
750 required: true,
751 where: ActorModel.wherePreferredUsername(name, 'Actor.preferredUsername'),
752 include: [
753 {
754 model: ServerModel,
755 required: true,
756 where: { host }
757 },
758 {
759 model: ActorImageModel,
760 required: false,
761 as: 'Banners'
762 }
763 ]
764 }
765 ]
766 }
767
768 return VideoChannelModel.unscoped()
769 .scope([ ScopeNames.WITH_ACCOUNT ])
770 .findOne(query)
771 }
772
773 toFormattedSummaryJSON (this: MChannelSummaryFormattable): VideoChannelSummary {
774 const actor = this.Actor.toFormattedSummaryJSON()
775
776 return {
777 id: this.id,
778 name: actor.name,
779 displayName: this.getDisplayName(),
780 url: actor.url,
781 host: actor.host,
782 avatars: actor.avatars
783 }
784 }
785
786 toFormattedJSON (this: MChannelFormattable): VideoChannel {
787 const viewsPerDayString = this.get('viewsPerDay') as string
788 const videosCount = this.get('videosCount') as number
789
790 let viewsPerDay: { date: Date, views: number }[]
791
792 if (viewsPerDayString) {
793 viewsPerDay = viewsPerDayString.split(',')
794 .map(v => {
795 const [ dateString, amount ] = v.split('|')
796
797 return {
798 date: new Date(dateString),
799 views: +amount
800 }
801 })
802 }
803
804 const totalViews = this.get('totalViews') as number
805
806 const actor = this.Actor.toFormattedJSON()
807 const videoChannel = {
808 id: this.id,
809 displayName: this.getDisplayName(),
810 description: this.description,
811 support: this.support,
812 isLocal: this.Actor.isOwned(),
813 updatedAt: this.updatedAt,
814
815 ownerAccount: undefined,
816
817 videosCount,
818 viewsPerDay,
819 totalViews,
820
821 avatars: actor.avatars
822 }
823
824 if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON()
825
826 return Object.assign(actor, videoChannel)
827 }
828
829 async toActivityPubObject (this: MChannelAP): Promise<ActivityPubActor> {
830 const obj = await this.Actor.toActivityPubObject(this.name)
831
832 return Object.assign(obj, {
833 summary: this.description,
834 support: this.support,
835 attributedTo: [
836 {
837 type: 'Person' as 'Person',
838 id: this.Account.Actor.url
839 }
840 ]
841 })
842 }
843
844 // Avoid error when running this method on MAccount... | MChannel...
845 getClientUrl (this: MAccountHost | MChannelHost) {
846 return WEBSERVER.URL + '/c/' + this.Actor.getIdentifier()
847 }
848
849 getDisplayName () {
850 return this.name
851 }
852
853 isOutdated () {
854 return this.Actor.isOutdated()
855 }
856
857 setAsUpdated (transaction?: Transaction) {
858 return setAsUpdated({ sequelize: this.sequelize, table: 'videoChannel', id: this.id, transaction })
859 }
860}
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
deleted file mode 100644
index ff5142809..000000000
--- a/server/models/video/video-comment.ts
+++ /dev/null
@@ -1,683 +0,0 @@
1import { FindOptions, Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize'
2import {
3 AllowNull,
4 BelongsTo,
5 Column,
6 CreatedAt,
7 DataType,
8 ForeignKey,
9 HasMany,
10 Is,
11 Model,
12 Scopes,
13 Table,
14 UpdatedAt
15} from 'sequelize-typescript'
16import { getServerActor } from '@server/models/application/application'
17import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
18import { pick, uniqify } from '@shared/core-utils'
19import { AttributesOnly } from '@shared/typescript-utils'
20import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
21import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
22import { VideoComment, VideoCommentAdmin } from '../../../shared/models/videos/comment/video-comment.model'
23import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
24import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
25import { regexpCapture } from '../../helpers/regexp'
26import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
27import {
28 MComment,
29 MCommentAdminFormattable,
30 MCommentAP,
31 MCommentFormattable,
32 MCommentId,
33 MCommentOwner,
34 MCommentOwnerReplyVideoLight,
35 MCommentOwnerVideo,
36 MCommentOwnerVideoFeed,
37 MCommentOwnerVideoReply,
38 MVideoImmutable
39} from '../../types/models/video'
40import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
41import { AccountModel } from '../account/account'
42import { ActorModel } from '../actor/actor'
43import { buildLocalAccountIdsIn, buildSQLAttributes, throwIfNotValid } from '../shared'
44import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder'
45import { VideoModel } from './video'
46import { VideoChannelModel } from './video-channel'
47
48export enum ScopeNames {
49 WITH_ACCOUNT = 'WITH_ACCOUNT',
50 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
51 WITH_VIDEO = 'WITH_VIDEO'
52}
53
54@Scopes(() => ({
55 [ScopeNames.WITH_ACCOUNT]: {
56 include: [
57 {
58 model: AccountModel
59 }
60 ]
61 },
62 [ScopeNames.WITH_IN_REPLY_TO]: {
63 include: [
64 {
65 model: VideoCommentModel,
66 as: 'InReplyToVideoComment'
67 }
68 ]
69 },
70 [ScopeNames.WITH_VIDEO]: {
71 include: [
72 {
73 model: VideoModel,
74 required: true,
75 include: [
76 {
77 model: VideoChannelModel,
78 required: true,
79 include: [
80 {
81 model: AccountModel,
82 required: true
83 }
84 ]
85 }
86 ]
87 }
88 ]
89 }
90}))
91@Table({
92 tableName: 'videoComment',
93 indexes: [
94 {
95 fields: [ 'videoId' ]
96 },
97 {
98 fields: [ 'videoId', 'originCommentId' ]
99 },
100 {
101 fields: [ 'url' ],
102 unique: true
103 },
104 {
105 fields: [ 'accountId' ]
106 },
107 {
108 fields: [
109 { name: 'createdAt', order: 'DESC' }
110 ]
111 }
112 ]
113})
114export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoCommentModel>>> {
115 @CreatedAt
116 createdAt: Date
117
118 @UpdatedAt
119 updatedAt: Date
120
121 @AllowNull(true)
122 @Column(DataType.DATE)
123 deletedAt: Date
124
125 @AllowNull(false)
126 @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
127 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
128 url: string
129
130 @AllowNull(false)
131 @Column(DataType.TEXT)
132 text: string
133
134 @ForeignKey(() => VideoCommentModel)
135 @Column
136 originCommentId: number
137
138 @BelongsTo(() => VideoCommentModel, {
139 foreignKey: {
140 name: 'originCommentId',
141 allowNull: true
142 },
143 as: 'OriginVideoComment',
144 onDelete: 'CASCADE'
145 })
146 OriginVideoComment: VideoCommentModel
147
148 @ForeignKey(() => VideoCommentModel)
149 @Column
150 inReplyToCommentId: number
151
152 @BelongsTo(() => VideoCommentModel, {
153 foreignKey: {
154 name: 'inReplyToCommentId',
155 allowNull: true
156 },
157 as: 'InReplyToVideoComment',
158 onDelete: 'CASCADE'
159 })
160 InReplyToVideoComment: VideoCommentModel | null
161
162 @ForeignKey(() => VideoModel)
163 @Column
164 videoId: number
165
166 @BelongsTo(() => VideoModel, {
167 foreignKey: {
168 allowNull: false
169 },
170 onDelete: 'CASCADE'
171 })
172 Video: VideoModel
173
174 @ForeignKey(() => AccountModel)
175 @Column
176 accountId: number
177
178 @BelongsTo(() => AccountModel, {
179 foreignKey: {
180 allowNull: true
181 },
182 onDelete: 'CASCADE'
183 })
184 Account: AccountModel
185
186 @HasMany(() => VideoCommentAbuseModel, {
187 foreignKey: {
188 name: 'videoCommentId',
189 allowNull: true
190 },
191 onDelete: 'set null'
192 })
193 CommentAbuses: VideoCommentAbuseModel[]
194
195 // ---------------------------------------------------------------------------
196
197 static getSQLAttributes (tableName: string, aliasPrefix = '') {
198 return buildSQLAttributes({
199 model: this,
200 tableName,
201 aliasPrefix
202 })
203 }
204
205 // ---------------------------------------------------------------------------
206
207 static loadById (id: number, t?: Transaction): Promise<MComment> {
208 const query: FindOptions = {
209 where: {
210 id
211 }
212 }
213
214 if (t !== undefined) query.transaction = t
215
216 return VideoCommentModel.findOne(query)
217 }
218
219 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Transaction): Promise<MCommentOwnerVideoReply> {
220 const query: FindOptions = {
221 where: {
222 id
223 }
224 }
225
226 if (t !== undefined) query.transaction = t
227
228 return VideoCommentModel
229 .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
230 .findOne(query)
231 }
232
233 static loadByUrlAndPopulateAccountAndVideo (url: string, t?: Transaction): Promise<MCommentOwnerVideo> {
234 const query: FindOptions = {
235 where: {
236 url
237 }
238 }
239
240 if (t !== undefined) query.transaction = t
241
242 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEO ]).findOne(query)
243 }
244
245 static loadByUrlAndPopulateReplyAndVideoUrlAndAccount (url: string, t?: Transaction): Promise<MCommentOwnerReplyVideoLight> {
246 const query: FindOptions = {
247 where: {
248 url
249 },
250 include: [
251 {
252 attributes: [ 'id', 'url' ],
253 model: VideoModel.unscoped()
254 }
255 ]
256 }
257
258 if (t !== undefined) query.transaction = t
259
260 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_ACCOUNT ]).findOne(query)
261 }
262
263 static listCommentsForApi (parameters: {
264 start: number
265 count: number
266 sort: string
267
268 onLocalVideo?: boolean
269 isLocal?: boolean
270 search?: string
271 searchAccount?: string
272 searchVideo?: string
273 }) {
274 const queryOptions: ListVideoCommentsOptions = {
275 ...pick(parameters, [ 'start', 'count', 'sort', 'isLocal', 'search', 'searchVideo', 'searchAccount', 'onLocalVideo' ]),
276
277 selectType: 'api',
278 notDeleted: true
279 }
280
281 return Promise.all([
282 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
283 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
284 ]).then(([ rows, count ]) => {
285 return { total: count, data: rows }
286 })
287 }
288
289 static async listThreadsForApi (parameters: {
290 videoId: number
291 isVideoOwned: boolean
292 start: number
293 count: number
294 sort: string
295 user?: MUserAccountId
296 }) {
297 const { videoId, user } = parameters
298
299 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
300
301 const commonOptions: ListVideoCommentsOptions = {
302 selectType: 'api',
303 videoId,
304 blockerAccountIds
305 }
306
307 const listOptions: ListVideoCommentsOptions = {
308 ...commonOptions,
309 ...pick(parameters, [ 'sort', 'start', 'count' ]),
310
311 isThread: true,
312 includeReplyCounters: true
313 }
314
315 const countOptions: ListVideoCommentsOptions = {
316 ...commonOptions,
317
318 isThread: true
319 }
320
321 const notDeletedCountOptions: ListVideoCommentsOptions = {
322 ...commonOptions,
323
324 notDeleted: true
325 }
326
327 return Promise.all([
328 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, listOptions).listComments<MCommentAdminFormattable>(),
329 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, countOptions).countComments(),
330 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, notDeletedCountOptions).countComments()
331 ]).then(([ rows, count, totalNotDeletedComments ]) => {
332 return { total: count, data: rows, totalNotDeletedComments }
333 })
334 }
335
336 static async listThreadCommentsForApi (parameters: {
337 videoId: number
338 threadId: number
339 user?: MUserAccountId
340 }) {
341 const { user } = parameters
342
343 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
344
345 const queryOptions: ListVideoCommentsOptions = {
346 ...pick(parameters, [ 'videoId', 'threadId' ]),
347
348 selectType: 'api',
349 sort: 'createdAt',
350
351 blockerAccountIds,
352 includeReplyCounters: true
353 }
354
355 return Promise.all([
356 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
357 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
358 ]).then(([ rows, count ]) => {
359 return { total: count, data: rows }
360 })
361 }
362
363 static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> {
364 const query = {
365 order: [ [ 'createdAt', order ] ] as Order,
366 where: {
367 id: {
368 [Op.in]: Sequelize.literal('(' +
369 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
370 `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
371 'UNION ' +
372 'SELECT "parent"."id", "parent"."inReplyToCommentId" FROM "videoComment" "parent" ' +
373 'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
374 ') ' +
375 'SELECT id FROM children' +
376 ')'),
377 [Op.ne]: comment.id
378 }
379 },
380 transaction: t
381 }
382
383 return VideoCommentModel
384 .scope([ ScopeNames.WITH_ACCOUNT ])
385 .findAll(query)
386 }
387
388 static async listAndCountByVideoForAP (parameters: {
389 video: MVideoImmutable
390 start: number
391 count: number
392 }) {
393 const { video } = parameters
394
395 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
396
397 const queryOptions: ListVideoCommentsOptions = {
398 ...pick(parameters, [ 'start', 'count' ]),
399
400 selectType: 'comment-only',
401 videoId: video.id,
402 sort: 'createdAt',
403
404 blockerAccountIds
405 }
406
407 return Promise.all([
408 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>(),
409 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
410 ]).then(([ rows, count ]) => {
411 return { total: count, data: rows }
412 })
413 }
414
415 static async listForFeed (parameters: {
416 start: number
417 count: number
418 videoId?: number
419 accountId?: number
420 videoChannelId?: number
421 }) {
422 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
423
424 const queryOptions: ListVideoCommentsOptions = {
425 ...pick(parameters, [ 'start', 'count', 'accountId', 'videoId', 'videoChannelId' ]),
426
427 selectType: 'feed',
428
429 sort: '-createdAt',
430 onPublicVideo: true,
431 notDeleted: true,
432
433 blockerAccountIds
434 }
435
436 return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentOwnerVideoFeed>()
437 }
438
439 static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) {
440 const queryOptions: ListVideoCommentsOptions = {
441 selectType: 'comment-only',
442
443 accountId: ofAccount.id,
444 videoAccountOwnerId: filter.onVideosOfAccount?.id,
445
446 notDeleted: true,
447 count: 5000
448 }
449
450 return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>()
451 }
452
453 static async getStats () {
454 const totalLocalVideoComments = await VideoCommentModel.count({
455 include: [
456 {
457 model: AccountModel.unscoped(),
458 required: true,
459 include: [
460 {
461 model: ActorModel.unscoped(),
462 required: true,
463 where: {
464 serverId: null
465 }
466 }
467 ]
468 }
469 ]
470 })
471 const totalVideoComments = await VideoCommentModel.count()
472
473 return {
474 totalLocalVideoComments,
475 totalVideoComments
476 }
477 }
478
479 static listRemoteCommentUrlsOfLocalVideos () {
480 const query = `SELECT "videoComment".url FROM "videoComment" ` +
481 `INNER JOIN account ON account.id = "videoComment"."accountId" ` +
482 `INNER JOIN actor ON actor.id = "account"."actorId" AND actor."serverId" IS NOT NULL ` +
483 `INNER JOIN video ON video.id = "videoComment"."videoId" AND video.remote IS FALSE`
484
485 return VideoCommentModel.sequelize.query<{ url: string }>(query, {
486 type: QueryTypes.SELECT,
487 raw: true
488 }).then(rows => rows.map(r => r.url))
489 }
490
491 static cleanOldCommentsOf (videoId: number, beforeUpdatedAt: Date) {
492 const query = {
493 where: {
494 updatedAt: {
495 [Op.lt]: beforeUpdatedAt
496 },
497 videoId,
498 accountId: {
499 [Op.notIn]: buildLocalAccountIdsIn()
500 },
501 // Do not delete Tombstones
502 deletedAt: null
503 }
504 }
505
506 return VideoCommentModel.destroy(query)
507 }
508
509 getCommentStaticPath () {
510 return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
511 }
512
513 getThreadId (): number {
514 return this.originCommentId || this.id
515 }
516
517 isOwned () {
518 if (!this.Account) return false
519
520 return this.Account.isOwned()
521 }
522
523 markAsDeleted () {
524 this.text = ''
525 this.deletedAt = new Date()
526 this.accountId = null
527 }
528
529 isDeleted () {
530 return this.deletedAt !== null
531 }
532
533 extractMentions () {
534 let result: string[] = []
535
536 const localMention = `@(${actorNameAlphabet}+)`
537 const remoteMention = `${localMention}@${WEBSERVER.HOST}`
538
539 const mentionRegex = this.isOwned()
540 ? '(?:(?:' + remoteMention + ')|(?:' + localMention + '))' // Include local mentions?
541 : '(?:' + remoteMention + ')'
542
543 const firstMentionRegex = new RegExp(`^${mentionRegex} `, 'g')
544 const endMentionRegex = new RegExp(` ${mentionRegex}$`, 'g')
545 const remoteMentionsRegex = new RegExp(' ' + remoteMention + ' ', 'g')
546
547 result = result.concat(
548 regexpCapture(this.text, firstMentionRegex)
549 .map(([ , username1, username2 ]) => username1 || username2),
550
551 regexpCapture(this.text, endMentionRegex)
552 .map(([ , username1, username2 ]) => username1 || username2),
553
554 regexpCapture(this.text, remoteMentionsRegex)
555 .map(([ , username ]) => username)
556 )
557
558 // Include local mentions
559 if (this.isOwned()) {
560 const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g')
561
562 result = result.concat(
563 regexpCapture(this.text, localMentionsRegex)
564 .map(([ , username ]) => username)
565 )
566 }
567
568 return uniqify(result)
569 }
570
571 toFormattedJSON (this: MCommentFormattable) {
572 return {
573 id: this.id,
574 url: this.url,
575 text: this.text,
576
577 threadId: this.getThreadId(),
578 inReplyToCommentId: this.inReplyToCommentId || null,
579 videoId: this.videoId,
580
581 createdAt: this.createdAt,
582 updatedAt: this.updatedAt,
583 deletedAt: this.deletedAt,
584
585 isDeleted: this.isDeleted(),
586
587 totalRepliesFromVideoAuthor: this.get('totalRepliesFromVideoAuthor') || 0,
588 totalReplies: this.get('totalReplies') || 0,
589
590 account: this.Account
591 ? this.Account.toFormattedJSON()
592 : null
593 } as VideoComment
594 }
595
596 toFormattedAdminJSON (this: MCommentAdminFormattable) {
597 return {
598 id: this.id,
599 url: this.url,
600 text: this.text,
601
602 threadId: this.getThreadId(),
603 inReplyToCommentId: this.inReplyToCommentId || null,
604 videoId: this.videoId,
605
606 createdAt: this.createdAt,
607 updatedAt: this.updatedAt,
608
609 video: {
610 id: this.Video.id,
611 uuid: this.Video.uuid,
612 name: this.Video.name
613 },
614
615 account: this.Account
616 ? this.Account.toFormattedJSON()
617 : null
618 } as VideoCommentAdmin
619 }
620
621 toActivityPubObject (this: MCommentAP, threadParentComments: MCommentOwner[]): VideoCommentObject | ActivityTombstoneObject {
622 let inReplyTo: string
623 // New thread, so in AS we reply to the video
624 if (this.inReplyToCommentId === null) {
625 inReplyTo = this.Video.url
626 } else {
627 inReplyTo = this.InReplyToVideoComment.url
628 }
629
630 if (this.isDeleted()) {
631 return {
632 id: this.url,
633 type: 'Tombstone',
634 formerType: 'Note',
635 inReplyTo,
636 published: this.createdAt.toISOString(),
637 updated: this.updatedAt.toISOString(),
638 deleted: this.deletedAt.toISOString()
639 }
640 }
641
642 const tag: ActivityTagObject[] = []
643 for (const parentComment of threadParentComments) {
644 if (!parentComment.Account) continue
645
646 const actor = parentComment.Account.Actor
647
648 tag.push({
649 type: 'Mention',
650 href: actor.url,
651 name: `@${actor.preferredUsername}@${actor.getHost()}`
652 })
653 }
654
655 return {
656 type: 'Note' as 'Note',
657 id: this.url,
658
659 content: this.text,
660 mediaType: 'text/markdown',
661
662 inReplyTo,
663 updated: this.updatedAt.toISOString(),
664 published: this.createdAt.toISOString(),
665 url: this.url,
666 attributedTo: this.Account.Actor.url,
667 tag
668 }
669 }
670
671 private static async buildBlockerAccountIds (options: {
672 user: MUserAccountId
673 }): Promise<number[]> {
674 const { user } = options
675
676 const serverActor = await getServerActor()
677 const blockerAccountIds = [ serverActor.Account.id ]
678
679 if (user) blockerAccountIds.push(user.Account.id)
680
681 return blockerAccountIds
682 }
683}
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
deleted file mode 100644
index ee34ad2ff..000000000
--- a/server/models/video/video-file.ts
+++ /dev/null
@@ -1,635 +0,0 @@
1import { remove } from 'fs-extra'
2import memoizee from 'memoizee'
3import { join } from 'path'
4import { FindOptions, Op, Transaction, WhereOptions } from 'sequelize'
5import {
6 AllowNull,
7 BelongsTo,
8 Column,
9 CreatedAt,
10 DataType,
11 Default,
12 DefaultScope,
13 ForeignKey,
14 HasMany,
15 Is,
16 Model,
17 Scopes,
18 Table,
19 UpdatedAt
20} from 'sequelize-typescript'
21import validator from 'validator'
22import { logger } from '@server/helpers/logger'
23import { extractVideo } from '@server/helpers/video'
24import { CONFIG } from '@server/initializers/config'
25import { buildRemoteVideoBaseUrl } from '@server/lib/activitypub/url'
26import {
27 getHLSPrivateFileUrl,
28 getHLSPublicFileUrl,
29 getWebVideoPrivateFileUrl,
30 getWebVideoPublicFileUrl
31} from '@server/lib/object-storage'
32import { getFSTorrentFilePath } from '@server/lib/paths'
33import { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
34import { isStreamingPlaylist, MStreamingPlaylistVideo, MVideo, MVideoWithHost } from '@server/types/models'
35import { VideoResolution, VideoStorage } from '@shared/models'
36import { AttributesOnly } from '@shared/typescript-utils'
37import {
38 isVideoFileExtnameValid,
39 isVideoFileInfoHashValid,
40 isVideoFileResolutionValid,
41 isVideoFileSizeValid,
42 isVideoFPSResolutionValid
43} from '../../helpers/custom-validators/videos'
44import {
45 LAZY_STATIC_PATHS,
46 MEMOIZE_LENGTH,
47 MEMOIZE_TTL,
48 STATIC_DOWNLOAD_PATHS,
49 STATIC_PATHS,
50 WEBSERVER
51} from '../../initializers/constants'
52import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file'
53import { VideoRedundancyModel } from '../redundancy/video-redundancy'
54import { doesExist, parseAggregateResult, throwIfNotValid } from '../shared'
55import { VideoModel } from './video'
56import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
57
58export enum ScopeNames {
59 WITH_VIDEO = 'WITH_VIDEO',
60 WITH_METADATA = 'WITH_METADATA',
61 WITH_VIDEO_OR_PLAYLIST = 'WITH_VIDEO_OR_PLAYLIST'
62}
63
64@DefaultScope(() => ({
65 attributes: {
66 exclude: [ 'metadata' ]
67 }
68}))
69@Scopes(() => ({
70 [ScopeNames.WITH_VIDEO]: {
71 include: [
72 {
73 model: VideoModel.unscoped(),
74 required: true
75 }
76 ]
77 },
78 [ScopeNames.WITH_VIDEO_OR_PLAYLIST]: (options: { whereVideo?: WhereOptions } = {}) => {
79 return {
80 include: [
81 {
82 model: VideoModel.unscoped(),
83 required: false,
84 where: options.whereVideo
85 },
86 {
87 model: VideoStreamingPlaylistModel.unscoped(),
88 required: false,
89 include: [
90 {
91 model: VideoModel.unscoped(),
92 required: true,
93 where: options.whereVideo
94 }
95 ]
96 }
97 ]
98 }
99 },
100 [ScopeNames.WITH_METADATA]: {
101 attributes: {
102 include: [ 'metadata' ]
103 }
104 }
105}))
106@Table({
107 tableName: 'videoFile',
108 indexes: [
109 {
110 fields: [ 'videoId' ],
111 where: {
112 videoId: {
113 [Op.ne]: null
114 }
115 }
116 },
117 {
118 fields: [ 'videoStreamingPlaylistId' ],
119 where: {
120 videoStreamingPlaylistId: {
121 [Op.ne]: null
122 }
123 }
124 },
125
126 {
127 fields: [ 'infoHash' ]
128 },
129
130 {
131 fields: [ 'torrentFilename' ],
132 unique: true
133 },
134
135 {
136 fields: [ 'filename' ],
137 unique: true
138 },
139
140 {
141 fields: [ 'videoId', 'resolution', 'fps' ],
142 unique: true,
143 where: {
144 videoId: {
145 [Op.ne]: null
146 }
147 }
148 },
149 {
150 fields: [ 'videoStreamingPlaylistId', 'resolution', 'fps' ],
151 unique: true,
152 where: {
153 videoStreamingPlaylistId: {
154 [Op.ne]: null
155 }
156 }
157 }
158 ]
159})
160export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>>> {
161 @CreatedAt
162 createdAt: Date
163
164 @UpdatedAt
165 updatedAt: Date
166
167 @AllowNull(false)
168 @Is('VideoFileResolution', value => throwIfNotValid(value, isVideoFileResolutionValid, 'resolution'))
169 @Column
170 resolution: number
171
172 @AllowNull(false)
173 @Is('VideoFileSize', value => throwIfNotValid(value, isVideoFileSizeValid, 'size'))
174 @Column(DataType.BIGINT)
175 size: number
176
177 @AllowNull(false)
178 @Is('VideoFileExtname', value => throwIfNotValid(value, isVideoFileExtnameValid, 'extname'))
179 @Column
180 extname: string
181
182 @AllowNull(true)
183 @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash', true))
184 @Column
185 infoHash: string
186
187 @AllowNull(false)
188 @Default(-1)
189 @Is('VideoFileFPS', value => throwIfNotValid(value, isVideoFPSResolutionValid, 'fps'))
190 @Column
191 fps: number
192
193 @AllowNull(true)
194 @Column(DataType.JSONB)
195 metadata: any
196
197 @AllowNull(true)
198 @Column
199 metadataUrl: string
200
201 // Could be null for remote files
202 @AllowNull(true)
203 @Column
204 fileUrl: string
205
206 // Could be null for live files
207 @AllowNull(true)
208 @Column
209 filename: string
210
211 // Could be null for remote files
212 @AllowNull(true)
213 @Column
214 torrentUrl: string
215
216 // Could be null for live files
217 @AllowNull(true)
218 @Column
219 torrentFilename: string
220
221 @ForeignKey(() => VideoModel)
222 @Column
223 videoId: number
224
225 @AllowNull(false)
226 @Default(VideoStorage.FILE_SYSTEM)
227 @Column
228 storage: VideoStorage
229
230 @BelongsTo(() => VideoModel, {
231 foreignKey: {
232 allowNull: true
233 },
234 onDelete: 'CASCADE'
235 })
236 Video: VideoModel
237
238 @ForeignKey(() => VideoStreamingPlaylistModel)
239 @Column
240 videoStreamingPlaylistId: number
241
242 @BelongsTo(() => VideoStreamingPlaylistModel, {
243 foreignKey: {
244 allowNull: true
245 },
246 onDelete: 'CASCADE'
247 })
248 VideoStreamingPlaylist: VideoStreamingPlaylistModel
249
250 @HasMany(() => VideoRedundancyModel, {
251 foreignKey: {
252 allowNull: true
253 },
254 onDelete: 'CASCADE',
255 hooks: true
256 })
257 RedundancyVideos: VideoRedundancyModel[]
258
259 static doesInfohashExistCached = memoizee(VideoFileModel.doesInfohashExist, {
260 promise: true,
261 max: MEMOIZE_LENGTH.INFO_HASH_EXISTS,
262 maxAge: MEMOIZE_TTL.INFO_HASH_EXISTS
263 })
264
265 static doesInfohashExist (infoHash: string) {
266 const query = 'SELECT 1 FROM "videoFile" WHERE "infoHash" = $infoHash LIMIT 1'
267
268 return doesExist(this.sequelize, query, { infoHash })
269 }
270
271 static async doesVideoExistForVideoFile (id: number, videoIdOrUUID: number | string) {
272 const videoFile = await VideoFileModel.loadWithVideoOrPlaylist(id, videoIdOrUUID)
273
274 return !!videoFile
275 }
276
277 static async doesOwnedTorrentFileExist (filename: string) {
278 const query = 'SELECT 1 FROM "videoFile" ' +
279 'LEFT JOIN "video" "webvideo" ON "webvideo"."id" = "videoFile"."videoId" AND "webvideo"."remote" IS FALSE ' +
280 'LEFT JOIN "videoStreamingPlaylist" ON "videoStreamingPlaylist"."id" = "videoFile"."videoStreamingPlaylistId" ' +
281 'LEFT JOIN "video" "hlsVideo" ON "hlsVideo"."id" = "videoStreamingPlaylist"."videoId" AND "hlsVideo"."remote" IS FALSE ' +
282 'WHERE "torrentFilename" = $filename AND ("hlsVideo"."id" IS NOT NULL OR "webvideo"."id" IS NOT NULL) LIMIT 1'
283
284 return doesExist(this.sequelize, query, { filename })
285 }
286
287 static async doesOwnedWebVideoFileExist (filename: string) {
288 const query = 'SELECT 1 FROM "videoFile" INNER JOIN "video" ON "video"."id" = "videoFile"."videoId" AND "video"."remote" IS FALSE ' +
289 `WHERE "filename" = $filename AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
290
291 return doesExist(this.sequelize, query, { filename })
292 }
293
294 static loadByFilename (filename: string) {
295 const query = {
296 where: {
297 filename
298 }
299 }
300
301 return VideoFileModel.findOne(query)
302 }
303
304 static loadWithVideoByFilename (filename: string): Promise<MVideoFileVideo | MVideoFileStreamingPlaylistVideo> {
305 const query = {
306 where: {
307 filename
308 }
309 }
310
311 return VideoFileModel.scope(ScopeNames.WITH_VIDEO_OR_PLAYLIST).findOne(query)
312 }
313
314 static loadWithVideoOrPlaylistByTorrentFilename (filename: string) {
315 const query = {
316 where: {
317 torrentFilename: filename
318 }
319 }
320
321 return VideoFileModel.scope(ScopeNames.WITH_VIDEO_OR_PLAYLIST).findOne(query)
322 }
323
324 static load (id: number): Promise<MVideoFile> {
325 return VideoFileModel.findByPk(id)
326 }
327
328 static loadWithMetadata (id: number) {
329 return VideoFileModel.scope(ScopeNames.WITH_METADATA).findByPk(id)
330 }
331
332 static loadWithVideo (id: number) {
333 return VideoFileModel.scope(ScopeNames.WITH_VIDEO).findByPk(id)
334 }
335
336 static loadWithVideoOrPlaylist (id: number, videoIdOrUUID: number | string) {
337 const whereVideo = validator.isUUID(videoIdOrUUID + '')
338 ? { uuid: videoIdOrUUID }
339 : { id: videoIdOrUUID }
340
341 const options = {
342 where: {
343 id
344 }
345 }
346
347 return VideoFileModel.scope({ method: [ ScopeNames.WITH_VIDEO_OR_PLAYLIST, whereVideo ] })
348 .findOne(options)
349 .then(file => {
350 // We used `required: false` so check we have at least a video or a streaming playlist
351 if (!file.Video && !file.VideoStreamingPlaylist) return null
352
353 return file
354 })
355 }
356
357 static listByStreamingPlaylist (streamingPlaylistId: number, transaction: Transaction) {
358 const query = {
359 include: [
360 {
361 model: VideoModel.unscoped(),
362 required: true,
363 include: [
364 {
365 model: VideoStreamingPlaylistModel.unscoped(),
366 required: true,
367 where: {
368 id: streamingPlaylistId
369 }
370 }
371 ]
372 }
373 ],
374 transaction
375 }
376
377 return VideoFileModel.findAll(query)
378 }
379
380 static getStats () {
381 const webVideoFilesQuery: FindOptions = {
382 include: [
383 {
384 attributes: [],
385 required: true,
386 model: VideoModel.unscoped(),
387 where: {
388 remote: false
389 }
390 }
391 ]
392 }
393
394 const hlsFilesQuery: FindOptions = {
395 include: [
396 {
397 attributes: [],
398 required: true,
399 model: VideoStreamingPlaylistModel.unscoped(),
400 include: [
401 {
402 attributes: [],
403 model: VideoModel.unscoped(),
404 required: true,
405 where: {
406 remote: false
407 }
408 }
409 ]
410 }
411 ]
412 }
413
414 return Promise.all([
415 VideoFileModel.aggregate('size', 'SUM', webVideoFilesQuery),
416 VideoFileModel.aggregate('size', 'SUM', hlsFilesQuery)
417 ]).then(([ webVideoResult, hlsResult ]) => ({
418 totalLocalVideoFilesSize: parseAggregateResult(webVideoResult) + parseAggregateResult(hlsResult)
419 }))
420 }
421
422 // Redefine upsert because sequelize does not use an appropriate where clause in the update query with 2 unique indexes
423 static async customUpsert (
424 videoFile: MVideoFile,
425 mode: 'streaming-playlist' | 'video',
426 transaction: Transaction
427 ) {
428 const baseFind = {
429 fps: videoFile.fps,
430 resolution: videoFile.resolution,
431 transaction
432 }
433
434 const element = mode === 'streaming-playlist'
435 ? await VideoFileModel.loadHLSFile({ ...baseFind, playlistId: videoFile.videoStreamingPlaylistId })
436 : await VideoFileModel.loadWebVideoFile({ ...baseFind, videoId: videoFile.videoId })
437
438 if (!element) return videoFile.save({ transaction })
439
440 for (const k of Object.keys(videoFile.toJSON())) {
441 element.set(k, videoFile[k])
442 }
443
444 return element.save({ transaction })
445 }
446
447 static async loadWebVideoFile (options: {
448 videoId: number
449 fps: number
450 resolution: number
451 transaction?: Transaction
452 }) {
453 const where = {
454 fps: options.fps,
455 resolution: options.resolution,
456 videoId: options.videoId
457 }
458
459 return VideoFileModel.findOne({ where, transaction: options.transaction })
460 }
461
462 static async loadHLSFile (options: {
463 playlistId: number
464 fps: number
465 resolution: number
466 transaction?: Transaction
467 }) {
468 const where = {
469 fps: options.fps,
470 resolution: options.resolution,
471 videoStreamingPlaylistId: options.playlistId
472 }
473
474 return VideoFileModel.findOne({ where, transaction: options.transaction })
475 }
476
477 static removeHLSFilesOfVideoId (videoStreamingPlaylistId: number) {
478 const options = {
479 where: { videoStreamingPlaylistId }
480 }
481
482 return VideoFileModel.destroy(options)
483 }
484
485 hasTorrent () {
486 return this.infoHash && this.torrentFilename
487 }
488
489 getVideoOrStreamingPlaylist (this: MVideoFileVideo | MVideoFileStreamingPlaylistVideo): MVideo | MStreamingPlaylistVideo {
490 if (this.videoId || (this as MVideoFileVideo).Video) return (this as MVideoFileVideo).Video
491
492 return (this as MVideoFileStreamingPlaylistVideo).VideoStreamingPlaylist
493 }
494
495 getVideo (this: MVideoFileVideo | MVideoFileStreamingPlaylistVideo): MVideo {
496 return extractVideo(this.getVideoOrStreamingPlaylist())
497 }
498
499 isAudio () {
500 return this.resolution === VideoResolution.H_NOVIDEO
501 }
502
503 isLive () {
504 return this.size === -1
505 }
506
507 isHLS () {
508 return !!this.videoStreamingPlaylistId
509 }
510
511 // ---------------------------------------------------------------------------
512
513 getObjectStorageUrl (video: MVideo) {
514 if (video.hasPrivateStaticPath() && CONFIG.OBJECT_STORAGE.PROXY.PROXIFY_PRIVATE_FILES === true) {
515 return this.getPrivateObjectStorageUrl(video)
516 }
517
518 return this.getPublicObjectStorageUrl()
519 }
520
521 private getPrivateObjectStorageUrl (video: MVideo) {
522 if (this.isHLS()) {
523 return getHLSPrivateFileUrl(video, this.filename)
524 }
525
526 return getWebVideoPrivateFileUrl(this.filename)
527 }
528
529 private getPublicObjectStorageUrl () {
530 if (this.isHLS()) {
531 return getHLSPublicFileUrl(this.fileUrl)
532 }
533
534 return getWebVideoPublicFileUrl(this.fileUrl)
535 }
536
537 // ---------------------------------------------------------------------------
538
539 getFileUrl (video: MVideo) {
540 if (video.isOwned()) {
541 if (this.storage === VideoStorage.OBJECT_STORAGE) {
542 return this.getObjectStorageUrl(video)
543 }
544
545 return WEBSERVER.URL + this.getFileStaticPath(video)
546 }
547
548 return this.fileUrl
549 }
550
551 // ---------------------------------------------------------------------------
552
553 getFileStaticPath (video: MVideo) {
554 if (this.isHLS()) return this.getHLSFileStaticPath(video)
555
556 return this.getWebVideoFileStaticPath(video)
557 }
558
559 private getWebVideoFileStaticPath (video: MVideo) {
560 if (isVideoInPrivateDirectory(video.privacy)) {
561 return join(STATIC_PATHS.PRIVATE_WEB_VIDEOS, this.filename)
562 }
563
564 return join(STATIC_PATHS.WEB_VIDEOS, this.filename)
565 }
566
567 private getHLSFileStaticPath (video: MVideo) {
568 if (isVideoInPrivateDirectory(video.privacy)) {
569 return join(STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS, video.uuid, this.filename)
570 }
571
572 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, video.uuid, this.filename)
573 }
574
575 // ---------------------------------------------------------------------------
576
577 getFileDownloadUrl (video: MVideoWithHost) {
578 const path = this.isHLS()
579 ? join(STATIC_DOWNLOAD_PATHS.HLS_VIDEOS, `${video.uuid}-${this.resolution}-fragmented${this.extname}`)
580 : join(STATIC_DOWNLOAD_PATHS.VIDEOS, `${video.uuid}-${this.resolution}${this.extname}`)
581
582 if (video.isOwned()) return WEBSERVER.URL + path
583
584 // FIXME: don't guess remote URL
585 return buildRemoteVideoBaseUrl(video, path)
586 }
587
588 getRemoteTorrentUrl (video: MVideo) {
589 if (video.isOwned()) throw new Error(`Video ${video.url} is not a remote video`)
590
591 return this.torrentUrl
592 }
593
594 // We proxify torrent requests so use a local URL
595 getTorrentUrl () {
596 if (!this.torrentFilename) return null
597
598 return WEBSERVER.URL + this.getTorrentStaticPath()
599 }
600
601 getTorrentStaticPath () {
602 if (!this.torrentFilename) return null
603
604 return join(LAZY_STATIC_PATHS.TORRENTS, this.torrentFilename)
605 }
606
607 getTorrentDownloadUrl () {
608 if (!this.torrentFilename) return null
609
610 return WEBSERVER.URL + join(STATIC_DOWNLOAD_PATHS.TORRENTS, this.torrentFilename)
611 }
612
613 removeTorrent () {
614 if (!this.torrentFilename) return null
615
616 const torrentPath = getFSTorrentFilePath(this)
617 return remove(torrentPath)
618 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
619 }
620
621 hasSameUniqueKeysThan (other: MVideoFile) {
622 return this.fps === other.fps &&
623 this.resolution === other.resolution &&
624 (
625 (this.videoId !== null && this.videoId === other.videoId) ||
626 (this.videoStreamingPlaylistId !== null && this.videoStreamingPlaylistId === other.videoStreamingPlaylistId)
627 )
628 }
629
630 withVideoOrPlaylist (videoOrPlaylist: MVideo | MStreamingPlaylistVideo) {
631 if (isStreamingPlaylist(videoOrPlaylist)) return Object.assign(this, { VideoStreamingPlaylist: videoOrPlaylist })
632
633 return Object.assign(this, { Video: videoOrPlaylist })
634 }
635}
diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts
deleted file mode 100644
index c040e0fda..000000000
--- a/server/models/video/video-import.ts
+++ /dev/null
@@ -1,267 +0,0 @@
1import { IncludeOptions, Op, WhereOptions } from 'sequelize'
2import {
3 AfterUpdate,
4 AllowNull,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 Default,
10 DefaultScope,
11 ForeignKey,
12 Is,
13 Model,
14 Table,
15 UpdatedAt
16} from 'sequelize-typescript'
17import { afterCommitIfTransaction } from '@server/helpers/database-utils'
18import { MVideoImportDefault, MVideoImportFormattable } from '@server/types/models/video/video-import'
19import { VideoImport, VideoImportState } from '@shared/models'
20import { AttributesOnly } from '@shared/typescript-utils'
21import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../helpers/custom-validators/video-imports'
22import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos'
23import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants'
24import { UserModel } from '../user/user'
25import { getSort, searchAttribute, throwIfNotValid } from '../shared'
26import { ScopeNames as VideoModelScopeNames, VideoModel } from './video'
27import { VideoChannelSyncModel } from './video-channel-sync'
28
29const defaultVideoScope = () => {
30 return VideoModel.scope([
31 VideoModelScopeNames.WITH_ACCOUNT_DETAILS,
32 VideoModelScopeNames.WITH_TAGS,
33 VideoModelScopeNames.WITH_THUMBNAILS
34 ])
35}
36
37@DefaultScope(() => ({
38 include: [
39 {
40 model: UserModel.unscoped(),
41 required: true
42 },
43 {
44 model: defaultVideoScope(),
45 required: false
46 },
47 {
48 model: VideoChannelSyncModel.unscoped(),
49 required: false
50 }
51 ]
52}))
53
54@Table({
55 tableName: 'videoImport',
56 indexes: [
57 {
58 fields: [ 'videoId' ],
59 unique: true
60 },
61 {
62 fields: [ 'userId' ]
63 }
64 ]
65})
66export class VideoImportModel extends Model<Partial<AttributesOnly<VideoImportModel>>> {
67 @CreatedAt
68 createdAt: Date
69
70 @UpdatedAt
71 updatedAt: Date
72
73 @AllowNull(true)
74 @Default(null)
75 @Is('VideoImportTargetUrl', value => throwIfNotValid(value, isVideoImportTargetUrlValid, 'targetUrl', true))
76 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL.max))
77 targetUrl: string
78
79 @AllowNull(true)
80 @Default(null)
81 @Is('VideoImportMagnetUri', value => throwIfNotValid(value, isVideoMagnetUriValid, 'magnetUri', true))
82 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL.max)) // Use the same constraints than URLs
83 magnetUri: string
84
85 @AllowNull(true)
86 @Default(null)
87 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_NAME.max))
88 torrentName: string
89
90 @AllowNull(false)
91 @Default(null)
92 @Is('VideoImportState', value => throwIfNotValid(value, isVideoImportStateValid, 'state'))
93 @Column
94 state: VideoImportState
95
96 @AllowNull(true)
97 @Default(null)
98 @Column(DataType.TEXT)
99 error: string
100
101 @ForeignKey(() => UserModel)
102 @Column
103 userId: number
104
105 @BelongsTo(() => UserModel, {
106 foreignKey: {
107 allowNull: false
108 },
109 onDelete: 'cascade'
110 })
111 User: UserModel
112
113 @ForeignKey(() => VideoModel)
114 @Column
115 videoId: number
116
117 @BelongsTo(() => VideoModel, {
118 foreignKey: {
119 allowNull: true
120 },
121 onDelete: 'set null'
122 })
123 Video: VideoModel
124
125 @ForeignKey(() => VideoChannelSyncModel)
126 @Column
127 videoChannelSyncId: number
128
129 @BelongsTo(() => VideoChannelSyncModel, {
130 foreignKey: {
131 allowNull: true
132 },
133 onDelete: 'set null'
134 })
135 VideoChannelSync: VideoChannelSyncModel
136
137 @AfterUpdate
138 static deleteVideoIfFailed (instance: VideoImportModel, options) {
139 if (instance.state === VideoImportState.FAILED) {
140 return afterCommitIfTransaction(options.transaction, () => instance.Video.destroy())
141 }
142
143 return undefined
144 }
145
146 static loadAndPopulateVideo (id: number): Promise<MVideoImportDefault> {
147 return VideoImportModel.findByPk(id)
148 }
149
150 static listUserVideoImportsForApi (options: {
151 userId: number
152 start: number
153 count: number
154 sort: string
155
156 search?: string
157 targetUrl?: string
158 videoChannelSyncId?: number
159 }) {
160 const { userId, start, count, sort, targetUrl, videoChannelSyncId, search } = options
161
162 const where: WhereOptions = { userId }
163 const include: IncludeOptions[] = [
164 {
165 attributes: [ 'id' ],
166 model: UserModel.unscoped(), // FIXME: Without this, sequelize try to COUNT(DISTINCT(*)) which is an invalid SQL query
167 required: true
168 },
169 {
170 model: VideoChannelSyncModel.unscoped(),
171 required: false
172 }
173 ]
174
175 if (targetUrl) where['targetUrl'] = targetUrl
176 if (videoChannelSyncId) where['videoChannelSyncId'] = videoChannelSyncId
177
178 if (search) {
179 include.push({
180 model: defaultVideoScope(),
181 required: true,
182 where: searchAttribute(search, 'name')
183 })
184 } else {
185 include.push({
186 model: defaultVideoScope(),
187 required: false
188 })
189 }
190
191 const query = {
192 distinct: true,
193 include,
194 offset: start,
195 limit: count,
196 order: getSort(sort),
197 where
198 }
199
200 return Promise.all([
201 VideoImportModel.unscoped().count(query),
202 VideoImportModel.findAll<MVideoImportDefault>(query)
203 ]).then(([ total, data ]) => ({ total, data }))
204 }
205
206 static async urlAlreadyImported (channelId: number, targetUrl: string): Promise<boolean> {
207 const element = await VideoImportModel.unscoped().findOne({
208 where: {
209 targetUrl,
210 state: {
211 [Op.in]: [ VideoImportState.PENDING, VideoImportState.PROCESSING, VideoImportState.SUCCESS ]
212 }
213 },
214 include: [
215 {
216 model: VideoModel,
217 required: true,
218 where: {
219 channelId
220 }
221 }
222 ]
223 })
224
225 return !!element
226 }
227
228 getTargetIdentifier () {
229 return this.targetUrl || this.magnetUri || this.torrentName
230 }
231
232 toFormattedJSON (this: MVideoImportFormattable): VideoImport {
233 const videoFormatOptions = {
234 completeDescription: true,
235 additionalAttributes: { state: true, waitTranscoding: true, scheduledUpdate: true }
236 }
237 const video = this.Video
238 ? Object.assign(this.Video.toFormattedJSON(videoFormatOptions), { tags: this.Video.Tags.map(t => t.name) })
239 : undefined
240
241 const videoChannelSync = this.VideoChannelSync
242 ? { id: this.VideoChannelSync.id, externalChannelUrl: this.VideoChannelSync.externalChannelUrl }
243 : undefined
244
245 return {
246 id: this.id,
247
248 targetUrl: this.targetUrl,
249 magnetUri: this.magnetUri,
250 torrentName: this.torrentName,
251
252 state: {
253 id: this.state,
254 label: VideoImportModel.getStateLabel(this.state)
255 },
256 error: this.error,
257 updatedAt: this.updatedAt.toISOString(),
258 createdAt: this.createdAt.toISOString(),
259 video,
260 videoChannelSync
261 }
262 }
263
264 private static getStateLabel (id: number) {
265 return VIDEO_IMPORT_STATES[id] || 'Unknown'
266 }
267}
diff --git a/server/models/video/video-job-info.ts b/server/models/video/video-job-info.ts
deleted file mode 100644
index 5845b8c74..000000000
--- a/server/models/video/video-job-info.ts
+++ /dev/null
@@ -1,121 +0,0 @@
1import { Op, QueryTypes, Transaction } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, IsInt, Model, Table, Unique, UpdatedAt } from 'sequelize-typescript'
3import { forceNumber } from '@shared/core-utils'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { VideoModel } from './video'
6
7export type VideoJobInfoColumnType = 'pendingMove' | 'pendingTranscode'
8
9@Table({
10 tableName: 'videoJobInfo',
11 indexes: [
12 {
13 fields: [ 'videoId' ],
14 where: {
15 videoId: {
16 [Op.ne]: null
17 }
18 }
19 }
20 ]
21})
22
23export class VideoJobInfoModel extends Model<Partial<AttributesOnly<VideoJobInfoModel>>> {
24 @CreatedAt
25 createdAt: Date
26
27 @UpdatedAt
28 updatedAt: Date
29
30 @AllowNull(false)
31 @Default(0)
32 @IsInt
33 @Column
34 pendingMove: number
35
36 @AllowNull(false)
37 @Default(0)
38 @IsInt
39 @Column
40 pendingTranscode: number
41
42 @ForeignKey(() => VideoModel)
43 @Unique
44 @Column
45 videoId: number
46
47 @BelongsTo(() => VideoModel, {
48 foreignKey: {
49 allowNull: false
50 },
51 onDelete: 'cascade'
52 })
53 Video: VideoModel
54
55 static load (videoId: number, transaction?: Transaction) {
56 const where = {
57 videoId
58 }
59
60 return VideoJobInfoModel.findOne({ where, transaction })
61 }
62
63 static async increaseOrCreate (videoUUID: string, column: VideoJobInfoColumnType, amountArg = 1): Promise<number> {
64 const options = { type: QueryTypes.SELECT as QueryTypes.SELECT, bind: { videoUUID } }
65 const amount = forceNumber(amountArg)
66
67 const [ result ] = await VideoJobInfoModel.sequelize.query<{ pendingMove: number }>(`
68 INSERT INTO "videoJobInfo" ("videoId", "${column}", "createdAt", "updatedAt")
69 SELECT
70 "video"."id" AS "videoId", ${amount}, NOW(), NOW()
71 FROM
72 "video"
73 WHERE
74 "video"."uuid" = $videoUUID
75 ON CONFLICT ("videoId") DO UPDATE
76 SET
77 "${column}" = "videoJobInfo"."${column}" + ${amount},
78 "updatedAt" = NOW()
79 RETURNING
80 "${column}"
81 `, options)
82
83 return result[column]
84 }
85
86 static async decrease (videoUUID: string, column: VideoJobInfoColumnType): Promise<number> {
87 const options = { type: QueryTypes.SELECT as QueryTypes.SELECT, bind: { videoUUID } }
88
89 const result = await VideoJobInfoModel.sequelize.query(`
90 UPDATE
91 "videoJobInfo"
92 SET
93 "${column}" = "videoJobInfo"."${column}" - 1,
94 "updatedAt" = NOW()
95 FROM "video"
96 WHERE
97 "video"."id" = "videoJobInfo"."videoId" AND "video"."uuid" = $videoUUID
98 RETURNING
99 "${column}";
100 `, options)
101
102 if (result.length === 0) return undefined
103
104 return result[0][column]
105 }
106
107 static async abortAllTasks (videoUUID: string, column: VideoJobInfoColumnType): Promise<void> {
108 const options = { type: QueryTypes.UPDATE as QueryTypes.UPDATE, bind: { videoUUID } }
109
110 await VideoJobInfoModel.sequelize.query(`
111 UPDATE
112 "videoJobInfo"
113 SET
114 "${column}" = 0,
115 "updatedAt" = NOW()
116 FROM "video"
117 WHERE
118 "video"."id" = "videoJobInfo"."videoId" AND "video"."uuid" = $videoUUID
119 `, options)
120 }
121}
diff --git a/server/models/video/video-live-replay-setting.ts b/server/models/video/video-live-replay-setting.ts
deleted file mode 100644
index 1c824dfa2..000000000
--- a/server/models/video/video-live-replay-setting.ts
+++ /dev/null
@@ -1,42 +0,0 @@
1import { isVideoPrivacyValid } from '@server/helpers/custom-validators/videos'
2import { MLiveReplaySetting } from '@server/types/models/video/video-live-replay-setting'
3import { VideoPrivacy } from '@shared/models/videos/video-privacy.enum'
4import { Transaction } from 'sequelize'
5import { AllowNull, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
6import { throwIfNotValid } from '../shared/sequelize-helpers'
7
8@Table({
9 tableName: 'videoLiveReplaySetting'
10})
11export class VideoLiveReplaySettingModel extends Model<VideoLiveReplaySettingModel> {
12
13 @CreatedAt
14 createdAt: Date
15
16 @UpdatedAt
17 updatedAt: Date
18
19 @AllowNull(false)
20 @Is('VideoPrivacy', value => throwIfNotValid(value, isVideoPrivacyValid, 'privacy'))
21 @Column
22 privacy: VideoPrivacy
23
24 static load (id: number, transaction?: Transaction): Promise<MLiveReplaySetting> {
25 return VideoLiveReplaySettingModel.findOne({
26 where: { id },
27 transaction
28 })
29 }
30
31 static removeSettings (id: number) {
32 return VideoLiveReplaySettingModel.destroy({
33 where: { id }
34 })
35 }
36
37 toFormattedJSON () {
38 return {
39 privacy: this.privacy
40 }
41 }
42}
diff --git a/server/models/video/video-live-session.ts b/server/models/video/video-live-session.ts
deleted file mode 100644
index 9426f5d11..000000000
--- a/server/models/video/video-live-session.ts
+++ /dev/null
@@ -1,217 +0,0 @@
1import { FindOptions } from 'sequelize'
2import {
3 AllowNull,
4 BeforeDestroy,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 ForeignKey,
10 Model,
11 Scopes,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { MVideoLiveSession, MVideoLiveSessionReplay } from '@server/types/models'
16import { uuidToShort } from '@shared/extra-utils'
17import { LiveVideoError, LiveVideoSession } from '@shared/models'
18import { AttributesOnly } from '@shared/typescript-utils'
19import { VideoModel } from './video'
20import { VideoLiveReplaySettingModel } from './video-live-replay-setting'
21
22export enum ScopeNames {
23 WITH_REPLAY = 'WITH_REPLAY'
24}
25
26@Scopes(() => ({
27 [ScopeNames.WITH_REPLAY]: {
28 include: [
29 {
30 model: VideoModel.unscoped(),
31 as: 'ReplayVideo',
32 required: false
33 },
34 {
35 model: VideoLiveReplaySettingModel,
36 required: false
37 }
38 ]
39 }
40}))
41@Table({
42 tableName: 'videoLiveSession',
43 indexes: [
44 {
45 fields: [ 'replayVideoId' ],
46 unique: true
47 },
48 {
49 fields: [ 'liveVideoId' ]
50 },
51 {
52 fields: [ 'replaySettingId' ],
53 unique: true
54 }
55 ]
56})
57export class VideoLiveSessionModel extends Model<Partial<AttributesOnly<VideoLiveSessionModel>>> {
58
59 @CreatedAt
60 createdAt: Date
61
62 @UpdatedAt
63 updatedAt: Date
64
65 @AllowNull(false)
66 @Column(DataType.DATE)
67 startDate: Date
68
69 @AllowNull(true)
70 @Column(DataType.DATE)
71 endDate: Date
72
73 @AllowNull(true)
74 @Column
75 error: LiveVideoError
76
77 @AllowNull(false)
78 @Column
79 saveReplay: boolean
80
81 @AllowNull(false)
82 @Column
83 endingProcessed: boolean
84
85 @ForeignKey(() => VideoModel)
86 @Column
87 replayVideoId: number
88
89 @BelongsTo(() => VideoModel, {
90 foreignKey: {
91 allowNull: true,
92 name: 'replayVideoId'
93 },
94 as: 'ReplayVideo',
95 onDelete: 'set null'
96 })
97 ReplayVideo: VideoModel
98
99 @ForeignKey(() => VideoModel)
100 @Column
101 liveVideoId: number
102
103 @BelongsTo(() => VideoModel, {
104 foreignKey: {
105 allowNull: true,
106 name: 'liveVideoId'
107 },
108 as: 'LiveVideo',
109 onDelete: 'set null'
110 })
111 LiveVideo: VideoModel
112
113 @ForeignKey(() => VideoLiveReplaySettingModel)
114 @Column
115 replaySettingId: number
116
117 @BelongsTo(() => VideoLiveReplaySettingModel, {
118 foreignKey: {
119 allowNull: true
120 },
121 onDelete: 'set null'
122 })
123 ReplaySetting: VideoLiveReplaySettingModel
124
125 @BeforeDestroy
126 static deleteReplaySetting (instance: VideoLiveSessionModel) {
127 return VideoLiveReplaySettingModel.destroy({
128 where: {
129 id: instance.replaySettingId
130 }
131 })
132 }
133
134 static load (id: number): Promise<MVideoLiveSession> {
135 return VideoLiveSessionModel.findOne({
136 where: { id }
137 })
138 }
139
140 static findSessionOfReplay (replayVideoId: number) {
141 const query = {
142 where: {
143 replayVideoId
144 }
145 }
146
147 return VideoLiveSessionModel.scope(ScopeNames.WITH_REPLAY).findOne(query)
148 }
149
150 static findCurrentSessionOf (videoUUID: string) {
151 return VideoLiveSessionModel.findOne({
152 where: {
153 endDate: null
154 },
155 include: [
156 {
157 model: VideoModel.unscoped(),
158 as: 'LiveVideo',
159 required: true,
160 where: {
161 uuid: videoUUID
162 }
163 }
164 ],
165 order: [ [ 'startDate', 'DESC' ] ]
166 })
167 }
168
169 static findLatestSessionOf (videoId: number) {
170 return VideoLiveSessionModel.findOne({
171 where: {
172 liveVideoId: videoId
173 },
174 order: [ [ 'startDate', 'DESC' ] ]
175 })
176 }
177
178 static listSessionsOfLiveForAPI (options: { videoId: number }) {
179 const { videoId } = options
180
181 const query: FindOptions<AttributesOnly<VideoLiveSessionModel>> = {
182 where: {
183 liveVideoId: videoId
184 },
185 order: [ [ 'startDate', 'ASC' ] ]
186 }
187
188 return VideoLiveSessionModel.scope(ScopeNames.WITH_REPLAY).findAll(query)
189 }
190
191 toFormattedJSON (this: MVideoLiveSessionReplay): LiveVideoSession {
192 const replayVideo = this.ReplayVideo
193 ? {
194 id: this.ReplayVideo.id,
195 uuid: this.ReplayVideo.uuid,
196 shortUUID: uuidToShort(this.ReplayVideo.uuid)
197 }
198 : undefined
199
200 const replaySettings = this.replaySettingId
201 ? this.ReplaySetting.toFormattedJSON()
202 : undefined
203
204 return {
205 id: this.id,
206 startDate: this.startDate.toISOString(),
207 endDate: this.endDate
208 ? this.endDate.toISOString()
209 : null,
210 endingProcessed: this.endingProcessed,
211 saveReplay: this.saveReplay,
212 replaySettings,
213 replayVideo,
214 error: this.error
215 }
216 }
217}
diff --git a/server/models/video/video-live.ts b/server/models/video/video-live.ts
deleted file mode 100644
index ca1118641..000000000
--- a/server/models/video/video-live.ts
+++ /dev/null
@@ -1,184 +0,0 @@
1import { Transaction } from 'sequelize'
2import {
3 AllowNull,
4 BeforeDestroy,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 DefaultScope,
10 ForeignKey,
11 Model,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { CONFIG } from '@server/initializers/config'
16import { WEBSERVER } from '@server/initializers/constants'
17import { MVideoLive, MVideoLiveVideoWithSetting } from '@server/types/models'
18import { LiveVideo, LiveVideoLatencyMode, VideoState } from '@shared/models'
19import { AttributesOnly } from '@shared/typescript-utils'
20import { VideoModel } from './video'
21import { VideoBlacklistModel } from './video-blacklist'
22import { VideoLiveReplaySettingModel } from './video-live-replay-setting'
23
24@DefaultScope(() => ({
25 include: [
26 {
27 model: VideoModel,
28 required: true,
29 include: [
30 {
31 model: VideoBlacklistModel,
32 required: false
33 }
34 ]
35 },
36 {
37 model: VideoLiveReplaySettingModel,
38 required: false
39 }
40 ]
41}))
42@Table({
43 tableName: 'videoLive',
44 indexes: [
45 {
46 fields: [ 'videoId' ],
47 unique: true
48 },
49 {
50 fields: [ 'replaySettingId' ],
51 unique: true
52 }
53 ]
54})
55export class VideoLiveModel extends Model<Partial<AttributesOnly<VideoLiveModel>>> {
56
57 @AllowNull(true)
58 @Column(DataType.STRING)
59 streamKey: string
60
61 @AllowNull(false)
62 @Column
63 saveReplay: boolean
64
65 @AllowNull(false)
66 @Column
67 permanentLive: boolean
68
69 @AllowNull(false)
70 @Column
71 latencyMode: LiveVideoLatencyMode
72
73 @CreatedAt
74 createdAt: Date
75
76 @UpdatedAt
77 updatedAt: Date
78
79 @ForeignKey(() => VideoModel)
80 @Column
81 videoId: number
82
83 @BelongsTo(() => VideoModel, {
84 foreignKey: {
85 allowNull: false
86 },
87 onDelete: 'cascade'
88 })
89 Video: VideoModel
90
91 @ForeignKey(() => VideoLiveReplaySettingModel)
92 @Column
93 replaySettingId: number
94
95 @BelongsTo(() => VideoLiveReplaySettingModel, {
96 foreignKey: {
97 allowNull: true
98 },
99 onDelete: 'set null'
100 })
101 ReplaySetting: VideoLiveReplaySettingModel
102
103 @BeforeDestroy
104 static deleteReplaySetting (instance: VideoLiveModel, options: { transaction: Transaction }) {
105 return VideoLiveReplaySettingModel.destroy({
106 where: {
107 id: instance.replaySettingId
108 },
109 transaction: options.transaction
110 })
111 }
112
113 static loadByStreamKey (streamKey: string) {
114 const query = {
115 where: {
116 streamKey
117 },
118 include: [
119 {
120 model: VideoModel.unscoped(),
121 required: true,
122 where: {
123 state: VideoState.WAITING_FOR_LIVE
124 },
125 include: [
126 {
127 model: VideoBlacklistModel.unscoped(),
128 required: false
129 }
130 ]
131 },
132 {
133 model: VideoLiveReplaySettingModel.unscoped(),
134 required: false
135 }
136 ]
137 }
138
139 return VideoLiveModel.findOne<MVideoLiveVideoWithSetting>(query)
140 }
141
142 static loadByVideoId (videoId: number) {
143 const query = {
144 where: {
145 videoId
146 }
147 }
148
149 return VideoLiveModel.findOne<MVideoLive>(query)
150 }
151
152 toFormattedJSON (canSeePrivateInformation: boolean): LiveVideo {
153 let privateInformation: Pick<LiveVideo, 'rtmpUrl' | 'rtmpsUrl' | 'streamKey'> | {} = {}
154
155 // If we don't have a stream key, it means this is a remote live so we don't specify the rtmp URL
156 // We also display these private information only to the live owne/moderators
157 if (this.streamKey && canSeePrivateInformation === true) {
158 privateInformation = {
159 streamKey: this.streamKey,
160
161 rtmpUrl: CONFIG.LIVE.RTMP.ENABLED
162 ? WEBSERVER.RTMP_BASE_LIVE_URL
163 : null,
164
165 rtmpsUrl: CONFIG.LIVE.RTMPS.ENABLED
166 ? WEBSERVER.RTMPS_BASE_LIVE_URL
167 : null
168 }
169 }
170
171 const replaySettings = this.replaySettingId
172 ? this.ReplaySetting.toFormattedJSON()
173 : undefined
174
175 return {
176 ...privateInformation,
177
178 permanentLive: this.permanentLive,
179 saveReplay: this.saveReplay,
180 replaySettings,
181 latencyMode: this.latencyMode
182 }
183 }
184}
diff --git a/server/models/video/video-password.ts b/server/models/video/video-password.ts
deleted file mode 100644
index 648366c3b..000000000
--- a/server/models/video/video-password.ts
+++ /dev/null
@@ -1,137 +0,0 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DefaultScope, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { VideoModel } from './video'
3import { AttributesOnly } from '@shared/typescript-utils'
4import { ResultList, VideoPassword } from '@shared/models'
5import { getSort, throwIfNotValid } from '../shared'
6import { FindOptions, Transaction } from 'sequelize'
7import { MVideoPassword } from '@server/types/models'
8import { isPasswordValid } from '@server/helpers/custom-validators/videos'
9import { pick } from '@shared/core-utils'
10
11@DefaultScope(() => ({
12 include: [
13 {
14 model: VideoModel.unscoped(),
15 required: true
16 }
17 ]
18}))
19@Table({
20 tableName: 'videoPassword',
21 indexes: [
22 {
23 fields: [ 'videoId', 'password' ],
24 unique: true
25 }
26 ]
27})
28export class VideoPasswordModel extends Model<Partial<AttributesOnly<VideoPasswordModel>>> {
29
30 @AllowNull(false)
31 @Is('VideoPassword', value => throwIfNotValid(value, isPasswordValid, 'videoPassword'))
32 @Column
33 password: string
34
35 @CreatedAt
36 createdAt: Date
37
38 @UpdatedAt
39 updatedAt: Date
40
41 @ForeignKey(() => VideoModel)
42 @Column
43 videoId: number
44
45 @BelongsTo(() => VideoModel, {
46 foreignKey: {
47 allowNull: false
48 },
49 onDelete: 'cascade'
50 })
51 Video: VideoModel
52
53 static async countByVideoId (videoId: number, t?: Transaction) {
54 const query: FindOptions = {
55 where: {
56 videoId
57 },
58 transaction: t
59 }
60
61 return VideoPasswordModel.count(query)
62 }
63
64 static async loadByIdAndVideo (options: { id: number, videoId: number, t?: Transaction }): Promise<MVideoPassword> {
65 const { id, videoId, t } = options
66 const query: FindOptions = {
67 where: {
68 id,
69 videoId
70 },
71 transaction: t
72 }
73
74 return VideoPasswordModel.findOne(query)
75 }
76
77 static async listPasswords (options: {
78 start: number
79 count: number
80 sort: string
81 videoId: number
82 }): Promise<ResultList<MVideoPassword>> {
83 const { start, count, sort, videoId } = options
84
85 const { count: total, rows: data } = await VideoPasswordModel.findAndCountAll({
86 where: { videoId },
87 order: getSort(sort),
88 offset: start,
89 limit: count
90 })
91
92 return { total, data }
93 }
94
95 static async addPasswords (passwords: string[], videoId: number, transaction?: Transaction): Promise<void> {
96 for (const password of passwords) {
97 await VideoPasswordModel.create({
98 password,
99 videoId
100 }, { transaction })
101 }
102 }
103
104 static async deleteAllPasswords (videoId: number, transaction?: Transaction) {
105 await VideoPasswordModel.destroy({
106 where: { videoId },
107 transaction
108 })
109 }
110
111 static async deletePassword (passwordId: number, transaction?: Transaction) {
112 await VideoPasswordModel.destroy({
113 where: { id: passwordId },
114 transaction
115 })
116 }
117
118 static async isACorrectPassword (options: {
119 videoId: number
120 password: string
121 }) {
122 const query = {
123 where: pick(options, [ 'videoId', 'password' ])
124 }
125 return VideoPasswordModel.findOne(query)
126 }
127
128 toFormattedJSON (): VideoPassword {
129 return {
130 id: this.id,
131 password: this.password,
132 videoId: this.videoId,
133 createdAt: this.createdAt,
134 updatedAt: this.updatedAt
135 }
136 }
137}
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
deleted file mode 100644
index 61ae6b9fe..000000000
--- a/server/models/video/video-playlist-element.ts
+++ /dev/null
@@ -1,370 +0,0 @@
1import { AggregateOptions, Op, ScopeOptions, Sequelize, Transaction } from 'sequelize'
2import {
3 AllowNull,
4 BelongsTo,
5 Column,
6 CreatedAt,
7 DataType,
8 Default,
9 ForeignKey,
10 Is,
11 IsInt,
12 Min,
13 Model,
14 Table,
15 UpdatedAt
16} from 'sequelize-typescript'
17import validator from 'validator'
18import { MUserAccountId } from '@server/types/models'
19import {
20 MVideoPlaylistElement,
21 MVideoPlaylistElementAP,
22 MVideoPlaylistElementFormattable,
23 MVideoPlaylistElementVideoUrlPlaylistPrivacy,
24 MVideoPlaylistVideoThumbnail
25} from '@server/types/models/video/video-playlist-element'
26import { forceNumber } from '@shared/core-utils'
27import { AttributesOnly } from '@shared/typescript-utils'
28import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object'
29import { VideoPrivacy } from '../../../shared/models/videos'
30import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../shared/models/videos/playlist/video-playlist-element.model'
31import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
32import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
33import { AccountModel } from '../account/account'
34import { getSort, throwIfNotValid } from '../shared'
35import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video'
36import { VideoPlaylistModel } from './video-playlist'
37
38@Table({
39 tableName: 'videoPlaylistElement',
40 indexes: [
41 {
42 fields: [ 'videoPlaylistId' ]
43 },
44 {
45 fields: [ 'videoId' ]
46 },
47 {
48 fields: [ 'url' ],
49 unique: true
50 }
51 ]
52})
53export class VideoPlaylistElementModel extends Model<Partial<AttributesOnly<VideoPlaylistElementModel>>> {
54 @CreatedAt
55 createdAt: Date
56
57 @UpdatedAt
58 updatedAt: Date
59
60 @AllowNull(true)
61 @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url', true))
62 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
63 url: string
64
65 @AllowNull(false)
66 @Default(1)
67 @IsInt
68 @Min(1)
69 @Column
70 position: number
71
72 @AllowNull(true)
73 @IsInt
74 @Min(0)
75 @Column
76 startTimestamp: number
77
78 @AllowNull(true)
79 @IsInt
80 @Min(0)
81 @Column
82 stopTimestamp: number
83
84 @ForeignKey(() => VideoPlaylistModel)
85 @Column
86 videoPlaylistId: number
87
88 @BelongsTo(() => VideoPlaylistModel, {
89 foreignKey: {
90 allowNull: false
91 },
92 onDelete: 'CASCADE'
93 })
94 VideoPlaylist: VideoPlaylistModel
95
96 @ForeignKey(() => VideoModel)
97 @Column
98 videoId: number
99
100 @BelongsTo(() => VideoModel, {
101 foreignKey: {
102 allowNull: true
103 },
104 onDelete: 'set null'
105 })
106 Video: VideoModel
107
108 static deleteAllOf (videoPlaylistId: number, transaction?: Transaction) {
109 const query = {
110 where: {
111 videoPlaylistId
112 },
113 transaction
114 }
115
116 return VideoPlaylistElementModel.destroy(query)
117 }
118
119 static listForApi (options: {
120 start: number
121 count: number
122 videoPlaylistId: number
123 serverAccount: AccountModel
124 user?: MUserAccountId
125 }) {
126 const accountIds = [ options.serverAccount.id ]
127 const videoScope: (ScopeOptions | string)[] = [
128 VideoScopeNames.WITH_BLACKLISTED
129 ]
130
131 if (options.user) {
132 accountIds.push(options.user.Account.id)
133 videoScope.push({ method: [ VideoScopeNames.WITH_USER_HISTORY, options.user.id ] })
134 }
135
136 const forApiOptions: ForAPIOptions = { withAccountBlockerIds: accountIds }
137 videoScope.push({
138 method: [
139 VideoScopeNames.FOR_API, forApiOptions
140 ]
141 })
142
143 const findQuery = {
144 offset: options.start,
145 limit: options.count,
146 order: getSort('position'),
147 where: {
148 videoPlaylistId: options.videoPlaylistId
149 },
150 include: [
151 {
152 model: VideoModel.scope(videoScope),
153 required: false
154 }
155 ]
156 }
157
158 const countQuery = {
159 where: {
160 videoPlaylistId: options.videoPlaylistId
161 }
162 }
163
164 return Promise.all([
165 VideoPlaylistElementModel.count(countQuery),
166 VideoPlaylistElementModel.findAll(findQuery)
167 ]).then(([ total, data ]) => ({ total, data }))
168 }
169
170 static loadByPlaylistAndVideo (videoPlaylistId: number, videoId: number): Promise<MVideoPlaylistElement> {
171 const query = {
172 where: {
173 videoPlaylistId,
174 videoId
175 }
176 }
177
178 return VideoPlaylistElementModel.findOne(query)
179 }
180
181 static loadById (playlistElementId: number | string): Promise<MVideoPlaylistElement> {
182 return VideoPlaylistElementModel.findByPk(playlistElementId)
183 }
184
185 static loadByPlaylistAndElementIdForAP (
186 playlistId: number | string,
187 playlistElementId: number
188 ): Promise<MVideoPlaylistElementVideoUrlPlaylistPrivacy> {
189 const playlistWhere = validator.isUUID('' + playlistId)
190 ? { uuid: playlistId }
191 : { id: playlistId }
192
193 const query = {
194 include: [
195 {
196 attributes: [ 'privacy' ],
197 model: VideoPlaylistModel.unscoped(),
198 where: playlistWhere
199 },
200 {
201 attributes: [ 'url' ],
202 model: VideoModel.unscoped()
203 }
204 ],
205 where: {
206 id: playlistElementId
207 }
208 }
209
210 return VideoPlaylistElementModel.findOne(query)
211 }
212
213 static listUrlsOfForAP (videoPlaylistId: number, start: number, count: number, t?: Transaction) {
214 const getQuery = (forCount: boolean) => {
215 return {
216 attributes: forCount
217 ? []
218 : [ 'url' ],
219 offset: start,
220 limit: count,
221 order: getSort('position'),
222 where: {
223 videoPlaylistId
224 },
225 transaction: t
226 }
227 }
228
229 return Promise.all([
230 VideoPlaylistElementModel.count(getQuery(true)),
231 VideoPlaylistElementModel.findAll(getQuery(false))
232 ]).then(([ total, rows ]) => ({
233 total,
234 data: rows.map(e => e.url)
235 }))
236 }
237
238 static loadFirstElementWithVideoThumbnail (videoPlaylistId: number): Promise<MVideoPlaylistVideoThumbnail> {
239 const query = {
240 order: getSort('position'),
241 where: {
242 videoPlaylistId
243 },
244 include: [
245 {
246 model: VideoModel.scope(VideoScopeNames.WITH_THUMBNAILS),
247 required: true
248 }
249 ]
250 }
251
252 return VideoPlaylistElementModel
253 .findOne(query)
254 }
255
256 static getNextPositionOf (videoPlaylistId: number, transaction?: Transaction) {
257 const query: AggregateOptions<number> = {
258 where: {
259 videoPlaylistId
260 },
261 transaction
262 }
263
264 return VideoPlaylistElementModel.max('position', query)
265 .then(position => position ? position + 1 : 1)
266 }
267
268 static reassignPositionOf (options: {
269 videoPlaylistId: number
270 firstPosition: number
271 endPosition: number
272 newPosition: number
273 transaction?: Transaction
274 }) {
275 const { videoPlaylistId, firstPosition, endPosition, newPosition, transaction } = options
276
277 const query = {
278 where: {
279 videoPlaylistId,
280 position: {
281 [Op.gte]: firstPosition,
282 [Op.lte]: endPosition
283 }
284 },
285 transaction,
286 validate: false // We use a literal to update the position
287 }
288
289 const positionQuery = Sequelize.literal(`${forceNumber(newPosition)} + "position" - ${forceNumber(firstPosition)}`)
290 return VideoPlaylistElementModel.update({ position: positionQuery }, query)
291 }
292
293 static increasePositionOf (
294 videoPlaylistId: number,
295 fromPosition: number,
296 by = 1,
297 transaction?: Transaction
298 ) {
299 const query = {
300 where: {
301 videoPlaylistId,
302 position: {
303 [Op.gte]: fromPosition
304 }
305 },
306 transaction
307 }
308
309 return VideoPlaylistElementModel.increment({ position: by }, query)
310 }
311
312 toFormattedJSON (
313 this: MVideoPlaylistElementFormattable,
314 options: { accountId?: number } = {}
315 ): VideoPlaylistElement {
316 return {
317 id: this.id,
318 position: this.position,
319 startTimestamp: this.startTimestamp,
320 stopTimestamp: this.stopTimestamp,
321
322 type: this.getType(options.accountId),
323
324 video: this.getVideoElement(options.accountId)
325 }
326 }
327
328 getType (this: MVideoPlaylistElementFormattable, accountId?: number) {
329 const video = this.Video
330
331 if (!video) return VideoPlaylistElementType.DELETED
332
333 // Owned video, don't filter it
334 if (accountId && video.VideoChannel.Account.id === accountId) return VideoPlaylistElementType.REGULAR
335
336 // Internal video?
337 if (video.privacy === VideoPrivacy.INTERNAL && accountId) return VideoPlaylistElementType.REGULAR
338
339 // Private, internal and password protected videos cannot be read without appropriate access (ownership, internal)
340 if (new Set([ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL, VideoPrivacy.PASSWORD_PROTECTED ]).has(video.privacy)) {
341 return VideoPlaylistElementType.PRIVATE
342 }
343
344 if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE
345
346 return VideoPlaylistElementType.REGULAR
347 }
348
349 getVideoElement (this: MVideoPlaylistElementFormattable, accountId?: number) {
350 if (!this.Video) return null
351 if (this.getType(accountId) !== VideoPlaylistElementType.REGULAR) return null
352
353 return this.Video.toFormattedJSON()
354 }
355
356 toActivityPubObject (this: MVideoPlaylistElementAP): PlaylistElementObject {
357 const base: PlaylistElementObject = {
358 id: this.url,
359 type: 'PlaylistElement',
360
361 url: this.Video?.url || null,
362 position: this.position
363 }
364
365 if (this.startTimestamp) base.startTimestamp = this.startTimestamp
366 if (this.stopTimestamp) base.stopTimestamp = this.stopTimestamp
367
368 return base
369 }
370}
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
deleted file mode 100644
index 15999d409..000000000
--- a/server/models/video/video-playlist.ts
+++ /dev/null
@@ -1,725 +0,0 @@
1import { join } from 'path'
2import { FindOptions, Includeable, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
3import {
4 AllowNull,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 Default,
10 ForeignKey,
11 HasMany,
12 HasOne,
13 Is,
14 IsUUID,
15 Model,
16 Scopes,
17 Table,
18 UpdatedAt
19} from 'sequelize-typescript'
20import { activityPubCollectionPagination } from '@server/lib/activitypub/collection'
21import { MAccountId, MChannelId } from '@server/types/models'
22import { buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@shared/core-utils'
23import { buildUUID, uuidToShort } from '@shared/extra-utils'
24import { ActivityIconObject, PlaylistObject, VideoPlaylist, VideoPlaylistPrivacy, VideoPlaylistType } from '@shared/models'
25import { AttributesOnly } from '@shared/typescript-utils'
26import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
27import {
28 isVideoPlaylistDescriptionValid,
29 isVideoPlaylistNameValid,
30 isVideoPlaylistPrivacyValid
31} from '../../helpers/custom-validators/video-playlists'
32import {
33 ACTIVITY_PUB,
34 CONSTRAINTS_FIELDS,
35 LAZY_STATIC_PATHS,
36 THUMBNAILS_SIZE,
37 VIDEO_PLAYLIST_PRIVACIES,
38 VIDEO_PLAYLIST_TYPES,
39 WEBSERVER
40} from '../../initializers/constants'
41import { MThumbnail } from '../../types/models/video/thumbnail'
42import {
43 MVideoPlaylistAccountThumbnail,
44 MVideoPlaylistAP,
45 MVideoPlaylistFormattable,
46 MVideoPlaylistFull,
47 MVideoPlaylistFullSummary,
48 MVideoPlaylistSummaryWithElements
49} from '../../types/models/video/video-playlist'
50import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
51import { ActorModel } from '../actor/actor'
52import {
53 buildServerIdsFollowedBy,
54 buildTrigramSearchIndex,
55 buildWhereIdOrUUID,
56 createSimilarityAttribute,
57 getPlaylistSort,
58 isOutdated,
59 setAsUpdated,
60 throwIfNotValid
61} from '../shared'
62import { ThumbnailModel } from './thumbnail'
63import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
64import { VideoPlaylistElementModel } from './video-playlist-element'
65
66enum ScopeNames {
67 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
68 WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH',
69 WITH_ACCOUNT_AND_CHANNEL_SUMMARY = 'WITH_ACCOUNT_AND_CHANNEL_SUMMARY',
70 WITH_ACCOUNT = 'WITH_ACCOUNT',
71 WITH_THUMBNAIL = 'WITH_THUMBNAIL',
72 WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL'
73}
74
75type AvailableForListOptions = {
76 followerActorId?: number
77 type?: VideoPlaylistType
78 accountId?: number
79 videoChannelId?: number
80 listMyPlaylists?: boolean
81 search?: string
82 host?: string
83 uuids?: string[]
84 withVideos?: boolean
85 forCount?: boolean
86}
87
88function getVideoLengthSelect () {
89 return 'SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id"'
90}
91
92@Scopes(() => ({
93 [ScopeNames.WITH_THUMBNAIL]: {
94 include: [
95 {
96 model: ThumbnailModel,
97 required: false
98 }
99 ]
100 },
101 [ScopeNames.WITH_VIDEOS_LENGTH]: {
102 attributes: {
103 include: [
104 [
105 literal(`(${getVideoLengthSelect()})`),
106 'videosLength'
107 ]
108 ]
109 }
110 } as FindOptions,
111 [ScopeNames.WITH_ACCOUNT]: {
112 include: [
113 {
114 model: AccountModel,
115 required: true
116 }
117 ]
118 },
119 [ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY]: {
120 include: [
121 {
122 model: AccountModel.scope(AccountScopeNames.SUMMARY),
123 required: true
124 },
125 {
126 model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
127 required: false
128 }
129 ]
130 },
131 [ScopeNames.WITH_ACCOUNT_AND_CHANNEL]: {
132 include: [
133 {
134 model: AccountModel,
135 required: true
136 },
137 {
138 model: VideoChannelModel,
139 required: false
140 }
141 ]
142 },
143 [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
144 const whereAnd: WhereOptions[] = []
145
146 const whereServer = options.host && options.host !== WEBSERVER.HOST
147 ? { host: options.host }
148 : undefined
149
150 let whereActor: WhereOptions = {}
151
152 if (options.host === WEBSERVER.HOST) {
153 whereActor = {
154 [Op.and]: [ { serverId: null } ]
155 }
156 }
157
158 if (options.listMyPlaylists !== true) {
159 whereAnd.push({
160 privacy: VideoPlaylistPrivacy.PUBLIC
161 })
162
163 // Only list local playlists
164 const whereActorOr: WhereOptions[] = [
165 {
166 serverId: null
167 }
168 ]
169
170 // … OR playlists that are on an instance followed by actorId
171 if (options.followerActorId) {
172 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId)
173
174 whereActorOr.push({
175 serverId: {
176 [Op.in]: literal(inQueryInstanceFollow)
177 }
178 })
179 }
180
181 Object.assign(whereActor, { [Op.or]: whereActorOr })
182 }
183
184 if (options.accountId) {
185 whereAnd.push({
186 ownerAccountId: options.accountId
187 })
188 }
189
190 if (options.videoChannelId) {
191 whereAnd.push({
192 videoChannelId: options.videoChannelId
193 })
194 }
195
196 if (options.type) {
197 whereAnd.push({
198 type: options.type
199 })
200 }
201
202 if (options.uuids) {
203 whereAnd.push({
204 uuid: {
205 [Op.in]: options.uuids
206 }
207 })
208 }
209
210 if (options.withVideos === true) {
211 whereAnd.push(
212 literal(`(${getVideoLengthSelect()}) != 0`)
213 )
214 }
215
216 let attributesInclude: any[] = [ literal('0 as similarity') ]
217
218 if (options.search) {
219 const escapedSearch = VideoPlaylistModel.sequelize.escape(options.search)
220 const escapedLikeSearch = VideoPlaylistModel.sequelize.escape('%' + options.search + '%')
221 attributesInclude = [ createSimilarityAttribute('VideoPlaylistModel.name', options.search) ]
222
223 whereAnd.push({
224 [Op.or]: [
225 Sequelize.literal(
226 'lower(immutable_unaccent("VideoPlaylistModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
227 ),
228 Sequelize.literal(
229 'lower(immutable_unaccent("VideoPlaylistModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
230 )
231 ]
232 })
233 }
234
235 const where = {
236 [Op.and]: whereAnd
237 }
238
239 const include: Includeable[] = [
240 {
241 model: AccountModel.scope({
242 method: [ AccountScopeNames.SUMMARY, { whereActor, whereServer, forCount: options.forCount } as SummaryOptions ]
243 }),
244 required: true
245 }
246 ]
247
248 if (options.forCount !== true) {
249 include.push({
250 model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
251 required: false
252 })
253 }
254
255 return {
256 attributes: {
257 include: attributesInclude
258 },
259 where,
260 include
261 } as FindOptions
262 }
263}))
264
265@Table({
266 tableName: 'videoPlaylist',
267 indexes: [
268 buildTrigramSearchIndex('video_playlist_name_trigram', 'name'),
269
270 {
271 fields: [ 'ownerAccountId' ]
272 },
273 {
274 fields: [ 'videoChannelId' ]
275 },
276 {
277 fields: [ 'url' ],
278 unique: true
279 }
280 ]
281})
282export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlaylistModel>>> {
283 @CreatedAt
284 createdAt: Date
285
286 @UpdatedAt
287 updatedAt: Date
288
289 @AllowNull(false)
290 @Is('VideoPlaylistName', value => throwIfNotValid(value, isVideoPlaylistNameValid, 'name'))
291 @Column
292 name: string
293
294 @AllowNull(true)
295 @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description', true))
296 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.DESCRIPTION.max))
297 description: string
298
299 @AllowNull(false)
300 @Is('VideoPlaylistPrivacy', value => throwIfNotValid(value, isVideoPlaylistPrivacyValid, 'privacy'))
301 @Column
302 privacy: VideoPlaylistPrivacy
303
304 @AllowNull(false)
305 @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
306 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
307 url: string
308
309 @AllowNull(false)
310 @Default(DataType.UUIDV4)
311 @IsUUID(4)
312 @Column(DataType.UUID)
313 uuid: string
314
315 @AllowNull(false)
316 @Default(VideoPlaylistType.REGULAR)
317 @Column
318 type: VideoPlaylistType
319
320 @ForeignKey(() => AccountModel)
321 @Column
322 ownerAccountId: number
323
324 @BelongsTo(() => AccountModel, {
325 foreignKey: {
326 allowNull: false
327 },
328 onDelete: 'CASCADE'
329 })
330 OwnerAccount: AccountModel
331
332 @ForeignKey(() => VideoChannelModel)
333 @Column
334 videoChannelId: number
335
336 @BelongsTo(() => VideoChannelModel, {
337 foreignKey: {
338 allowNull: true
339 },
340 onDelete: 'CASCADE'
341 })
342 VideoChannel: VideoChannelModel
343
344 @HasMany(() => VideoPlaylistElementModel, {
345 foreignKey: {
346 name: 'videoPlaylistId',
347 allowNull: false
348 },
349 onDelete: 'CASCADE'
350 })
351 VideoPlaylistElements: VideoPlaylistElementModel[]
352
353 @HasOne(() => ThumbnailModel, {
354 foreignKey: {
355 name: 'videoPlaylistId',
356 allowNull: true
357 },
358 onDelete: 'CASCADE',
359 hooks: true
360 })
361 Thumbnail: ThumbnailModel
362
363 static listForApi (options: AvailableForListOptions & {
364 start: number
365 count: number
366 sort: string
367 }) {
368 const query = {
369 offset: options.start,
370 limit: options.count,
371 order: getPlaylistSort(options.sort)
372 }
373
374 const commonAvailableForListOptions = pick(options, [
375 'type',
376 'followerActorId',
377 'accountId',
378 'videoChannelId',
379 'listMyPlaylists',
380 'search',
381 'host',
382 'uuids'
383 ])
384
385 const scopesFind: (string | ScopeOptions)[] = [
386 {
387 method: [
388 ScopeNames.AVAILABLE_FOR_LIST,
389 {
390 ...commonAvailableForListOptions,
391
392 withVideos: options.withVideos || false
393 } as AvailableForListOptions
394 ]
395 },
396 ScopeNames.WITH_VIDEOS_LENGTH,
397 ScopeNames.WITH_THUMBNAIL
398 ]
399
400 const scopesCount: (string | ScopeOptions)[] = [
401 {
402 method: [
403 ScopeNames.AVAILABLE_FOR_LIST,
404
405 {
406 ...commonAvailableForListOptions,
407
408 withVideos: options.withVideos || false,
409 forCount: true
410 } as AvailableForListOptions
411 ]
412 },
413 ScopeNames.WITH_VIDEOS_LENGTH
414 ]
415
416 return Promise.all([
417 VideoPlaylistModel.scope(scopesCount).count(),
418 VideoPlaylistModel.scope(scopesFind).findAll(query)
419 ]).then(([ count, rows ]) => ({ total: count, data: rows }))
420 }
421
422 static searchForApi (options: Pick<AvailableForListOptions, 'followerActorId' | 'search' | 'host' | 'uuids'> & {
423 start: number
424 count: number
425 sort: string
426 }) {
427 return VideoPlaylistModel.listForApi({
428 ...options,
429
430 type: VideoPlaylistType.REGULAR,
431 listMyPlaylists: false,
432 withVideos: true
433 })
434 }
435
436 static listPublicUrlsOfForAP (options: { account?: MAccountId, channel?: MChannelId }, start: number, count: number) {
437 const where = {
438 privacy: VideoPlaylistPrivacy.PUBLIC
439 }
440
441 if (options.account) {
442 Object.assign(where, { ownerAccountId: options.account.id })
443 }
444
445 if (options.channel) {
446 Object.assign(where, { videoChannelId: options.channel.id })
447 }
448
449 const getQuery = (forCount: boolean) => {
450 return {
451 attributes: forCount === true
452 ? []
453 : [ 'url' ],
454 offset: start,
455 limit: count,
456 where
457 }
458 }
459
460 return Promise.all([
461 VideoPlaylistModel.count(getQuery(true)),
462 VideoPlaylistModel.findAll(getQuery(false))
463 ]).then(([ total, rows ]) => ({
464 total,
465 data: rows.map(p => p.url)
466 }))
467 }
468
469 static listPlaylistSummariesOf (accountId: number, videoIds: number[]): Promise<MVideoPlaylistSummaryWithElements[]> {
470 const query = {
471 attributes: [ 'id', 'name', 'uuid' ],
472 where: {
473 ownerAccountId: accountId
474 },
475 include: [
476 {
477 attributes: [ 'id', 'videoId', 'startTimestamp', 'stopTimestamp' ],
478 model: VideoPlaylistElementModel.unscoped(),
479 where: {
480 videoId: {
481 [Op.in]: videoIds
482 }
483 },
484 required: true
485 }
486 ]
487 }
488
489 return VideoPlaylistModel.findAll(query)
490 }
491
492 static doesPlaylistExist (url: string) {
493 const query = {
494 attributes: [ 'id' ],
495 where: {
496 url
497 }
498 }
499
500 return VideoPlaylistModel
501 .findOne(query)
502 .then(e => !!e)
503 }
504
505 static loadWithAccountAndChannelSummary (id: number | string, transaction: Transaction): Promise<MVideoPlaylistFullSummary> {
506 const where = buildWhereIdOrUUID(id)
507
508 const query = {
509 where,
510 transaction
511 }
512
513 return VideoPlaylistModel
514 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
515 .findOne(query)
516 }
517
518 static loadWithAccountAndChannel (id: number | string, transaction: Transaction): Promise<MVideoPlaylistFull> {
519 const where = buildWhereIdOrUUID(id)
520
521 const query = {
522 where,
523 transaction
524 }
525
526 return VideoPlaylistModel
527 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
528 .findOne(query)
529 }
530
531 static loadByUrlAndPopulateAccount (url: string): Promise<MVideoPlaylistAccountThumbnail> {
532 const query = {
533 where: {
534 url
535 }
536 }
537
538 return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query)
539 }
540
541 static loadByUrlWithAccountAndChannelSummary (url: string): Promise<MVideoPlaylistFullSummary> {
542 const query = {
543 where: {
544 url
545 }
546 }
547
548 return VideoPlaylistModel
549 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
550 .findOne(query)
551 }
552
553 static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
554 return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
555 }
556
557 static getTypeLabel (type: VideoPlaylistType) {
558 return VIDEO_PLAYLIST_TYPES[type] || 'Unknown'
559 }
560
561 static resetPlaylistsOfChannel (videoChannelId: number, transaction: Transaction) {
562 const query = {
563 where: {
564 videoChannelId
565 },
566 transaction
567 }
568
569 return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query)
570 }
571
572 async setAndSaveThumbnail (thumbnail: MThumbnail, t: Transaction) {
573 thumbnail.videoPlaylistId = this.id
574
575 this.Thumbnail = await thumbnail.save({ transaction: t })
576 }
577
578 hasThumbnail () {
579 return !!this.Thumbnail
580 }
581
582 hasGeneratedThumbnail () {
583 return this.hasThumbnail() && this.Thumbnail.automaticallyGenerated === true
584 }
585
586 generateThumbnailName () {
587 const extension = '.jpg'
588
589 return 'playlist-' + buildUUID() + extension
590 }
591
592 getThumbnailUrl () {
593 if (!this.hasThumbnail()) return null
594
595 return WEBSERVER.URL + LAZY_STATIC_PATHS.THUMBNAILS + this.Thumbnail.filename
596 }
597
598 getThumbnailStaticPath () {
599 if (!this.hasThumbnail()) return null
600
601 return join(LAZY_STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename)
602 }
603
604 getWatchStaticPath () {
605 return buildPlaylistWatchPath({ shortUUID: uuidToShort(this.uuid) })
606 }
607
608 getEmbedStaticPath () {
609 return buildPlaylistEmbedPath(this)
610 }
611
612 static async getStats () {
613 const totalLocalPlaylists = await VideoPlaylistModel.count({
614 include: [
615 {
616 model: AccountModel.unscoped(),
617 required: true,
618 include: [
619 {
620 model: ActorModel.unscoped(),
621 required: true,
622 where: {
623 serverId: null
624 }
625 }
626 ]
627 }
628 ],
629 where: {
630 privacy: VideoPlaylistPrivacy.PUBLIC
631 }
632 })
633
634 return {
635 totalLocalPlaylists
636 }
637 }
638
639 setAsRefreshed () {
640 return setAsUpdated({ sequelize: this.sequelize, table: 'videoPlaylist', id: this.id })
641 }
642
643 setVideosLength (videosLength: number) {
644 this.set('videosLength' as any, videosLength, { raw: true })
645 }
646
647 isOwned () {
648 return this.OwnerAccount.isOwned()
649 }
650
651 isOutdated () {
652 if (this.isOwned()) return false
653
654 return isOutdated(this, ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL)
655 }
656
657 toFormattedJSON (this: MVideoPlaylistFormattable): VideoPlaylist {
658 return {
659 id: this.id,
660 uuid: this.uuid,
661 shortUUID: uuidToShort(this.uuid),
662
663 isLocal: this.isOwned(),
664
665 url: this.url,
666
667 displayName: this.name,
668 description: this.description,
669 privacy: {
670 id: this.privacy,
671 label: VideoPlaylistModel.getPrivacyLabel(this.privacy)
672 },
673
674 thumbnailPath: this.getThumbnailStaticPath(),
675 embedPath: this.getEmbedStaticPath(),
676
677 type: {
678 id: this.type,
679 label: VideoPlaylistModel.getTypeLabel(this.type)
680 },
681
682 videosLength: this.get('videosLength') as number,
683
684 createdAt: this.createdAt,
685 updatedAt: this.updatedAt,
686
687 ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(),
688 videoChannel: this.VideoChannel
689 ? this.VideoChannel.toFormattedSummaryJSON()
690 : null
691 }
692 }
693
694 toActivityPubObject (this: MVideoPlaylistAP, page: number, t: Transaction): Promise<PlaylistObject> {
695 const handler = (start: number, count: number) => {
696 return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t)
697 }
698
699 let icon: ActivityIconObject
700 if (this.hasThumbnail()) {
701 icon = {
702 type: 'Image' as 'Image',
703 url: this.getThumbnailUrl(),
704 mediaType: 'image/jpeg' as 'image/jpeg',
705 width: THUMBNAILS_SIZE.width,
706 height: THUMBNAILS_SIZE.height
707 }
708 }
709
710 return activityPubCollectionPagination(this.url, handler, page)
711 .then(o => {
712 return Object.assign(o, {
713 type: 'Playlist' as 'Playlist',
714 name: this.name,
715 content: this.description,
716 mediaType: 'text/markdown' as 'text/markdown',
717 uuid: this.uuid,
718 published: this.createdAt.toISOString(),
719 updated: this.updatedAt.toISOString(),
720 attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],
721 icon
722 })
723 })
724 }
725}
diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts
deleted file mode 100644
index b4de2b20f..000000000
--- a/server/models/video/video-share.ts
+++ /dev/null
@@ -1,216 +0,0 @@
1import { literal, Op, QueryTypes, Transaction } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3import { forceNumber } from '@shared/core-utils'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
6import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
7import { MActorDefault, MActorFollowersUrl, MActorId } from '../../types/models'
8import { MVideoShareActor, MVideoShareFull } from '../../types/models/video'
9import { ActorModel } from '../actor/actor'
10import { buildLocalActorIdsIn, throwIfNotValid } from '../shared'
11import { VideoModel } from './video'
12
13enum ScopeNames {
14 FULL = 'FULL',
15 WITH_ACTOR = 'WITH_ACTOR'
16}
17
18@Scopes(() => ({
19 [ScopeNames.FULL]: {
20 include: [
21 {
22 model: ActorModel,
23 required: true
24 },
25 {
26 model: VideoModel,
27 required: true
28 }
29 ]
30 },
31 [ScopeNames.WITH_ACTOR]: {
32 include: [
33 {
34 model: ActorModel,
35 required: true
36 }
37 ]
38 }
39}))
40@Table({
41 tableName: 'videoShare',
42 indexes: [
43 {
44 fields: [ 'actorId' ]
45 },
46 {
47 fields: [ 'videoId' ]
48 },
49 {
50 fields: [ 'url' ],
51 unique: true
52 }
53 ]
54})
55export class VideoShareModel extends Model<Partial<AttributesOnly<VideoShareModel>>> {
56
57 @AllowNull(false)
58 @Is('VideoShareUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
59 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_SHARE.URL.max))
60 url: string
61
62 @CreatedAt
63 createdAt: Date
64
65 @UpdatedAt
66 updatedAt: Date
67
68 @ForeignKey(() => ActorModel)
69 @Column
70 actorId: number
71
72 @BelongsTo(() => ActorModel, {
73 foreignKey: {
74 allowNull: false
75 },
76 onDelete: 'cascade'
77 })
78 Actor: ActorModel
79
80 @ForeignKey(() => VideoModel)
81 @Column
82 videoId: number
83
84 @BelongsTo(() => VideoModel, {
85 foreignKey: {
86 allowNull: false
87 },
88 onDelete: 'cascade'
89 })
90 Video: VideoModel
91
92 static load (actorId: number | string, videoId: number | string, t?: Transaction): Promise<MVideoShareActor> {
93 return VideoShareModel.scope(ScopeNames.WITH_ACTOR).findOne({
94 where: {
95 actorId,
96 videoId
97 },
98 transaction: t
99 })
100 }
101
102 static loadByUrl (url: string, t: Transaction): Promise<MVideoShareFull> {
103 return VideoShareModel.scope(ScopeNames.FULL).findOne({
104 where: {
105 url
106 },
107 transaction: t
108 })
109 }
110
111 static listActorIdsAndFollowerUrlsByShare (videoId: number, t: Transaction) {
112 const query = `SELECT "actor"."id" AS "id", "actor"."followersUrl" AS "followersUrl" ` +
113 `FROM "videoShare" ` +
114 `INNER JOIN "actor" ON "actor"."id" = "videoShare"."actorId" ` +
115 `WHERE "videoShare"."videoId" = :videoId`
116
117 const options = {
118 type: QueryTypes.SELECT as QueryTypes.SELECT,
119 replacements: { videoId },
120 transaction: t
121 }
122
123 return VideoShareModel.sequelize.query<MActorId & MActorFollowersUrl>(query, options)
124 }
125
126 static loadActorsWhoSharedVideosOf (actorOwnerId: number, t: Transaction): Promise<MActorDefault[]> {
127 const safeOwnerId = forceNumber(actorOwnerId)
128
129 // /!\ On actor model
130 const query = {
131 where: {
132 [Op.and]: [
133 literal(
134 `EXISTS (` +
135 ` SELECT 1 FROM "videoShare" ` +
136 ` INNER JOIN "video" ON "videoShare"."videoId" = "video"."id" ` +
137 ` INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ` +
138 ` INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ` +
139 ` WHERE "videoShare"."actorId" = "ActorModel"."id" AND "account"."actorId" = ${safeOwnerId} ` +
140 ` LIMIT 1` +
141 `)`
142 )
143 ]
144 },
145 transaction: t
146 }
147
148 return ActorModel.findAll(query)
149 }
150
151 static loadActorsByVideoChannel (videoChannelId: number, t: Transaction): Promise<MActorDefault[]> {
152 const safeChannelId = forceNumber(videoChannelId)
153
154 // /!\ On actor model
155 const query = {
156 where: {
157 [Op.and]: [
158 literal(
159 `EXISTS (` +
160 ` SELECT 1 FROM "videoShare" ` +
161 ` INNER JOIN "video" ON "videoShare"."videoId" = "video"."id" ` +
162 ` WHERE "videoShare"."actorId" = "ActorModel"."id" AND "video"."channelId" = ${safeChannelId} ` +
163 ` LIMIT 1` +
164 `)`
165 )
166 ]
167 },
168 transaction: t
169 }
170
171 return ActorModel.findAll(query)
172 }
173
174 static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Transaction) {
175 const query = {
176 offset: start,
177 limit: count,
178 where: {
179 videoId
180 },
181 transaction: t
182 }
183
184 return Promise.all([
185 VideoShareModel.count(query),
186 VideoShareModel.findAll(query)
187 ]).then(([ total, data ]) => ({ total, data }))
188 }
189
190 static listRemoteShareUrlsOfLocalVideos () {
191 const query = `SELECT "videoShare".url FROM "videoShare" ` +
192 `INNER JOIN actor ON actor.id = "videoShare"."actorId" AND actor."serverId" IS NOT NULL ` +
193 `INNER JOIN video ON video.id = "videoShare"."videoId" AND video.remote IS FALSE`
194
195 return VideoShareModel.sequelize.query<{ url: string }>(query, {
196 type: QueryTypes.SELECT,
197 raw: true
198 }).then(rows => rows.map(r => r.url))
199 }
200
201 static cleanOldSharesOf (videoId: number, beforeUpdatedAt: Date) {
202 const query = {
203 where: {
204 updatedAt: {
205 [Op.lt]: beforeUpdatedAt
206 },
207 videoId,
208 actorId: {
209 [Op.notIn]: buildLocalActorIdsIn()
210 }
211 }
212 }
213
214 return VideoShareModel.destroy(query)
215 }
216}
diff --git a/server/models/video/video-source.ts b/server/models/video/video-source.ts
deleted file mode 100644
index 1b6868b85..000000000
--- a/server/models/video/video-source.ts
+++ /dev/null
@@ -1,56 +0,0 @@
1import { Transaction } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { VideoSource } from '@shared/models/videos/video-source'
4import { AttributesOnly } from '@shared/typescript-utils'
5import { getSort } from '../shared'
6import { VideoModel } from './video'
7
8@Table({
9 tableName: 'videoSource',
10 indexes: [
11 {
12 fields: [ 'videoId' ]
13 },
14 {
15 fields: [ { name: 'createdAt', order: 'DESC' } ]
16 }
17 ]
18})
19export class VideoSourceModel extends Model<Partial<AttributesOnly<VideoSourceModel>>> {
20 @CreatedAt
21 createdAt: Date
22
23 @UpdatedAt
24 updatedAt: Date
25
26 @AllowNull(false)
27 @Column
28 filename: string
29
30 @ForeignKey(() => VideoModel)
31 @Column
32 videoId: number
33
34 @BelongsTo(() => VideoModel, {
35 foreignKey: {
36 allowNull: false
37 },
38 onDelete: 'cascade'
39 })
40 Video: VideoModel
41
42 static loadLatest (videoId: number, transaction?: Transaction) {
43 return VideoSourceModel.findOne({
44 where: { videoId },
45 order: getSort('-createdAt'),
46 transaction
47 })
48 }
49
50 toFormattedJSON (): VideoSource {
51 return {
52 filename: this.filename,
53 createdAt: this.createdAt.toISOString()
54 }
55 }
56}
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
deleted file mode 100644
index a85c79c9f..000000000
--- a/server/models/video/video-streaming-playlist.ts
+++ /dev/null
@@ -1,328 +0,0 @@
1import memoizee from 'memoizee'
2import { join } from 'path'
3import { Op, Transaction } from 'sequelize'
4import {
5 AllowNull,
6 BelongsTo,
7 Column,
8 CreatedAt,
9 DataType,
10 Default,
11 ForeignKey,
12 HasMany,
13 Is,
14 Model,
15 Table,
16 UpdatedAt
17} from 'sequelize-typescript'
18import { CONFIG } from '@server/initializers/config'
19import { getHLSPrivateFileUrl, getHLSPublicFileUrl } from '@server/lib/object-storage'
20import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename } from '@server/lib/paths'
21import { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
22import { VideoFileModel } from '@server/models/video/video-file'
23import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models'
24import { sha1 } from '@shared/extra-utils'
25import { VideoStorage } from '@shared/models'
26import { AttributesOnly } from '@shared/typescript-utils'
27import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
28import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
29import { isArrayOf } from '../../helpers/custom-validators/misc'
30import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
31import {
32 CONSTRAINTS_FIELDS,
33 MEMOIZE_LENGTH,
34 MEMOIZE_TTL,
35 P2P_MEDIA_LOADER_PEER_VERSION,
36 STATIC_PATHS,
37 WEBSERVER
38} from '../../initializers/constants'
39import { VideoRedundancyModel } from '../redundancy/video-redundancy'
40import { doesExist, throwIfNotValid } from '../shared'
41import { VideoModel } from './video'
42
43@Table({
44 tableName: 'videoStreamingPlaylist',
45 indexes: [
46 {
47 fields: [ 'videoId' ]
48 },
49 {
50 fields: [ 'videoId', 'type' ],
51 unique: true
52 },
53 {
54 fields: [ 'p2pMediaLoaderInfohashes' ],
55 using: 'gin'
56 }
57 ]
58})
59export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<VideoStreamingPlaylistModel>>> {
60 @CreatedAt
61 createdAt: Date
62
63 @UpdatedAt
64 updatedAt: Date
65
66 @AllowNull(false)
67 @Column
68 type: VideoStreamingPlaylistType
69
70 @AllowNull(false)
71 @Column
72 playlistFilename: string
73
74 @AllowNull(true)
75 @Is('PlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'playlist url', true))
76 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
77 playlistUrl: string
78
79 @AllowNull(false)
80 @Is('VideoStreamingPlaylistInfoHashes', value => throwIfNotValid(value, v => isArrayOf(v, isVideoFileInfoHashValid), 'info hashes'))
81 @Column(DataType.ARRAY(DataType.STRING))
82 p2pMediaLoaderInfohashes: string[]
83
84 @AllowNull(false)
85 @Column
86 p2pMediaLoaderPeerVersion: number
87
88 @AllowNull(false)
89 @Column
90 segmentsSha256Filename: string
91
92 @AllowNull(true)
93 @Is('VideoStreamingSegmentsSha256Url', value => throwIfNotValid(value, isActivityPubUrlValid, 'segments sha256 url', true))
94 @Column
95 segmentsSha256Url: string
96
97 @ForeignKey(() => VideoModel)
98 @Column
99 videoId: number
100
101 @AllowNull(false)
102 @Default(VideoStorage.FILE_SYSTEM)
103 @Column
104 storage: VideoStorage
105
106 @BelongsTo(() => VideoModel, {
107 foreignKey: {
108 allowNull: false
109 },
110 onDelete: 'CASCADE'
111 })
112 Video: VideoModel
113
114 @HasMany(() => VideoFileModel, {
115 foreignKey: {
116 allowNull: true
117 },
118 onDelete: 'CASCADE'
119 })
120 VideoFiles: VideoFileModel[]
121
122 @HasMany(() => VideoRedundancyModel, {
123 foreignKey: {
124 allowNull: false
125 },
126 onDelete: 'CASCADE',
127 hooks: true
128 })
129 RedundancyVideos: VideoRedundancyModel[]
130
131 static doesInfohashExistCached = memoizee(VideoStreamingPlaylistModel.doesInfohashExist, {
132 promise: true,
133 max: MEMOIZE_LENGTH.INFO_HASH_EXISTS,
134 maxAge: MEMOIZE_TTL.INFO_HASH_EXISTS
135 })
136
137 static doesInfohashExist (infoHash: string) {
138 const query = 'SELECT 1 FROM "videoStreamingPlaylist" WHERE $infoHash = ANY("p2pMediaLoaderInfohashes") LIMIT 1'
139
140 return doesExist(this.sequelize, query, { infoHash })
141 }
142
143 static buildP2PMediaLoaderInfoHashes (playlistUrl: string, files: unknown[]) {
144 const hashes: string[] = []
145
146 // https://github.com/Novage/p2p-media-loader/blob/master/p2p-media-loader-core/lib/p2p-media-manager.ts#L115
147 for (let i = 0; i < files.length; i++) {
148 hashes.push(sha1(`${P2P_MEDIA_LOADER_PEER_VERSION}${playlistUrl}+V${i}`))
149 }
150
151 return hashes
152 }
153
154 static listByIncorrectPeerVersion () {
155 const query = {
156 where: {
157 p2pMediaLoaderPeerVersion: {
158 [Op.ne]: P2P_MEDIA_LOADER_PEER_VERSION
159 }
160 },
161 include: [
162 {
163 model: VideoModel.unscoped(),
164 required: true
165 }
166 ]
167 }
168
169 return VideoStreamingPlaylistModel.findAll(query)
170 }
171
172 static loadWithVideoAndFiles (id: number) {
173 const options = {
174 include: [
175 {
176 model: VideoModel.unscoped(),
177 required: true
178 },
179 {
180 model: VideoFileModel.unscoped()
181 }
182 ]
183 }
184
185 return VideoStreamingPlaylistModel.findByPk<MStreamingPlaylistFilesVideo>(id, options)
186 }
187
188 static loadWithVideo (id: number) {
189 const options = {
190 include: [
191 {
192 model: VideoModel.unscoped(),
193 required: true
194 }
195 ]
196 }
197
198 return VideoStreamingPlaylistModel.findByPk(id, options)
199 }
200
201 static loadHLSPlaylistByVideo (videoId: number, transaction?: Transaction): Promise<MStreamingPlaylist> {
202 const options = {
203 where: {
204 type: VideoStreamingPlaylistType.HLS,
205 videoId
206 },
207 transaction
208 }
209
210 return VideoStreamingPlaylistModel.findOne(options)
211 }
212
213 static async loadOrGenerate (video: MVideo, transaction?: Transaction) {
214 let playlist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id, transaction)
215
216 if (!playlist) {
217 playlist = new VideoStreamingPlaylistModel({
218 p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
219 type: VideoStreamingPlaylistType.HLS,
220 storage: VideoStorage.FILE_SYSTEM,
221 p2pMediaLoaderInfohashes: [],
222 playlistFilename: generateHLSMasterPlaylistFilename(video.isLive),
223 segmentsSha256Filename: generateHlsSha256SegmentsFilename(video.isLive),
224 videoId: video.id
225 })
226
227 await playlist.save({ transaction })
228 }
229
230 return Object.assign(playlist, { Video: video })
231 }
232
233 static doesOwnedHLSPlaylistExist (videoUUID: string) {
234 const query = `SELECT 1 FROM "videoStreamingPlaylist" ` +
235 `INNER JOIN "video" ON "video"."id" = "videoStreamingPlaylist"."videoId" ` +
236 `AND "video"."remote" IS FALSE AND "video"."uuid" = $videoUUID ` +
237 `AND "storage" = ${VideoStorage.FILE_SYSTEM} LIMIT 1`
238
239 return doesExist(this.sequelize, query, { videoUUID })
240 }
241
242 assignP2PMediaLoaderInfoHashes (video: MVideo, files: unknown[]) {
243 const masterPlaylistUrl = this.getMasterPlaylistUrl(video)
244
245 this.p2pMediaLoaderInfohashes = VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(masterPlaylistUrl, files)
246 }
247
248 // ---------------------------------------------------------------------------
249
250 getMasterPlaylistUrl (video: MVideo) {
251 if (video.isOwned()) {
252 if (this.storage === VideoStorage.OBJECT_STORAGE) {
253 return this.getMasterPlaylistObjectStorageUrl(video)
254 }
255
256 return WEBSERVER.URL + this.getMasterPlaylistStaticPath(video)
257 }
258
259 return this.playlistUrl
260 }
261
262 private getMasterPlaylistObjectStorageUrl (video: MVideo) {
263 if (video.hasPrivateStaticPath() && CONFIG.OBJECT_STORAGE.PROXY.PROXIFY_PRIVATE_FILES === true) {
264 return getHLSPrivateFileUrl(video, this.playlistFilename)
265 }
266
267 return getHLSPublicFileUrl(this.playlistUrl)
268 }
269
270 // ---------------------------------------------------------------------------
271
272 getSha256SegmentsUrl (video: MVideo) {
273 if (video.isOwned()) {
274 if (this.storage === VideoStorage.OBJECT_STORAGE) {
275 return this.getSha256SegmentsObjectStorageUrl(video)
276 }
277
278 return WEBSERVER.URL + this.getSha256SegmentsStaticPath(video)
279 }
280
281 return this.segmentsSha256Url
282 }
283
284 private getSha256SegmentsObjectStorageUrl (video: MVideo) {
285 if (video.hasPrivateStaticPath() && CONFIG.OBJECT_STORAGE.PROXY.PROXIFY_PRIVATE_FILES === true) {
286 return getHLSPrivateFileUrl(video, this.segmentsSha256Filename)
287 }
288
289 return getHLSPublicFileUrl(this.segmentsSha256Url)
290 }
291
292 // ---------------------------------------------------------------------------
293
294 getStringType () {
295 if (this.type === VideoStreamingPlaylistType.HLS) return 'hls'
296
297 return 'unknown'
298 }
299
300 getTrackerUrls (baseUrlHttp: string, baseUrlWs: string) {
301 return [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
302 }
303
304 hasSameUniqueKeysThan (other: MStreamingPlaylist) {
305 return this.type === other.type &&
306 this.videoId === other.videoId
307 }
308
309 withVideo (video: MVideo) {
310 return Object.assign(this, { Video: video })
311 }
312
313 private getMasterPlaylistStaticPath (video: MVideo) {
314 if (isVideoInPrivateDirectory(video.privacy)) {
315 return join(STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS, video.uuid, this.playlistFilename)
316 }
317
318 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, video.uuid, this.playlistFilename)
319 }
320
321 private getSha256SegmentsStaticPath (video: MVideo) {
322 if (isVideoInPrivateDirectory(video.privacy)) {
323 return join(STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS, video.uuid, this.segmentsSha256Filename)
324 }
325
326 return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, video.uuid, this.segmentsSha256Filename)
327 }
328}
diff --git a/server/models/video/video-tag.ts b/server/models/video/video-tag.ts
deleted file mode 100644
index 7e880c968..000000000
--- a/server/models/video/video-tag.ts
+++ /dev/null
@@ -1,31 +0,0 @@
1import { Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { AttributesOnly } from '@shared/typescript-utils'
3import { TagModel } from './tag'
4import { VideoModel } from './video'
5
6@Table({
7 tableName: 'videoTag',
8 indexes: [
9 {
10 fields: [ 'videoId' ]
11 },
12 {
13 fields: [ 'tagId' ]
14 }
15 ]
16})
17export class VideoTagModel extends Model<Partial<AttributesOnly<VideoTagModel>>> {
18 @CreatedAt
19 createdAt: Date
20
21 @UpdatedAt
22 updatedAt: Date
23
24 @ForeignKey(() => VideoModel)
25 @Column
26 videoId: number
27
28 @ForeignKey(() => TagModel)
29 @Column
30 tagId: number
31}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
deleted file mode 100644
index 73308182d..000000000
--- a/server/models/video/video.ts
+++ /dev/null
@@ -1,2047 +0,0 @@
1import Bluebird from 'bluebird'
2import { remove } from 'fs-extra'
3import { maxBy, minBy } from 'lodash'
4import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
5import {
6 AfterCreate,
7 AfterDestroy,
8 AfterUpdate,
9 AllowNull,
10 BeforeDestroy,
11 BelongsTo,
12 BelongsToMany,
13 Column,
14 CreatedAt,
15 DataType,
16 Default,
17 ForeignKey,
18 HasMany,
19 HasOne,
20 Is,
21 IsInt,
22 IsUUID,
23 Min,
24 Model,
25 Scopes,
26 Table,
27 UpdatedAt
28} from 'sequelize-typescript'
29import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
30import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
31import { LiveManager } from '@server/lib/live/live-manager'
32import { removeHLSFileObjectStorageByFilename, removeHLSObjectStorage, removeWebVideoObjectStorage } from '@server/lib/object-storage'
33import { tracer } from '@server/lib/opentelemetry/tracing'
34import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths'
35import { Hooks } from '@server/lib/plugins/hooks'
36import { VideoPathManager } from '@server/lib/video-path-manager'
37import { isVideoInPrivateDirectory } from '@server/lib/video-privacy'
38import { getServerActor } from '@server/models/application/application'
39import { ModelCache } from '@server/models/shared/model-cache'
40import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
41import { uuidToShort } from '@shared/extra-utils'
42import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream } from '@shared/ffmpeg'
43import {
44 ResultList,
45 ThumbnailType,
46 UserRight,
47 Video,
48 VideoDetails,
49 VideoFile,
50 VideoInclude,
51 VideoObject,
52 VideoPrivacy,
53 VideoRateType,
54 VideoState,
55 VideoStorage,
56 VideoStreamingPlaylistType
57} from '@shared/models'
58import { AttributesOnly } from '@shared/typescript-utils'
59import { peertubeTruncate } from '../../helpers/core-utils'
60import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
61import { exists, isArray, isBooleanValid, isUUIDValid } from '../../helpers/custom-validators/misc'
62import {
63 isVideoDescriptionValid,
64 isVideoDurationValid,
65 isVideoNameValid,
66 isVideoPrivacyValid,
67 isVideoStateValid,
68 isVideoSupportValid
69} from '../../helpers/custom-validators/videos'
70import { logger } from '../../helpers/logger'
71import { CONFIG } from '../../initializers/config'
72import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
73import { sendDeleteVideo } from '../../lib/activitypub/send'
74import {
75 MChannel,
76 MChannelAccountDefault,
77 MChannelId,
78 MStoryboard,
79 MStreamingPlaylist,
80 MStreamingPlaylistFilesVideo,
81 MUserAccountId,
82 MUserId,
83 MVideo,
84 MVideoAccountLight,
85 MVideoAccountLightBlacklistAllFiles,
86 MVideoAP,
87 MVideoAPLight,
88 MVideoCaptionLanguageUrl,
89 MVideoDetails,
90 MVideoFileVideo,
91 MVideoFormattable,
92 MVideoFormattableDetails,
93 MVideoForUser,
94 MVideoFullLight,
95 MVideoId,
96 MVideoImmutable,
97 MVideoThumbnail,
98 MVideoThumbnailBlacklist,
99 MVideoWithAllFiles,
100 MVideoWithFile
101} from '../../types/models'
102import { MThumbnail } from '../../types/models/video/thumbnail'
103import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file'
104import { VideoAbuseModel } from '../abuse/video-abuse'
105import { AccountModel } from '../account/account'
106import { AccountVideoRateModel } from '../account/account-video-rate'
107import { ActorModel } from '../actor/actor'
108import { ActorImageModel } from '../actor/actor-image'
109import { VideoRedundancyModel } from '../redundancy/video-redundancy'
110import { ServerModel } from '../server/server'
111import { TrackerModel } from '../server/tracker'
112import { VideoTrackerModel } from '../server/video-tracker'
113import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, setAsUpdated, throwIfNotValid } from '../shared'
114import { UserModel } from '../user/user'
115import { UserVideoHistoryModel } from '../user/user-video-history'
116import { VideoViewModel } from '../view/video-view'
117import { videoModelToActivityPubObject } from './formatter/video-activity-pub-format'
118import {
119 videoFilesModelToFormattedJSON,
120 VideoFormattingJSONOptions,
121 videoModelToFormattedDetailsJSON,
122 videoModelToFormattedJSON
123} from './formatter/video-api-format'
124import { ScheduleVideoUpdateModel } from './schedule-video-update'
125import {
126 BuildVideosListQueryOptions,
127 DisplayOnlyForFollowerOptions,
128 VideoModelGetQueryBuilder,
129 VideosIdListQueryBuilder,
130 VideosModelListQueryBuilder
131} from './sql/video'
132import { StoryboardModel } from './storyboard'
133import { TagModel } from './tag'
134import { ThumbnailModel } from './thumbnail'
135import { VideoBlacklistModel } from './video-blacklist'
136import { VideoCaptionModel } from './video-caption'
137import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
138import { VideoCommentModel } from './video-comment'
139import { VideoFileModel } from './video-file'
140import { VideoImportModel } from './video-import'
141import { VideoJobInfoModel } from './video-job-info'
142import { VideoLiveModel } from './video-live'
143import { VideoPasswordModel } from './video-password'
144import { VideoPlaylistElementModel } from './video-playlist-element'
145import { VideoShareModel } from './video-share'
146import { VideoSourceModel } from './video-source'
147import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
148import { VideoTagModel } from './video-tag'
149
150export enum ScopeNames {
151 FOR_API = 'FOR_API',
152 WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
153 WITH_TAGS = 'WITH_TAGS',
154 WITH_WEB_VIDEO_FILES = 'WITH_WEB_VIDEO_FILES',
155 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
156 WITH_BLACKLISTED = 'WITH_BLACKLISTED',
157 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
158 WITH_IMMUTABLE_ATTRIBUTES = 'WITH_IMMUTABLE_ATTRIBUTES',
159 WITH_USER_HISTORY = 'WITH_USER_HISTORY',
160 WITH_THUMBNAILS = 'WITH_THUMBNAILS'
161}
162
163export type ForAPIOptions = {
164 ids?: number[]
165
166 videoPlaylistId?: number
167
168 withAccountBlockerIds?: number[]
169}
170
171@Scopes(() => ({
172 [ScopeNames.WITH_IMMUTABLE_ATTRIBUTES]: {
173 attributes: [ 'id', 'url', 'uuid', 'remote' ]
174 },
175 [ScopeNames.FOR_API]: (options: ForAPIOptions) => {
176 const include: Includeable[] = [
177 {
178 model: VideoChannelModel.scope({
179 method: [
180 VideoChannelScopeNames.SUMMARY, {
181 withAccount: true,
182 withAccountBlockerIds: options.withAccountBlockerIds
183 } as SummaryOptions
184 ]
185 }),
186 required: true
187 },
188 {
189 attributes: [ 'type', 'filename' ],
190 model: ThumbnailModel,
191 required: false
192 }
193 ]
194
195 const query: FindOptions = {}
196
197 if (options.ids) {
198 query.where = {
199 id: {
200 [Op.in]: options.ids
201 }
202 }
203 }
204
205 if (options.videoPlaylistId) {
206 include.push({
207 model: VideoPlaylistElementModel.unscoped(),
208 required: true,
209 where: {
210 videoPlaylistId: options.videoPlaylistId
211 }
212 })
213 }
214
215 query.include = include
216
217 return query
218 },
219 [ScopeNames.WITH_THUMBNAILS]: {
220 include: [
221 {
222 model: ThumbnailModel,
223 required: false
224 }
225 ]
226 },
227 [ScopeNames.WITH_ACCOUNT_DETAILS]: {
228 include: [
229 {
230 model: VideoChannelModel.unscoped(),
231 required: true,
232 include: [
233 {
234 attributes: {
235 exclude: [ 'privateKey', 'publicKey' ]
236 },
237 model: ActorModel.unscoped(),
238 required: true,
239 include: [
240 {
241 attributes: [ 'host' ],
242 model: ServerModel.unscoped(),
243 required: false
244 },
245 {
246 model: ActorImageModel,
247 as: 'Avatars',
248 required: false
249 }
250 ]
251 },
252 {
253 model: AccountModel.unscoped(),
254 required: true,
255 include: [
256 {
257 model: ActorModel.unscoped(),
258 attributes: {
259 exclude: [ 'privateKey', 'publicKey' ]
260 },
261 required: true,
262 include: [
263 {
264 attributes: [ 'host' ],
265 model: ServerModel.unscoped(),
266 required: false
267 },
268 {
269 model: ActorImageModel,
270 as: 'Avatars',
271 required: false
272 }
273 ]
274 }
275 ]
276 }
277 ]
278 }
279 ]
280 },
281 [ScopeNames.WITH_TAGS]: {
282 include: [ TagModel ]
283 },
284 [ScopeNames.WITH_BLACKLISTED]: {
285 include: [
286 {
287 attributes: [ 'id', 'reason', 'unfederated' ],
288 model: VideoBlacklistModel,
289 required: false
290 }
291 ]
292 },
293 [ScopeNames.WITH_WEB_VIDEO_FILES]: (withRedundancies = false) => {
294 let subInclude: any[] = []
295
296 if (withRedundancies === true) {
297 subInclude = [
298 {
299 attributes: [ 'fileUrl' ],
300 model: VideoRedundancyModel.unscoped(),
301 required: false
302 }
303 ]
304 }
305
306 return {
307 include: [
308 {
309 model: VideoFileModel,
310 separate: true,
311 required: false,
312 include: subInclude
313 }
314 ]
315 }
316 },
317 [ScopeNames.WITH_STREAMING_PLAYLISTS]: (withRedundancies = false) => {
318 const subInclude: IncludeOptions[] = [
319 {
320 model: VideoFileModel,
321 required: false
322 }
323 ]
324
325 if (withRedundancies === true) {
326 subInclude.push({
327 attributes: [ 'fileUrl' ],
328 model: VideoRedundancyModel.unscoped(),
329 required: false
330 })
331 }
332
333 return {
334 include: [
335 {
336 model: VideoStreamingPlaylistModel.unscoped(),
337 required: false,
338 separate: true,
339 include: subInclude
340 }
341 ]
342 }
343 },
344 [ScopeNames.WITH_SCHEDULED_UPDATE]: {
345 include: [
346 {
347 model: ScheduleVideoUpdateModel.unscoped(),
348 required: false
349 }
350 ]
351 },
352 [ScopeNames.WITH_USER_HISTORY]: (userId: number) => {
353 return {
354 include: [
355 {
356 attributes: [ 'currentTime' ],
357 model: UserVideoHistoryModel.unscoped(),
358 required: false,
359 where: {
360 userId
361 }
362 }
363 ]
364 }
365 }
366}))
367@Table({
368 tableName: 'video',
369 indexes: [
370 buildTrigramSearchIndex('video_name_trigram', 'name'),
371
372 { fields: [ 'createdAt' ] },
373 {
374 fields: [
375 { name: 'publishedAt', order: 'DESC' },
376 { name: 'id', order: 'ASC' }
377 ]
378 },
379 { fields: [ 'duration' ] },
380 {
381 fields: [
382 { name: 'views', order: 'DESC' },
383 { name: 'id', order: 'ASC' }
384 ]
385 },
386 { fields: [ 'channelId' ] },
387 {
388 fields: [ 'originallyPublishedAt' ],
389 where: {
390 originallyPublishedAt: {
391 [Op.ne]: null
392 }
393 }
394 },
395 {
396 fields: [ 'category' ], // We don't care videos with an unknown category
397 where: {
398 category: {
399 [Op.ne]: null
400 }
401 }
402 },
403 {
404 fields: [ 'licence' ], // We don't care videos with an unknown licence
405 where: {
406 licence: {
407 [Op.ne]: null
408 }
409 }
410 },
411 {
412 fields: [ 'language' ], // We don't care videos with an unknown language
413 where: {
414 language: {
415 [Op.ne]: null
416 }
417 }
418 },
419 {
420 fields: [ 'nsfw' ], // Most of the videos are not NSFW
421 where: {
422 nsfw: true
423 }
424 },
425 {
426 fields: [ 'remote' ], // Only index local videos
427 where: {
428 remote: false
429 }
430 },
431 {
432 fields: [ 'uuid' ],
433 unique: true
434 },
435 {
436 fields: [ 'url' ],
437 unique: true
438 }
439 ]
440})
441export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
442
443 @AllowNull(false)
444 @Default(DataType.UUIDV4)
445 @IsUUID(4)
446 @Column(DataType.UUID)
447 uuid: string
448
449 @AllowNull(false)
450 @Is('VideoName', value => throwIfNotValid(value, isVideoNameValid, 'name'))
451 @Column
452 name: string
453
454 @AllowNull(true)
455 @Default(null)
456 @Column
457 category: number
458
459 @AllowNull(true)
460 @Default(null)
461 @Column
462 licence: number
463
464 @AllowNull(true)
465 @Default(null)
466 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max))
467 language: string
468
469 @AllowNull(false)
470 @Is('VideoPrivacy', value => throwIfNotValid(value, isVideoPrivacyValid, 'privacy'))
471 @Column
472 privacy: VideoPrivacy
473
474 @AllowNull(false)
475 @Is('VideoNSFW', value => throwIfNotValid(value, isBooleanValid, 'NSFW boolean'))
476 @Column
477 nsfw: boolean
478
479 @AllowNull(true)
480 @Default(null)
481 @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description', true))
482 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max))
483 description: string
484
485 @AllowNull(true)
486 @Default(null)
487 @Is('VideoSupport', value => throwIfNotValid(value, isVideoSupportValid, 'support', true))
488 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.SUPPORT.max))
489 support: string
490
491 @AllowNull(false)
492 @Is('VideoDuration', value => throwIfNotValid(value, isVideoDurationValid, 'duration'))
493 @Column
494 duration: number
495
496 @AllowNull(false)
497 @Default(0)
498 @IsInt
499 @Min(0)
500 @Column
501 views: number
502
503 @AllowNull(false)
504 @Default(0)
505 @IsInt
506 @Min(0)
507 @Column
508 likes: number
509
510 @AllowNull(false)
511 @Default(0)
512 @IsInt
513 @Min(0)
514 @Column
515 dislikes: number
516
517 @AllowNull(false)
518 @Column
519 remote: boolean
520
521 @AllowNull(false)
522 @Default(false)
523 @Column
524 isLive: boolean
525
526 @AllowNull(false)
527 @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
528 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
529 url: string
530
531 @AllowNull(false)
532 @Column
533 commentsEnabled: boolean
534
535 @AllowNull(false)
536 @Column
537 downloadEnabled: boolean
538
539 @AllowNull(false)
540 @Column
541 waitTranscoding: boolean
542
543 @AllowNull(false)
544 @Default(null)
545 @Is('VideoState', value => throwIfNotValid(value, isVideoStateValid, 'state'))
546 @Column
547 state: VideoState
548
549 // We already have the information in videoSource table for local videos, but we prefer to normalize it for performance
550 // And also to store the info from remote instances
551 @AllowNull(true)
552 @Column
553 inputFileUpdatedAt: Date
554
555 @CreatedAt
556 createdAt: Date
557
558 @UpdatedAt
559 updatedAt: Date
560
561 @AllowNull(false)
562 @Default(DataType.NOW)
563 @Column
564 publishedAt: Date
565
566 @AllowNull(true)
567 @Default(null)
568 @Column
569 originallyPublishedAt: Date
570
571 @ForeignKey(() => VideoChannelModel)
572 @Column
573 channelId: number
574
575 @BelongsTo(() => VideoChannelModel, {
576 foreignKey: {
577 allowNull: true
578 },
579 onDelete: 'cascade'
580 })
581 VideoChannel: VideoChannelModel
582
583 @BelongsToMany(() => TagModel, {
584 foreignKey: 'videoId',
585 through: () => VideoTagModel,
586 onDelete: 'CASCADE'
587 })
588 Tags: TagModel[]
589
590 @BelongsToMany(() => TrackerModel, {
591 foreignKey: 'videoId',
592 through: () => VideoTrackerModel,
593 onDelete: 'CASCADE'
594 })
595 Trackers: TrackerModel[]
596
597 @HasMany(() => ThumbnailModel, {
598 foreignKey: {
599 name: 'videoId',
600 allowNull: true
601 },
602 hooks: true,
603 onDelete: 'cascade'
604 })
605 Thumbnails: ThumbnailModel[]
606
607 @HasMany(() => VideoPlaylistElementModel, {
608 foreignKey: {
609 name: 'videoId',
610 allowNull: true
611 },
612 onDelete: 'set null'
613 })
614 VideoPlaylistElements: VideoPlaylistElementModel[]
615
616 @HasOne(() => VideoSourceModel, {
617 foreignKey: {
618 name: 'videoId',
619 allowNull: false
620 },
621 onDelete: 'CASCADE'
622 })
623 VideoSource: VideoSourceModel
624
625 @HasMany(() => VideoAbuseModel, {
626 foreignKey: {
627 name: 'videoId',
628 allowNull: true
629 },
630 onDelete: 'set null'
631 })
632 VideoAbuses: VideoAbuseModel[]
633
634 @HasMany(() => VideoFileModel, {
635 foreignKey: {
636 name: 'videoId',
637 allowNull: true
638 },
639 hooks: true,
640 onDelete: 'cascade'
641 })
642 VideoFiles: VideoFileModel[]
643
644 @HasMany(() => VideoStreamingPlaylistModel, {
645 foreignKey: {
646 name: 'videoId',
647 allowNull: false
648 },
649 hooks: true,
650 onDelete: 'cascade'
651 })
652 VideoStreamingPlaylists: VideoStreamingPlaylistModel[]
653
654 @HasMany(() => VideoShareModel, {
655 foreignKey: {
656 name: 'videoId',
657 allowNull: false
658 },
659 onDelete: 'cascade'
660 })
661 VideoShares: VideoShareModel[]
662
663 @HasMany(() => AccountVideoRateModel, {
664 foreignKey: {
665 name: 'videoId',
666 allowNull: false
667 },
668 onDelete: 'cascade'
669 })
670 AccountVideoRates: AccountVideoRateModel[]
671
672 @HasMany(() => VideoCommentModel, {
673 foreignKey: {
674 name: 'videoId',
675 allowNull: false
676 },
677 onDelete: 'cascade',
678 hooks: true
679 })
680 VideoComments: VideoCommentModel[]
681
682 @HasMany(() => VideoViewModel, {
683 foreignKey: {
684 name: 'videoId',
685 allowNull: false
686 },
687 onDelete: 'cascade'
688 })
689 VideoViews: VideoViewModel[]
690
691 @HasMany(() => UserVideoHistoryModel, {
692 foreignKey: {
693 name: 'videoId',
694 allowNull: false
695 },
696 onDelete: 'cascade'
697 })
698 UserVideoHistories: UserVideoHistoryModel[]
699
700 @HasOne(() => ScheduleVideoUpdateModel, {
701 foreignKey: {
702 name: 'videoId',
703 allowNull: false
704 },
705 onDelete: 'cascade'
706 })
707 ScheduleVideoUpdate: ScheduleVideoUpdateModel
708
709 @HasOne(() => VideoBlacklistModel, {
710 foreignKey: {
711 name: 'videoId',
712 allowNull: false
713 },
714 onDelete: 'cascade'
715 })
716 VideoBlacklist: VideoBlacklistModel
717
718 @HasOne(() => VideoLiveModel, {
719 foreignKey: {
720 name: 'videoId',
721 allowNull: false
722 },
723 hooks: true,
724 onDelete: 'cascade'
725 })
726 VideoLive: VideoLiveModel
727
728 @HasOne(() => VideoImportModel, {
729 foreignKey: {
730 name: 'videoId',
731 allowNull: true
732 },
733 onDelete: 'set null'
734 })
735 VideoImport: VideoImportModel
736
737 @HasMany(() => VideoCaptionModel, {
738 foreignKey: {
739 name: 'videoId',
740 allowNull: false
741 },
742 onDelete: 'cascade',
743 hooks: true,
744 ['separate' as any]: true
745 })
746 VideoCaptions: VideoCaptionModel[]
747
748 @HasMany(() => VideoPasswordModel, {
749 foreignKey: {
750 name: 'videoId',
751 allowNull: false
752 },
753 onDelete: 'cascade'
754 })
755 VideoPasswords: VideoPasswordModel[]
756
757 @HasOne(() => VideoJobInfoModel, {
758 foreignKey: {
759 name: 'videoId',
760 allowNull: false
761 },
762 onDelete: 'cascade'
763 })
764 VideoJobInfo: VideoJobInfoModel
765
766 @HasOne(() => StoryboardModel, {
767 foreignKey: {
768 name: 'videoId',
769 allowNull: false
770 },
771 onDelete: 'cascade',
772 hooks: true
773 })
774 Storyboard: StoryboardModel
775
776 @AfterCreate
777 static notifyCreate (video: MVideo) {
778 InternalEventEmitter.Instance.emit('video-created', { video })
779 }
780
781 @AfterUpdate
782 static notifyUpdate (video: MVideo) {
783 InternalEventEmitter.Instance.emit('video-updated', { video })
784 }
785
786 @AfterDestroy
787 static notifyDestroy (video: MVideo) {
788 InternalEventEmitter.Instance.emit('video-deleted', { video })
789 }
790
791 @BeforeDestroy
792 static async sendDelete (instance: MVideoAccountLight, options: { transaction: Transaction }) {
793 if (!instance.isOwned()) return undefined
794
795 // Lazy load channels
796 if (!instance.VideoChannel) {
797 instance.VideoChannel = await instance.$get('VideoChannel', {
798 include: [
799 ActorModel,
800 AccountModel
801 ],
802 transaction: options.transaction
803 }) as MChannelAccountDefault
804 }
805
806 return sendDeleteVideo(instance, options.transaction)
807 }
808
809 @BeforeDestroy
810 static async removeFiles (instance: VideoModel, options) {
811 const tasks: Promise<any>[] = []
812
813 logger.info('Removing files of video %s.', instance.url)
814
815 if (instance.isOwned()) {
816 if (!Array.isArray(instance.VideoFiles)) {
817 instance.VideoFiles = await instance.$get('VideoFiles', { transaction: options.transaction })
818 }
819
820 // Remove physical files and torrents
821 instance.VideoFiles.forEach(file => {
822 tasks.push(instance.removeWebVideoFile(file))
823 })
824
825 // Remove playlists file
826 if (!Array.isArray(instance.VideoStreamingPlaylists)) {
827 instance.VideoStreamingPlaylists = await instance.$get('VideoStreamingPlaylists', { transaction: options.transaction })
828 }
829
830 for (const p of instance.VideoStreamingPlaylists) {
831 tasks.push(instance.removeStreamingPlaylistFiles(p))
832 }
833 }
834
835 // Do not wait video deletion because we could be in a transaction
836 Promise.all(tasks)
837 .then(() => logger.info('Removed files of video %s.', instance.url))
838 .catch(err => logger.error('Some errors when removing files of video %s in before destroy hook.', instance.uuid, { err }))
839
840 return undefined
841 }
842
843 @BeforeDestroy
844 static stopLiveIfNeeded (instance: VideoModel) {
845 if (!instance.isLive) return
846
847 logger.info('Stopping live of video %s after video deletion.', instance.uuid)
848
849 LiveManager.Instance.stopSessionOf(instance.uuid, null)
850 }
851
852 @BeforeDestroy
853 static invalidateCache (instance: VideoModel) {
854 ModelCache.Instance.invalidateCache('video', instance.id)
855 }
856
857 @BeforeDestroy
858 static async saveEssentialDataToAbuses (instance: VideoModel, options) {
859 const tasks: Promise<any>[] = []
860
861 if (!Array.isArray(instance.VideoAbuses)) {
862 instance.VideoAbuses = await instance.$get('VideoAbuses', { transaction: options.transaction })
863
864 if (instance.VideoAbuses.length === 0) return undefined
865 }
866
867 logger.info('Saving video abuses details of video %s.', instance.url)
868
869 if (!instance.Trackers) instance.Trackers = await instance.$get('Trackers', { transaction: options.transaction })
870 const details = instance.toFormattedDetailsJSON()
871
872 for (const abuse of instance.VideoAbuses) {
873 abuse.deletedVideo = details
874 tasks.push(abuse.save({ transaction: options.transaction }))
875 }
876
877 await Promise.all(tasks)
878 }
879
880 static listLocalIds (): Promise<number[]> {
881 const query = {
882 attributes: [ 'id' ],
883 raw: true,
884 where: {
885 remote: false
886 }
887 }
888
889 return VideoModel.findAll(query)
890 .then(rows => rows.map(r => r.id))
891 }
892
893 static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
894 function getRawQuery (select: string) {
895 const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' +
896 'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
897 'INNER JOIN "account" AS "Account" ON "Account"."id" = "VideoChannel"."accountId" ' +
898 'WHERE "Account"."actorId" = ' + actorId
899 const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
900 'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
901 'WHERE "VideoShare"."actorId" = ' + actorId
902
903 return `(${queryVideo}) UNION (${queryVideoShare})`
904 }
905
906 const rawQuery = getRawQuery('"Video"."id"')
907 const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
908
909 const query = {
910 distinct: true,
911 offset: start,
912 limit: count,
913 order: getVideoSort('-createdAt', [ 'Tags', 'name', 'ASC' ]),
914 where: {
915 id: {
916 [Op.in]: Sequelize.literal('(' + rawQuery + ')')
917 },
918 [Op.or]: getPrivaciesForFederation()
919 },
920 include: [
921 {
922 attributes: [ 'filename', 'language', 'fileUrl' ],
923 model: VideoCaptionModel.unscoped(),
924 required: false
925 },
926 {
927 model: StoryboardModel.unscoped(),
928 required: false
929 },
930 {
931 attributes: [ 'id', 'url' ],
932 model: VideoShareModel.unscoped(),
933 required: false,
934 // We only want videos shared by this actor
935 where: {
936 [Op.and]: [
937 {
938 id: {
939 [Op.not]: null
940 }
941 },
942 {
943 actorId
944 }
945 ]
946 },
947 include: [
948 {
949 attributes: [ 'id', 'url' ],
950 model: ActorModel.unscoped()
951 }
952 ]
953 },
954 {
955 model: VideoChannelModel.unscoped(),
956 required: true,
957 include: [
958 {
959 attributes: [ 'name' ],
960 model: AccountModel.unscoped(),
961 required: true,
962 include: [
963 {
964 attributes: [ 'id', 'url', 'followersUrl' ],
965 model: ActorModel.unscoped(),
966 required: true
967 }
968 ]
969 },
970 {
971 attributes: [ 'id', 'url', 'followersUrl' ],
972 model: ActorModel.unscoped(),
973 required: true
974 }
975 ]
976 },
977 {
978 model: VideoStreamingPlaylistModel.unscoped(),
979 required: false,
980 include: [
981 {
982 model: VideoFileModel,
983 required: false
984 }
985 ]
986 },
987 VideoLiveModel.unscoped(),
988 VideoFileModel,
989 TagModel
990 ]
991 }
992
993 return Bluebird.all([
994 VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findAll(query),
995 VideoModel.sequelize.query<{ total: string }>(rawCountQuery, { type: QueryTypes.SELECT })
996 ]).then(([ rows, totals ]) => {
997 // totals: totalVideos + totalVideoShares
998 let totalVideos = 0
999 let totalVideoShares = 0
1000 if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
1001 if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
1002
1003 const total = totalVideos + totalVideoShares
1004 return {
1005 data: rows,
1006 total
1007 }
1008 })
1009 }
1010
1011 static async listPublishedLiveUUIDs () {
1012 const options = {
1013 attributes: [ 'uuid' ],
1014 where: {
1015 isLive: true,
1016 remote: false,
1017 state: VideoState.PUBLISHED
1018 }
1019 }
1020
1021 const result = await VideoModel.findAll(options)
1022
1023 return result.map(v => v.uuid)
1024 }
1025
1026 static listUserVideosForApi (options: {
1027 accountId: number
1028 start: number
1029 count: number
1030 sort: string
1031
1032 channelId?: number
1033 isLive?: boolean
1034 search?: string
1035 }) {
1036 const { accountId, channelId, start, count, sort, search, isLive } = options
1037
1038 function buildBaseQuery (forCount: boolean): FindOptions {
1039 const where: WhereOptions = {}
1040
1041 if (search) {
1042 where.name = {
1043 [Op.iLike]: '%' + search + '%'
1044 }
1045 }
1046
1047 if (exists(isLive)) {
1048 where.isLive = isLive
1049 }
1050
1051 const channelWhere = channelId
1052 ? { id: channelId }
1053 : {}
1054
1055 const baseQuery = {
1056 offset: start,
1057 limit: count,
1058 where,
1059 order: getVideoSort(sort),
1060 include: [
1061 {
1062 model: forCount
1063 ? VideoChannelModel.unscoped()
1064 : VideoChannelModel,
1065 required: true,
1066 where: channelWhere,
1067 include: [
1068 {
1069 model: forCount
1070 ? AccountModel.unscoped()
1071 : AccountModel,
1072 where: {
1073 id: accountId
1074 },
1075 required: true
1076 }
1077 ]
1078 }
1079 ]
1080 }
1081
1082 return baseQuery
1083 }
1084
1085 const countQuery = buildBaseQuery(true)
1086 const findQuery = buildBaseQuery(false)
1087
1088 const findScopes: (string | ScopeOptions)[] = [
1089 ScopeNames.WITH_SCHEDULED_UPDATE,
1090 ScopeNames.WITH_BLACKLISTED,
1091 ScopeNames.WITH_THUMBNAILS
1092 ]
1093
1094 return Promise.all([
1095 VideoModel.count(countQuery),
1096 VideoModel.scope(findScopes).findAll<MVideoForUser>(findQuery)
1097 ]).then(([ count, rows ]) => {
1098 return {
1099 data: rows,
1100 total: count
1101 }
1102 })
1103 }
1104
1105 static async listForApi (options: {
1106 start: number
1107 count: number
1108 sort: string
1109
1110 nsfw: boolean
1111 isLive?: boolean
1112 isLocal?: boolean
1113 include?: VideoInclude
1114
1115 hasFiles?: boolean // default false
1116
1117 hasWebtorrentFiles?: boolean // TODO: remove in v7
1118 hasWebVideoFiles?: boolean
1119
1120 hasHLSFiles?: boolean
1121
1122 categoryOneOf?: number[]
1123 licenceOneOf?: number[]
1124 languageOneOf?: string[]
1125 tagsOneOf?: string[]
1126 tagsAllOf?: string[]
1127 privacyOneOf?: VideoPrivacy[]
1128
1129 accountId?: number
1130 videoChannelId?: number
1131
1132 displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
1133
1134 videoPlaylistId?: number
1135
1136 trendingDays?: number
1137
1138 user?: MUserAccountId
1139 historyOfUser?: MUserId
1140
1141 countVideos?: boolean
1142
1143 search?: string
1144
1145 excludeAlreadyWatched?: boolean
1146 }) {
1147 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
1148 VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
1149
1150 const trendingDays = options.sort.endsWith('trending')
1151 ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
1152 : undefined
1153
1154 let trendingAlgorithm: string
1155 if (options.sort.endsWith('hot')) trendingAlgorithm = 'hot'
1156 if (options.sort.endsWith('best')) trendingAlgorithm = 'best'
1157
1158 const serverActor = await getServerActor()
1159
1160 const queryOptions = {
1161 ...pick(options, [
1162 'start',
1163 'count',
1164 'sort',
1165 'nsfw',
1166 'isLive',
1167 'categoryOneOf',
1168 'licenceOneOf',
1169 'languageOneOf',
1170 'tagsOneOf',
1171 'tagsAllOf',
1172 'privacyOneOf',
1173 'isLocal',
1174 'include',
1175 'displayOnlyForFollower',
1176 'hasFiles',
1177 'accountId',
1178 'videoChannelId',
1179 'videoPlaylistId',
1180 'user',
1181 'historyOfUser',
1182 'hasHLSFiles',
1183 'hasWebtorrentFiles',
1184 'hasWebVideoFiles',
1185 'search',
1186 'excludeAlreadyWatched'
1187 ]),
1188
1189 serverAccountIdForBlock: serverActor.Account.id,
1190 trendingDays,
1191 trendingAlgorithm
1192 }
1193
1194 return VideoModel.getAvailableForApi(queryOptions, options.countVideos)
1195 }
1196
1197 static async searchAndPopulateAccountAndServer (options: {
1198 start: number
1199 count: number
1200 sort: string
1201
1202 nsfw?: boolean
1203 isLive?: boolean
1204 isLocal?: boolean
1205 include?: VideoInclude
1206
1207 categoryOneOf?: number[]
1208 licenceOneOf?: number[]
1209 languageOneOf?: string[]
1210 tagsOneOf?: string[]
1211 tagsAllOf?: string[]
1212 privacyOneOf?: VideoPrivacy[]
1213
1214 displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
1215
1216 user?: MUserAccountId
1217
1218 hasWebtorrentFiles?: boolean // TODO: remove in v7
1219 hasWebVideoFiles?: boolean
1220
1221 hasHLSFiles?: boolean
1222
1223 search?: string
1224
1225 host?: string
1226 startDate?: string // ISO 8601
1227 endDate?: string // ISO 8601
1228 originallyPublishedStartDate?: string
1229 originallyPublishedEndDate?: string
1230
1231 durationMin?: number // seconds
1232 durationMax?: number // seconds
1233 uuids?: string[]
1234
1235 excludeAlreadyWatched?: boolean
1236 }) {
1237 VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
1238 VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
1239
1240 const serverActor = await getServerActor()
1241
1242 const queryOptions = {
1243 ...pick(options, [
1244 'include',
1245 'nsfw',
1246 'isLive',
1247 'categoryOneOf',
1248 'licenceOneOf',
1249 'languageOneOf',
1250 'tagsOneOf',
1251 'tagsAllOf',
1252 'privacyOneOf',
1253 'user',
1254 'isLocal',
1255 'host',
1256 'start',
1257 'count',
1258 'sort',
1259 'startDate',
1260 'endDate',
1261 'originallyPublishedStartDate',
1262 'originallyPublishedEndDate',
1263 'durationMin',
1264 'durationMax',
1265 'hasHLSFiles',
1266 'hasWebtorrentFiles',
1267 'hasWebVideoFiles',
1268 'uuids',
1269 'search',
1270 'displayOnlyForFollower',
1271 'excludeAlreadyWatched'
1272 ]),
1273 serverAccountIdForBlock: serverActor.Account.id
1274 }
1275
1276 return VideoModel.getAvailableForApi(queryOptions)
1277 }
1278
1279 static countLives (options: {
1280 remote: boolean
1281 mode: 'published' | 'not-ended'
1282 }) {
1283 const query = {
1284 where: {
1285 remote: options.remote,
1286 isLive: true,
1287 state: options.mode === 'not-ended'
1288 ? { [Op.ne]: VideoState.LIVE_ENDED }
1289 : { [Op.eq]: VideoState.PUBLISHED }
1290 }
1291 }
1292
1293 return VideoModel.count(query)
1294 }
1295
1296 static countVideosUploadedByUserSince (userId: number, since: Date) {
1297 const options = {
1298 include: [
1299 {
1300 model: VideoChannelModel.unscoped(),
1301 required: true,
1302 include: [
1303 {
1304 model: AccountModel.unscoped(),
1305 required: true,
1306 include: [
1307 {
1308 model: UserModel.unscoped(),
1309 required: true,
1310 where: {
1311 id: userId
1312 }
1313 }
1314 ]
1315 }
1316 ]
1317 }
1318 ],
1319 where: {
1320 createdAt: {
1321 [Op.gte]: since
1322 }
1323 }
1324 }
1325
1326 return VideoModel.unscoped().count(options)
1327 }
1328
1329 static countLivesOfAccount (accountId: number) {
1330 const options = {
1331 where: {
1332 remote: false,
1333 isLive: true,
1334 state: {
1335 [Op.ne]: VideoState.LIVE_ENDED
1336 }
1337 },
1338 include: [
1339 {
1340 required: true,
1341 model: VideoChannelModel.unscoped(),
1342 where: {
1343 accountId
1344 }
1345 }
1346 ]
1347 }
1348
1349 return VideoModel.count(options)
1350 }
1351
1352 static load (id: number | string, transaction?: Transaction): Promise<MVideoThumbnail> {
1353 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1354
1355 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails' })
1356 }
1357
1358 static loadWithBlacklist (id: number | string, transaction?: Transaction): Promise<MVideoThumbnailBlacklist> {
1359 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1360
1361 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails-blacklist' })
1362 }
1363
1364 static loadImmutableAttributes (id: number | string, t?: Transaction): Promise<MVideoImmutable> {
1365 const fun = () => {
1366 const query = {
1367 where: buildWhereIdOrUUID(id),
1368 transaction: t
1369 }
1370
1371 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1372 }
1373
1374 return ModelCache.Instance.doCache({
1375 cacheType: 'load-video-immutable-id',
1376 key: '' + id,
1377 deleteKey: 'video',
1378 fun
1379 })
1380 }
1381
1382 static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Promise<MVideoImmutable> {
1383 const fun = () => {
1384 const query: FindOptions = {
1385 where: {
1386 url
1387 },
1388 transaction
1389 }
1390
1391 return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
1392 }
1393
1394 return ModelCache.Instance.doCache({
1395 cacheType: 'load-video-immutable-url',
1396 key: url,
1397 deleteKey: 'video',
1398 fun
1399 })
1400 }
1401
1402 static loadOnlyId (id: number | string, transaction?: Transaction): Promise<MVideoId> {
1403 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1404
1405 return queryBuilder.queryVideo({ id, transaction, type: 'id' })
1406 }
1407
1408 static loadWithFiles (id: number | string, transaction?: Transaction, logging?: boolean): Promise<MVideoWithAllFiles> {
1409 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1410
1411 return queryBuilder.queryVideo({ id, transaction, type: 'all-files', logging })
1412 }
1413
1414 static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoThumbnail> {
1415 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1416
1417 return queryBuilder.queryVideo({ url, transaction, type: 'thumbnails' })
1418 }
1419
1420 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise<MVideoAccountLightBlacklistAllFiles> {
1421 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1422
1423 return queryBuilder.queryVideo({ url, transaction, type: 'account-blacklist-files' })
1424 }
1425
1426 static loadFull (id: number | string, t?: Transaction, userId?: number): Promise<MVideoFullLight> {
1427 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1428
1429 return queryBuilder.queryVideo({ id, transaction: t, type: 'full', userId })
1430 }
1431
1432 static loadForGetAPI (parameters: {
1433 id: number | string
1434 transaction?: Transaction
1435 userId?: number
1436 }): Promise<MVideoDetails> {
1437 const { id, transaction, userId } = parameters
1438 const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
1439
1440 return queryBuilder.queryVideo({ id, transaction, type: 'api', userId })
1441 }
1442
1443 static async getStats () {
1444 const serverActor = await getServerActor()
1445
1446 let totalLocalVideoViews = await VideoModel.sum('views', {
1447 where: {
1448 remote: false
1449 }
1450 })
1451
1452 // Sequelize could return null...
1453 if (!totalLocalVideoViews) totalLocalVideoViews = 0
1454
1455 const baseOptions = {
1456 start: 0,
1457 count: 0,
1458 sort: '-publishedAt',
1459 nsfw: null,
1460 displayOnlyForFollower: {
1461 actorId: serverActor.id,
1462 orLocalVideos: true
1463 }
1464 }
1465
1466 const { total: totalLocalVideos } = await VideoModel.listForApi({
1467 ...baseOptions,
1468
1469 isLocal: true
1470 })
1471
1472 const { total: totalVideos } = await VideoModel.listForApi(baseOptions)
1473
1474 return {
1475 totalLocalVideos,
1476 totalLocalVideoViews,
1477 totalVideos
1478 }
1479 }
1480
1481 static incrementViews (id: number, views: number) {
1482 return VideoModel.increment('views', {
1483 by: views,
1484 where: {
1485 id
1486 }
1487 })
1488 }
1489
1490 static updateRatesOf (videoId: number, type: VideoRateType, count: number, t: Transaction) {
1491 const field = type === 'like'
1492 ? 'likes'
1493 : 'dislikes'
1494
1495 const rawQuery = `UPDATE "video" SET "${field}" = :count WHERE "video"."id" = :videoId`
1496
1497 return AccountVideoRateModel.sequelize.query(rawQuery, {
1498 transaction: t,
1499 replacements: { videoId, rateType: type, count },
1500 type: QueryTypes.UPDATE
1501 })
1502 }
1503
1504 static syncLocalRates (videoId: number, type: VideoRateType, t: Transaction) {
1505 const field = type === 'like'
1506 ? 'likes'
1507 : 'dislikes'
1508
1509 const rawQuery = `UPDATE "video" SET "${field}" = ` +
1510 '(' +
1511 'SELECT COUNT(id) FROM "accountVideoRate" WHERE "accountVideoRate"."videoId" = "video"."id" AND type = :rateType' +
1512 ') ' +
1513 'WHERE "video"."id" = :videoId'
1514
1515 return AccountVideoRateModel.sequelize.query(rawQuery, {
1516 transaction: t,
1517 replacements: { videoId, rateType: type },
1518 type: QueryTypes.UPDATE
1519 })
1520 }
1521
1522 static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
1523 // Instances only share videos
1524 const query = 'SELECT 1 FROM "videoShare" ' +
1525 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
1526 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "videoShare"."videoId" = $videoId ' +
1527 'UNION ' +
1528 'SELECT 1 FROM "video" ' +
1529 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
1530 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
1531 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "account"."actorId" ' +
1532 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "video"."id" = $videoId ' +
1533 'LIMIT 1'
1534
1535 const options = {
1536 type: QueryTypes.SELECT as QueryTypes.SELECT,
1537 bind: { followerActorId, videoId },
1538 raw: true
1539 }
1540
1541 return VideoModel.sequelize.query(query, options)
1542 .then(results => results.length === 1)
1543 }
1544
1545 static bulkUpdateSupportField (ofChannel: MChannel, t: Transaction) {
1546 const options = {
1547 where: {
1548 channelId: ofChannel.id
1549 },
1550 transaction: t
1551 }
1552
1553 return VideoModel.update({ support: ofChannel.support }, options)
1554 }
1555
1556 static getAllIdsFromChannel (videoChannel: MChannelId): Promise<number[]> {
1557 const query = {
1558 attributes: [ 'id' ],
1559 where: {
1560 channelId: videoChannel.id
1561 }
1562 }
1563
1564 return VideoModel.findAll(query)
1565 .then(videos => videos.map(v => v.id))
1566 }
1567
1568 // threshold corresponds to how many video the field should have to be returned
1569 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1570 const serverActor = await getServerActor()
1571
1572 const queryOptions: BuildVideosListQueryOptions = {
1573 attributes: [ `"${field}"` ],
1574 group: `GROUP BY "${field}"`,
1575 having: `HAVING COUNT("${field}") >= ${threshold}`,
1576 start: 0,
1577 sort: 'random',
1578 count,
1579 serverAccountIdForBlock: serverActor.Account.id,
1580 displayOnlyForFollower: {
1581 actorId: serverActor.id,
1582 orLocalVideos: true
1583 }
1584 }
1585
1586 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1587
1588 return queryBuilder.queryVideoIds(queryOptions)
1589 .then(rows => rows.map(r => r[field]))
1590 }
1591
1592 static buildTrendingQuery (trendingDays: number) {
1593 return {
1594 attributes: [],
1595 subQuery: false,
1596 model: VideoViewModel,
1597 required: false,
1598 where: {
1599 startDate: {
1600 // FIXME: ts error
1601 [Op.gte as any]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1602 }
1603 }
1604 }
1605 }
1606
1607 private static async getAvailableForApi (
1608 options: BuildVideosListQueryOptions,
1609 countVideos = true
1610 ): Promise<ResultList<VideoModel>> {
1611 const span = tracer.startSpan('peertube.VideoModel.getAvailableForApi')
1612
1613 function getCount () {
1614 if (countVideos !== true) return Promise.resolve(undefined)
1615
1616 const countOptions = Object.assign({}, options, { isCount: true })
1617 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1618
1619 return queryBuilder.countVideoIds(countOptions)
1620 }
1621
1622 function getModels () {
1623 if (options.count === 0) return Promise.resolve([])
1624
1625 const queryBuilder = new VideosModelListQueryBuilder(VideoModel.sequelize)
1626
1627 return queryBuilder.queryVideos(options)
1628 }
1629
1630 const [ count, rows ] = await Promise.all([ getCount(), getModels() ])
1631
1632 span.end()
1633
1634 return {
1635 data: rows,
1636 total: count
1637 }
1638 }
1639
1640 private static throwIfPrivateIncludeWithoutUser (include: VideoInclude, user: MUserAccountId) {
1641 if (VideoModel.isPrivateInclude(include) && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1642 throw new Error('Try to include protected videos but user cannot see all videos')
1643 }
1644 }
1645
1646 private static throwIfPrivacyOneOfWithoutUser (privacyOneOf: VideoPrivacy[], user: MUserAccountId) {
1647 if (privacyOneOf && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
1648 throw new Error('Try to choose video privacies but user cannot see all videos')
1649 }
1650 }
1651
1652 private static isPrivateInclude (include: VideoInclude) {
1653 return include & VideoInclude.BLACKLISTED ||
1654 include & VideoInclude.BLOCKED_OWNER ||
1655 include & VideoInclude.NOT_PUBLISHED_STATE
1656 }
1657
1658 isBlacklisted () {
1659 return !!this.VideoBlacklist
1660 }
1661
1662 isBlocked () {
1663 return this.VideoChannel.Account.Actor.Server?.isBlocked() || this.VideoChannel.Account.isBlocked()
1664 }
1665
1666 getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) {
1667 const files = this.getAllFiles()
1668 const file = fun(files, file => file.resolution)
1669 if (!file) return undefined
1670
1671 if (file.videoId) {
1672 return Object.assign(file, { Video: this })
1673 }
1674
1675 if (file.videoStreamingPlaylistId) {
1676 const streamingPlaylistWithVideo = Object.assign(this.VideoStreamingPlaylists[0], { Video: this })
1677
1678 return Object.assign(file, { VideoStreamingPlaylist: streamingPlaylistWithVideo })
1679 }
1680
1681 throw new Error('File is not associated to a video of a playlist')
1682 }
1683
1684 getMaxQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1685 return this.getQualityFileBy(maxBy)
1686 }
1687
1688 getMinQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
1689 return this.getQualityFileBy(minBy)
1690 }
1691
1692 getWebVideoFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
1693 if (Array.isArray(this.VideoFiles) === false) return undefined
1694
1695 const file = this.VideoFiles.find(f => f.resolution === resolution)
1696 if (!file) return undefined
1697
1698 return Object.assign(file, { Video: this })
1699 }
1700
1701 hasWebVideoFiles () {
1702 return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0
1703 }
1704
1705 async addAndSaveThumbnail (thumbnail: MThumbnail, transaction?: Transaction) {
1706 thumbnail.videoId = this.id
1707
1708 const savedThumbnail = await thumbnail.save({ transaction })
1709
1710 if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
1711
1712 this.Thumbnails = this.Thumbnails.filter(t => t.id !== savedThumbnail.id)
1713 this.Thumbnails.push(savedThumbnail)
1714 }
1715
1716 getMiniature () {
1717 if (Array.isArray(this.Thumbnails) === false) return undefined
1718
1719 return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
1720 }
1721
1722 hasPreview () {
1723 return !!this.getPreview()
1724 }
1725
1726 getPreview () {
1727 if (Array.isArray(this.Thumbnails) === false) return undefined
1728
1729 return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
1730 }
1731
1732 isOwned () {
1733 return this.remote === false
1734 }
1735
1736 getWatchStaticPath () {
1737 return buildVideoWatchPath({ shortUUID: uuidToShort(this.uuid) })
1738 }
1739
1740 getEmbedStaticPath () {
1741 return buildVideoEmbedPath(this)
1742 }
1743
1744 getMiniatureStaticPath () {
1745 const thumbnail = this.getMiniature()
1746 if (!thumbnail) return null
1747
1748 return thumbnail.getLocalStaticPath()
1749 }
1750
1751 getPreviewStaticPath () {
1752 const preview = this.getPreview()
1753 if (!preview) return null
1754
1755 return preview.getLocalStaticPath()
1756 }
1757
1758 toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
1759 return videoModelToFormattedJSON(this, options)
1760 }
1761
1762 toFormattedDetailsJSON (this: MVideoFormattableDetails): VideoDetails {
1763 return videoModelToFormattedDetailsJSON(this)
1764 }
1765
1766 getFormattedWebVideoFilesJSON (includeMagnet = true): VideoFile[] {
1767 return videoFilesModelToFormattedJSON(this, this.VideoFiles, { includeMagnet })
1768 }
1769
1770 getFormattedHLSVideoFilesJSON (includeMagnet = true): VideoFile[] {
1771 let acc: VideoFile[] = []
1772
1773 for (const p of this.VideoStreamingPlaylists) {
1774 acc = acc.concat(videoFilesModelToFormattedJSON(this, p.VideoFiles, { includeMagnet }))
1775 }
1776
1777 return acc
1778 }
1779
1780 getFormattedAllVideoFilesJSON (includeMagnet = true): VideoFile[] {
1781 let files: VideoFile[] = []
1782
1783 if (Array.isArray(this.VideoFiles)) {
1784 files = files.concat(this.getFormattedWebVideoFilesJSON(includeMagnet))
1785 }
1786
1787 if (Array.isArray(this.VideoStreamingPlaylists)) {
1788 files = files.concat(this.getFormattedHLSVideoFilesJSON(includeMagnet))
1789 }
1790
1791 return files
1792 }
1793
1794 toActivityPubObject (this: MVideoAP): Promise<VideoObject> {
1795 return Hooks.wrapObject(
1796 videoModelToActivityPubObject(this),
1797 'filter:activity-pub.video.json-ld.build.result',
1798 { video: this }
1799 )
1800 }
1801
1802 async lightAPToFullAP (this: MVideoAPLight, transaction: Transaction): Promise<MVideoAP> {
1803 const videoAP = this as MVideoAP
1804
1805 const getCaptions = () => {
1806 if (isArray(videoAP.VideoCaptions)) return videoAP.VideoCaptions
1807
1808 return this.$get('VideoCaptions', {
1809 attributes: [ 'filename', 'language', 'fileUrl' ],
1810 transaction
1811 }) as Promise<MVideoCaptionLanguageUrl[]>
1812 }
1813
1814 const getStoryboard = () => {
1815 if (videoAP.Storyboard) return videoAP.Storyboard
1816
1817 return this.$get('Storyboard', { transaction }) as Promise<MStoryboard>
1818 }
1819
1820 const [ captions, storyboard ] = await Promise.all([ getCaptions(), getStoryboard() ])
1821
1822 return Object.assign(this, {
1823 VideoCaptions: captions,
1824 Storyboard: storyboard
1825 })
1826 }
1827
1828 getTruncatedDescription () {
1829 if (!this.description) return null
1830
1831 const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
1832 return peertubeTruncate(this.description, { length: maxLength })
1833 }
1834
1835 getAllFiles () {
1836 let files: MVideoFile[] = []
1837
1838 if (Array.isArray(this.VideoFiles)) {
1839 files = files.concat(this.VideoFiles)
1840 }
1841
1842 if (Array.isArray(this.VideoStreamingPlaylists)) {
1843 for (const p of this.VideoStreamingPlaylists) {
1844 if (Array.isArray(p.VideoFiles)) {
1845 files = files.concat(p.VideoFiles)
1846 }
1847 }
1848 }
1849
1850 return files
1851 }
1852
1853 probeMaxQualityFile () {
1854 const file = this.getMaxQualityFile()
1855 const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
1856
1857 return VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(videoOrPlaylist), async originalFilePath => {
1858 const probe = await ffprobePromise(originalFilePath)
1859
1860 const { audioStream } = await getAudioStream(originalFilePath, probe)
1861 const hasAudio = await hasAudioStream(originalFilePath, probe)
1862 const fps = await getVideoStreamFPS(originalFilePath, probe)
1863
1864 return {
1865 audioStream,
1866 hasAudio,
1867 fps,
1868
1869 ...await getVideoStreamDimensionsInfo(originalFilePath, probe)
1870 }
1871 })
1872 }
1873
1874 getDescriptionAPIPath () {
1875 return `/api/${API_VERSION}/videos/${this.uuid}/description`
1876 }
1877
1878 getHLSPlaylist (): MStreamingPlaylistFilesVideo {
1879 if (!this.VideoStreamingPlaylists) return undefined
1880
1881 const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
1882 if (!playlist) return undefined
1883
1884 return playlist.withVideo(this)
1885 }
1886
1887 setHLSPlaylist (playlist: MStreamingPlaylist) {
1888 const toAdd = [ playlist ] as [ VideoStreamingPlaylistModel ]
1889
1890 if (Array.isArray(this.VideoStreamingPlaylists) === false || this.VideoStreamingPlaylists.length === 0) {
1891 this.VideoStreamingPlaylists = toAdd
1892 return
1893 }
1894
1895 this.VideoStreamingPlaylists = this.VideoStreamingPlaylists
1896 .filter(s => s.type !== VideoStreamingPlaylistType.HLS)
1897 .concat(toAdd)
1898 }
1899
1900 removeWebVideoFile (videoFile: MVideoFile, isRedundancy = false) {
1901 const filePath = isRedundancy
1902 ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile)
1903 : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile)
1904
1905 const promises: Promise<any>[] = [ remove(filePath) ]
1906 if (!isRedundancy) promises.push(videoFile.removeTorrent())
1907
1908 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
1909 promises.push(removeWebVideoObjectStorage(videoFile))
1910 }
1911
1912 return Promise.all(promises)
1913 }
1914
1915 async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) {
1916 const directoryPath = isRedundancy
1917 ? getHLSRedundancyDirectory(this)
1918 : getHLSDirectory(this)
1919
1920 await remove(directoryPath)
1921
1922 if (isRedundancy !== true) {
1923 const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo
1924 streamingPlaylistWithFiles.Video = this
1925
1926 if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) {
1927 streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles')
1928 }
1929
1930 // Remove physical files and torrents
1931 await Promise.all(
1932 streamingPlaylistWithFiles.VideoFiles.map(file => file.removeTorrent())
1933 )
1934
1935 if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
1936 await removeHLSObjectStorage(streamingPlaylist.withVideo(this))
1937 }
1938 }
1939 }
1940
1941 async removeStreamingPlaylistVideoFile (streamingPlaylist: MStreamingPlaylist, videoFile: MVideoFile) {
1942 const filePath = VideoPathManager.Instance.getFSHLSOutputPath(this, videoFile.filename)
1943 await videoFile.removeTorrent()
1944 await remove(filePath)
1945
1946 const resolutionFilename = getHlsResolutionPlaylistFilename(videoFile.filename)
1947 await remove(VideoPathManager.Instance.getFSHLSOutputPath(this, resolutionFilename))
1948
1949 if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
1950 await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), videoFile.filename)
1951 await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), resolutionFilename)
1952 }
1953 }
1954
1955 async removeStreamingPlaylistFile (streamingPlaylist: MStreamingPlaylist, filename: string) {
1956 const filePath = VideoPathManager.Instance.getFSHLSOutputPath(this, filename)
1957 await remove(filePath)
1958
1959 if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
1960 await removeHLSFileObjectStorageByFilename(streamingPlaylist.withVideo(this), filename)
1961 }
1962 }
1963
1964 isOutdated () {
1965 if (this.isOwned()) return false
1966
1967 return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
1968 }
1969
1970 hasPrivacyForFederation () {
1971 return isPrivacyForFederation(this.privacy)
1972 }
1973
1974 hasStateForFederation () {
1975 return isStateForFederation(this.state)
1976 }
1977
1978 isNewVideo (newPrivacy: VideoPrivacy) {
1979 return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true
1980 }
1981
1982 setAsRefreshed (transaction?: Transaction) {
1983 return setAsUpdated({ sequelize: this.sequelize, table: 'video', id: this.id, transaction })
1984 }
1985
1986 // ---------------------------------------------------------------------------
1987
1988 requiresUserAuth (options: {
1989 urlParamId: string
1990 checkBlacklist: boolean
1991 }) {
1992 const { urlParamId, checkBlacklist } = options
1993
1994 if (this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL) {
1995 return true
1996 }
1997
1998 if (this.privacy === VideoPrivacy.UNLISTED) {
1999 if (urlParamId && !isUUIDValid(urlParamId)) return true
2000
2001 return false
2002 }
2003
2004 if (checkBlacklist && this.VideoBlacklist) return true
2005
2006 if (this.privacy === VideoPrivacy.PUBLIC || this.privacy === VideoPrivacy.PASSWORD_PROTECTED) {
2007 return false
2008 }
2009
2010 throw new Error(`Unknown video privacy ${this.privacy} to know if the video requires auth`)
2011 }
2012
2013 hasPrivateStaticPath () {
2014 return isVideoInPrivateDirectory(this.privacy)
2015 }
2016
2017 // ---------------------------------------------------------------------------
2018
2019 async setNewState (newState: VideoState, isNewVideo: boolean, transaction: Transaction) {
2020 if (this.state === newState) throw new Error('Cannot use same state ' + newState)
2021
2022 this.state = newState
2023
2024 if (this.state === VideoState.PUBLISHED && isNewVideo) {
2025 this.publishedAt = new Date()
2026 }
2027
2028 await this.save({ transaction })
2029 }
2030
2031 getBandwidthBits (this: MVideo, videoFile: MVideoFile) {
2032 if (!this.duration) return videoFile.size
2033
2034 return Math.ceil((videoFile.size * 8) / this.duration)
2035 }
2036
2037 getTrackerUrls () {
2038 if (this.isOwned()) {
2039 return [
2040 WEBSERVER.URL + '/tracker/announce',
2041 WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket'
2042 ]
2043 }
2044
2045 return this.Trackers.map(t => t.url)
2046 }
2047}