diff options
Diffstat (limited to 'server/models/video/video.ts')
-rw-r--r-- | server/models/video/video.ts | 555 |
1 files changed, 104 insertions, 451 deletions
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 @@ | |||
1 | import * as Bluebird from 'bluebird' | 1 | import * as Bluebird from 'bluebird' |
2 | import { map, maxBy } from 'lodash' | 2 | import { maxBy } from 'lodash' |
3 | import * as magnetUtil from 'magnet-uri' | 3 | import * as magnetUtil from 'magnet-uri' |
4 | import * as parseTorrent from 'parse-torrent' | 4 | import * as parseTorrent from 'parse-torrent' |
5 | import { extname, join } from 'path' | 5 | import { join } from 'path' |
6 | import * as Sequelize from 'sequelize' | 6 | import * as Sequelize from 'sequelize' |
7 | import { | 7 | import { |
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' |
30 | import { ActivityUrlObject, VideoPrivacy, VideoResolution, VideoState } from '../../../shared' | 30 | import { VideoPrivacy, VideoState } from '../../../shared' |
31 | import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' | 31 | import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' |
32 | import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos' | 32 | import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos' |
33 | import { VideoFilter } from '../../../shared/models/videos/video-query.type' | 33 | import { 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' |
48 | import { generateImageFromVideoFile, getVideoFileFPS, getVideoFileResolution, transcode } from '../../helpers/ffmpeg-utils' | 48 | import { generateImageFromVideoFile, getVideoFileResolution } from '../../helpers/ffmpeg-utils' |
49 | import { logger } from '../../helpers/logger' | 49 | import { logger } from '../../helpers/logger' |
50 | import { getServerActor } from '../../helpers/utils' | 50 | import { getServerActor } from '../../helpers/utils' |
51 | import { | 51 | import { |
@@ -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' |
68 | import { | ||
69 | getVideoCommentsActivityPubUrl, | ||
70 | getVideoDislikesActivityPubUrl, | ||
71 | getVideoLikesActivityPubUrl, | ||
72 | getVideoSharesActivityPubUrl | ||
73 | } from '../../lib/activitypub' | ||
74 | import { sendDeleteVideo } from '../../lib/activitypub/send' | 67 | import { sendDeleteVideo } from '../../lib/activitypub/send' |
75 | import { AccountModel } from '../account/account' | 68 | import { AccountModel } from '../account/account' |
76 | import { AccountVideoRateModel } from '../account/account-video-rate' | 69 | import { AccountVideoRateModel } from '../account/account-video-rate' |
@@ -88,9 +81,17 @@ import { VideoTagModel } from './video-tag' | |||
88 | import { ScheduleVideoUpdateModel } from './schedule-video-update' | 81 | import { ScheduleVideoUpdateModel } from './schedule-video-update' |
89 | import { VideoCaptionModel } from './video-caption' | 82 | import { VideoCaptionModel } from './video-caption' |
90 | import { VideoBlacklistModel } from './video-blacklist' | 83 | import { VideoBlacklistModel } from './video-blacklist' |
91 | import { copy, remove, rename, stat, writeFile } from 'fs-extra' | 84 | import { remove, writeFile } from 'fs-extra' |
92 | import { VideoViewModel } from './video-views' | 85 | import { VideoViewModel } from './video-views' |
93 | import { VideoRedundancyModel } from '../redundancy/video-redundancy' | 86 | import { VideoRedundancyModel } from '../redundancy/video-redundancy' |
87 | import { | ||
88 | videoFilesModelToFormattedJSON, | ||
89 | VideoFormattingJSONOptions, | ||
90 | videoModelToActivityPubObject, | ||
91 | videoModelToFormattedDetailsJSON, | ||
92 | videoModelToFormattedJSON | ||
93 | } from './video-format-utils' | ||
94 | import * 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 |
96 | const indexes: Sequelize.DefineIndexesOptions[] = [ | 97 | const 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 | ||