aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2018-09-20 16:24:31 +0200
committerGitHub <noreply@github.com>2018-09-20 16:24:31 +0200
commit0491173a61aed66205c017e0d7e0503ea316c144 (patch)
treece6621597505f9518cfdf0981977d097c63f9fad /server/models
parent8704acf49efc770d73bf07c10468ed8c74d28a83 (diff)
parent6247b2057b792cea155a1abd9788c363ae7d2cc2 (diff)
downloadPeerTube-0491173a61aed66205c017e0d7e0503ea316c144.tar.gz
PeerTube-0491173a61aed66205c017e0d7e0503ea316c144.tar.zst
PeerTube-0491173a61aed66205c017e0d7e0503ea316c144.zip
Merge branch 'develop' into cli-wrapper
Diffstat (limited to 'server/models')
-rw-r--r--server/models/account/account.ts4
-rw-r--r--server/models/account/user.ts9
-rw-r--r--server/models/activitypub/actor.ts35
-rw-r--r--server/models/oauth/oauth-token.ts47
-rw-r--r--server/models/redundancy/video-redundancy.ts189
-rw-r--r--server/models/video/tag.ts5
-rw-r--r--server/models/video/video-format-utils.ts296
-rw-r--r--server/models/video/video.ts555
8 files changed, 627 insertions, 513 deletions
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index 6bbfc6f4e..580d920ce 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -134,8 +134,8 @@ export class AccountModel extends Model<AccountModel> {
134 return undefined 134 return undefined
135 } 135 }
136 136
137 static load (id: number) { 137 static load (id: number, transaction?: Sequelize.Transaction) {
138 return AccountModel.findById(id) 138 return AccountModel.findById(id, { transaction })
139 } 139 }
140 140
141 static loadByUUID (uuid: string) { 141 static loadByUUID (uuid: string) {
diff --git a/server/models/account/user.ts b/server/models/account/user.ts
index 680b1d52d..e56b0bf40 100644
--- a/server/models/account/user.ts
+++ b/server/models/account/user.ts
@@ -1,5 +1,7 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { 2import {
3 AfterDelete,
4 AfterUpdate,
3 AllowNull, 5 AllowNull,
4 BeforeCreate, 6 BeforeCreate,
5 BeforeUpdate, 7 BeforeUpdate,
@@ -39,6 +41,7 @@ import { AccountModel } from './account'
39import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type' 41import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type'
40import { values } from 'lodash' 42import { values } from 'lodash'
41import { NSFW_POLICY_TYPES } from '../../initializers' 43import { NSFW_POLICY_TYPES } from '../../initializers'
44import { clearCacheByUserId } from '../../lib/oauth-model'
42 45
43enum ScopeNames { 46enum ScopeNames {
44 WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL' 47 WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL'
@@ -168,6 +171,12 @@ export class UserModel extends Model<UserModel> {
168 } 171 }
169 } 172 }
170 173
174 @AfterUpdate
175 @AfterDelete
176 static removeTokenCache (instance: UserModel) {
177 return clearCacheByUserId(instance.id)
178 }
179
171 static countTotal () { 180 static countTotal () {
172 return this.count() 181 return this.count()
173 } 182 }
diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts
index ef8dd9f7c..f8bb59323 100644
--- a/server/models/activitypub/actor.ts
+++ b/server/models/activitypub/actor.ts
@@ -266,6 +266,18 @@ export class ActorModel extends Model<ActorModel> {
266 return ActorModel.unscoped().findById(id) 266 return ActorModel.unscoped().findById(id)
267 } 267 }
268 268
269 static isActorUrlExist (url: string) {
270 const query = {
271 raw: true,
272 where: {
273 url
274 }
275 }
276
277 return ActorModel.unscoped().findOne(query)
278 .then(a => !!a)
279 }
280
269 static listByFollowersUrls (followersUrls: string[], transaction?: Sequelize.Transaction) { 281 static listByFollowersUrls (followersUrls: string[], transaction?: Sequelize.Transaction) {
270 const query = { 282 const query = {
271 where: { 283 where: {
@@ -315,6 +327,29 @@ export class ActorModel extends Model<ActorModel> {
315 where: { 327 where: {
316 url 328 url
317 }, 329 },
330 transaction,
331 include: [
332 {
333 attributes: [ 'id' ],
334 model: AccountModel.unscoped(),
335 required: false
336 },
337 {
338 attributes: [ 'id' ],
339 model: VideoChannelModel.unscoped(),
340 required: false
341 }
342 ]
343 }
344
345 return ActorModel.unscoped().findOne(query)
346 }
347
348 static loadByUrlAndPopulateAccountAndChannel (url: string, transaction?: Sequelize.Transaction) {
349 const query = {
350 where: {
351 url
352 },
318 transaction 353 transaction
319 } 354 }
320 355
diff --git a/server/models/oauth/oauth-token.ts b/server/models/oauth/oauth-token.ts
index 4c53848dc..ef9592c04 100644
--- a/server/models/oauth/oauth-token.ts
+++ b/server/models/oauth/oauth-token.ts
@@ -1,9 +1,23 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 1import {
2 AfterDelete,
3 AfterUpdate,
4 AllowNull,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 ForeignKey,
9 Model,
10 Scopes,
11 Table,
12 UpdatedAt
13} from 'sequelize-typescript'
2import { logger } from '../../helpers/logger' 14import { logger } from '../../helpers/logger'
3import { AccountModel } from '../account/account'
4import { UserModel } from '../account/user' 15import { UserModel } from '../account/user'
5import { OAuthClientModel } from './oauth-client' 16import { OAuthClientModel } from './oauth-client'
6import { Transaction } from 'sequelize' 17import { Transaction } from 'sequelize'
18import { AccountModel } from '../account/account'
19import { ActorModel } from '../activitypub/actor'
20import { clearCacheByToken } from '../../lib/oauth-model'
7 21
8export type OAuthTokenInfo = { 22export type OAuthTokenInfo = {
9 refreshToken: string 23 refreshToken: string
@@ -17,18 +31,27 @@ export type OAuthTokenInfo = {
17} 31}
18 32
19enum ScopeNames { 33enum ScopeNames {
20 WITH_ACCOUNT = 'WITH_ACCOUNT' 34 WITH_USER = 'WITH_USER'
21} 35}
22 36
23@Scopes({ 37@Scopes({
24 [ScopeNames.WITH_ACCOUNT]: { 38 [ScopeNames.WITH_USER]: {
25 include: [ 39 include: [
26 { 40 {
27 model: () => UserModel, 41 model: () => UserModel.unscoped(),
42 required: true,
28 include: [ 43 include: [
29 { 44 {
30 model: () => AccountModel, 45 attributes: [ 'id' ],
31 required: true 46 model: () => AccountModel.unscoped(),
47 required: true,
48 include: [
49 {
50 attributes: [ 'id' ],
51 model: () => ActorModel.unscoped(),
52 required: true
53 }
54 ]
32 } 55 }
33 ] 56 ]
34 } 57 }
@@ -102,6 +125,12 @@ export class OAuthTokenModel extends Model<OAuthTokenModel> {
102 }) 125 })
103 OAuthClients: OAuthClientModel[] 126 OAuthClients: OAuthClientModel[]
104 127
128 @AfterUpdate
129 @AfterDelete
130 static removeTokenCache (token: OAuthTokenModel) {
131 return clearCacheByToken(token.accessToken)
132 }
133
105 static getByRefreshTokenAndPopulateClient (refreshToken: string) { 134 static getByRefreshTokenAndPopulateClient (refreshToken: string) {
106 const query = { 135 const query = {
107 where: { 136 where: {
@@ -138,7 +167,7 @@ export class OAuthTokenModel extends Model<OAuthTokenModel> {
138 } 167 }
139 } 168 }
140 169
141 return OAuthTokenModel.scope(ScopeNames.WITH_ACCOUNT).findOne(query).then(token => { 170 return OAuthTokenModel.scope(ScopeNames.WITH_USER).findOne(query).then(token => {
142 if (token) token['user'] = token.User 171 if (token) token['user'] = token.User
143 172
144 return token 173 return token
@@ -152,7 +181,7 @@ export class OAuthTokenModel extends Model<OAuthTokenModel> {
152 } 181 }
153 } 182 }
154 183
155 return OAuthTokenModel.scope(ScopeNames.WITH_ACCOUNT) 184 return OAuthTokenModel.scope(ScopeNames.WITH_USER)
156 .findOne(query) 185 .findOne(query)
157 .then(token => { 186 .then(token => {
158 if (token) { 187 if (token) {
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts
index 48ec77206..fb07287a8 100644
--- a/server/models/redundancy/video-redundancy.ts
+++ b/server/models/redundancy/video-redundancy.ts
@@ -14,11 +14,10 @@ import {
14 UpdatedAt 14 UpdatedAt
15} from 'sequelize-typescript' 15} from 'sequelize-typescript'
16import { ActorModel } from '../activitypub/actor' 16import { ActorModel } from '../activitypub/actor'
17import { throwIfNotValid } from '../utils' 17import { getVideoSort, throwIfNotValid } from '../utils'
18import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc' 18import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc'
19import { CONSTRAINTS_FIELDS, VIDEO_EXT_MIMETYPE } from '../../initializers' 19import { CONFIG, CONSTRAINTS_FIELDS, VIDEO_EXT_MIMETYPE } from '../../initializers'
20import { VideoFileModel } from '../video/video-file' 20import { VideoFileModel } from '../video/video-file'
21import { isDateValid } from '../../helpers/custom-validators/misc'
22import { getServerActor } from '../../helpers/utils' 21import { getServerActor } from '../../helpers/utils'
23import { VideoModel } from '../video/video' 22import { VideoModel } from '../video/video'
24import { VideoRedundancyStrategy } from '../../../shared/models/redundancy' 23import { VideoRedundancyStrategy } from '../../../shared/models/redundancy'
@@ -28,6 +27,7 @@ import { VideoChannelModel } from '../video/video-channel'
28import { ServerModel } from '../server/server' 27import { ServerModel } from '../server/server'
29import { sample } from 'lodash' 28import { sample } from 'lodash'
30import { isTestInstance } from '../../helpers/core-utils' 29import { isTestInstance } from '../../helpers/core-utils'
30import * as Bluebird from 'bluebird'
31 31
32export enum ScopeNames { 32export enum ScopeNames {
33 WITH_VIDEO = 'WITH_VIDEO' 33 WITH_VIDEO = 'WITH_VIDEO'
@@ -145,65 +145,90 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
145 return VideoRedundancyModel.findOne(query) 145 return VideoRedundancyModel.findOne(query)
146 } 146 }
147 147
148 static async getVideoSample (p: Bluebird<VideoModel[]>) {
149 const rows = await p
150 const ids = rows.map(r => r.id)
151 const id = sample(ids)
152
153 return VideoModel.loadWithFile(id, undefined, !isTestInstance())
154 }
155
148 static async findMostViewToDuplicate (randomizedFactor: number) { 156 static async findMostViewToDuplicate (randomizedFactor: number) {
149 // On VideoModel! 157 // On VideoModel!
150 const query = { 158 const query = {
159 attributes: [ 'id', 'views' ],
151 logging: !isTestInstance(), 160 logging: !isTestInstance(),
152 limit: randomizedFactor, 161 limit: randomizedFactor,
153 order: [ [ 'views', 'DESC' ] ], 162 order: getVideoSort('-views'),
154 include: [ 163 include: [
155 { 164 await VideoRedundancyModel.buildVideoFileForDuplication(),
156 model: VideoFileModel.unscoped(), 165 VideoRedundancyModel.buildServerRedundancyInclude()
157 required: true,
158 where: {
159 id: {
160 [ Sequelize.Op.notIn ]: await VideoRedundancyModel.buildExcludeIn()
161 }
162 }
163 },
164 {
165 attributes: [],
166 model: VideoChannelModel.unscoped(),
167 required: true,
168 include: [
169 {
170 attributes: [],
171 model: ActorModel.unscoped(),
172 required: true,
173 include: [
174 {
175 attributes: [],
176 model: ServerModel.unscoped(),
177 required: true,
178 where: {
179 redundancyAllowed: true
180 }
181 }
182 ]
183 }
184 ]
185 }
186 ] 166 ]
187 } 167 }
188 168
189 const rows = await VideoModel.unscoped().findAll(query) 169 return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
170 }
190 171
191 return sample(rows) 172 static async findTrendingToDuplicate (randomizedFactor: number) {
173 // On VideoModel!
174 const query = {
175 attributes: [ 'id', 'views' ],
176 subQuery: false,
177 logging: !isTestInstance(),
178 group: 'VideoModel.id',
179 limit: randomizedFactor,
180 order: getVideoSort('-trending'),
181 include: [
182 await VideoRedundancyModel.buildVideoFileForDuplication(),
183 VideoRedundancyModel.buildServerRedundancyInclude(),
184
185 VideoModel.buildTrendingQuery(CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS)
186 ]
187 }
188
189 return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
192 } 190 }
193 191
194 static async getVideoFiles (strategy: VideoRedundancyStrategy) { 192 static async findRecentlyAddedToDuplicate (randomizedFactor: number, minViews: number) {
193 // On VideoModel!
194 const query = {
195 attributes: [ 'id', 'publishedAt' ],
196 logging: !isTestInstance(),
197 limit: randomizedFactor,
198 order: getVideoSort('-publishedAt'),
199 where: {
200 views: {
201 [ Sequelize.Op.gte ]: minViews
202 }
203 },
204 include: [
205 await VideoRedundancyModel.buildVideoFileForDuplication(),
206 VideoRedundancyModel.buildServerRedundancyInclude()
207 ]
208 }
209
210 return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
211 }
212
213 static async getTotalDuplicated (strategy: VideoRedundancyStrategy) {
195 const actor = await getServerActor() 214 const actor = await getServerActor()
196 215
197 const queryVideoFiles = { 216 const options = {
198 logging: !isTestInstance(), 217 logging: !isTestInstance(),
199 where: { 218 include: [
200 actorId: actor.id, 219 {
201 strategy 220 attributes: [],
202 } 221 model: VideoRedundancyModel,
222 required: true,
223 where: {
224 actorId: actor.id,
225 strategy
226 }
227 }
228 ]
203 } 229 }
204 230
205 return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO) 231 return VideoFileModel.sum('size', options)
206 .findAll(queryVideoFiles)
207 } 232 }
208 233
209 static listAllExpired () { 234 static listAllExpired () {
@@ -211,7 +236,7 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
211 logging: !isTestInstance(), 236 logging: !isTestInstance(),
212 where: { 237 where: {
213 expiresOn: { 238 expiresOn: {
214 [Sequelize.Op.lt]: new Date() 239 [ Sequelize.Op.lt ]: new Date()
215 } 240 }
216 } 241 }
217 } 242 }
@@ -220,6 +245,37 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
220 .findAll(query) 245 .findAll(query)
221 } 246 }
222 247
248 static async getStats (strategy: VideoRedundancyStrategy) {
249 const actor = await getServerActor()
250
251 const query = {
252 raw: true,
253 attributes: [
254 [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoFile.size')), '0'), 'totalUsed' ],
255 [ Sequelize.fn('COUNT', Sequelize.fn('DISTINCT', 'videoId')), 'totalVideos' ],
256 [ Sequelize.fn('COUNT', 'videoFileId'), 'totalVideoFiles' ]
257 ],
258 where: {
259 strategy,
260 actorId: actor.id
261 },
262 include: [
263 {
264 attributes: [],
265 model: VideoFileModel,
266 required: true
267 }
268 ]
269 }
270
271 return VideoRedundancyModel.find(query as any) // FIXME: typings
272 .then((r: any) => ({
273 totalUsed: parseInt(r.totalUsed.toString(), 10),
274 totalVideos: r.totalVideos,
275 totalVideoFiles: r.totalVideoFiles
276 }))
277 }
278
223 toActivityPubObject (): CacheFileObject { 279 toActivityPubObject (): CacheFileObject {
224 return { 280 return {
225 id: this.url, 281 id: this.url,
@@ -237,13 +293,50 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
237 } 293 }
238 } 294 }
239 295
240 private static async buildExcludeIn () { 296 // Don't include video files we already duplicated
297 private static async buildVideoFileForDuplication () {
241 const actor = await getServerActor() 298 const actor = await getServerActor()
242 299
243 return Sequelize.literal( 300 const notIn = Sequelize.literal(
244 '(' + 301 '(' +
245 `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id} AND "expiresOn" >= NOW()` + 302 `SELECT "videoFileId" FROM "videoRedundancy" WHERE "actorId" = ${actor.id} AND "expiresOn" >= NOW()` +
246 ')' 303 ')'
247 ) 304 )
305
306 return {
307 attributes: [],
308 model: VideoFileModel.unscoped(),
309 required: true,
310 where: {
311 id: {
312 [ Sequelize.Op.notIn ]: notIn
313 }
314 }
315 }
316 }
317
318 private static buildServerRedundancyInclude () {
319 return {
320 attributes: [],
321 model: VideoChannelModel.unscoped(),
322 required: true,
323 include: [
324 {
325 attributes: [],
326 model: ActorModel.unscoped(),
327 required: true,
328 include: [
329 {
330 attributes: [],
331 model: ServerModel.unscoped(),
332 required: true,
333 where: {
334 redundancyAllowed: true
335 }
336 }
337 ]
338 }
339 ]
340 }
248 } 341 }
249} 342}
diff --git a/server/models/video/tag.ts b/server/models/video/tag.ts
index e39a418cd..b39621eaf 100644
--- a/server/models/video/tag.ts
+++ b/server/models/video/tag.ts
@@ -48,11 +48,10 @@ export class TagModel extends Model<TagModel> {
48 }, 48 },
49 defaults: { 49 defaults: {
50 name: tag 50 name: tag
51 } 51 },
52 transaction
52 } 53 }
53 54
54 if (transaction) query['transaction'] = transaction
55
56 const promise = TagModel.findOrCreate(query) 55 const promise = TagModel.findOrCreate(query)
57 .then(([ tagInstance ]) => tagInstance) 56 .then(([ tagInstance ]) => tagInstance)
58 tasks.push(promise) 57 tasks.push(promise)
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts
new file mode 100644
index 000000000..a9a58624d
--- /dev/null
+++ b/server/models/video/video-format-utils.ts
@@ -0,0 +1,296 @@
1import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
2import { VideoModel } from './video'
3import { VideoFileModel } from './video-file'
4import { ActivityUrlObject, VideoTorrentObject } from '../../../shared/models/activitypub/objects'
5import { CONFIG, THUMBNAILS_SIZE, VIDEO_EXT_MIMETYPE } from '../../initializers'
6import { VideoCaptionModel } from './video-caption'
7import {
8 getVideoCommentsActivityPubUrl,
9 getVideoDislikesActivityPubUrl,
10 getVideoLikesActivityPubUrl,
11 getVideoSharesActivityPubUrl
12} from '../../lib/activitypub'
13
14export type VideoFormattingJSONOptions = {
15 additionalAttributes: {
16 state?: boolean,
17 waitTranscoding?: boolean,
18 scheduledUpdate?: boolean,
19 blacklistInfo?: boolean
20 }
21}
22function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video {
23 const formattedAccount = video.VideoChannel.Account.toFormattedJSON()
24 const formattedVideoChannel = video.VideoChannel.toFormattedJSON()
25
26 const videoObject: Video = {
27 id: video.id,
28 uuid: video.uuid,
29 name: video.name,
30 category: {
31 id: video.category,
32 label: VideoModel.getCategoryLabel(video.category)
33 },
34 licence: {
35 id: video.licence,
36 label: VideoModel.getLicenceLabel(video.licence)
37 },
38 language: {
39 id: video.language,
40 label: VideoModel.getLanguageLabel(video.language)
41 },
42 privacy: {
43 id: video.privacy,
44 label: VideoModel.getPrivacyLabel(video.privacy)
45 },
46 nsfw: video.nsfw,
47 description: video.getTruncatedDescription(),
48 isLocal: video.isOwned(),
49 duration: video.duration,
50 views: video.views,
51 likes: video.likes,
52 dislikes: video.dislikes,
53 thumbnailPath: video.getThumbnailStaticPath(),
54 previewPath: video.getPreviewStaticPath(),
55 embedPath: video.getEmbedStaticPath(),
56 createdAt: video.createdAt,
57 updatedAt: video.updatedAt,
58 publishedAt: video.publishedAt,
59 account: {
60 id: formattedAccount.id,
61 uuid: formattedAccount.uuid,
62 name: formattedAccount.name,
63 displayName: formattedAccount.displayName,
64 url: formattedAccount.url,
65 host: formattedAccount.host,
66 avatar: formattedAccount.avatar
67 },
68 channel: {
69 id: formattedVideoChannel.id,
70 uuid: formattedVideoChannel.uuid,
71 name: formattedVideoChannel.name,
72 displayName: formattedVideoChannel.displayName,
73 url: formattedVideoChannel.url,
74 host: formattedVideoChannel.host,
75 avatar: formattedVideoChannel.avatar
76 }
77 }
78
79 if (options) {
80 if (options.additionalAttributes.state === true) {
81 videoObject.state = {
82 id: video.state,
83 label: VideoModel.getStateLabel(video.state)
84 }
85 }
86
87 if (options.additionalAttributes.waitTranscoding === true) {
88 videoObject.waitTranscoding = video.waitTranscoding
89 }
90
91 if (options.additionalAttributes.scheduledUpdate === true && video.ScheduleVideoUpdate) {
92 videoObject.scheduledUpdate = {
93 updateAt: video.ScheduleVideoUpdate.updateAt,
94 privacy: video.ScheduleVideoUpdate.privacy || undefined
95 }
96 }
97
98 if (options.additionalAttributes.blacklistInfo === true) {
99 videoObject.blacklisted = !!video.VideoBlacklist
100 videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null
101 }
102 }
103
104 return videoObject
105}
106
107function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
108 const formattedJson = video.toFormattedJSON({
109 additionalAttributes: {
110 scheduledUpdate: true,
111 blacklistInfo: true
112 }
113 })
114
115 const tags = video.Tags ? video.Tags.map(t => t.name) : []
116 const detailsJson = {
117 support: video.support,
118 descriptionPath: video.getDescriptionAPIPath(),
119 channel: video.VideoChannel.toFormattedJSON(),
120 account: video.VideoChannel.Account.toFormattedJSON(),
121 tags,
122 commentsEnabled: video.commentsEnabled,
123 waitTranscoding: video.waitTranscoding,
124 state: {
125 id: video.state,
126 label: VideoModel.getStateLabel(video.state)
127 },
128 files: []
129 }
130
131 // Format and sort video files
132 detailsJson.files = videoFilesModelToFormattedJSON(video, video.VideoFiles)
133
134 return Object.assign(formattedJson, detailsJson)
135}
136
137function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] {
138 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
139
140 return videoFiles
141 .map(videoFile => {
142 let resolutionLabel = videoFile.resolution + 'p'
143
144 return {
145 resolution: {
146 id: videoFile.resolution,
147 label: resolutionLabel
148 },
149 magnetUri: video.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
150 size: videoFile.size,
151 fps: videoFile.fps,
152 torrentUrl: video.getTorrentUrl(videoFile, baseUrlHttp),
153 torrentDownloadUrl: video.getTorrentDownloadUrl(videoFile, baseUrlHttp),
154 fileUrl: video.getVideoFileUrl(videoFile, baseUrlHttp),
155 fileDownloadUrl: video.getVideoFileDownloadUrl(videoFile, baseUrlHttp)
156 } as VideoFile
157 })
158 .sort((a, b) => {
159 if (a.resolution.id < b.resolution.id) return 1
160 if (a.resolution.id === b.resolution.id) return 0
161 return -1
162 })
163}
164
165function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
166 const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
167 if (!video.Tags) video.Tags = []
168
169 const tag = video.Tags.map(t => ({
170 type: 'Hashtag' as 'Hashtag',
171 name: t.name
172 }))
173
174 let language
175 if (video.language) {
176 language = {
177 identifier: video.language,
178 name: VideoModel.getLanguageLabel(video.language)
179 }
180 }
181
182 let category
183 if (video.category) {
184 category = {
185 identifier: video.category + '',
186 name: VideoModel.getCategoryLabel(video.category)
187 }
188 }
189
190 let licence
191 if (video.licence) {
192 licence = {
193 identifier: video.licence + '',
194 name: VideoModel.getLicenceLabel(video.licence)
195 }
196 }
197
198 const url: ActivityUrlObject[] = []
199 for (const file of video.VideoFiles) {
200 url.push({
201 type: 'Link',
202 mimeType: VIDEO_EXT_MIMETYPE[ file.extname ] as any,
203 href: video.getVideoFileUrl(file, baseUrlHttp),
204 height: file.resolution,
205 size: file.size,
206 fps: file.fps
207 })
208
209 url.push({
210 type: 'Link',
211 mimeType: 'application/x-bittorrent' as 'application/x-bittorrent',
212 href: video.getTorrentUrl(file, baseUrlHttp),
213 height: file.resolution
214 })
215
216 url.push({
217 type: 'Link',
218 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
219 href: video.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
220 height: file.resolution
221 })
222 }
223
224 // Add video url too
225 url.push({
226 type: 'Link',
227 mimeType: 'text/html',
228 href: CONFIG.WEBSERVER.URL + '/videos/watch/' + video.uuid
229 })
230
231 const subtitleLanguage = []
232 for (const caption of video.VideoCaptions) {
233 subtitleLanguage.push({
234 identifier: caption.language,
235 name: VideoCaptionModel.getLanguageLabel(caption.language)
236 })
237 }
238
239 return {
240 type: 'Video' as 'Video',
241 id: video.url,
242 name: video.name,
243 duration: getActivityStreamDuration(video.duration),
244 uuid: video.uuid,
245 tag,
246 category,
247 licence,
248 language,
249 views: video.views,
250 sensitive: video.nsfw,
251 waitTranscoding: video.waitTranscoding,
252 state: video.state,
253 commentsEnabled: video.commentsEnabled,
254 published: video.publishedAt.toISOString(),
255 updated: video.updatedAt.toISOString(),
256 mediaType: 'text/markdown',
257 content: video.getTruncatedDescription(),
258 support: video.support,
259 subtitleLanguage,
260 icon: {
261 type: 'Image',
262 url: video.getThumbnailUrl(baseUrlHttp),
263 mediaType: 'image/jpeg',
264 width: THUMBNAILS_SIZE.width,
265 height: THUMBNAILS_SIZE.height
266 },
267 url,
268 likes: getVideoLikesActivityPubUrl(video),
269 dislikes: getVideoDislikesActivityPubUrl(video),
270 shares: getVideoSharesActivityPubUrl(video),
271 comments: getVideoCommentsActivityPubUrl(video),
272 attributedTo: [
273 {
274 type: 'Person',
275 id: video.VideoChannel.Account.Actor.url
276 },
277 {
278 type: 'Group',
279 id: video.VideoChannel.Actor.url
280 }
281 ]
282 }
283}
284
285function getActivityStreamDuration (duration: number) {
286 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
287 return 'PT' + duration + 'S'
288}
289
290export {
291 videoModelToFormattedJSON,
292 videoModelToFormattedDetailsJSON,
293 videoFilesModelToFormattedJSON,
294 videoModelToActivityPubObject,
295 getActivityStreamDuration
296}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 27c631dcd..6c89c16bf 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -1,8 +1,8 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import { map, maxBy } from 'lodash' 2import { maxBy } from 'lodash'
3import * as magnetUtil from 'magnet-uri' 3import * as magnetUtil from 'magnet-uri'
4import * as parseTorrent from 'parse-torrent' 4import * as parseTorrent from 'parse-torrent'
5import { extname, join } from 'path' 5import { join } from 'path'
6import * as Sequelize from 'sequelize' 6import * as Sequelize from 'sequelize'
7import { 7import {
8 AllowNull, 8 AllowNull,
@@ -27,7 +27,7 @@ import {
27 Table, 27 Table,
28 UpdatedAt 28 UpdatedAt
29} from 'sequelize-typescript' 29} from 'sequelize-typescript'
30import { ActivityUrlObject, VideoPrivacy, VideoResolution, VideoState } from '../../../shared' 30import { VideoPrivacy, VideoState } from '../../../shared'
31import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' 31import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
32import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos' 32import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
33import { VideoFilter } from '../../../shared/models/videos/video-query.type' 33import { VideoFilter } from '../../../shared/models/videos/video-query.type'
@@ -45,7 +45,7 @@ import {
45 isVideoStateValid, 45 isVideoStateValid,
46 isVideoSupportValid 46 isVideoSupportValid
47} from '../../helpers/custom-validators/videos' 47} from '../../helpers/custom-validators/videos'
48import { generateImageFromVideoFile, getVideoFileFPS, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils' 48import { generateImageFromVideoFile, getVideoFileResolution } from '../../helpers/ffmpeg-utils'
49import { logger } from '../../helpers/logger' 49import { logger } from '../../helpers/logger'
50import { getServerActor } from '../../helpers/utils' 50import { getServerActor } from '../../helpers/utils'
51import { 51import {
@@ -59,18 +59,11 @@ import {
59 STATIC_PATHS, 59 STATIC_PATHS,
60 THUMBNAILS_SIZE, 60 THUMBNAILS_SIZE,
61 VIDEO_CATEGORIES, 61 VIDEO_CATEGORIES,
62 VIDEO_EXT_MIMETYPE,
63 VIDEO_LANGUAGES, 62 VIDEO_LANGUAGES,
64 VIDEO_LICENCES, 63 VIDEO_LICENCES,
65 VIDEO_PRIVACIES, 64 VIDEO_PRIVACIES,
66 VIDEO_STATES 65 VIDEO_STATES
67} from '../../initializers' 66} from '../../initializers'
68import {
69 getVideoCommentsActivityPubUrl,
70 getVideoDislikesActivityPubUrl,
71 getVideoLikesActivityPubUrl,
72 getVideoSharesActivityPubUrl
73} from '../../lib/activitypub'
74import { sendDeleteVideo } from '../../lib/activitypub/send' 67import { sendDeleteVideo } from '../../lib/activitypub/send'
75import { AccountModel } from '../account/account' 68import { AccountModel } from '../account/account'
76import { AccountVideoRateModel } from '../account/account-video-rate' 69import { AccountVideoRateModel } from '../account/account-video-rate'
@@ -88,9 +81,17 @@ import { VideoTagModel } from './video-tag'
88import { ScheduleVideoUpdateModel } from './schedule-video-update' 81import { ScheduleVideoUpdateModel } from './schedule-video-update'
89import { VideoCaptionModel } from './video-caption' 82import { VideoCaptionModel } from './video-caption'
90import { VideoBlacklistModel } from './video-blacklist' 83import { VideoBlacklistModel } from './video-blacklist'
91import { copy, remove, rename, stat, writeFile } from 'fs-extra' 84import { remove, writeFile } from 'fs-extra'
92import { VideoViewModel } from './video-views' 85import { VideoViewModel } from './video-views'
93import { VideoRedundancyModel } from '../redundancy/video-redundancy' 86import { VideoRedundancyModel } from '../redundancy/video-redundancy'
87import {
88 videoFilesModelToFormattedJSON,
89 VideoFormattingJSONOptions,
90 videoModelToActivityPubObject,
91 videoModelToFormattedDetailsJSON,
92 videoModelToFormattedJSON
93} from './video-format-utils'
94import * as validator from 'validator'
94 95
95// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation 96// FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
96const indexes: Sequelize.DefineIndexesOptions[] = [ 97const indexes: Sequelize.DefineIndexesOptions[] = [
@@ -221,6 +222,7 @@ type AvailableForListIDsOptions = {
221 }, 222 },
222 [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => { 223 [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => {
223 const query: IFindOptions<VideoModel> = { 224 const query: IFindOptions<VideoModel> = {
225 raw: true,
224 attributes: [ 'id' ], 226 attributes: [ 'id' ],
225 where: { 227 where: {
226 id: { 228 id: {
@@ -387,16 +389,7 @@ type AvailableForListIDsOptions = {
387 } 389 }
388 390
389 if (options.trendingDays) { 391 if (options.trendingDays) {
390 query.include.push({ 392 query.include.push(VideoModel.buildTrendingQuery(options.trendingDays))
391 attributes: [],
392 model: VideoViewModel,
393 required: false,
394 where: {
395 startDate: {
396 [ Sequelize.Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * options.trendingDays)
397 }
398 }
399 })
400 393
401 query.subQuery = false 394 query.subQuery = false
402 } 395 }
@@ -474,6 +467,7 @@ type AvailableForListIDsOptions = {
474 required: false, 467 required: false,
475 include: [ 468 include: [
476 { 469 {
470 attributes: [ 'fileUrl' ],
477 model: () => VideoRedundancyModel.unscoped(), 471 model: () => VideoRedundancyModel.unscoped(),
478 required: false 472 required: false
479 } 473 }
@@ -937,7 +931,7 @@ export class VideoModel extends Model<VideoModel> {
937 videoChannelId?: number, 931 videoChannelId?: number,
938 actorId?: number 932 actorId?: number
939 trendingDays?: number 933 trendingDays?: number
940 }) { 934 }, countVideos = true) {
941 const query: IFindOptions<VideoModel> = { 935 const query: IFindOptions<VideoModel> = {
942 offset: options.start, 936 offset: options.start,
943 limit: options.count, 937 limit: options.count,
@@ -970,7 +964,7 @@ export class VideoModel extends Model<VideoModel> {
970 trendingDays 964 trendingDays
971 } 965 }
972 966
973 return VideoModel.getAvailableForApi(query, queryOptions) 967 return VideoModel.getAvailableForApi(query, queryOptions, countVideos)
974 } 968 }
975 969
976 static async searchAndPopulateAccountAndServer (options: { 970 static async searchAndPopulateAccountAndServer (options: {
@@ -1070,41 +1064,34 @@ export class VideoModel extends Model<VideoModel> {
1070 return VideoModel.getAvailableForApi(query, queryOptions) 1064 return VideoModel.getAvailableForApi(query, queryOptions)
1071 } 1065 }
1072 1066
1073 static load (id: number, t?: Sequelize.Transaction) { 1067 static load (id: number | string, t?: Sequelize.Transaction) {
1074 const options = t ? { transaction: t } : undefined 1068 const where = VideoModel.buildWhereIdOrUUID(id)
1075 1069 const options = {
1076 return VideoModel.findById(id, options) 1070 where,
1077 } 1071 transaction: t
1078
1079 static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
1080 const query: IFindOptions<VideoModel> = {
1081 where: {
1082 url
1083 }
1084 } 1072 }
1085 1073
1086 if (t !== undefined) query.transaction = t 1074 return VideoModel.findOne(options)
1087
1088 return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
1089 } 1075 }
1090 1076
1091 static loadAndPopulateAccountAndServerAndTags (id: number) { 1077 static loadOnlyId (id: number | string, t?: Sequelize.Transaction) {
1078 const where = VideoModel.buildWhereIdOrUUID(id)
1079
1092 const options = { 1080 const options = {
1093 order: [ [ 'Tags', 'name', 'ASC' ] ] 1081 attributes: [ 'id' ],
1082 where,
1083 transaction: t
1094 } 1084 }
1095 1085
1096 return VideoModel 1086 return VideoModel.findOne(options)
1097 .scope([
1098 ScopeNames.WITH_TAGS,
1099 ScopeNames.WITH_BLACKLISTED,
1100 ScopeNames.WITH_FILES,
1101 ScopeNames.WITH_ACCOUNT_DETAILS,
1102 ScopeNames.WITH_SCHEDULED_UPDATE
1103 ])
1104 .findById(id, options)
1105 } 1087 }
1106 1088
1107 static loadByUUID (uuid: string) { 1089 static loadWithFile (id: number, t?: Sequelize.Transaction, logging?: boolean) {
1090 return VideoModel.scope(ScopeNames.WITH_FILES)
1091 .findById(id, { transaction: t, logging })
1092 }
1093
1094 static loadByUUIDWithFile (uuid: string) {
1108 const options = { 1095 const options = {
1109 where: { 1096 where: {
1110 uuid 1097 uuid
@@ -1116,12 +1103,34 @@ export class VideoModel extends Model<VideoModel> {
1116 .findOne(options) 1103 .findOne(options)
1117 } 1104 }
1118 1105
1119 static loadByUUIDAndPopulateAccountAndServerAndTags (uuid: string, t?: Sequelize.Transaction) { 1106 static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
1120 const options = { 1107 const query: IFindOptions<VideoModel> = {
1121 order: [ [ 'Tags', 'name', 'ASC' ] ],
1122 where: { 1108 where: {
1123 uuid 1109 url
1110 },
1111 transaction
1112 }
1113
1114 return VideoModel.findOne(query)
1115 }
1116
1117 static loadByUrlAndPopulateAccount (url: string, transaction?: Sequelize.Transaction) {
1118 const query: IFindOptions<VideoModel> = {
1119 where: {
1120 url
1124 }, 1121 },
1122 transaction
1123 }
1124
1125 return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
1126 }
1127
1128 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction) {
1129 const where = VideoModel.buildWhereIdOrUUID(id)
1130
1131 const options = {
1132 order: [ [ 'Tags', 'name', 'ASC' ] ],
1133 where,
1125 transaction: t 1134 transaction: t
1126 } 1135 }
1127 1136
@@ -1169,7 +1178,14 @@ export class VideoModel extends Model<VideoModel> {
1169 } 1178 }
1170 1179
1171 // threshold corresponds to how many video the field should have to be returned 1180 // threshold corresponds to how many video the field should have to be returned
1172 static getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) { 1181 static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
1182 const actorId = (await getServerActor()).id
1183
1184 const scopeOptions = {
1185 actorId,
1186 includeLocalVideos: true
1187 }
1188
1173 const query: IFindOptions<VideoModel> = { 1189 const query: IFindOptions<VideoModel> = {
1174 attributes: [ field ], 1190 attributes: [ field ],
1175 limit: count, 1191 limit: count,
@@ -1177,20 +1193,28 @@ export class VideoModel extends Model<VideoModel> {
1177 having: Sequelize.where(Sequelize.fn('COUNT', Sequelize.col(field)), { 1193 having: Sequelize.where(Sequelize.fn('COUNT', Sequelize.col(field)), {
1178 [ Sequelize.Op.gte ]: threshold 1194 [ Sequelize.Op.gte ]: threshold
1179 }) as any, // FIXME: typings 1195 }) as any, // FIXME: typings
1180 where: {
1181 [ field ]: {
1182 [ Sequelize.Op.not ]: null
1183 },
1184 privacy: VideoPrivacy.PUBLIC,
1185 state: VideoState.PUBLISHED
1186 },
1187 order: [ this.sequelize.random() ] 1196 order: [ this.sequelize.random() ]
1188 } 1197 }
1189 1198
1190 return VideoModel.findAll(query) 1199 return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST_IDS, scopeOptions ] })
1200 .findAll(query)
1191 .then(rows => rows.map(r => r[ field ])) 1201 .then(rows => rows.map(r => r[ field ]))
1192 } 1202 }
1193 1203
1204 static buildTrendingQuery (trendingDays: number) {
1205 return {
1206 attributes: [],
1207 subQuery: false,
1208 model: VideoViewModel,
1209 required: false,
1210 where: {
1211 startDate: {
1212 [ Sequelize.Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
1213 }
1214 }
1215 }
1216 }
1217
1194 private static buildActorWhereWithFilter (filter?: VideoFilter) { 1218 private static buildActorWhereWithFilter (filter?: VideoFilter) {
1195 if (filter && filter === 'local') { 1219 if (filter && filter === 'local') {
1196 return { 1220 return {
@@ -1201,7 +1225,7 @@ export class VideoModel extends Model<VideoModel> {
1201 return {} 1225 return {}
1202 } 1226 }
1203 1227
1204 private static async getAvailableForApi (query: IFindOptions<VideoModel>, options: AvailableForListIDsOptions) { 1228 private static async getAvailableForApi (query: IFindOptions<VideoModel>, options: AvailableForListIDsOptions, countVideos = true) {
1205 const idsScope = { 1229 const idsScope = {
1206 method: [ 1230 method: [
1207 ScopeNames.AVAILABLE_FOR_LIST_IDS, options 1231 ScopeNames.AVAILABLE_FOR_LIST_IDS, options
@@ -1218,7 +1242,7 @@ export class VideoModel extends Model<VideoModel> {
1218 } 1242 }
1219 1243
1220 const [ count, rowsId ] = await Promise.all([ 1244 const [ count, rowsId ] = await Promise.all([
1221 VideoModel.scope(countScope).count(countQuery), 1245 countVideos ? VideoModel.scope(countScope).count(countQuery) : Promise.resolve(undefined),
1222 VideoModel.scope(idsScope).findAll(query) 1246 VideoModel.scope(idsScope).findAll(query)
1223 ]) 1247 ])
1224 const ids = rowsId.map(r => r.id) 1248 const ids = rowsId.map(r => r.id)
@@ -1247,26 +1271,30 @@ export class VideoModel extends Model<VideoModel> {
1247 } 1271 }
1248 } 1272 }
1249 1273
1250 private static getCategoryLabel (id: number) { 1274 static getCategoryLabel (id: number) {
1251 return VIDEO_CATEGORIES[ id ] || 'Misc' 1275 return VIDEO_CATEGORIES[ id ] || 'Misc'
1252 } 1276 }
1253 1277
1254 private static getLicenceLabel (id: number) { 1278 static getLicenceLabel (id: number) {
1255 return VIDEO_LICENCES[ id ] || 'Unknown' 1279 return VIDEO_LICENCES[ id ] || 'Unknown'
1256 } 1280 }
1257 1281
1258 private static getLanguageLabel (id: string) { 1282 static getLanguageLabel (id: string) {
1259 return VIDEO_LANGUAGES[ id ] || 'Unknown' 1283 return VIDEO_LANGUAGES[ id ] || 'Unknown'
1260 } 1284 }
1261 1285
1262 private static getPrivacyLabel (id: number) { 1286 static getPrivacyLabel (id: number) {
1263 return VIDEO_PRIVACIES[ id ] || 'Unknown' 1287 return VIDEO_PRIVACIES[ id ] || 'Unknown'
1264 } 1288 }
1265 1289
1266 private static getStateLabel (id: number) { 1290 static getStateLabel (id: number) {
1267 return VIDEO_STATES[ id ] || 'Unknown' 1291 return VIDEO_STATES[ id ] || 'Unknown'
1268 } 1292 }
1269 1293
1294 static buildWhereIdOrUUID (id: number | string) {
1295 return validator.isInt('' + id) ? { id } : { uuid: id }
1296 }
1297
1270 getOriginalFile () { 1298 getOriginalFile () {
1271 if (Array.isArray(this.VideoFiles) === false) return undefined 1299 if (Array.isArray(this.VideoFiles) === false) return undefined
1272 1300
@@ -1359,273 +1387,20 @@ export class VideoModel extends Model<VideoModel> {
1359 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName()) 1387 return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
1360 } 1388 }
1361 1389
1362 toFormattedJSON (options?: { 1390 toFormattedJSON (options?: VideoFormattingJSONOptions): Video {
1363 additionalAttributes: { 1391 return videoModelToFormattedJSON(this, options)
1364 state?: boolean,
1365 waitTranscoding?: boolean,
1366 scheduledUpdate?: boolean,
1367 blacklistInfo?: boolean
1368 }
1369 }): Video {
1370 const formattedAccount = this.VideoChannel.Account.toFormattedJSON()
1371 const formattedVideoChannel = this.VideoChannel.toFormattedJSON()
1372
1373 const videoObject: Video = {
1374 id: this.id,
1375 uuid: this.uuid,
1376 name: this.name,
1377 category: {
1378 id: this.category,
1379 label: VideoModel.getCategoryLabel(this.category)
1380 },
1381 licence: {
1382 id: this.licence,
1383 label: VideoModel.getLicenceLabel(this.licence)
1384 },
1385 language: {
1386 id: this.language,
1387 label: VideoModel.getLanguageLabel(this.language)
1388 },
1389 privacy: {
1390 id: this.privacy,
1391 label: VideoModel.getPrivacyLabel(this.privacy)
1392 },
1393 nsfw: this.nsfw,
1394 description: this.getTruncatedDescription(),
1395 isLocal: this.isOwned(),
1396 duration: this.duration,
1397 views: this.views,
1398 likes: this.likes,
1399 dislikes: this.dislikes,
1400 thumbnailPath: this.getThumbnailStaticPath(),
1401 previewPath: this.getPreviewStaticPath(),
1402 embedPath: this.getEmbedStaticPath(),
1403 createdAt: this.createdAt,
1404 updatedAt: this.updatedAt,
1405 publishedAt: this.publishedAt,
1406 account: {
1407 id: formattedAccount.id,
1408 uuid: formattedAccount.uuid,
1409 name: formattedAccount.name,
1410 displayName: formattedAccount.displayName,
1411 url: formattedAccount.url,
1412 host: formattedAccount.host,
1413 avatar: formattedAccount.avatar
1414 },
1415 channel: {
1416 id: formattedVideoChannel.id,
1417 uuid: formattedVideoChannel.uuid,
1418 name: formattedVideoChannel.name,
1419 displayName: formattedVideoChannel.displayName,
1420 url: formattedVideoChannel.url,
1421 host: formattedVideoChannel.host,
1422 avatar: formattedVideoChannel.avatar
1423 }
1424 }
1425
1426 if (options) {
1427 if (options.additionalAttributes.state === true) {
1428 videoObject.state = {
1429 id: this.state,
1430 label: VideoModel.getStateLabel(this.state)
1431 }
1432 }
1433
1434 if (options.additionalAttributes.waitTranscoding === true) {
1435 videoObject.waitTranscoding = this.waitTranscoding
1436 }
1437
1438 if (options.additionalAttributes.scheduledUpdate === true && this.ScheduleVideoUpdate) {
1439 videoObject.scheduledUpdate = {
1440 updateAt: this.ScheduleVideoUpdate.updateAt,
1441 privacy: this.ScheduleVideoUpdate.privacy || undefined
1442 }
1443 }
1444
1445 if (options.additionalAttributes.blacklistInfo === true) {
1446 videoObject.blacklisted = !!this.VideoBlacklist
1447 videoObject.blacklistedReason = this.VideoBlacklist ? this.VideoBlacklist.reason : null
1448 }
1449 }
1450
1451 return videoObject
1452 } 1392 }
1453 1393
1454 toFormattedDetailsJSON (): VideoDetails { 1394 toFormattedDetailsJSON (): VideoDetails {
1455 const formattedJson = this.toFormattedJSON({ 1395 return videoModelToFormattedDetailsJSON(this)
1456 additionalAttributes: {
1457 scheduledUpdate: true,
1458 blacklistInfo: true
1459 }
1460 })
1461
1462 const detailsJson = {
1463 support: this.support,
1464 descriptionPath: this.getDescriptionPath(),
1465 channel: this.VideoChannel.toFormattedJSON(),
1466 account: this.VideoChannel.Account.toFormattedJSON(),
1467 tags: map(this.Tags, 'name'),
1468 commentsEnabled: this.commentsEnabled,
1469 waitTranscoding: this.waitTranscoding,
1470 state: {
1471 id: this.state,
1472 label: VideoModel.getStateLabel(this.state)
1473 },
1474 files: []
1475 }
1476
1477 // Format and sort video files
1478 detailsJson.files = this.getFormattedVideoFilesJSON()
1479
1480 return Object.assign(formattedJson, detailsJson)
1481 } 1396 }
1482 1397
1483 getFormattedVideoFilesJSON (): VideoFile[] { 1398 getFormattedVideoFilesJSON (): VideoFile[] {
1484 const { baseUrlHttp, baseUrlWs } = this.getBaseUrls() 1399 return videoFilesModelToFormattedJSON(this, this.VideoFiles)
1485
1486 return this.VideoFiles
1487 .map(videoFile => {
1488 let resolutionLabel = videoFile.resolution + 'p'
1489
1490 return {
1491 resolution: {
1492 id: videoFile.resolution,
1493 label: resolutionLabel
1494 },
1495 magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
1496 size: videoFile.size,
1497 fps: videoFile.fps,
1498 torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
1499 torrentDownloadUrl: this.getTorrentDownloadUrl(videoFile, baseUrlHttp),
1500 fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp),
1501 fileDownloadUrl: this.getVideoFileDownloadUrl(videoFile, baseUrlHttp)
1502 } as VideoFile
1503 })
1504 .sort((a, b) => {
1505 if (a.resolution.id < b.resolution.id) return 1
1506 if (a.resolution.id === b.resolution.id) return 0
1507 return -1
1508 })
1509 } 1400 }
1510 1401
1511 toActivityPubObject (): VideoTorrentObject { 1402 toActivityPubObject (): VideoTorrentObject {
1512 const { baseUrlHttp, baseUrlWs } = this.getBaseUrls() 1403 return videoModelToActivityPubObject(this)
1513 if (!this.Tags) this.Tags = []
1514
1515 const tag = this.Tags.map(t => ({
1516 type: 'Hashtag' as 'Hashtag',
1517 name: t.name
1518 }))
1519
1520 let language
1521 if (this.language) {
1522 language = {
1523 identifier: this.language,
1524 name: VideoModel.getLanguageLabel(this.language)
1525 }
1526 }
1527
1528 let category
1529 if (this.category) {
1530 category = {
1531 identifier: this.category + '',
1532 name: VideoModel.getCategoryLabel(this.category)
1533 }
1534 }
1535
1536 let licence
1537 if (this.licence) {
1538 licence = {
1539 identifier: this.licence + '',
1540 name: VideoModel.getLicenceLabel(this.licence)
1541 }
1542 }
1543
1544 const url: ActivityUrlObject[] = []
1545 for (const file of this.VideoFiles) {
1546 url.push({
1547 type: 'Link',
1548 mimeType: VIDEO_EXT_MIMETYPE[ file.extname ] as any,
1549 href: this.getVideoFileUrl(file, baseUrlHttp),
1550 height: file.resolution,
1551 size: file.size,
1552 fps: file.fps
1553 })
1554
1555 url.push({
1556 type: 'Link',
1557 mimeType: 'application/x-bittorrent' as 'application/x-bittorrent',
1558 href: this.getTorrentUrl(file, baseUrlHttp),
1559 height: file.resolution
1560 })
1561
1562 url.push({
1563 type: 'Link',
1564 mimeType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
1565 href: this.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
1566 height: file.resolution
1567 })
1568 }
1569
1570 // Add video url too
1571 url.push({
1572 type: 'Link',
1573 mimeType: 'text/html',
1574 href: CONFIG.WEBSERVER.URL + '/videos/watch/' + this.uuid
1575 })
1576
1577 const subtitleLanguage = []
1578 for (const caption of this.VideoCaptions) {
1579 subtitleLanguage.push({
1580 identifier: caption.language,
1581 name: VideoCaptionModel.getLanguageLabel(caption.language)
1582 })
1583 }
1584
1585 return {
1586 type: 'Video' as 'Video',
1587 id: this.url,
1588 name: this.name,
1589 duration: this.getActivityStreamDuration(),
1590 uuid: this.uuid,
1591 tag,
1592 category,
1593 licence,
1594 language,
1595 views: this.views,
1596 sensitive: this.nsfw,
1597 waitTranscoding: this.waitTranscoding,
1598 state: this.state,
1599 commentsEnabled: this.commentsEnabled,
1600 published: this.publishedAt.toISOString(),
1601 updated: this.updatedAt.toISOString(),
1602 mediaType: 'text/markdown',
1603 content: this.getTruncatedDescription(),
1604 support: this.support,
1605 subtitleLanguage,
1606 icon: {
1607 type: 'Image',
1608 url: this.getThumbnailUrl(baseUrlHttp),
1609 mediaType: 'image/jpeg',
1610 width: THUMBNAILS_SIZE.width,
1611 height: THUMBNAILS_SIZE.height
1612 },
1613 url,
1614 likes: getVideoLikesActivityPubUrl(this),
1615 dislikes: getVideoDislikesActivityPubUrl(this),
1616 shares: getVideoSharesActivityPubUrl(this),
1617 comments: getVideoCommentsActivityPubUrl(this),
1618 attributedTo: [
1619 {
1620 type: 'Person',
1621 id: this.VideoChannel.Account.Actor.url
1622 },
1623 {
1624 type: 'Group',
1625 id: this.VideoChannel.Actor.url
1626 }
1627 ]
1628 }
1629 } 1404 }
1630 1405
1631 getTruncatedDescription () { 1406 getTruncatedDescription () {
@@ -1635,130 +1410,13 @@ export class VideoModel extends Model<VideoModel> {
1635 return peertubeTruncate(this.description, maxLength) 1410 return peertubeTruncate(this.description, maxLength)
1636 } 1411 }
1637 1412
1638 async optimizeOriginalVideofile () {
1639 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1640 const newExtname = '.mp4'
1641 const inputVideoFile = this.getOriginalFile()
1642 const videoInputPath = join(videosDirectory, this.getVideoFilename(inputVideoFile))
1643 const videoTranscodedPath = join(videosDirectory, this.id + '-transcoded' + newExtname)
1644
1645 const transcodeOptions = {
1646 inputPath: videoInputPath,
1647 outputPath: videoTranscodedPath
1648 }
1649
1650 // Could be very long!
1651 await transcode(transcodeOptions)
1652
1653 try {
1654 await remove(videoInputPath)
1655
1656 // Important to do this before getVideoFilename() to take in account the new file extension
1657 inputVideoFile.set('extname', newExtname)
1658
1659 const videoOutputPath = this.getVideoFilePath(inputVideoFile)
1660 await rename(videoTranscodedPath, videoOutputPath)
1661 const stats = await stat(videoOutputPath)
1662 const fps = await getVideoFileFPS(videoOutputPath)
1663
1664 inputVideoFile.set('size', stats.size)
1665 inputVideoFile.set('fps', fps)
1666
1667 await this.createTorrentAndSetInfoHash(inputVideoFile)
1668 await inputVideoFile.save()
1669
1670 } catch (err) {
1671 // Auto destruction...
1672 this.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err }))
1673
1674 throw err
1675 }
1676 }
1677
1678 async transcodeOriginalVideofile (resolution: VideoResolution, isPortraitMode: boolean) {
1679 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
1680 const extname = '.mp4'
1681
1682 // We are sure it's x264 in mp4 because optimizeOriginalVideofile was already executed
1683 const videoInputPath = join(videosDirectory, this.getVideoFilename(this.getOriginalFile()))
1684
1685 const newVideoFile = new VideoFileModel({
1686 resolution,
1687 extname,
1688 size: 0,
1689 videoId: this.id
1690 })
1691 const videoOutputPath = join(videosDirectory, this.getVideoFilename(newVideoFile))
1692
1693 const transcodeOptions = {
1694 inputPath: videoInputPath,
1695 outputPath: videoOutputPath,
1696 resolution,
1697 isPortraitMode
1698 }
1699
1700 await transcode(transcodeOptions)
1701
1702 const stats = await stat(videoOutputPath)
1703 const fps = await getVideoFileFPS(videoOutputPath)
1704
1705 newVideoFile.set('size', stats.size)
1706 newVideoFile.set('fps', fps)
1707
1708 await this.createTorrentAndSetInfoHash(newVideoFile)
1709
1710 await newVideoFile.save()
1711
1712 this.VideoFiles.push(newVideoFile)
1713 }
1714
1715 async importVideoFile (inputFilePath: string) {
1716 const { videoFileResolution } = await getVideoFileResolution(inputFilePath)
1717 const { size } = await stat(inputFilePath)
1718 const fps = await getVideoFileFPS(inputFilePath)
1719
1720 let updatedVideoFile = new VideoFileModel({
1721 resolution: videoFileResolution,
1722 extname: extname(inputFilePath),
1723 size,
1724 fps,
1725 videoId: this.id
1726 })
1727
1728 const currentVideoFile = this.VideoFiles.find(videoFile => videoFile.resolution === updatedVideoFile.resolution)
1729
1730 if (currentVideoFile) {
1731 // Remove old file and old torrent
1732 await this.removeFile(currentVideoFile)
1733 await this.removeTorrent(currentVideoFile)
1734 // Remove the old video file from the array
1735 this.VideoFiles = this.VideoFiles.filter(f => f !== currentVideoFile)
1736
1737 // Update the database
1738 currentVideoFile.set('extname', updatedVideoFile.extname)
1739 currentVideoFile.set('size', updatedVideoFile.size)
1740 currentVideoFile.set('fps', updatedVideoFile.fps)
1741
1742 updatedVideoFile = currentVideoFile
1743 }
1744
1745 const outputPath = this.getVideoFilePath(updatedVideoFile)
1746 await copy(inputFilePath, outputPath)
1747
1748 await this.createTorrentAndSetInfoHash(updatedVideoFile)
1749
1750 await updatedVideoFile.save()
1751
1752 this.VideoFiles.push(updatedVideoFile)
1753 }
1754
1755 getOriginalFileResolution () { 1413 getOriginalFileResolution () {
1756 const originalFilePath = this.getVideoFilePath(this.getOriginalFile()) 1414 const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
1757 1415
1758 return getVideoFileResolution(originalFilePath) 1416 return getVideoFileResolution(originalFilePath)
1759 } 1417 }
1760 1418
1761 getDescriptionPath () { 1419 getDescriptionAPIPath () {
1762 return `/api/${API_VERSION}/videos/${this.uuid}/description` 1420 return `/api/${API_VERSION}/videos/${this.uuid}/description`
1763 } 1421 }
1764 1422
@@ -1786,11 +1444,6 @@ export class VideoModel extends Model<VideoModel> {
1786 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err })) 1444 .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
1787 } 1445 }
1788 1446
1789 getActivityStreamDuration () {
1790 // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
1791 return 'PT' + this.duration + 'S'
1792 }
1793
1794 isOutdated () { 1447 isOutdated () {
1795 if (this.isOwned()) return false 1448 if (this.isOwned()) return false
1796 1449