diff options
Diffstat (limited to 'server/models/video')
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 @@ | |||
1 | export * from './video-activity-pub-format' | ||
2 | export * 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 @@ | |||
1 | export * 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 @@ | |||
1 | import { MVideoFile } from '@server/types/models' | ||
2 | |||
3 | export 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 @@ | |||
1 | import { isArray } from '@server/helpers/custom-validators/misc' | ||
2 | import { generateMagnetUri } from '@server/helpers/webtorrent' | ||
3 | import { getActivityStreamDuration } from '@server/lib/activitypub/activity' | ||
4 | import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls' | ||
5 | import { | ||
6 | ActivityIconObject, | ||
7 | ActivityPlaylistUrlObject, | ||
8 | ActivityPubStoryboard, | ||
9 | ActivityTagObject, | ||
10 | ActivityTrackerUrlObject, | ||
11 | ActivityUrlObject, | ||
12 | VideoObject | ||
13 | } from '@shared/models' | ||
14 | import { MIMETYPES, WEBSERVER } from '../../../initializers/constants' | ||
15 | import { | ||
16 | getLocalVideoCommentsActivityPubUrl, | ||
17 | getLocalVideoDislikesActivityPubUrl, | ||
18 | getLocalVideoLikesActivityPubUrl, | ||
19 | getLocalVideoSharesActivityPubUrl | ||
20 | } from '../../../lib/activitypub/url' | ||
21 | import { MStreamingPlaylistFiles, MUserId, MVideo, MVideoAP, MVideoFile } from '../../../types/models' | ||
22 | import { VideoCaptionModel } from '../video-caption' | ||
23 | import { sortByResolutionDesc } from './shared' | ||
24 | import { getCategoryLabel, getLanguageLabel, getLicenceLabel } from './video-api-format' | ||
25 | |||
26 | export 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 | |||
118 | function 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 | |||
136 | function 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 | |||
163 | function 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 | |||
220 | function 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 | |||
232 | function 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 | |||
249 | function 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 | |||
267 | function 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 | |||
276 | function 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 | |||
287 | function 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 @@ | |||
1 | import { generateMagnetUri } from '@server/helpers/webtorrent' | ||
2 | import { tracer } from '@server/lib/opentelemetry/tracing' | ||
3 | import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls' | ||
4 | import { VideoViewsManager } from '@server/lib/views/video-views-manager' | ||
5 | import { uuidToShort } from '@shared/extra-utils' | ||
6 | import { | ||
7 | Video, | ||
8 | VideoAdditionalAttributes, | ||
9 | VideoDetails, | ||
10 | VideoFile, | ||
11 | VideoInclude, | ||
12 | VideosCommonQueryAfterSanitize, | ||
13 | VideoStreamingPlaylist | ||
14 | } from '@shared/models' | ||
15 | import { isArray } from '../../../helpers/custom-validators/misc' | ||
16 | import { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES, VIDEO_STATES } from '../../../initializers/constants' | ||
17 | import { MServer, MStreamingPlaylistRedundanciesOpt, MVideoFormattable, MVideoFormattableDetails } from '../../../types/models' | ||
18 | import { MVideoFileRedundanciesOpt } from '../../../types/models/video/video-file' | ||
19 | import { sortByResolutionDesc } from './shared' | ||
20 | |||
21 | export 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 | |||
34 | export 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 | |||
51 | export 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 | |||
125 | export 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 | |||
166 | export 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 | |||
188 | export 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 | |||
237 | export function getCategoryLabel (id: number) { | ||
238 | return VIDEO_CATEGORIES[id] || 'Unknown' | ||
239 | } | ||
240 | |||
241 | export function getLicenceLabel (id: number) { | ||
242 | return VIDEO_LICENCES[id] || 'Unknown' | ||
243 | } | ||
244 | |||
245 | export function getLanguageLabel (id: string) { | ||
246 | return VIDEO_LANGUAGES[id] || 'Unknown' | ||
247 | } | ||
248 | |||
249 | export function getPrivacyLabel (id: number) { | ||
250 | return VIDEO_PRIVACIES[id] || 'Unknown' | ||
251 | } | ||
252 | |||
253 | export function getStateLabel (id: number) { | ||
254 | return VIDEO_STATES[id] || 'Unknown' | ||
255 | } | ||
256 | |||
257 | // --------------------------------------------------------------------------- | ||
258 | // Private | ||
259 | // --------------------------------------------------------------------------- | ||
260 | |||
261 | function 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 @@ | |||
1 | import { Op, Transaction } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { MScheduleVideoUpdateFormattable, MScheduleVideoUpdate } from '@server/types/models' | ||
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { VideoPrivacy } from '../../../shared/models/videos' | ||
6 | import { 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 | }) | ||
20 | export 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 @@ | |||
1 | import { Model, Sequelize, Transaction } from 'sequelize' | ||
2 | import { AbstractRunQuery, ModelBuilder } from '@server/models/shared' | ||
3 | import { ActorImageType, VideoPrivacy } from '@shared/models' | ||
4 | import { createSafeIn, getSort, parseRowCountResult } from '../../../shared' | ||
5 | import { VideoCommentTableAttributes } from './video-comment-table-attributes' | ||
6 | |||
7 | export 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 | |||
37 | export 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 @@ | |||
1 | import { Memoize } from '@server/helpers/memoize' | ||
2 | import { AccountModel } from '@server/models/account/account' | ||
3 | import { ActorModel } from '@server/models/actor/actor' | ||
4 | import { ActorImageModel } from '@server/models/actor/actor-image' | ||
5 | import { ServerModel } from '@server/models/server/server' | ||
6 | import { VideoCommentModel } from '../../video-comment' | ||
7 | |||
8 | export 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 @@ | |||
1 | export * from './video-model-get-query-builder' | ||
2 | export * from './videos-id-list-query-builder' | ||
3 | export * 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 @@ | |||
1 | import { Sequelize } from 'sequelize' | ||
2 | import validator from 'validator' | ||
3 | import { MUserAccountId } from '@server/types/models' | ||
4 | import { ActorImageType } from '@shared/models' | ||
5 | import { AbstractRunQuery } from '../../../../shared/abstract-run-query' | ||
6 | import { createSafeIn } from '../../../../shared' | ||
7 | import { VideoTableAttributes } from './video-table-attributes' | ||
8 | |||
9 | /** | ||
10 | * | ||
11 | * Abstract builder to create SQL query and fetch video models | ||
12 | * | ||
13 | */ | ||
14 | |||
15 | export 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 @@ | |||
1 | import { Sequelize, Transaction } from 'sequelize' | ||
2 | import { AbstractVideoQueryBuilder } from './abstract-video-query-builder' | ||
3 | |||
4 | export 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 | |||
21 | export 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 | |||
2 | import { AccountModel } from '@server/models/account/account' | ||
3 | import { AccountBlocklistModel } from '@server/models/account/account-blocklist' | ||
4 | import { ActorModel } from '@server/models/actor/actor' | ||
5 | import { ActorImageModel } from '@server/models/actor/actor-image' | ||
6 | import { VideoRedundancyModel } from '@server/models/redundancy/video-redundancy' | ||
7 | import { ServerModel } from '@server/models/server/server' | ||
8 | import { ServerBlocklistModel } from '@server/models/server/server-blocklist' | ||
9 | import { TrackerModel } from '@server/models/server/tracker' | ||
10 | import { UserVideoHistoryModel } from '@server/models/user/user-video-history' | ||
11 | import { VideoInclude } from '@shared/models' | ||
12 | import { ScheduleVideoUpdateModel } from '../../../schedule-video-update' | ||
13 | import { TagModel } from '../../../tag' | ||
14 | import { ThumbnailModel } from '../../../thumbnail' | ||
15 | import { VideoModel } from '../../../video' | ||
16 | import { VideoBlacklistModel } from '../../../video-blacklist' | ||
17 | import { VideoChannelModel } from '../../../video-channel' | ||
18 | import { VideoFileModel } from '../../../video-file' | ||
19 | import { VideoLiveModel } from '../../../video-live' | ||
20 | import { VideoStreamingPlaylistModel } from '../../../video-streaming-playlist' | ||
21 | import { VideoTableAttributes } from './video-table-attributes' | ||
22 | |||
23 | type SQLRow = { [id: string]: string | number } | ||
24 | |||
25 | /** | ||
26 | * | ||
27 | * Build video models from SQL rows | ||
28 | * | ||
29 | */ | ||
30 | |||
31 | export 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 | */ | ||
7 | export 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 @@ | |||
1 | import { Sequelize, Transaction } from 'sequelize' | ||
2 | import { pick } from '@shared/core-utils' | ||
3 | import { AbstractVideoQueryBuilder } from './shared/abstract-video-query-builder' | ||
4 | import { VideoFileQueryBuilder } from './shared/video-file-query-builder' | ||
5 | import { VideoModelBuilder } from './shared/video-model-builder' | ||
6 | import { 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 | |||
14 | export type GetType = | ||
15 | 'api' | | ||
16 | 'full' | | ||
17 | 'account-blacklist-files' | | ||
18 | 'all-files' | | ||
19 | 'thumbnails' | | ||
20 | 'thumbnails-blacklist' | | ||
21 | 'id' | | ||
22 | 'blacklist-rights' | ||
23 | |||
24 | export 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 | |||
36 | export 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 | |||
92 | export 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 @@ | |||
1 | import { Sequelize, Transaction } from 'sequelize' | ||
2 | import validator from 'validator' | ||
3 | import { exists } from '@server/helpers/custom-validators/misc' | ||
4 | import { WEBSERVER } from '@server/initializers/constants' | ||
5 | import { buildSortDirectionAndField } from '@server/models/shared' | ||
6 | import { MUserAccountId, MUserId } from '@server/types/models' | ||
7 | import { forceNumber } from '@shared/core-utils' | ||
8 | import { VideoInclude, VideoPrivacy, VideoState } from '@shared/models' | ||
9 | import { createSafeIn, parseRowCountResult } from '../../../shared' | ||
10 | import { AbstractRunQuery } from '../../../shared/abstract-run-query' | ||
11 | |||
12 | /** | ||
13 | * | ||
14 | * Build videos list SQL query to fetch rows | ||
15 | * | ||
16 | */ | ||
17 | |||
18 | export type DisplayOnlyForFollowerOptions = { | ||
19 | actorId: number | ||
20 | orLocalVideos: boolean | ||
21 | } | ||
22 | |||
23 | export 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 | |||
87 | export 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 @@ | |||
1 | import { Sequelize } from 'sequelize' | ||
2 | import { pick } from '@shared/core-utils' | ||
3 | import { VideoInclude } from '@shared/models' | ||
4 | import { AbstractVideoQueryBuilder } from './shared/abstract-video-query-builder' | ||
5 | import { VideoFileQueryBuilder } from './shared/video-file-query-builder' | ||
6 | import { VideoModelBuilder } from './shared/video-model-builder' | ||
7 | import { BuildVideosListQueryOptions, VideosIdListQueryBuilder } from './videos-id-list-query-builder' | ||
8 | |||
9 | /** | ||
10 | * | ||
11 | * Build videos list SQL query and create video models | ||
12 | * | ||
13 | */ | ||
14 | |||
15 | export 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 @@ | |||
1 | import { remove } from 'fs-extra' | ||
2 | import { join } from 'path' | ||
3 | import { AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
4 | import { CONFIG } from '@server/initializers/config' | ||
5 | import { MStoryboard, MStoryboardVideo, MVideo } from '@server/types/models' | ||
6 | import { Storyboard } from '@shared/models' | ||
7 | import { AttributesOnly } from '@shared/typescript-utils' | ||
8 | import { logger } from '../../helpers/logger' | ||
9 | import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, WEBSERVER } from '../../initializers/constants' | ||
10 | import { VideoModel } from './video' | ||
11 | import { 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 | }) | ||
26 | export 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 @@ | |||
1 | import { col, fn, QueryTypes, Transaction } from 'sequelize' | ||
2 | import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { MTag } from '@server/types/models' | ||
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { VideoPrivacy, VideoState } from '../../../shared/models/videos' | ||
6 | import { isVideoTagValid } from '../../helpers/custom-validators/videos' | ||
7 | import { throwIfNotValid } from '../shared' | ||
8 | import { VideoModel } from './video' | ||
9 | import { 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 | }) | ||
25 | export 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 @@ | |||
1 | import { remove } from 'fs-extra' | ||
2 | import { join } from 'path' | ||
3 | import { | ||
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' | ||
18 | import { afterCommitIfTransaction } from '@server/helpers/database-utils' | ||
19 | import { MThumbnail, MThumbnailVideo, MVideo } from '@server/types/models' | ||
20 | import { AttributesOnly } from '@shared/typescript-utils' | ||
21 | import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' | ||
22 | import { logger } from '../../helpers/logger' | ||
23 | import { CONFIG } from '../../initializers/config' | ||
24 | import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, WEBSERVER } from '../../initializers/constants' | ||
25 | import { VideoModel } from './video' | ||
26 | import { 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 | }) | ||
44 | export 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 @@ | |||
1 | import { FindOptions } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { MVideoBlacklist, MVideoBlacklistFormattable } from '@server/types/models' | ||
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos' | ||
6 | import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist' | ||
7 | import { CONSTRAINTS_FIELDS } from '../../initializers/constants' | ||
8 | import { getBlacklistSort, searchAttribute, throwIfNotValid } from '../shared' | ||
9 | import { ThumbnailModel } from './thumbnail' | ||
10 | import { VideoModel } from './video' | ||
11 | import { 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 | }) | ||
22 | export 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 @@ | |||
1 | import { remove } from 'fs-extra' | ||
2 | import { join } from 'path' | ||
3 | import { Op, OrderItem, Transaction } from 'sequelize' | ||
4 | import { | ||
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' | ||
18 | import { MVideo, MVideoCaption, MVideoCaptionFormattable, MVideoCaptionLanguageUrl, MVideoCaptionVideo } from '@server/types/models' | ||
19 | import { buildUUID } from '@shared/extra-utils' | ||
20 | import { AttributesOnly } from '@shared/typescript-utils' | ||
21 | import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model' | ||
22 | import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' | ||
23 | import { logger } from '../../helpers/logger' | ||
24 | import { CONFIG } from '../../initializers/config' | ||
25 | import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, VIDEO_LANGUAGES, WEBSERVER } from '../../initializers/constants' | ||
26 | import { buildWhereIdOrUUID, throwIfNotValid } from '../shared' | ||
27 | import { VideoModel } from './video' | ||
28 | |||
29 | export 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 | }) | ||
61 | export 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 @@ | |||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { MVideoChangeOwnershipFormattable, MVideoChangeOwnershipFull } from '@server/types/models/video/video-change-ownership' | ||
3 | import { AttributesOnly } from '@shared/typescript-utils' | ||
4 | import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos' | ||
5 | import { AccountModel } from '../account/account' | ||
6 | import { getSort } from '../shared' | ||
7 | import { ScopeNames as VideoScopeNames, VideoModel } from './video' | ||
8 | |||
9 | enum 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 | })) | ||
57 | export 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 @@ | |||
1 | import { Op } from 'sequelize' | ||
2 | import { | ||
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' | ||
16 | import { isUrlValid } from '@server/helpers/custom-validators/activitypub/misc' | ||
17 | import { isVideoChannelSyncStateValid } from '@server/helpers/custom-validators/video-channel-syncs' | ||
18 | import { CONSTRAINTS_FIELDS, VIDEO_CHANNEL_SYNC_STATE } from '@server/initializers/constants' | ||
19 | import { MChannelSync, MChannelSyncChannel, MChannelSyncFormattable } from '@server/types/models' | ||
20 | import { VideoChannelSync, VideoChannelSyncState } from '@shared/models' | ||
21 | import { AttributesOnly } from '@shared/typescript-utils' | ||
22 | import { AccountModel } from '../account/account' | ||
23 | import { UserModel } from '../user/user' | ||
24 | import { getChannelSyncSort, throwIfNotValid } from '../shared' | ||
25 | import { 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 | }) | ||
43 | export 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 @@ | |||
1 | import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction, WhereOptions } from 'sequelize' | ||
2 | import { | ||
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' | ||
23 | import { CONFIG } from '@server/initializers/config' | ||
24 | import { InternalEventEmitter } from '@server/lib/internal-event-emitter' | ||
25 | import { MAccountHost } from '@server/types/models' | ||
26 | import { forceNumber, pick } from '@shared/core-utils' | ||
27 | import { AttributesOnly } from '@shared/typescript-utils' | ||
28 | import { ActivityPubActor } from '../../../shared/models/activitypub' | ||
29 | import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos' | ||
30 | import { | ||
31 | isVideoChannelDescriptionValid, | ||
32 | isVideoChannelDisplayNameValid, | ||
33 | isVideoChannelSupportValid | ||
34 | } from '../../helpers/custom-validators/video-channels' | ||
35 | import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' | ||
36 | import { sendDeleteActor } from '../../lib/activitypub/send' | ||
37 | import { | ||
38 | MChannel, | ||
39 | MChannelActor, | ||
40 | MChannelAP, | ||
41 | MChannelBannerAccountDefault, | ||
42 | MChannelFormattable, | ||
43 | MChannelHost, | ||
44 | MChannelSummaryFormattable | ||
45 | } from '../../types/models/video' | ||
46 | import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account' | ||
47 | import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor' | ||
48 | import { ActorFollowModel } from '../actor/actor-follow' | ||
49 | import { ActorImageModel } from '../actor/actor-image' | ||
50 | import { ServerModel } from '../server/server' | ||
51 | import { | ||
52 | buildServerIdsFollowedBy, | ||
53 | buildTrigramSearchIndex, | ||
54 | createSimilarityAttribute, | ||
55 | getSort, | ||
56 | setAsUpdated, | ||
57 | throwIfNotValid | ||
58 | } from '../shared' | ||
59 | import { VideoModel } from './video' | ||
60 | import { VideoPlaylistModel } from './video-playlist' | ||
61 | |||
62 | export 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 | |||
72 | type AvailableForListOptions = { | ||
73 | actorId: number | ||
74 | search?: string | ||
75 | host?: string | ||
76 | handles?: string[] | ||
77 | forCount?: boolean | ||
78 | } | ||
79 | |||
80 | type AvailableWithStatsOptions = { | ||
81 | daysPrior: number | ||
82 | } | ||
83 | |||
84 | export 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 | }) | ||
357 | export 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 @@ | |||
1 | import { FindOptions, Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize' | ||
2 | import { | ||
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' | ||
16 | import { getServerActor } from '@server/models/application/application' | ||
17 | import { MAccount, MAccountId, MUserAccountId } from '@server/types/models' | ||
18 | import { pick, uniqify } from '@shared/core-utils' | ||
19 | import { AttributesOnly } from '@shared/typescript-utils' | ||
20 | import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects' | ||
21 | import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' | ||
22 | import { VideoComment, VideoCommentAdmin } from '../../../shared/models/videos/comment/video-comment.model' | ||
23 | import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor' | ||
24 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
25 | import { regexpCapture } from '../../helpers/regexp' | ||
26 | import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' | ||
27 | import { | ||
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' | ||
40 | import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse' | ||
41 | import { AccountModel } from '../account/account' | ||
42 | import { ActorModel } from '../actor/actor' | ||
43 | import { buildLocalAccountIdsIn, buildSQLAttributes, throwIfNotValid } from '../shared' | ||
44 | import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder' | ||
45 | import { VideoModel } from './video' | ||
46 | import { VideoChannelModel } from './video-channel' | ||
47 | |||
48 | export 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 | }) | ||
114 | export 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 @@ | |||
1 | import { remove } from 'fs-extra' | ||
2 | import memoizee from 'memoizee' | ||
3 | import { join } from 'path' | ||
4 | import { FindOptions, Op, Transaction, WhereOptions } from 'sequelize' | ||
5 | import { | ||
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' | ||
21 | import validator from 'validator' | ||
22 | import { logger } from '@server/helpers/logger' | ||
23 | import { extractVideo } from '@server/helpers/video' | ||
24 | import { CONFIG } from '@server/initializers/config' | ||
25 | import { buildRemoteVideoBaseUrl } from '@server/lib/activitypub/url' | ||
26 | import { | ||
27 | getHLSPrivateFileUrl, | ||
28 | getHLSPublicFileUrl, | ||
29 | getWebVideoPrivateFileUrl, | ||
30 | getWebVideoPublicFileUrl | ||
31 | } from '@server/lib/object-storage' | ||
32 | import { getFSTorrentFilePath } from '@server/lib/paths' | ||
33 | import { isVideoInPrivateDirectory } from '@server/lib/video-privacy' | ||
34 | import { isStreamingPlaylist, MStreamingPlaylistVideo, MVideo, MVideoWithHost } from '@server/types/models' | ||
35 | import { VideoResolution, VideoStorage } from '@shared/models' | ||
36 | import { AttributesOnly } from '@shared/typescript-utils' | ||
37 | import { | ||
38 | isVideoFileExtnameValid, | ||
39 | isVideoFileInfoHashValid, | ||
40 | isVideoFileResolutionValid, | ||
41 | isVideoFileSizeValid, | ||
42 | isVideoFPSResolutionValid | ||
43 | } from '../../helpers/custom-validators/videos' | ||
44 | import { | ||
45 | LAZY_STATIC_PATHS, | ||
46 | MEMOIZE_LENGTH, | ||
47 | MEMOIZE_TTL, | ||
48 | STATIC_DOWNLOAD_PATHS, | ||
49 | STATIC_PATHS, | ||
50 | WEBSERVER | ||
51 | } from '../../initializers/constants' | ||
52 | import { MVideoFile, MVideoFileStreamingPlaylistVideo, MVideoFileVideo } from '../../types/models/video/video-file' | ||
53 | import { VideoRedundancyModel } from '../redundancy/video-redundancy' | ||
54 | import { doesExist, parseAggregateResult, throwIfNotValid } from '../shared' | ||
55 | import { VideoModel } from './video' | ||
56 | import { VideoStreamingPlaylistModel } from './video-streaming-playlist' | ||
57 | |||
58 | export 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 | }) | ||
160 | export 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 @@ | |||
1 | import { IncludeOptions, Op, WhereOptions } from 'sequelize' | ||
2 | import { | ||
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' | ||
17 | import { afterCommitIfTransaction } from '@server/helpers/database-utils' | ||
18 | import { MVideoImportDefault, MVideoImportFormattable } from '@server/types/models/video/video-import' | ||
19 | import { VideoImport, VideoImportState } from '@shared/models' | ||
20 | import { AttributesOnly } from '@shared/typescript-utils' | ||
21 | import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../helpers/custom-validators/video-imports' | ||
22 | import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos' | ||
23 | import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants' | ||
24 | import { UserModel } from '../user/user' | ||
25 | import { getSort, searchAttribute, throwIfNotValid } from '../shared' | ||
26 | import { ScopeNames as VideoModelScopeNames, VideoModel } from './video' | ||
27 | import { VideoChannelSyncModel } from './video-channel-sync' | ||
28 | |||
29 | const 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 | }) | ||
66 | export 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 @@ | |||
1 | import { Op, QueryTypes, Transaction } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, IsInt, Model, Table, Unique, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { forceNumber } from '@shared/core-utils' | ||
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { VideoModel } from './video' | ||
6 | |||
7 | export 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 | |||
23 | export 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 @@ | |||
1 | import { isVideoPrivacyValid } from '@server/helpers/custom-validators/videos' | ||
2 | import { MLiveReplaySetting } from '@server/types/models/video/video-live-replay-setting' | ||
3 | import { VideoPrivacy } from '@shared/models/videos/video-privacy.enum' | ||
4 | import { Transaction } from 'sequelize' | ||
5 | import { AllowNull, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
6 | import { throwIfNotValid } from '../shared/sequelize-helpers' | ||
7 | |||
8 | @Table({ | ||
9 | tableName: 'videoLiveReplaySetting' | ||
10 | }) | ||
11 | export 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 @@ | |||
1 | import { FindOptions } from 'sequelize' | ||
2 | import { | ||
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' | ||
15 | import { MVideoLiveSession, MVideoLiveSessionReplay } from '@server/types/models' | ||
16 | import { uuidToShort } from '@shared/extra-utils' | ||
17 | import { LiveVideoError, LiveVideoSession } from '@shared/models' | ||
18 | import { AttributesOnly } from '@shared/typescript-utils' | ||
19 | import { VideoModel } from './video' | ||
20 | import { VideoLiveReplaySettingModel } from './video-live-replay-setting' | ||
21 | |||
22 | export 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 | }) | ||
57 | export 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 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { | ||
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' | ||
15 | import { CONFIG } from '@server/initializers/config' | ||
16 | import { WEBSERVER } from '@server/initializers/constants' | ||
17 | import { MVideoLive, MVideoLiveVideoWithSetting } from '@server/types/models' | ||
18 | import { LiveVideo, LiveVideoLatencyMode, VideoState } from '@shared/models' | ||
19 | import { AttributesOnly } from '@shared/typescript-utils' | ||
20 | import { VideoModel } from './video' | ||
21 | import { VideoBlacklistModel } from './video-blacklist' | ||
22 | import { 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 | }) | ||
55 | export 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 @@ | |||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, DefaultScope, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { VideoModel } from './video' | ||
3 | import { AttributesOnly } from '@shared/typescript-utils' | ||
4 | import { ResultList, VideoPassword } from '@shared/models' | ||
5 | import { getSort, throwIfNotValid } from '../shared' | ||
6 | import { FindOptions, Transaction } from 'sequelize' | ||
7 | import { MVideoPassword } from '@server/types/models' | ||
8 | import { isPasswordValid } from '@server/helpers/custom-validators/videos' | ||
9 | import { 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 | }) | ||
28 | export 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 @@ | |||
1 | import { AggregateOptions, Op, ScopeOptions, Sequelize, Transaction } from 'sequelize' | ||
2 | import { | ||
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' | ||
17 | import validator from 'validator' | ||
18 | import { MUserAccountId } from '@server/types/models' | ||
19 | import { | ||
20 | MVideoPlaylistElement, | ||
21 | MVideoPlaylistElementAP, | ||
22 | MVideoPlaylistElementFormattable, | ||
23 | MVideoPlaylistElementVideoUrlPlaylistPrivacy, | ||
24 | MVideoPlaylistVideoThumbnail | ||
25 | } from '@server/types/models/video/video-playlist-element' | ||
26 | import { forceNumber } from '@shared/core-utils' | ||
27 | import { AttributesOnly } from '@shared/typescript-utils' | ||
28 | import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object' | ||
29 | import { VideoPrivacy } from '../../../shared/models/videos' | ||
30 | import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../shared/models/videos/playlist/video-playlist-element.model' | ||
31 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
32 | import { CONSTRAINTS_FIELDS } from '../../initializers/constants' | ||
33 | import { AccountModel } from '../account/account' | ||
34 | import { getSort, throwIfNotValid } from '../shared' | ||
35 | import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video' | ||
36 | import { 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 | }) | ||
53 | export 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 @@ | |||
1 | import { join } from 'path' | ||
2 | import { FindOptions, Includeable, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' | ||
3 | import { | ||
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' | ||
20 | import { activityPubCollectionPagination } from '@server/lib/activitypub/collection' | ||
21 | import { MAccountId, MChannelId } from '@server/types/models' | ||
22 | import { buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@shared/core-utils' | ||
23 | import { buildUUID, uuidToShort } from '@shared/extra-utils' | ||
24 | import { ActivityIconObject, PlaylistObject, VideoPlaylist, VideoPlaylistPrivacy, VideoPlaylistType } from '@shared/models' | ||
25 | import { AttributesOnly } from '@shared/typescript-utils' | ||
26 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
27 | import { | ||
28 | isVideoPlaylistDescriptionValid, | ||
29 | isVideoPlaylistNameValid, | ||
30 | isVideoPlaylistPrivacyValid | ||
31 | } from '../../helpers/custom-validators/video-playlists' | ||
32 | import { | ||
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' | ||
41 | import { MThumbnail } from '../../types/models/video/thumbnail' | ||
42 | import { | ||
43 | MVideoPlaylistAccountThumbnail, | ||
44 | MVideoPlaylistAP, | ||
45 | MVideoPlaylistFormattable, | ||
46 | MVideoPlaylistFull, | ||
47 | MVideoPlaylistFullSummary, | ||
48 | MVideoPlaylistSummaryWithElements | ||
49 | } from '../../types/models/video/video-playlist' | ||
50 | import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account' | ||
51 | import { ActorModel } from '../actor/actor' | ||
52 | import { | ||
53 | buildServerIdsFollowedBy, | ||
54 | buildTrigramSearchIndex, | ||
55 | buildWhereIdOrUUID, | ||
56 | createSimilarityAttribute, | ||
57 | getPlaylistSort, | ||
58 | isOutdated, | ||
59 | setAsUpdated, | ||
60 | throwIfNotValid | ||
61 | } from '../shared' | ||
62 | import { ThumbnailModel } from './thumbnail' | ||
63 | import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' | ||
64 | import { VideoPlaylistElementModel } from './video-playlist-element' | ||
65 | |||
66 | enum 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 | |||
75 | type 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 | |||
88 | function 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 | }) | ||
282 | export 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 @@ | |||
1 | import { literal, Op, QueryTypes, Transaction } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { forceNumber } from '@shared/core-utils' | ||
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
6 | import { CONSTRAINTS_FIELDS } from '../../initializers/constants' | ||
7 | import { MActorDefault, MActorFollowersUrl, MActorId } from '../../types/models' | ||
8 | import { MVideoShareActor, MVideoShareFull } from '../../types/models/video' | ||
9 | import { ActorModel } from '../actor/actor' | ||
10 | import { buildLocalActorIdsIn, throwIfNotValid } from '../shared' | ||
11 | import { VideoModel } from './video' | ||
12 | |||
13 | enum 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 | }) | ||
55 | export 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 @@ | |||
1 | import { Transaction } from 'sequelize' | ||
2 | import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
3 | import { VideoSource } from '@shared/models/videos/video-source' | ||
4 | import { AttributesOnly } from '@shared/typescript-utils' | ||
5 | import { getSort } from '../shared' | ||
6 | import { 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 | }) | ||
19 | export 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 @@ | |||
1 | import memoizee from 'memoizee' | ||
2 | import { join } from 'path' | ||
3 | import { Op, Transaction } from 'sequelize' | ||
4 | import { | ||
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' | ||
18 | import { CONFIG } from '@server/initializers/config' | ||
19 | import { getHLSPrivateFileUrl, getHLSPublicFileUrl } from '@server/lib/object-storage' | ||
20 | import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename } from '@server/lib/paths' | ||
21 | import { isVideoInPrivateDirectory } from '@server/lib/video-privacy' | ||
22 | import { VideoFileModel } from '@server/models/video/video-file' | ||
23 | import { MStreamingPlaylist, MStreamingPlaylistFilesVideo, MVideo } from '@server/types/models' | ||
24 | import { sha1 } from '@shared/extra-utils' | ||
25 | import { VideoStorage } from '@shared/models' | ||
26 | import { AttributesOnly } from '@shared/typescript-utils' | ||
27 | import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type' | ||
28 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
29 | import { isArrayOf } from '../../helpers/custom-validators/misc' | ||
30 | import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos' | ||
31 | import { | ||
32 | CONSTRAINTS_FIELDS, | ||
33 | MEMOIZE_LENGTH, | ||
34 | MEMOIZE_TTL, | ||
35 | P2P_MEDIA_LOADER_PEER_VERSION, | ||
36 | STATIC_PATHS, | ||
37 | WEBSERVER | ||
38 | } from '../../initializers/constants' | ||
39 | import { VideoRedundancyModel } from '../redundancy/video-redundancy' | ||
40 | import { doesExist, throwIfNotValid } from '../shared' | ||
41 | import { 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 | }) | ||
59 | export 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 @@ | |||
1 | import { Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' | ||
2 | import { AttributesOnly } from '@shared/typescript-utils' | ||
3 | import { TagModel } from './tag' | ||
4 | import { VideoModel } from './video' | ||
5 | |||
6 | @Table({ | ||
7 | tableName: 'videoTag', | ||
8 | indexes: [ | ||
9 | { | ||
10 | fields: [ 'videoId' ] | ||
11 | }, | ||
12 | { | ||
13 | fields: [ 'tagId' ] | ||
14 | } | ||
15 | ] | ||
16 | }) | ||
17 | export 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 @@ | |||
1 | import Bluebird from 'bluebird' | ||
2 | import { remove } from 'fs-extra' | ||
3 | import { maxBy, minBy } from 'lodash' | ||
4 | import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' | ||
5 | import { | ||
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' | ||
29 | import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video' | ||
30 | import { InternalEventEmitter } from '@server/lib/internal-event-emitter' | ||
31 | import { LiveManager } from '@server/lib/live/live-manager' | ||
32 | import { removeHLSFileObjectStorageByFilename, removeHLSObjectStorage, removeWebVideoObjectStorage } from '@server/lib/object-storage' | ||
33 | import { tracer } from '@server/lib/opentelemetry/tracing' | ||
34 | import { getHLSDirectory, getHLSRedundancyDirectory, getHlsResolutionPlaylistFilename } from '@server/lib/paths' | ||
35 | import { Hooks } from '@server/lib/plugins/hooks' | ||
36 | import { VideoPathManager } from '@server/lib/video-path-manager' | ||
37 | import { isVideoInPrivateDirectory } from '@server/lib/video-privacy' | ||
38 | import { getServerActor } from '@server/models/application/application' | ||
39 | import { ModelCache } from '@server/models/shared/model-cache' | ||
40 | import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils' | ||
41 | import { uuidToShort } from '@shared/extra-utils' | ||
42 | import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamFPS, hasAudioStream } from '@shared/ffmpeg' | ||
43 | import { | ||
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' | ||
58 | import { AttributesOnly } from '@shared/typescript-utils' | ||
59 | import { peertubeTruncate } from '../../helpers/core-utils' | ||
60 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
61 | import { exists, isArray, isBooleanValid, isUUIDValid } from '../../helpers/custom-validators/misc' | ||
62 | import { | ||
63 | isVideoDescriptionValid, | ||
64 | isVideoDurationValid, | ||
65 | isVideoNameValid, | ||
66 | isVideoPrivacyValid, | ||
67 | isVideoStateValid, | ||
68 | isVideoSupportValid | ||
69 | } from '../../helpers/custom-validators/videos' | ||
70 | import { logger } from '../../helpers/logger' | ||
71 | import { CONFIG } from '../../initializers/config' | ||
72 | import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' | ||
73 | import { sendDeleteVideo } from '../../lib/activitypub/send' | ||
74 | import { | ||
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' | ||
102 | import { MThumbnail } from '../../types/models/video/thumbnail' | ||
103 | import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file' | ||
104 | import { VideoAbuseModel } from '../abuse/video-abuse' | ||
105 | import { AccountModel } from '../account/account' | ||
106 | import { AccountVideoRateModel } from '../account/account-video-rate' | ||
107 | import { ActorModel } from '../actor/actor' | ||
108 | import { ActorImageModel } from '../actor/actor-image' | ||
109 | import { VideoRedundancyModel } from '../redundancy/video-redundancy' | ||
110 | import { ServerModel } from '../server/server' | ||
111 | import { TrackerModel } from '../server/tracker' | ||
112 | import { VideoTrackerModel } from '../server/video-tracker' | ||
113 | import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, setAsUpdated, throwIfNotValid } from '../shared' | ||
114 | import { UserModel } from '../user/user' | ||
115 | import { UserVideoHistoryModel } from '../user/user-video-history' | ||
116 | import { VideoViewModel } from '../view/video-view' | ||
117 | import { videoModelToActivityPubObject } from './formatter/video-activity-pub-format' | ||
118 | import { | ||
119 | videoFilesModelToFormattedJSON, | ||
120 | VideoFormattingJSONOptions, | ||
121 | videoModelToFormattedDetailsJSON, | ||
122 | videoModelToFormattedJSON | ||
123 | } from './formatter/video-api-format' | ||
124 | import { ScheduleVideoUpdateModel } from './schedule-video-update' | ||
125 | import { | ||
126 | BuildVideosListQueryOptions, | ||
127 | DisplayOnlyForFollowerOptions, | ||
128 | VideoModelGetQueryBuilder, | ||
129 | VideosIdListQueryBuilder, | ||
130 | VideosModelListQueryBuilder | ||
131 | } from './sql/video' | ||
132 | import { StoryboardModel } from './storyboard' | ||
133 | import { TagModel } from './tag' | ||
134 | import { ThumbnailModel } from './thumbnail' | ||
135 | import { VideoBlacklistModel } from './video-blacklist' | ||
136 | import { VideoCaptionModel } from './video-caption' | ||
137 | import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' | ||
138 | import { VideoCommentModel } from './video-comment' | ||
139 | import { VideoFileModel } from './video-file' | ||
140 | import { VideoImportModel } from './video-import' | ||
141 | import { VideoJobInfoModel } from './video-job-info' | ||
142 | import { VideoLiveModel } from './video-live' | ||
143 | import { VideoPasswordModel } from './video-password' | ||
144 | import { VideoPlaylistElementModel } from './video-playlist-element' | ||
145 | import { VideoShareModel } from './video-share' | ||
146 | import { VideoSourceModel } from './video-source' | ||
147 | import { VideoStreamingPlaylistModel } from './video-streaming-playlist' | ||
148 | import { VideoTagModel } from './video-tag' | ||
149 | |||
150 | export 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 | |||
163 | export 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 | }) | ||
441 | export 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 | } | ||