video.ts 45 KB

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