2
1

video.ts 51 KB

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