video.ts 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600
  1. import * as Bluebird from 'bluebird'
  2. import { maxBy } from 'lodash'
  3. import * as magnetUtil from 'magnet-uri'
  4. import * as parseTorrent from 'parse-torrent'
  5. import { join } from 'path'
  6. import * as Sequelize from 'sequelize'
  7. import {
  8. AllowNull,
  9. BeforeDestroy,
  10. BelongsTo,
  11. BelongsToMany,
  12. Column,
  13. CreatedAt,
  14. DataType,
  15. Default,
  16. ForeignKey,
  17. HasMany,
  18. HasOne,
  19. IFindOptions,
  20. IIncludeOptions,
  21. Is,
  22. IsInt,
  23. IsUUID,
  24. Min,
  25. Model,
  26. Scopes,
  27. Table,
  28. UpdatedAt
  29. } from 'sequelize-typescript'
  30. import { UserRight, VideoPrivacy, VideoState } from '../../../shared'
  31. import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
  32. import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
  33. import { VideoFilter } from '../../../shared/models/videos/video-query.type'
  34. import { createTorrentPromise, peertubeTruncate } from '../../helpers/core-utils'
  35. import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
  36. import { isArray, isBooleanValid } from '../../helpers/custom-validators/misc'
  37. import {
  38. isVideoCategoryValid,
  39. isVideoDescriptionValid,
  40. isVideoDurationValid,
  41. isVideoLanguageValid,
  42. isVideoLicenceValid,
  43. isVideoNameValid,
  44. isVideoPrivacyValid,
  45. isVideoStateValid,
  46. isVideoSupportValid
  47. } from '../../helpers/custom-validators/videos'
  48. import { generateImageFromVideoFile, getVideoFileResolution } from '../../helpers/ffmpeg-utils'
  49. import { logger } from '../../helpers/logger'
  50. import { getServerActor } from '../../helpers/utils'
  51. import {
  52. ACTIVITY_PUB,
  53. API_VERSION,
  54. CONFIG,
  55. CONSTRAINTS_FIELDS,
  56. PREVIEWS_SIZE,
  57. REMOTE_SCHEME,
  58. STATIC_DOWNLOAD_PATHS,
  59. STATIC_PATHS,
  60. THUMBNAILS_SIZE,
  61. VIDEO_CATEGORIES,
  62. VIDEO_LANGUAGES,
  63. VIDEO_LICENCES,
  64. VIDEO_PRIVACIES,
  65. VIDEO_STATES
  66. } from '../../initializers'
  67. import { sendDeleteVideo } from '../../lib/activitypub/send'
  68. import { AccountModel } from '../account/account'
  69. import { AccountVideoRateModel } from '../account/account-video-rate'
  70. import { ActorModel } from '../activitypub/actor'
  71. import { AvatarModel } from '../avatar/avatar'
  72. import { ServerModel } from '../server/server'
  73. import { buildBlockedAccountSQL, buildTrigramSearchIndex, createSimilarityAttribute, getVideoSort, throwIfNotValid } from '../utils'
  74. import { TagModel } from './tag'
  75. import { VideoAbuseModel } from './video-abuse'
  76. import { VideoChannelModel } from './video-channel'
  77. import { VideoCommentModel } from './video-comment'
  78. import { VideoFileModel } from './video-file'
  79. import { VideoShareModel } from './video-share'
  80. import { VideoTagModel } from './video-tag'
  81. import { ScheduleVideoUpdateModel } from './schedule-video-update'
  82. import { VideoCaptionModel } from './video-caption'
  83. import { VideoBlacklistModel } from './video-blacklist'
  84. import { remove, writeFile } from 'fs-extra'
  85. import { VideoViewModel } from './video-views'
  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'
  95. import { UserVideoHistoryModel } from '../account/user-video-history'
  96. import { UserModel } from '../account/user'
  97. // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
  98. const indexes: Sequelize.DefineIndexesOptions[] = [
  99. buildTrigramSearchIndex('video_name_trigram', 'name'),
  100. { fields: [ 'createdAt' ] },
  101. { fields: [ 'publishedAt' ] },
  102. { fields: [ 'duration' ] },
  103. { fields: [ 'category' ] },
  104. { fields: [ 'licence' ] },
  105. { fields: [ 'nsfw' ] },
  106. { fields: [ 'language' ] },
  107. { fields: [ 'waitTranscoding' ] },
  108. { fields: [ 'state' ] },
  109. { fields: [ 'remote' ] },
  110. { fields: [ 'views' ] },
  111. { fields: [ 'likes' ] },
  112. { fields: [ 'channelId' ] },
  113. {
  114. fields: [ 'uuid' ],
  115. unique: true
  116. },
  117. {
  118. fields: [ 'url' ],
  119. unique: true
  120. }
  121. ]
  122. export enum ScopeNames {
  123. AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS',
  124. FOR_API = 'FOR_API',
  125. WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
  126. WITH_TAGS = 'WITH_TAGS',
  127. WITH_FILES = 'WITH_FILES',
  128. WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
  129. WITH_BLACKLISTED = 'WITH_BLACKLISTED',
  130. WITH_USER_HISTORY = 'WITH_USER_HISTORY'
  131. }
  132. type ForAPIOptions = {
  133. ids: number[]
  134. withFiles?: boolean
  135. }
  136. type AvailableForListIDsOptions = {
  137. serverAccountId: number
  138. actorId: number
  139. includeLocalVideos: boolean
  140. filter?: VideoFilter
  141. categoryOneOf?: number[]
  142. nsfw?: boolean
  143. licenceOneOf?: number[]
  144. languageOneOf?: string[]
  145. tagsOneOf?: string[]
  146. tagsAllOf?: string[]
  147. withFiles?: boolean
  148. accountId?: number
  149. videoChannelId?: number
  150. trendingDays?: number
  151. user?: UserModel
  152. }
  153. @Scopes({
  154. [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => {
  155. const accountInclude = {
  156. attributes: [ 'id', 'name' ],
  157. model: AccountModel.unscoped(),
  158. required: true,
  159. include: [
  160. {
  161. attributes: [ 'id', 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
  162. model: ActorModel.unscoped(),
  163. required: true,
  164. include: [
  165. {
  166. attributes: [ 'host' ],
  167. model: ServerModel.unscoped(),
  168. required: false
  169. },
  170. {
  171. model: AvatarModel.unscoped(),
  172. required: false
  173. }
  174. ]
  175. }
  176. ]
  177. }
  178. const videoChannelInclude = {
  179. attributes: [ 'name', 'description', 'id' ],
  180. model: VideoChannelModel.unscoped(),
  181. required: true,
  182. include: [
  183. {
  184. attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
  185. model: ActorModel.unscoped(),
  186. required: true,
  187. include: [
  188. {
  189. attributes: [ 'host' ],
  190. model: ServerModel.unscoped(),
  191. required: false
  192. },
  193. {
  194. model: AvatarModel.unscoped(),
  195. required: false
  196. }
  197. ]
  198. },
  199. accountInclude
  200. ]
  201. }
  202. const query: IFindOptions<VideoModel> = {
  203. where: {
  204. id: {
  205. [ Sequelize.Op.any ]: options.ids
  206. }
  207. },
  208. include: [ videoChannelInclude ]
  209. }
  210. if (options.withFiles === true) {
  211. query.include.push({
  212. model: VideoFileModel.unscoped(),
  213. required: true
  214. })
  215. }
  216. return query
  217. },
  218. [ ScopeNames.AVAILABLE_FOR_LIST_IDS ]: (options: AvailableForListIDsOptions) => {
  219. const query: IFindOptions<VideoModel> = {
  220. raw: true,
  221. attributes: [ 'id' ],
  222. where: {
  223. id: {
  224. [ Sequelize.Op.and ]: [
  225. {
  226. [ Sequelize.Op.notIn ]: Sequelize.literal(
  227. '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
  228. )
  229. }
  230. ]
  231. },
  232. channelId: {
  233. [ Sequelize.Op.notIn ]: Sequelize.literal(
  234. '(' +
  235. 'SELECT id FROM "videoChannel" WHERE "accountId" IN (' +
  236. buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) +
  237. ')' +
  238. ')'
  239. )
  240. }
  241. },
  242. include: []
  243. }
  244. // Only list public/published videos
  245. if (!options.filter || options.filter !== 'all-local') {
  246. const privacyWhere = {
  247. // Always list public videos
  248. privacy: VideoPrivacy.PUBLIC,
  249. // Always list published videos, or videos that are being transcoded but on which we don't want to wait for transcoding
  250. [ Sequelize.Op.or ]: [
  251. {
  252. state: VideoState.PUBLISHED
  253. },
  254. {
  255. [ Sequelize.Op.and ]: {
  256. state: VideoState.TO_TRANSCODE,
  257. waitTranscoding: false
  258. }
  259. }
  260. ]
  261. }
  262. Object.assign(query.where, privacyWhere)
  263. }
  264. if (options.filter || options.accountId || options.videoChannelId) {
  265. const videoChannelInclude: IIncludeOptions = {
  266. attributes: [],
  267. model: VideoChannelModel.unscoped(),
  268. required: true
  269. }
  270. if (options.videoChannelId) {
  271. videoChannelInclude.where = {
  272. id: options.videoChannelId
  273. }
  274. }
  275. if (options.filter || options.accountId) {
  276. const accountInclude: IIncludeOptions = {
  277. attributes: [],
  278. model: AccountModel.unscoped(),
  279. required: true
  280. }
  281. if (options.filter) {
  282. accountInclude.include = [
  283. {
  284. attributes: [],
  285. model: ActorModel.unscoped(),
  286. required: true,
  287. where: VideoModel.buildActorWhereWithFilter(options.filter)
  288. }
  289. ]
  290. }
  291. if (options.accountId) {
  292. accountInclude.where = { id: options.accountId }
  293. }
  294. videoChannelInclude.include = [ accountInclude ]
  295. }
  296. query.include.push(videoChannelInclude)
  297. }
  298. if (options.actorId) {
  299. let localVideosReq = ''
  300. if (options.includeLocalVideos === true) {
  301. localVideosReq = ' UNION ALL ' +
  302. 'SELECT "video"."id" AS "id" FROM "video" ' +
  303. 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
  304. 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
  305. 'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
  306. 'WHERE "actor"."serverId" IS NULL'
  307. }
  308. // Force actorId to be a number to avoid SQL injections
  309. const actorIdNumber = parseInt(options.actorId.toString(), 10)
  310. query.where[ 'id' ][ Sequelize.Op.and ].push({
  311. [ Sequelize.Op.in ]: Sequelize.literal(
  312. '(' +
  313. 'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' +
  314. 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
  315. 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
  316. ' UNION ALL ' +
  317. 'SELECT "video"."id" AS "id" FROM "video" ' +
  318. 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
  319. 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId" ' +
  320. 'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
  321. 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' +
  322. 'WHERE "actorFollow"."actorId" = ' + actorIdNumber +
  323. localVideosReq +
  324. ')'
  325. )
  326. })
  327. }
  328. if (options.withFiles === true) {
  329. query.where[ 'id' ][ Sequelize.Op.and ].push({
  330. [ Sequelize.Op.in ]: Sequelize.literal(
  331. '(SELECT "videoId" FROM "videoFile")'
  332. )
  333. })
  334. }
  335. // FIXME: issues with sequelize count when making a join on n:m relation, so we just make a IN()
  336. if (options.tagsAllOf || options.tagsOneOf) {
  337. const createTagsIn = (tags: string[]) => {
  338. return tags.map(t => VideoModel.sequelize.escape(t))
  339. .join(', ')
  340. }
  341. if (options.tagsOneOf) {
  342. query.where[ 'id' ][ Sequelize.Op.and ].push({
  343. [ Sequelize.Op.in ]: Sequelize.literal(
  344. '(' +
  345. 'SELECT "videoId" FROM "videoTag" ' +
  346. 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
  347. 'WHERE "tag"."name" IN (' + createTagsIn(options.tagsOneOf) + ')' +
  348. ')'
  349. )
  350. })
  351. }
  352. if (options.tagsAllOf) {
  353. query.where[ 'id' ][ Sequelize.Op.and ].push({
  354. [ Sequelize.Op.in ]: Sequelize.literal(
  355. '(' +
  356. 'SELECT "videoId" FROM "videoTag" ' +
  357. 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
  358. 'WHERE "tag"."name" IN (' + createTagsIn(options.tagsAllOf) + ')' +
  359. 'GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + options.tagsAllOf.length +
  360. ')'
  361. )
  362. })
  363. }
  364. }
  365. if (options.nsfw === true || options.nsfw === false) {
  366. query.where[ 'nsfw' ] = options.nsfw
  367. }
  368. if (options.categoryOneOf) {
  369. query.where[ 'category' ] = {
  370. [ Sequelize.Op.or ]: options.categoryOneOf
  371. }
  372. }
  373. if (options.licenceOneOf) {
  374. query.where[ 'licence' ] = {
  375. [ Sequelize.Op.or ]: options.licenceOneOf
  376. }
  377. }
  378. if (options.languageOneOf) {
  379. query.where[ 'language' ] = {
  380. [ Sequelize.Op.or ]: options.languageOneOf
  381. }
  382. }
  383. if (options.trendingDays) {
  384. query.include.push(VideoModel.buildTrendingQuery(options.trendingDays))
  385. query.subQuery = false
  386. }
  387. return query
  388. },
  389. [ ScopeNames.WITH_ACCOUNT_DETAILS ]: {
  390. include: [
  391. {
  392. model: () => VideoChannelModel.unscoped(),
  393. required: true,
  394. include: [
  395. {
  396. attributes: {
  397. exclude: [ 'privateKey', 'publicKey' ]
  398. },
  399. model: () => ActorModel.unscoped(),
  400. required: true,
  401. include: [
  402. {
  403. attributes: [ 'host' ],
  404. model: () => ServerModel.unscoped(),
  405. required: false
  406. },
  407. {
  408. model: () => AvatarModel.unscoped(),
  409. required: false
  410. }
  411. ]
  412. },
  413. {
  414. model: () => AccountModel.unscoped(),
  415. required: true,
  416. include: [
  417. {
  418. model: () => ActorModel.unscoped(),
  419. attributes: {
  420. exclude: [ 'privateKey', 'publicKey' ]
  421. },
  422. required: true,
  423. include: [
  424. {
  425. attributes: [ 'host' ],
  426. model: () => ServerModel.unscoped(),
  427. required: false
  428. },
  429. {
  430. model: () => AvatarModel.unscoped(),
  431. required: false
  432. }
  433. ]
  434. }
  435. ]
  436. }
  437. ]
  438. }
  439. ]
  440. },
  441. [ ScopeNames.WITH_TAGS ]: {
  442. include: [ () => TagModel ]
  443. },
  444. [ ScopeNames.WITH_BLACKLISTED ]: {
  445. include: [
  446. {
  447. attributes: [ 'id', 'reason' ],
  448. model: () => VideoBlacklistModel,
  449. required: false
  450. }
  451. ]
  452. },
  453. [ ScopeNames.WITH_FILES ]: {
  454. include: [
  455. {
  456. model: () => VideoFileModel.unscoped(),
  457. // FIXME: typings
  458. [ 'separate' as any ]: true, // We may have multiple files, having multiple redundancies so let's separate this join
  459. required: false,
  460. include: [
  461. {
  462. attributes: [ 'fileUrl' ],
  463. model: () => VideoRedundancyModel.unscoped(),
  464. required: false
  465. }
  466. ]
  467. }
  468. ]
  469. },
  470. [ ScopeNames.WITH_SCHEDULED_UPDATE ]: {
  471. include: [
  472. {
  473. model: () => ScheduleVideoUpdateModel.unscoped(),
  474. required: false
  475. }
  476. ]
  477. },
  478. [ ScopeNames.WITH_USER_HISTORY ]: (userId: number) => {
  479. return {
  480. include: [
  481. {
  482. attributes: [ 'currentTime' ],
  483. model: UserVideoHistoryModel.unscoped(),
  484. required: false,
  485. where: {
  486. userId
  487. }
  488. }
  489. ]
  490. }
  491. }
  492. })
  493. @Table({
  494. tableName: 'video',
  495. indexes
  496. })
  497. export class VideoModel extends Model<VideoModel> {
  498. @AllowNull(false)
  499. @Default(DataType.UUIDV4)
  500. @IsUUID(4)
  501. @Column(DataType.UUID)
  502. uuid: string
  503. @AllowNull(false)
  504. @Is('VideoName', value => throwIfNotValid(value, isVideoNameValid, 'name'))
  505. @Column
  506. name: string
  507. @AllowNull(true)
  508. @Default(null)
  509. @Is('VideoCategory', value => throwIfNotValid(value, isVideoCategoryValid, 'category'))
  510. @Column
  511. category: number
  512. @AllowNull(true)
  513. @Default(null)
  514. @Is('VideoLicence', value => throwIfNotValid(value, isVideoLicenceValid, 'licence'))
  515. @Column
  516. licence: number
  517. @AllowNull(true)
  518. @Default(null)
  519. @Is('VideoLanguage', value => throwIfNotValid(value, isVideoLanguageValid, 'language'))
  520. @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max))
  521. language: string
  522. @AllowNull(false)
  523. @Is('VideoPrivacy', value => throwIfNotValid(value, isVideoPrivacyValid, 'privacy'))
  524. @Column
  525. privacy: number
  526. @AllowNull(false)
  527. @Is('VideoNSFW', value => throwIfNotValid(value, isBooleanValid, 'NSFW boolean'))
  528. @Column
  529. nsfw: boolean
  530. @AllowNull(true)
  531. @Default(null)
  532. @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description'))
  533. @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max))
  534. description: string
  535. @AllowNull(true)
  536. @Default(null)
  537. @Is('VideoSupport', value => throwIfNotValid(value, isVideoSupportValid, 'support'))
  538. @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.SUPPORT.max))
  539. support: string
  540. @AllowNull(false)
  541. @Is('VideoDuration', value => throwIfNotValid(value, isVideoDurationValid, 'duration'))
  542. @Column
  543. duration: number
  544. @AllowNull(false)
  545. @Default(0)
  546. @IsInt
  547. @Min(0)
  548. @Column
  549. views: number
  550. @AllowNull(false)
  551. @Default(0)
  552. @IsInt
  553. @Min(0)
  554. @Column
  555. likes: number
  556. @AllowNull(false)
  557. @Default(0)
  558. @IsInt
  559. @Min(0)
  560. @Column
  561. dislikes: number
  562. @AllowNull(false)
  563. @Column
  564. remote: boolean
  565. @AllowNull(false)
  566. @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
  567. @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
  568. url: string
  569. @AllowNull(false)
  570. @Column
  571. commentsEnabled: boolean
  572. @AllowNull(false)
  573. @Column
  574. waitTranscoding: boolean
  575. @AllowNull(false)
  576. @Default(null)
  577. @Is('VideoState', value => throwIfNotValid(value, isVideoStateValid, 'state'))
  578. @Column
  579. state: VideoState
  580. @CreatedAt
  581. createdAt: Date
  582. @UpdatedAt
  583. updatedAt: Date
  584. @AllowNull(false)
  585. @Default(Sequelize.NOW)
  586. @Column
  587. publishedAt: Date
  588. @ForeignKey(() => VideoChannelModel)
  589. @Column
  590. channelId: number
  591. @BelongsTo(() => VideoChannelModel, {
  592. foreignKey: {
  593. allowNull: true
  594. },
  595. hooks: true
  596. })
  597. VideoChannel: VideoChannelModel
  598. @BelongsToMany(() => TagModel, {
  599. foreignKey: 'videoId',
  600. through: () => VideoTagModel,
  601. onDelete: 'CASCADE'
  602. })
  603. Tags: TagModel[]
  604. @HasMany(() => VideoAbuseModel, {
  605. foreignKey: {
  606. name: 'videoId',
  607. allowNull: false
  608. },
  609. onDelete: 'cascade'
  610. })
  611. VideoAbuses: VideoAbuseModel[]
  612. @HasMany(() => VideoFileModel, {
  613. foreignKey: {
  614. name: 'videoId',
  615. allowNull: false
  616. },
  617. hooks: true,
  618. onDelete: 'cascade'
  619. })
  620. VideoFiles: VideoFileModel[]
  621. @HasMany(() => VideoShareModel, {
  622. foreignKey: {
  623. name: 'videoId',
  624. allowNull: false
  625. },
  626. onDelete: 'cascade'
  627. })
  628. VideoShares: VideoShareModel[]
  629. @HasMany(() => AccountVideoRateModel, {
  630. foreignKey: {
  631. name: 'videoId',
  632. allowNull: false
  633. },
  634. onDelete: 'cascade'
  635. })
  636. AccountVideoRates: AccountVideoRateModel[]
  637. @HasMany(() => VideoCommentModel, {
  638. foreignKey: {
  639. name: 'videoId',
  640. allowNull: false
  641. },
  642. onDelete: 'cascade',
  643. hooks: true
  644. })
  645. VideoComments: VideoCommentModel[]
  646. @HasMany(() => VideoViewModel, {
  647. foreignKey: {
  648. name: 'videoId',
  649. allowNull: false
  650. },
  651. onDelete: 'cascade'
  652. })
  653. VideoViews: VideoViewModel[]
  654. @HasMany(() => UserVideoHistoryModel, {
  655. foreignKey: {
  656. name: 'videoId',
  657. allowNull: false
  658. },
  659. onDelete: 'cascade'
  660. })
  661. UserVideoHistories: UserVideoHistoryModel[]
  662. @HasOne(() => ScheduleVideoUpdateModel, {
  663. foreignKey: {
  664. name: 'videoId',
  665. allowNull: false
  666. },
  667. onDelete: 'cascade'
  668. })
  669. ScheduleVideoUpdate: ScheduleVideoUpdateModel
  670. @HasOne(() => VideoBlacklistModel, {
  671. foreignKey: {
  672. name: 'videoId',
  673. allowNull: false
  674. },
  675. onDelete: 'cascade'
  676. })
  677. VideoBlacklist: VideoBlacklistModel
  678. @HasMany(() => VideoCaptionModel, {
  679. foreignKey: {
  680. name: 'videoId',
  681. allowNull: false
  682. },
  683. onDelete: 'cascade',
  684. hooks: true,
  685. [ 'separate' as any ]: true
  686. })
  687. VideoCaptions: VideoCaptionModel[]
  688. @BeforeDestroy
  689. static async sendDelete (instance: VideoModel, options) {
  690. if (instance.isOwned()) {
  691. if (!instance.VideoChannel) {
  692. instance.VideoChannel = await instance.$get('VideoChannel', {
  693. include: [
  694. {
  695. model: AccountModel,
  696. include: [ ActorModel ]
  697. }
  698. ],
  699. transaction: options.transaction
  700. }) as VideoChannelModel
  701. }
  702. return sendDeleteVideo(instance, options.transaction)
  703. }
  704. return undefined
  705. }
  706. @BeforeDestroy
  707. static async removeFiles (instance: VideoModel) {
  708. const tasks: Promise<any>[] = []
  709. logger.info('Removing files of video %s.', instance.url)
  710. tasks.push(instance.removeThumbnail())
  711. if (instance.isOwned()) {
  712. if (!Array.isArray(instance.VideoFiles)) {
  713. instance.VideoFiles = await instance.$get('VideoFiles') as VideoFileModel[]
  714. }
  715. tasks.push(instance.removePreview())
  716. // Remove physical files and torrents
  717. instance.VideoFiles.forEach(file => {
  718. tasks.push(instance.removeFile(file))
  719. tasks.push(instance.removeTorrent(file))
  720. })
  721. }
  722. // Do not wait video deletion because we could be in a transaction
  723. Promise.all(tasks)
  724. .catch(err => {
  725. logger.error('Some errors when removing files of video %s in before destroy hook.', instance.uuid, { err })
  726. })
  727. return undefined
  728. }
  729. static list () {
  730. return VideoModel.scope(ScopeNames.WITH_FILES).findAll()
  731. }
  732. static listLocal () {
  733. const query = {
  734. where: {
  735. remote: false
  736. }
  737. }
  738. return VideoModel.scope(ScopeNames.WITH_FILES).findAll(query)
  739. }
  740. static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
  741. function getRawQuery (select: string) {
  742. const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' +
  743. 'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
  744. 'INNER JOIN "account" AS "Account" ON "Account"."id" = "VideoChannel"."accountId" ' +
  745. 'WHERE "Account"."actorId" = ' + actorId
  746. const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
  747. 'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
  748. 'WHERE "VideoShare"."actorId" = ' + actorId
  749. return `(${queryVideo}) UNION (${queryVideoShare})`
  750. }
  751. const rawQuery = getRawQuery('"Video"."id"')
  752. const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
  753. const query = {
  754. distinct: true,
  755. offset: start,
  756. limit: count,
  757. order: getVideoSort('createdAt', [ 'Tags', 'name', 'ASC' ]),
  758. where: {
  759. id: {
  760. [ Sequelize.Op.in ]: Sequelize.literal('(' + rawQuery + ')')
  761. },
  762. [ Sequelize.Op.or ]: [
  763. { privacy: VideoPrivacy.PUBLIC },
  764. { privacy: VideoPrivacy.UNLISTED }
  765. ]
  766. },
  767. include: [
  768. {
  769. attributes: [ 'language' ],
  770. model: VideoCaptionModel.unscoped(),
  771. required: false
  772. },
  773. {
  774. attributes: [ 'id', 'url' ],
  775. model: VideoShareModel.unscoped(),
  776. required: false,
  777. // We only want videos shared by this actor
  778. where: {
  779. [ Sequelize.Op.and ]: [
  780. {
  781. id: {
  782. [ Sequelize.Op.not ]: null
  783. }
  784. },
  785. {
  786. actorId
  787. }
  788. ]
  789. },
  790. include: [
  791. {
  792. attributes: [ 'id', 'url' ],
  793. model: ActorModel.unscoped()
  794. }
  795. ]
  796. },
  797. {
  798. model: VideoChannelModel.unscoped(),
  799. required: true,
  800. include: [
  801. {
  802. attributes: [ 'name' ],
  803. model: AccountModel.unscoped(),
  804. required: true,
  805. include: [
  806. {
  807. attributes: [ 'id', 'url', 'followersUrl' ],
  808. model: ActorModel.unscoped(),
  809. required: true
  810. }
  811. ]
  812. },
  813. {
  814. attributes: [ 'id', 'url', 'followersUrl' ],
  815. model: ActorModel.unscoped(),
  816. required: true
  817. }
  818. ]
  819. },
  820. VideoFileModel,
  821. TagModel
  822. ]
  823. }
  824. return Bluebird.all([
  825. // FIXME: typing issue
  826. VideoModel.findAll(query as any),
  827. VideoModel.sequelize.query(rawCountQuery, { type: Sequelize.QueryTypes.SELECT })
  828. ]).then(([ rows, totals ]) => {
  829. // totals: totalVideos + totalVideoShares
  830. let totalVideos = 0
  831. let totalVideoShares = 0
  832. if (totals[ 0 ]) totalVideos = parseInt(totals[ 0 ].total, 10)
  833. if (totals[ 1 ]) totalVideoShares = parseInt(totals[ 1 ].total, 10)
  834. const total = totalVideos + totalVideoShares
  835. return {
  836. data: rows,
  837. total: total
  838. }
  839. })
  840. }
  841. static listUserVideosForApi (accountId: number, start: number, count: number, sort: string, withFiles = false) {
  842. const query: IFindOptions<VideoModel> = {
  843. offset: start,
  844. limit: count,
  845. order: getVideoSort(sort),
  846. include: [
  847. {
  848. model: VideoChannelModel,
  849. required: true,
  850. include: [
  851. {
  852. model: AccountModel,
  853. where: {
  854. id: accountId
  855. },
  856. required: true
  857. }
  858. ]
  859. },
  860. {
  861. model: ScheduleVideoUpdateModel,
  862. required: false
  863. },
  864. {
  865. model: VideoBlacklistModel,
  866. required: false
  867. }
  868. ]
  869. }
  870. if (withFiles === true) {
  871. query.include.push({
  872. model: VideoFileModel.unscoped(),
  873. required: true
  874. })
  875. }
  876. return VideoModel.findAndCountAll(query).then(({ rows, count }) => {
  877. return {
  878. data: rows,
  879. total: count
  880. }
  881. })
  882. }
  883. static async listForApi (options: {
  884. start: number,
  885. count: number,
  886. sort: string,
  887. nsfw: boolean,
  888. includeLocalVideos: boolean,
  889. withFiles: boolean,
  890. categoryOneOf?: number[],
  891. licenceOneOf?: number[],
  892. languageOneOf?: string[],
  893. tagsOneOf?: string[],
  894. tagsAllOf?: string[],
  895. filter?: VideoFilter,
  896. accountId?: number,
  897. videoChannelId?: number,
  898. actorId?: number
  899. trendingDays?: number,
  900. user?: UserModel
  901. }, countVideos = true) {
  902. if (options.filter && options.filter === 'all-local' && !options.user.hasRight(UserRight.SEE_ALL_VIDEOS)) {
  903. throw new Error('Try to filter all-local but no user has not the see all videos right')
  904. }
  905. const query: IFindOptions<VideoModel> = {
  906. offset: options.start,
  907. limit: options.count,
  908. order: getVideoSort(options.sort)
  909. }
  910. let trendingDays: number
  911. if (options.sort.endsWith('trending')) {
  912. trendingDays = CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
  913. query.group = 'VideoModel.id'
  914. }
  915. const serverActor = await getServerActor()
  916. // actorId === null has a meaning, so just check undefined
  917. const actorId = options.actorId !== undefined ? options.actorId : serverActor.id
  918. const queryOptions = {
  919. actorId,
  920. serverAccountId: serverActor.Account.id,
  921. nsfw: options.nsfw,
  922. categoryOneOf: options.categoryOneOf,
  923. licenceOneOf: options.licenceOneOf,
  924. languageOneOf: options.languageOneOf,
  925. tagsOneOf: options.tagsOneOf,
  926. tagsAllOf: options.tagsAllOf,
  927. filter: options.filter,
  928. withFiles: options.withFiles,
  929. accountId: options.accountId,
  930. videoChannelId: options.videoChannelId,
  931. includeLocalVideos: options.includeLocalVideos,
  932. user: options.user,
  933. trendingDays
  934. }
  935. return VideoModel.getAvailableForApi(query, queryOptions, countVideos)
  936. }
  937. static async searchAndPopulateAccountAndServer (options: {
  938. includeLocalVideos: boolean
  939. search?: string
  940. start?: number
  941. count?: number
  942. sort?: string
  943. startDate?: string // ISO 8601
  944. endDate?: string // ISO 8601
  945. nsfw?: boolean
  946. categoryOneOf?: number[]
  947. licenceOneOf?: number[]
  948. languageOneOf?: string[]
  949. tagsOneOf?: string[]
  950. tagsAllOf?: string[]
  951. durationMin?: number // seconds
  952. durationMax?: number // seconds
  953. user?: UserModel,
  954. filter?: VideoFilter
  955. }) {
  956. const whereAnd = []
  957. if (options.startDate || options.endDate) {
  958. const publishedAtRange = {}
  959. if (options.startDate) publishedAtRange[ Sequelize.Op.gte ] = options.startDate
  960. if (options.endDate) publishedAtRange[ Sequelize.Op.lte ] = options.endDate
  961. whereAnd.push({ publishedAt: publishedAtRange })
  962. }
  963. if (options.durationMin || options.durationMax) {
  964. const durationRange = {}
  965. if (options.durationMin) durationRange[ Sequelize.Op.gte ] = options.durationMin
  966. if (options.durationMax) durationRange[ Sequelize.Op.lte ] = options.durationMax
  967. whereAnd.push({ duration: durationRange })
  968. }
  969. const attributesInclude = []
  970. const escapedSearch = VideoModel.sequelize.escape(options.search)
  971. const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
  972. if (options.search) {
  973. whereAnd.push(
  974. {
  975. id: {
  976. [ Sequelize.Op.in ]: Sequelize.literal(
  977. '(' +
  978. 'SELECT "video"."id" FROM "video" ' +
  979. 'WHERE ' +
  980. 'lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
  981. 'lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
  982. 'UNION ALL ' +
  983. 'SELECT "video"."id" FROM "video" LEFT JOIN "videoTag" ON "videoTag"."videoId" = "video"."id" ' +
  984. 'INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
  985. 'WHERE "tag"."name" = ' + escapedSearch +
  986. ')'
  987. )
  988. }
  989. }
  990. )
  991. attributesInclude.push(createSimilarityAttribute('VideoModel.name', options.search))
  992. }
  993. // Cannot search on similarity if we don't have a search
  994. if (!options.search) {
  995. attributesInclude.push(
  996. Sequelize.literal('0 as similarity')
  997. )
  998. }
  999. const query: IFindOptions<VideoModel> = {
  1000. attributes: {
  1001. include: attributesInclude
  1002. },
  1003. offset: options.start,
  1004. limit: options.count,
  1005. order: getVideoSort(options.sort),
  1006. where: {
  1007. [ Sequelize.Op.and ]: whereAnd
  1008. }
  1009. }
  1010. const serverActor = await getServerActor()
  1011. const queryOptions = {
  1012. actorId: serverActor.id,
  1013. serverAccountId: serverActor.Account.id,
  1014. includeLocalVideos: options.includeLocalVideos,
  1015. nsfw: options.nsfw,
  1016. categoryOneOf: options.categoryOneOf,
  1017. licenceOneOf: options.licenceOneOf,
  1018. languageOneOf: options.languageOneOf,
  1019. tagsOneOf: options.tagsOneOf,
  1020. tagsAllOf: options.tagsAllOf,
  1021. user: options.user,
  1022. filter: options.filter
  1023. }
  1024. return VideoModel.getAvailableForApi(query, queryOptions)
  1025. }
  1026. static load (id: number | string, t?: Sequelize.Transaction) {
  1027. const where = VideoModel.buildWhereIdOrUUID(id)
  1028. const options = {
  1029. where,
  1030. transaction: t
  1031. }
  1032. return VideoModel.findOne(options)
  1033. }
  1034. static loadOnlyId (id: number | string, t?: Sequelize.Transaction) {
  1035. const where = VideoModel.buildWhereIdOrUUID(id)
  1036. const options = {
  1037. attributes: [ 'id' ],
  1038. where,
  1039. transaction: t
  1040. }
  1041. return VideoModel.findOne(options)
  1042. }
  1043. static loadWithFile (id: number, t?: Sequelize.Transaction, logging?: boolean) {
  1044. return VideoModel.scope(ScopeNames.WITH_FILES)
  1045. .findById(id, { transaction: t, logging })
  1046. }
  1047. static loadByUUIDWithFile (uuid: string) {
  1048. const options = {
  1049. where: {
  1050. uuid
  1051. }
  1052. }
  1053. return VideoModel
  1054. .scope([ ScopeNames.WITH_FILES ])
  1055. .findOne(options)
  1056. }
  1057. static loadByUrl (url: string, transaction?: Sequelize.Transaction) {
  1058. const query: IFindOptions<VideoModel> = {
  1059. where: {
  1060. url
  1061. },
  1062. transaction
  1063. }
  1064. return VideoModel.findOne(query)
  1065. }
  1066. static loadByUrlAndPopulateAccount (url: string, transaction?: Sequelize.Transaction) {
  1067. const query: IFindOptions<VideoModel> = {
  1068. where: {
  1069. url
  1070. },
  1071. transaction
  1072. }
  1073. return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query)
  1074. }
  1075. static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Sequelize.Transaction, userId?: number) {
  1076. const where = VideoModel.buildWhereIdOrUUID(id)
  1077. const options = {
  1078. order: [ [ 'Tags', 'name', 'ASC' ] ],
  1079. where,
  1080. transaction: t
  1081. }
  1082. const scopes = [
  1083. ScopeNames.WITH_TAGS,
  1084. ScopeNames.WITH_BLACKLISTED,
  1085. ScopeNames.WITH_FILES,
  1086. ScopeNames.WITH_ACCOUNT_DETAILS,
  1087. ScopeNames.WITH_SCHEDULED_UPDATE
  1088. ]
  1089. if (userId) {
  1090. scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] } as any) // FIXME: typings
  1091. }
  1092. return VideoModel
  1093. .scope(scopes)
  1094. .findOne(options)
  1095. }
  1096. static async getStats () {
  1097. const totalLocalVideos = await VideoModel.count({
  1098. where: {
  1099. remote: false
  1100. }
  1101. })
  1102. const totalVideos = await VideoModel.count()
  1103. let totalLocalVideoViews = await VideoModel.sum('views', {
  1104. where: {
  1105. remote: false
  1106. }
  1107. })
  1108. // Sequelize could return null...
  1109. if (!totalLocalVideoViews) totalLocalVideoViews = 0
  1110. return {
  1111. totalLocalVideos,
  1112. totalLocalVideoViews,
  1113. totalVideos
  1114. }
  1115. }
  1116. static incrementViews (id: number, views: number) {
  1117. return VideoModel.increment('views', {
  1118. by: views,
  1119. where: {
  1120. id
  1121. }
  1122. })
  1123. }
  1124. // threshold corresponds to how many video the field should have to be returned
  1125. static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
  1126. const serverActor = await getServerActor()
  1127. const actorId = serverActor.id
  1128. const scopeOptions: AvailableForListIDsOptions = {
  1129. serverAccountId: serverActor.Account.id,
  1130. actorId,
  1131. includeLocalVideos: true
  1132. }
  1133. const query: IFindOptions<VideoModel> = {
  1134. attributes: [ field ],
  1135. limit: count,
  1136. group: field,
  1137. having: Sequelize.where(Sequelize.fn('COUNT', Sequelize.col(field)), {
  1138. [ Sequelize.Op.gte ]: threshold
  1139. }) as any, // FIXME: typings
  1140. order: [ this.sequelize.random() ]
  1141. }
  1142. return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST_IDS, scopeOptions ] })
  1143. .findAll(query)
  1144. .then(rows => rows.map(r => r[ field ]))
  1145. }
  1146. static buildTrendingQuery (trendingDays: number) {
  1147. return {
  1148. attributes: [],
  1149. subQuery: false,
  1150. model: VideoViewModel,
  1151. required: false,
  1152. where: {
  1153. startDate: {
  1154. [ Sequelize.Op.gte ]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
  1155. }
  1156. }
  1157. }
  1158. }
  1159. private static buildActorWhereWithFilter (filter?: VideoFilter) {
  1160. if (filter && (filter === 'local' || filter === 'all-local')) {
  1161. return {
  1162. serverId: null
  1163. }
  1164. }
  1165. return {}
  1166. }
  1167. private static async getAvailableForApi (
  1168. query: IFindOptions<VideoModel>,
  1169. options: AvailableForListIDsOptions,
  1170. countVideos = true
  1171. ) {
  1172. const idsScope = {
  1173. method: [
  1174. ScopeNames.AVAILABLE_FOR_LIST_IDS, options
  1175. ]
  1176. }
  1177. // Remove trending sort on count, because it uses a group by
  1178. const countOptions = Object.assign({}, options, { trendingDays: undefined })
  1179. const countQuery = Object.assign({}, query, { attributes: undefined, group: undefined })
  1180. const countScope = {
  1181. method: [
  1182. ScopeNames.AVAILABLE_FOR_LIST_IDS, countOptions
  1183. ]
  1184. }
  1185. const [ count, rowsId ] = await Promise.all([
  1186. countVideos ? VideoModel.scope(countScope).count(countQuery) : Promise.resolve(undefined),
  1187. VideoModel.scope(idsScope).findAll(query)
  1188. ])
  1189. const ids = rowsId.map(r => r.id)
  1190. if (ids.length === 0) return { data: [], total: count }
  1191. // FIXME: typings
  1192. const apiScope: any[] = [
  1193. {
  1194. method: [ ScopeNames.FOR_API, { ids, withFiles: options.withFiles } as ForAPIOptions ]
  1195. }
  1196. ]
  1197. if (options.user) {
  1198. apiScope.push({ method: [ ScopeNames.WITH_USER_HISTORY, options.user.id ] })
  1199. }
  1200. const secondQuery = {
  1201. offset: 0,
  1202. limit: query.limit,
  1203. attributes: query.attributes,
  1204. order: [ // Keep original order
  1205. Sequelize.literal(
  1206. ids.map(id => `"VideoModel".id = ${id} DESC`).join(', ')
  1207. )
  1208. ]
  1209. }
  1210. const rows = await VideoModel.scope(apiScope).findAll(secondQuery)
  1211. return {
  1212. data: rows,
  1213. total: count
  1214. }
  1215. }
  1216. static getCategoryLabel (id: number) {
  1217. return VIDEO_CATEGORIES[ id ] || 'Misc'
  1218. }
  1219. static getLicenceLabel (id: number) {
  1220. return VIDEO_LICENCES[ id ] || 'Unknown'
  1221. }
  1222. static getLanguageLabel (id: string) {
  1223. return VIDEO_LANGUAGES[ id ] || 'Unknown'
  1224. }
  1225. static getPrivacyLabel (id: number) {
  1226. return VIDEO_PRIVACIES[ id ] || 'Unknown'
  1227. }
  1228. static getStateLabel (id: number) {
  1229. return VIDEO_STATES[ id ] || 'Unknown'
  1230. }
  1231. static buildWhereIdOrUUID (id: number | string) {
  1232. return validator.isInt('' + id) ? { id } : { uuid: id }
  1233. }
  1234. getOriginalFile () {
  1235. if (Array.isArray(this.VideoFiles) === false) return undefined
  1236. // The original file is the file that have the higher resolution
  1237. return maxBy(this.VideoFiles, file => file.resolution)
  1238. }
  1239. getVideoFilename (videoFile: VideoFileModel) {
  1240. return this.uuid + '-' + videoFile.resolution + videoFile.extname
  1241. }
  1242. getThumbnailName () {
  1243. // We always have a copy of the thumbnail
  1244. const extension = '.jpg'
  1245. return this.uuid + extension
  1246. }
  1247. getPreviewName () {
  1248. const extension = '.jpg'
  1249. return this.uuid + extension
  1250. }
  1251. getTorrentFileName (videoFile: VideoFileModel) {
  1252. const extension = '.torrent'
  1253. return this.uuid + '-' + videoFile.resolution + extension
  1254. }
  1255. isOwned () {
  1256. return this.remote === false
  1257. }
  1258. createPreview (videoFile: VideoFileModel) {
  1259. return generateImageFromVideoFile(
  1260. this.getVideoFilePath(videoFile),
  1261. CONFIG.STORAGE.PREVIEWS_DIR,
  1262. this.getPreviewName(),
  1263. PREVIEWS_SIZE
  1264. )
  1265. }
  1266. createThumbnail (videoFile: VideoFileModel) {
  1267. return generateImageFromVideoFile(
  1268. this.getVideoFilePath(videoFile),
  1269. CONFIG.STORAGE.THUMBNAILS_DIR,
  1270. this.getThumbnailName(),
  1271. THUMBNAILS_SIZE
  1272. )
  1273. }
  1274. getTorrentFilePath (videoFile: VideoFileModel) {
  1275. return join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
  1276. }
  1277. getVideoFilePath (videoFile: VideoFileModel) {
  1278. return join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
  1279. }
  1280. async createTorrentAndSetInfoHash (videoFile: VideoFileModel) {
  1281. const options = {
  1282. // Keep the extname, it's used by the client to stream the file inside a web browser
  1283. name: `${this.name} ${videoFile.resolution}p${videoFile.extname}`,
  1284. createdBy: 'PeerTube',
  1285. announceList: [
  1286. [ CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT + '/tracker/socket' ],
  1287. [ CONFIG.WEBSERVER.URL + '/tracker/announce' ]
  1288. ],
  1289. urlList: [ CONFIG.WEBSERVER.URL + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile) ]
  1290. }
  1291. const torrent = await createTorrentPromise(this.getVideoFilePath(videoFile), options)
  1292. const filePath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
  1293. logger.info('Creating torrent %s.', filePath)
  1294. await writeFile(filePath, torrent)
  1295. const parsedTorrent = parseTorrent(torrent)
  1296. videoFile.infoHash = parsedTorrent.infoHash
  1297. }
  1298. getEmbedStaticPath () {
  1299. return '/videos/embed/' + this.uuid
  1300. }
  1301. getThumbnailStaticPath () {
  1302. return join(STATIC_PATHS.THUMBNAILS, this.getThumbnailName())
  1303. }
  1304. getPreviewStaticPath () {
  1305. return join(STATIC_PATHS.PREVIEWS, this.getPreviewName())
  1306. }
  1307. toFormattedJSON (options?: VideoFormattingJSONOptions): Video {
  1308. return videoModelToFormattedJSON(this, options)
  1309. }
  1310. toFormattedDetailsJSON (): VideoDetails {
  1311. return videoModelToFormattedDetailsJSON(this)
  1312. }
  1313. getFormattedVideoFilesJSON (): VideoFile[] {
  1314. return videoFilesModelToFormattedJSON(this, this.VideoFiles)
  1315. }
  1316. toActivityPubObject (): VideoTorrentObject {
  1317. return videoModelToActivityPubObject(this)
  1318. }
  1319. getTruncatedDescription () {
  1320. if (!this.description) return null
  1321. const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
  1322. return peertubeTruncate(this.description, maxLength)
  1323. }
  1324. getOriginalFileResolution () {
  1325. const originalFilePath = this.getVideoFilePath(this.getOriginalFile())
  1326. return getVideoFileResolution(originalFilePath)
  1327. }
  1328. getDescriptionAPIPath () {
  1329. return `/api/${API_VERSION}/videos/${this.uuid}/description`
  1330. }
  1331. removeThumbnail () {
  1332. const thumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, this.getThumbnailName())
  1333. return remove(thumbnailPath)
  1334. .catch(err => logger.warn('Cannot delete thumbnail %s.', thumbnailPath, { err }))
  1335. }
  1336. removePreview () {
  1337. const previewPath = join(CONFIG.STORAGE.PREVIEWS_DIR + this.getPreviewName())
  1338. return remove(previewPath)
  1339. .catch(err => logger.warn('Cannot delete preview %s.', previewPath, { err }))
  1340. }
  1341. removeFile (videoFile: VideoFileModel) {
  1342. const filePath = join(CONFIG.STORAGE.VIDEOS_DIR, this.getVideoFilename(videoFile))
  1343. return remove(filePath)
  1344. .catch(err => logger.warn('Cannot delete file %s.', filePath, { err }))
  1345. }
  1346. removeTorrent (videoFile: VideoFileModel) {
  1347. const torrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, this.getTorrentFileName(videoFile))
  1348. return remove(torrentPath)
  1349. .catch(err => logger.warn('Cannot delete torrent %s.', torrentPath, { err }))
  1350. }
  1351. isOutdated () {
  1352. if (this.isOwned()) return false
  1353. const now = Date.now()
  1354. const createdAtTime = this.createdAt.getTime()
  1355. const updatedAtTime = this.updatedAt.getTime()
  1356. return (now - createdAtTime) > ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL &&
  1357. (now - updatedAtTime) > ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL
  1358. }
  1359. getBaseUrls () {
  1360. let baseUrlHttp
  1361. let baseUrlWs
  1362. if (this.isOwned()) {
  1363. baseUrlHttp = CONFIG.WEBSERVER.URL
  1364. baseUrlWs = CONFIG.WEBSERVER.WS + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
  1365. } else {
  1366. baseUrlHttp = REMOTE_SCHEME.HTTP + '://' + this.VideoChannel.Account.Actor.Server.host
  1367. baseUrlWs = REMOTE_SCHEME.WS + '://' + this.VideoChannel.Account.Actor.Server.host
  1368. }
  1369. return { baseUrlHttp, baseUrlWs }
  1370. }
  1371. generateMagnetUri (videoFile: VideoFileModel, baseUrlHttp: string, baseUrlWs: string) {
  1372. const xs = this.getTorrentUrl(videoFile, baseUrlHttp)
  1373. const announce = [ baseUrlWs + '/tracker/socket', baseUrlHttp + '/tracker/announce' ]
  1374. let urlList = [ this.getVideoFileUrl(videoFile, baseUrlHttp) ]
  1375. const redundancies = videoFile.RedundancyVideos
  1376. if (isArray(redundancies)) urlList = urlList.concat(redundancies.map(r => r.fileUrl))
  1377. const magnetHash = {
  1378. xs,
  1379. announce,
  1380. urlList,
  1381. infoHash: videoFile.infoHash,
  1382. name: this.name
  1383. }
  1384. return magnetUtil.encode(magnetHash)
  1385. }
  1386. getThumbnailUrl (baseUrlHttp: string) {
  1387. return baseUrlHttp + STATIC_PATHS.THUMBNAILS + this.getThumbnailName()
  1388. }
  1389. getTorrentUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
  1390. return baseUrlHttp + STATIC_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
  1391. }
  1392. getTorrentDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
  1393. return baseUrlHttp + STATIC_DOWNLOAD_PATHS.TORRENTS + this.getTorrentFileName(videoFile)
  1394. }
  1395. getVideoFileUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
  1396. return baseUrlHttp + STATIC_PATHS.WEBSEED + this.getVideoFilename(videoFile)
  1397. }
  1398. getVideoFileDownloadUrl (videoFile: VideoFileModel, baseUrlHttp: string) {
  1399. return baseUrlHttp + STATIC_DOWNLOAD_PATHS.VIDEOS + this.getVideoFilename(videoFile)
  1400. }
  1401. }