video.ts 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870
  1. import Bluebird from 'bluebird'
  2. import { remove } from 'fs-extra'
  3. import { maxBy, minBy } from 'lodash'
  4. import { join } from 'path'
  5. import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
  6. import {
  7. AllowNull,
  8. BeforeDestroy,
  9. BelongsTo,
  10. BelongsToMany,
  11. Column,
  12. CreatedAt,
  13. DataType,
  14. Default,
  15. ForeignKey,
  16. HasMany,
  17. HasOne,
  18. Is,
  19. IsInt,
  20. IsUUID,
  21. Min,
  22. Model,
  23. Scopes,
  24. Table,
  25. UpdatedAt
  26. } from 'sequelize-typescript'
  27. import { buildNSFWFilter } from '@server/helpers/express-utils'
  28. import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
  29. import { LiveManager } from '@server/lib/live/live-manager'
  30. import { removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage'
  31. import { getHLSDirectory, getHLSRedundancyDirectory } from '@server/lib/paths'
  32. import { VideoPathManager } from '@server/lib/video-path-manager'
  33. import { getServerActor } from '@server/models/application/application'
  34. import { ModelCache } from '@server/models/model-cache'
  35. import { buildVideoEmbedPath, buildVideoWatchPath, pick } from '@shared/core-utils'
  36. import { ffprobePromise, getAudioStream, uuidToShort } from '@shared/extra-utils'
  37. import {
  38. ResultList,
  39. ThumbnailType,
  40. UserRight,
  41. Video,
  42. VideoDetails,
  43. VideoFile,
  44. VideoInclude,
  45. VideoObject,
  46. VideoPrivacy,
  47. VideoRateType,
  48. VideoState,
  49. VideoStorage,
  50. VideoStreamingPlaylistType
  51. } from '@shared/models'
  52. import { AttributesOnly } from '@shared/typescript-utils'
  53. import { peertubeTruncate } from '../../helpers/core-utils'
  54. import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
  55. import { exists, isBooleanValid } from '../../helpers/custom-validators/misc'
  56. import {
  57. isVideoDescriptionValid,
  58. isVideoDurationValid,
  59. isVideoNameValid,
  60. isVideoPrivacyValid,
  61. isVideoStateValid,
  62. isVideoSupportValid
  63. } from '../../helpers/custom-validators/videos'
  64. import { getVideoStreamDimensionsInfo } from '../../helpers/ffmpeg'
  65. import { logger } from '../../helpers/logger'
  66. import { CONFIG } from '../../initializers/config'
  67. import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
  68. import { sendDeleteVideo } from '../../lib/activitypub/send'
  69. import {
  70. MChannel,
  71. MChannelAccountDefault,
  72. MChannelId,
  73. MStreamingPlaylist,
  74. MStreamingPlaylistFilesVideo,
  75. MUserAccountId,
  76. MUserId,
  77. MVideo,
  78. MVideoAccountLight,
  79. MVideoAccountLightBlacklistAllFiles,
  80. MVideoAP,
  81. MVideoDetails,
  82. MVideoFileVideo,
  83. MVideoFormattable,
  84. MVideoFormattableDetails,
  85. MVideoForUser,
  86. MVideoFullLight,
  87. MVideoId,
  88. MVideoImmutable,
  89. MVideoThumbnail,
  90. MVideoThumbnailBlacklist,
  91. MVideoWithAllFiles,
  92. MVideoWithFile
  93. } from '../../types/models'
  94. import { MThumbnail } from '../../types/models/video/thumbnail'
  95. import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file'
  96. import { VideoAbuseModel } from '../abuse/video-abuse'
  97. import { AccountModel } from '../account/account'
  98. import { AccountVideoRateModel } from '../account/account-video-rate'
  99. import { ActorModel } from '../actor/actor'
  100. import { ActorImageModel } from '../actor/actor-image'
  101. import { VideoRedundancyModel } from '../redundancy/video-redundancy'
  102. import { ServerModel } from '../server/server'
  103. import { TrackerModel } from '../server/tracker'
  104. import { VideoTrackerModel } from '../server/video-tracker'
  105. import { setAsUpdated } from '../shared'
  106. import { UserModel } from '../user/user'
  107. import { UserVideoHistoryModel } from '../user/user-video-history'
  108. import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
  109. import { VideoViewModel } from '../view/video-view'
  110. import {
  111. videoFilesModelToFormattedJSON,
  112. VideoFormattingJSONOptions,
  113. videoModelToActivityPubObject,
  114. videoModelToFormattedDetailsJSON,
  115. videoModelToFormattedJSON
  116. } from './formatter/video-format-utils'
  117. import { ScheduleVideoUpdateModel } from './schedule-video-update'
  118. import {
  119. BuildVideosListQueryOptions,
  120. DisplayOnlyForFollowerOptions,
  121. VideoModelGetQueryBuilder,
  122. VideosIdListQueryBuilder,
  123. VideosModelListQueryBuilder
  124. } from './sql/video'
  125. import { TagModel } from './tag'
  126. import { ThumbnailModel } from './thumbnail'
  127. import { VideoBlacklistModel } from './video-blacklist'
  128. import { VideoCaptionModel } from './video-caption'
  129. import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
  130. import { VideoCommentModel } from './video-comment'
  131. import { VideoFileModel } from './video-file'
  132. import { VideoImportModel } from './video-import'
  133. import { VideoJobInfoModel } from './video-job-info'
  134. import { VideoLiveModel } from './video-live'
  135. import { VideoPlaylistElementModel } from './video-playlist-element'
  136. import { VideoShareModel } from './video-share'
  137. import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
  138. import { VideoTagModel } from './video-tag'
  139. export enum ScopeNames {
  140. FOR_API = 'FOR_API',
  141. WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
  142. WITH_TAGS = 'WITH_TAGS',
  143. WITH_WEBTORRENT_FILES = 'WITH_WEBTORRENT_FILES',
  144. WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
  145. WITH_BLACKLISTED = 'WITH_BLACKLISTED',
  146. WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
  147. WITH_IMMUTABLE_ATTRIBUTES = 'WITH_IMMUTABLE_ATTRIBUTES',
  148. WITH_USER_HISTORY = 'WITH_USER_HISTORY',
  149. WITH_THUMBNAILS = 'WITH_THUMBNAILS'
  150. }
  151. export type ForAPIOptions = {
  152. ids?: number[]
  153. videoPlaylistId?: number
  154. withAccountBlockerIds?: number[]
  155. }
  156. @Scopes(() => ({
  157. [ScopeNames.WITH_IMMUTABLE_ATTRIBUTES]: {
  158. attributes: [ 'id', 'url', 'uuid', 'remote' ]
  159. },
  160. [ScopeNames.FOR_API]: (options: ForAPIOptions) => {
  161. const include: Includeable[] = [
  162. {
  163. model: VideoChannelModel.scope({
  164. method: [
  165. VideoChannelScopeNames.SUMMARY, {
  166. withAccount: true,
  167. withAccountBlockerIds: options.withAccountBlockerIds
  168. } as SummaryOptions
  169. ]
  170. }),
  171. required: true
  172. },
  173. {
  174. attributes: [ 'type', 'filename' ],
  175. model: ThumbnailModel,
  176. required: false
  177. }
  178. ]
  179. const query: FindOptions = {}
  180. if (options.ids) {
  181. query.where = {
  182. id: {
  183. [Op.in]: options.ids
  184. }
  185. }
  186. }
  187. if (options.videoPlaylistId) {
  188. include.push({
  189. model: VideoPlaylistElementModel.unscoped(),
  190. required: true,
  191. where: {
  192. videoPlaylistId: options.videoPlaylistId
  193. }
  194. })
  195. }
  196. query.include = include
  197. return query
  198. },
  199. [ScopeNames.WITH_THUMBNAILS]: {
  200. include: [
  201. {
  202. model: ThumbnailModel,
  203. required: false
  204. }
  205. ]
  206. },
  207. [ScopeNames.WITH_ACCOUNT_DETAILS]: {
  208. include: [
  209. {
  210. model: VideoChannelModel.unscoped(),
  211. required: true,
  212. include: [
  213. {
  214. attributes: {
  215. exclude: [ 'privateKey', 'publicKey' ]
  216. },
  217. model: ActorModel.unscoped(),
  218. required: true,
  219. include: [
  220. {
  221. attributes: [ 'host' ],
  222. model: ServerModel.unscoped(),
  223. required: false
  224. },
  225. {
  226. model: ActorImageModel,
  227. as: 'Avatars',
  228. required: false
  229. }
  230. ]
  231. },
  232. {
  233. model: AccountModel.unscoped(),
  234. required: true,
  235. include: [
  236. {
  237. model: ActorModel.unscoped(),
  238. attributes: {
  239. exclude: [ 'privateKey', 'publicKey' ]
  240. },
  241. required: true,
  242. include: [
  243. {
  244. attributes: [ 'host' ],
  245. model: ServerModel.unscoped(),
  246. required: false
  247. },
  248. {
  249. model: ActorImageModel,
  250. as: 'Avatars',
  251. required: false
  252. }
  253. ]
  254. }
  255. ]
  256. }
  257. ]
  258. }
  259. ]
  260. },
  261. [ScopeNames.WITH_TAGS]: {
  262. include: [ TagModel ]
  263. },
  264. [ScopeNames.WITH_BLACKLISTED]: {
  265. include: [
  266. {
  267. attributes: [ 'id', 'reason', 'unfederated' ],
  268. model: VideoBlacklistModel,
  269. required: false
  270. }
  271. ]
  272. },
  273. [ScopeNames.WITH_WEBTORRENT_FILES]: (withRedundancies = false) => {
  274. let subInclude: any[] = []
  275. if (withRedundancies === true) {
  276. subInclude = [
  277. {
  278. attributes: [ 'fileUrl' ],
  279. model: VideoRedundancyModel.unscoped(),
  280. required: false
  281. }
  282. ]
  283. }
  284. return {
  285. include: [
  286. {
  287. model: VideoFileModel,
  288. separate: true,
  289. required: false,
  290. include: subInclude
  291. }
  292. ]
  293. }
  294. },
  295. [ScopeNames.WITH_STREAMING_PLAYLISTS]: (withRedundancies = false) => {
  296. const subInclude: IncludeOptions[] = [
  297. {
  298. model: VideoFileModel,
  299. required: false
  300. }
  301. ]
  302. if (withRedundancies === true) {
  303. subInclude.push({
  304. attributes: [ 'fileUrl' ],
  305. model: VideoRedundancyModel.unscoped(),
  306. required: false
  307. })
  308. }
  309. return {
  310. include: [
  311. {
  312. model: VideoStreamingPlaylistModel.unscoped(),
  313. required: false,
  314. separate: true,
  315. include: subInclude
  316. }
  317. ]
  318. }
  319. },
  320. [ScopeNames.WITH_SCHEDULED_UPDATE]: {
  321. include: [
  322. {
  323. model: ScheduleVideoUpdateModel.unscoped(),
  324. required: false
  325. }
  326. ]
  327. },
  328. [ScopeNames.WITH_USER_HISTORY]: (userId: number) => {
  329. return {
  330. include: [
  331. {
  332. attributes: [ 'currentTime' ],
  333. model: UserVideoHistoryModel.unscoped(),
  334. required: false,
  335. where: {
  336. userId
  337. }
  338. }
  339. ]
  340. }
  341. }
  342. }))
  343. @Table({
  344. tableName: 'video',
  345. indexes: [
  346. buildTrigramSearchIndex('video_name_trigram', 'name'),
  347. { fields: [ 'createdAt' ] },
  348. {
  349. fields: [
  350. { name: 'publishedAt', order: 'DESC' },
  351. { name: 'id', order: 'ASC' }
  352. ]
  353. },
  354. { fields: [ 'duration' ] },
  355. {
  356. fields: [
  357. { name: 'views', order: 'DESC' },
  358. { name: 'id', order: 'ASC' }
  359. ]
  360. },
  361. { fields: [ 'channelId' ] },
  362. {
  363. fields: [ 'originallyPublishedAt' ],
  364. where: {
  365. originallyPublishedAt: {
  366. [Op.ne]: null
  367. }
  368. }
  369. },
  370. {
  371. fields: [ 'category' ], // We don't care videos with an unknown category
  372. where: {
  373. category: {
  374. [Op.ne]: null
  375. }
  376. }
  377. },
  378. {
  379. fields: [ 'licence' ], // We don't care videos with an unknown licence
  380. where: {
  381. licence: {
  382. [Op.ne]: null
  383. }
  384. }
  385. },
  386. {
  387. fields: [ 'language' ], // We don't care videos with an unknown language
  388. where: {
  389. language: {
  390. [Op.ne]: null
  391. }
  392. }
  393. },
  394. {
  395. fields: [ 'nsfw' ], // Most of the videos are not NSFW
  396. where: {
  397. nsfw: true
  398. }
  399. },
  400. {
  401. fields: [ 'remote' ], // Only index local videos
  402. where: {
  403. remote: false
  404. }
  405. },
  406. {
  407. fields: [ 'uuid' ],
  408. unique: true
  409. },
  410. {
  411. fields: [ 'url' ],
  412. unique: true
  413. }
  414. ]
  415. })
  416. export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
  417. @AllowNull(false)
  418. @Default(DataType.UUIDV4)
  419. @IsUUID(4)
  420. @Column(DataType.UUID)
  421. uuid: string
  422. @AllowNull(false)
  423. @Is('VideoName', value => throwIfNotValid(value, isVideoNameValid, 'name'))
  424. @Column
  425. name: string
  426. @AllowNull(true)
  427. @Default(null)
  428. @Column
  429. category: number
  430. @AllowNull(true)
  431. @Default(null)
  432. @Column
  433. licence: number
  434. @AllowNull(true)
  435. @Default(null)
  436. @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max))
  437. language: string
  438. @AllowNull(false)
  439. @Is('VideoPrivacy', value => throwIfNotValid(value, isVideoPrivacyValid, 'privacy'))
  440. @Column
  441. privacy: VideoPrivacy
  442. @AllowNull(false)
  443. @Is('VideoNSFW', value => throwIfNotValid(value, isBooleanValid, 'NSFW boolean'))
  444. @Column
  445. nsfw: boolean
  446. @AllowNull(true)
  447. @Default(null)
  448. @Is('VideoDescription', value => throwIfNotValid(value, isVideoDescriptionValid, 'description', true))
  449. @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max))
  450. description: string
  451. @AllowNull(true)
  452. @Default(null)
  453. @Is('VideoSupport', value => throwIfNotValid(value, isVideoSupportValid, 'support', true))
  454. @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.SUPPORT.max))
  455. support: string
  456. @AllowNull(false)
  457. @Is('VideoDuration', value => throwIfNotValid(value, isVideoDurationValid, 'duration'))
  458. @Column
  459. duration: number
  460. @AllowNull(false)
  461. @Default(0)
  462. @IsInt
  463. @Min(0)
  464. @Column
  465. views: number
  466. @AllowNull(false)
  467. @Default(0)
  468. @IsInt
  469. @Min(0)
  470. @Column
  471. likes: number
  472. @AllowNull(false)
  473. @Default(0)
  474. @IsInt
  475. @Min(0)
  476. @Column
  477. dislikes: number
  478. @AllowNull(false)
  479. @Column
  480. remote: boolean
  481. @AllowNull(false)
  482. @Default(false)
  483. @Column
  484. isLive: boolean
  485. @AllowNull(false)
  486. @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
  487. @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
  488. url: string
  489. @AllowNull(false)
  490. @Column
  491. commentsEnabled: boolean
  492. @AllowNull(false)
  493. @Column
  494. downloadEnabled: boolean
  495. @AllowNull(false)
  496. @Column
  497. waitTranscoding: boolean
  498. @AllowNull(false)
  499. @Default(null)
  500. @Is('VideoState', value => throwIfNotValid(value, isVideoStateValid, 'state'))
  501. @Column
  502. state: VideoState
  503. @CreatedAt
  504. createdAt: Date
  505. @UpdatedAt
  506. updatedAt: Date
  507. @AllowNull(false)
  508. @Default(DataType.NOW)
  509. @Column
  510. publishedAt: Date
  511. @AllowNull(true)
  512. @Default(null)
  513. @Column
  514. originallyPublishedAt: Date
  515. @ForeignKey(() => VideoChannelModel)
  516. @Column
  517. channelId: number
  518. @BelongsTo(() => VideoChannelModel, {
  519. foreignKey: {
  520. allowNull: true
  521. },
  522. onDelete: 'cascade'
  523. })
  524. VideoChannel: VideoChannelModel
  525. @BelongsToMany(() => TagModel, {
  526. foreignKey: 'videoId',
  527. through: () => VideoTagModel,
  528. onDelete: 'CASCADE'
  529. })
  530. Tags: TagModel[]
  531. @BelongsToMany(() => TrackerModel, {
  532. foreignKey: 'videoId',
  533. through: () => VideoTrackerModel,
  534. onDelete: 'CASCADE'
  535. })
  536. Trackers: TrackerModel[]
  537. @HasMany(() => ThumbnailModel, {
  538. foreignKey: {
  539. name: 'videoId',
  540. allowNull: true
  541. },
  542. hooks: true,
  543. onDelete: 'cascade'
  544. })
  545. Thumbnails: ThumbnailModel[]
  546. @HasMany(() => VideoPlaylistElementModel, {
  547. foreignKey: {
  548. name: 'videoId',
  549. allowNull: true
  550. },
  551. onDelete: 'set null'
  552. })
  553. VideoPlaylistElements: VideoPlaylistElementModel[]
  554. @HasMany(() => VideoAbuseModel, {
  555. foreignKey: {
  556. name: 'videoId',
  557. allowNull: true
  558. },
  559. onDelete: 'set null'
  560. })
  561. VideoAbuses: VideoAbuseModel[]
  562. @HasMany(() => VideoFileModel, {
  563. foreignKey: {
  564. name: 'videoId',
  565. allowNull: true
  566. },
  567. hooks: true,
  568. onDelete: 'cascade'
  569. })
  570. VideoFiles: VideoFileModel[]
  571. @HasMany(() => VideoStreamingPlaylistModel, {
  572. foreignKey: {
  573. name: 'videoId',
  574. allowNull: false
  575. },
  576. hooks: true,
  577. onDelete: 'cascade'
  578. })
  579. VideoStreamingPlaylists: VideoStreamingPlaylistModel[]
  580. @HasMany(() => VideoShareModel, {
  581. foreignKey: {
  582. name: 'videoId',
  583. allowNull: false
  584. },
  585. onDelete: 'cascade'
  586. })
  587. VideoShares: VideoShareModel[]
  588. @HasMany(() => AccountVideoRateModel, {
  589. foreignKey: {
  590. name: 'videoId',
  591. allowNull: false
  592. },
  593. onDelete: 'cascade'
  594. })
  595. AccountVideoRates: AccountVideoRateModel[]
  596. @HasMany(() => VideoCommentModel, {
  597. foreignKey: {
  598. name: 'videoId',
  599. allowNull: false
  600. },
  601. onDelete: 'cascade',
  602. hooks: true
  603. })
  604. VideoComments: VideoCommentModel[]
  605. @HasMany(() => VideoViewModel, {
  606. foreignKey: {
  607. name: 'videoId',
  608. allowNull: false
  609. },
  610. onDelete: 'cascade'
  611. })
  612. VideoViews: VideoViewModel[]
  613. @HasMany(() => UserVideoHistoryModel, {
  614. foreignKey: {
  615. name: 'videoId',
  616. allowNull: false
  617. },
  618. onDelete: 'cascade'
  619. })
  620. UserVideoHistories: UserVideoHistoryModel[]
  621. @HasOne(() => ScheduleVideoUpdateModel, {
  622. foreignKey: {
  623. name: 'videoId',
  624. allowNull: false
  625. },
  626. onDelete: 'cascade'
  627. })
  628. ScheduleVideoUpdate: ScheduleVideoUpdateModel
  629. @HasOne(() => VideoBlacklistModel, {
  630. foreignKey: {
  631. name: 'videoId',
  632. allowNull: false
  633. },
  634. onDelete: 'cascade'
  635. })
  636. VideoBlacklist: VideoBlacklistModel
  637. @HasOne(() => VideoLiveModel, {
  638. foreignKey: {
  639. name: 'videoId',
  640. allowNull: false
  641. },
  642. onDelete: 'cascade'
  643. })
  644. VideoLive: VideoLiveModel
  645. @HasOne(() => VideoImportModel, {
  646. foreignKey: {
  647. name: 'videoId',
  648. allowNull: true
  649. },
  650. onDelete: 'set null'
  651. })
  652. VideoImport: VideoImportModel
  653. @HasMany(() => VideoCaptionModel, {
  654. foreignKey: {
  655. name: 'videoId',
  656. allowNull: false
  657. },
  658. onDelete: 'cascade',
  659. hooks: true,
  660. ['separate' as any]: true
  661. })
  662. VideoCaptions: VideoCaptionModel[]
  663. @HasOne(() => VideoJobInfoModel, {
  664. foreignKey: {
  665. name: 'videoId',
  666. allowNull: false
  667. },
  668. onDelete: 'cascade'
  669. })
  670. VideoJobInfo: VideoJobInfoModel
  671. @BeforeDestroy
  672. static async sendDelete (instance: MVideoAccountLight, options) {
  673. if (!instance.isOwned()) return undefined
  674. // Lazy load channels
  675. if (!instance.VideoChannel) {
  676. instance.VideoChannel = await instance.$get('VideoChannel', {
  677. include: [
  678. ActorModel,
  679. AccountModel
  680. ],
  681. transaction: options.transaction
  682. }) as MChannelAccountDefault
  683. }
  684. return sendDeleteVideo(instance, options.transaction)
  685. }
  686. @BeforeDestroy
  687. static async removeFiles (instance: VideoModel, options) {
  688. const tasks: Promise<any>[] = []
  689. logger.info('Removing files of video %s.', instance.url)
  690. if (instance.isOwned()) {
  691. if (!Array.isArray(instance.VideoFiles)) {
  692. instance.VideoFiles = await instance.$get('VideoFiles', { transaction: options.transaction })
  693. }
  694. // Remove physical files and torrents
  695. instance.VideoFiles.forEach(file => {
  696. tasks.push(instance.removeWebTorrentFileAndTorrent(file))
  697. })
  698. // Remove playlists file
  699. if (!Array.isArray(instance.VideoStreamingPlaylists)) {
  700. instance.VideoStreamingPlaylists = await instance.$get('VideoStreamingPlaylists', { transaction: options.transaction })
  701. }
  702. for (const p of instance.VideoStreamingPlaylists) {
  703. tasks.push(instance.removeStreamingPlaylistFiles(p))
  704. }
  705. }
  706. // Do not wait video deletion because we could be in a transaction
  707. Promise.all(tasks)
  708. .catch(err => {
  709. logger.error('Some errors when removing files of video %s in before destroy hook.', instance.uuid, { err })
  710. })
  711. return undefined
  712. }
  713. @BeforeDestroy
  714. static stopLiveIfNeeded (instance: VideoModel) {
  715. if (!instance.isLive) return
  716. logger.info('Stopping live of video %s after video deletion.', instance.uuid)
  717. LiveManager.Instance.stopSessionOf(instance.id, null)
  718. }
  719. @BeforeDestroy
  720. static invalidateCache (instance: VideoModel) {
  721. ModelCache.Instance.invalidateCache('video', instance.id)
  722. }
  723. @BeforeDestroy
  724. static async saveEssentialDataToAbuses (instance: VideoModel, options) {
  725. const tasks: Promise<any>[] = []
  726. if (!Array.isArray(instance.VideoAbuses)) {
  727. instance.VideoAbuses = await instance.$get('VideoAbuses', { transaction: options.transaction })
  728. if (instance.VideoAbuses.length === 0) return undefined
  729. }
  730. logger.info('Saving video abuses details of video %s.', instance.url)
  731. if (!instance.Trackers) instance.Trackers = await instance.$get('Trackers', { transaction: options.transaction })
  732. const details = instance.toFormattedDetailsJSON()
  733. for (const abuse of instance.VideoAbuses) {
  734. abuse.deletedVideo = details
  735. tasks.push(abuse.save({ transaction: options.transaction }))
  736. }
  737. await Promise.all(tasks)
  738. }
  739. static listLocalIds (): Promise<number[]> {
  740. const query = {
  741. attributes: [ 'id' ],
  742. raw: true,
  743. where: {
  744. remote: false
  745. }
  746. }
  747. return VideoModel.findAll(query)
  748. .then(rows => rows.map(r => r.id))
  749. }
  750. static listAllAndSharedByActorForOutbox (actorId: number, start: number, count: number) {
  751. function getRawQuery (select: string) {
  752. const queryVideo = 'SELECT ' + select + ' FROM "video" AS "Video" ' +
  753. 'INNER JOIN "videoChannel" AS "VideoChannel" ON "VideoChannel"."id" = "Video"."channelId" ' +
  754. 'INNER JOIN "account" AS "Account" ON "Account"."id" = "VideoChannel"."accountId" ' +
  755. 'WHERE "Account"."actorId" = ' + actorId
  756. const queryVideoShare = 'SELECT ' + select + ' FROM "videoShare" AS "VideoShare" ' +
  757. 'INNER JOIN "video" AS "Video" ON "Video"."id" = "VideoShare"."videoId" ' +
  758. 'WHERE "VideoShare"."actorId" = ' + actorId
  759. return `(${queryVideo}) UNION (${queryVideoShare})`
  760. }
  761. const rawQuery = getRawQuery('"Video"."id"')
  762. const rawCountQuery = getRawQuery('COUNT("Video"."id") as "total"')
  763. const query = {
  764. distinct: true,
  765. offset: start,
  766. limit: count,
  767. order: getVideoSort('-createdAt', [ 'Tags', 'name', 'ASC' ]),
  768. where: {
  769. id: {
  770. [Op.in]: Sequelize.literal('(' + rawQuery + ')')
  771. },
  772. [Op.or]: getPrivaciesForFederation()
  773. },
  774. include: [
  775. {
  776. attributes: [ 'filename', 'language', 'fileUrl' ],
  777. model: VideoCaptionModel.unscoped(),
  778. required: false
  779. },
  780. {
  781. attributes: [ 'id', 'url' ],
  782. model: VideoShareModel.unscoped(),
  783. required: false,
  784. // We only want videos shared by this actor
  785. where: {
  786. [Op.and]: [
  787. {
  788. id: {
  789. [Op.not]: null
  790. }
  791. },
  792. {
  793. actorId
  794. }
  795. ]
  796. },
  797. include: [
  798. {
  799. attributes: [ 'id', 'url' ],
  800. model: ActorModel.unscoped()
  801. }
  802. ]
  803. },
  804. {
  805. model: VideoChannelModel.unscoped(),
  806. required: true,
  807. include: [
  808. {
  809. attributes: [ 'name' ],
  810. model: AccountModel.unscoped(),
  811. required: true,
  812. include: [
  813. {
  814. attributes: [ 'id', 'url', 'followersUrl' ],
  815. model: ActorModel.unscoped(),
  816. required: true
  817. }
  818. ]
  819. },
  820. {
  821. attributes: [ 'id', 'url', 'followersUrl' ],
  822. model: ActorModel.unscoped(),
  823. required: true
  824. }
  825. ]
  826. },
  827. {
  828. model: VideoStreamingPlaylistModel.unscoped(),
  829. required: false,
  830. include: [
  831. {
  832. model: VideoFileModel,
  833. required: false
  834. }
  835. ]
  836. },
  837. VideoLiveModel.unscoped(),
  838. VideoFileModel,
  839. TagModel
  840. ]
  841. }
  842. return Bluebird.all([
  843. VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findAll(query),
  844. VideoModel.sequelize.query<{ total: string }>(rawCountQuery, { type: QueryTypes.SELECT })
  845. ]).then(([ rows, totals ]) => {
  846. // totals: totalVideos + totalVideoShares
  847. let totalVideos = 0
  848. let totalVideoShares = 0
  849. if (totals[0]) totalVideos = parseInt(totals[0].total, 10)
  850. if (totals[1]) totalVideoShares = parseInt(totals[1].total, 10)
  851. const total = totalVideos + totalVideoShares
  852. return {
  853. data: rows,
  854. total: total
  855. }
  856. })
  857. }
  858. static async listPublishedLiveUUIDs () {
  859. const options = {
  860. attributes: [ 'uuid' ],
  861. where: {
  862. isLive: true,
  863. remote: false,
  864. state: VideoState.PUBLISHED
  865. }
  866. }
  867. const result = await VideoModel.findAll(options)
  868. return result.map(v => v.uuid)
  869. }
  870. static listUserVideosForApi (options: {
  871. accountId: number
  872. start: number
  873. count: number
  874. sort: string
  875. channelId?: number
  876. isLive?: boolean
  877. search?: string
  878. }) {
  879. const { accountId, channelId, start, count, sort, search, isLive } = options
  880. function buildBaseQuery (): FindOptions {
  881. const where: WhereOptions = {}
  882. if (search) {
  883. where.name = {
  884. [Op.iLike]: '%' + search + '%'
  885. }
  886. }
  887. if (exists(isLive)) {
  888. where.isLive = isLive
  889. }
  890. const channelWhere = channelId
  891. ? { id: channelId }
  892. : {}
  893. const baseQuery = {
  894. offset: start,
  895. limit: count,
  896. where,
  897. order: getVideoSort(sort),
  898. include: [
  899. {
  900. model: VideoChannelModel,
  901. required: true,
  902. where: channelWhere,
  903. include: [
  904. {
  905. model: AccountModel,
  906. where: {
  907. id: accountId
  908. },
  909. required: true
  910. }
  911. ]
  912. }
  913. ]
  914. }
  915. return baseQuery
  916. }
  917. const countQuery = buildBaseQuery()
  918. const findQuery = buildBaseQuery()
  919. const findScopes: (string | ScopeOptions)[] = [
  920. ScopeNames.WITH_SCHEDULED_UPDATE,
  921. ScopeNames.WITH_BLACKLISTED,
  922. ScopeNames.WITH_THUMBNAILS
  923. ]
  924. return Promise.all([
  925. VideoModel.count(countQuery),
  926. VideoModel.scope(findScopes).findAll<MVideoForUser>(findQuery)
  927. ]).then(([ count, rows ]) => {
  928. return {
  929. data: rows,
  930. total: count
  931. }
  932. })
  933. }
  934. static async listForApi (options: {
  935. start: number
  936. count: number
  937. sort: string
  938. nsfw: boolean
  939. isLive?: boolean
  940. isLocal?: boolean
  941. include?: VideoInclude
  942. hasFiles?: boolean // default false
  943. hasWebtorrentFiles?: boolean
  944. hasHLSFiles?: boolean
  945. categoryOneOf?: number[]
  946. licenceOneOf?: number[]
  947. languageOneOf?: string[]
  948. tagsOneOf?: string[]
  949. tagsAllOf?: string[]
  950. privacyOneOf?: VideoPrivacy[]
  951. accountId?: number
  952. videoChannelId?: number
  953. displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
  954. videoPlaylistId?: number
  955. trendingDays?: number
  956. user?: MUserAccountId
  957. historyOfUser?: MUserId
  958. countVideos?: boolean
  959. search?: string
  960. }) {
  961. VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
  962. VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
  963. const trendingDays = options.sort.endsWith('trending')
  964. ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
  965. : undefined
  966. let trendingAlgorithm: string
  967. if (options.sort.endsWith('hot')) trendingAlgorithm = 'hot'
  968. if (options.sort.endsWith('best')) trendingAlgorithm = 'best'
  969. const serverActor = await getServerActor()
  970. const queryOptions = {
  971. ...pick(options, [
  972. 'start',
  973. 'count',
  974. 'sort',
  975. 'nsfw',
  976. 'isLive',
  977. 'categoryOneOf',
  978. 'licenceOneOf',
  979. 'languageOneOf',
  980. 'tagsOneOf',
  981. 'tagsAllOf',
  982. 'privacyOneOf',
  983. 'isLocal',
  984. 'include',
  985. 'displayOnlyForFollower',
  986. 'hasFiles',
  987. 'accountId',
  988. 'videoChannelId',
  989. 'videoPlaylistId',
  990. 'user',
  991. 'historyOfUser',
  992. 'hasHLSFiles',
  993. 'hasWebtorrentFiles',
  994. 'search'
  995. ]),
  996. serverAccountIdForBlock: serverActor.Account.id,
  997. trendingDays,
  998. trendingAlgorithm
  999. }
  1000. return VideoModel.getAvailableForApi(queryOptions, options.countVideos)
  1001. }
  1002. static async searchAndPopulateAccountAndServer (options: {
  1003. start: number
  1004. count: number
  1005. sort: string
  1006. nsfw?: boolean
  1007. isLive?: boolean
  1008. isLocal?: boolean
  1009. include?: VideoInclude
  1010. categoryOneOf?: number[]
  1011. licenceOneOf?: number[]
  1012. languageOneOf?: string[]
  1013. tagsOneOf?: string[]
  1014. tagsAllOf?: string[]
  1015. privacyOneOf?: VideoPrivacy[]
  1016. displayOnlyForFollower: DisplayOnlyForFollowerOptions | null
  1017. user?: MUserAccountId
  1018. hasWebtorrentFiles?: boolean
  1019. hasHLSFiles?: boolean
  1020. search?: string
  1021. host?: string
  1022. startDate?: string // ISO 8601
  1023. endDate?: string // ISO 8601
  1024. originallyPublishedStartDate?: string
  1025. originallyPublishedEndDate?: string
  1026. durationMin?: number // seconds
  1027. durationMax?: number // seconds
  1028. uuids?: string[]
  1029. }) {
  1030. VideoModel.throwIfPrivateIncludeWithoutUser(options.include, options.user)
  1031. VideoModel.throwIfPrivacyOneOfWithoutUser(options.privacyOneOf, options.user)
  1032. const serverActor = await getServerActor()
  1033. const queryOptions = {
  1034. ...pick(options, [
  1035. 'include',
  1036. 'nsfw',
  1037. 'isLive',
  1038. 'categoryOneOf',
  1039. 'licenceOneOf',
  1040. 'languageOneOf',
  1041. 'tagsOneOf',
  1042. 'tagsAllOf',
  1043. 'privacyOneOf',
  1044. 'user',
  1045. 'isLocal',
  1046. 'host',
  1047. 'start',
  1048. 'count',
  1049. 'sort',
  1050. 'startDate',
  1051. 'endDate',
  1052. 'originallyPublishedStartDate',
  1053. 'originallyPublishedEndDate',
  1054. 'durationMin',
  1055. 'durationMax',
  1056. 'hasHLSFiles',
  1057. 'hasWebtorrentFiles',
  1058. 'uuids',
  1059. 'search',
  1060. 'displayOnlyForFollower'
  1061. ]),
  1062. serverAccountIdForBlock: serverActor.Account.id
  1063. }
  1064. return VideoModel.getAvailableForApi(queryOptions)
  1065. }
  1066. static countLocalLives () {
  1067. const options = {
  1068. where: {
  1069. remote: false,
  1070. isLive: true,
  1071. state: {
  1072. [Op.ne]: VideoState.LIVE_ENDED
  1073. }
  1074. }
  1075. }
  1076. return VideoModel.count(options)
  1077. }
  1078. static countVideosUploadedByUserSince (userId: number, since: Date) {
  1079. const options = {
  1080. include: [
  1081. {
  1082. model: VideoChannelModel.unscoped(),
  1083. required: true,
  1084. include: [
  1085. {
  1086. model: AccountModel.unscoped(),
  1087. required: true,
  1088. include: [
  1089. {
  1090. model: UserModel.unscoped(),
  1091. required: true,
  1092. where: {
  1093. id: userId
  1094. }
  1095. }
  1096. ]
  1097. }
  1098. ]
  1099. }
  1100. ],
  1101. where: {
  1102. createdAt: {
  1103. [Op.gte]: since
  1104. }
  1105. }
  1106. }
  1107. return VideoModel.unscoped().count(options)
  1108. }
  1109. static countLivesOfAccount (accountId: number) {
  1110. const options = {
  1111. where: {
  1112. remote: false,
  1113. isLive: true,
  1114. state: {
  1115. [Op.ne]: VideoState.LIVE_ENDED
  1116. }
  1117. },
  1118. include: [
  1119. {
  1120. required: true,
  1121. model: VideoChannelModel.unscoped(),
  1122. where: {
  1123. accountId
  1124. }
  1125. }
  1126. ]
  1127. }
  1128. return VideoModel.count(options)
  1129. }
  1130. static load (id: number | string, transaction?: Transaction): Promise<MVideoThumbnail> {
  1131. const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
  1132. return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails' })
  1133. }
  1134. static loadWithBlacklist (id: number | string, transaction?: Transaction): Promise<MVideoThumbnailBlacklist> {
  1135. const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
  1136. return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails-blacklist' })
  1137. }
  1138. static loadImmutableAttributes (id: number | string, t?: Transaction): Promise<MVideoImmutable> {
  1139. const fun = () => {
  1140. const query = {
  1141. where: buildWhereIdOrUUID(id),
  1142. transaction: t
  1143. }
  1144. return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
  1145. }
  1146. return ModelCache.Instance.doCache({
  1147. cacheType: 'load-video-immutable-id',
  1148. key: '' + id,
  1149. deleteKey: 'video',
  1150. fun
  1151. })
  1152. }
  1153. static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Promise<MVideoImmutable> {
  1154. const fun = () => {
  1155. const query: FindOptions = {
  1156. where: {
  1157. url
  1158. },
  1159. transaction
  1160. }
  1161. return VideoModel.scope(ScopeNames.WITH_IMMUTABLE_ATTRIBUTES).findOne(query)
  1162. }
  1163. return ModelCache.Instance.doCache({
  1164. cacheType: 'load-video-immutable-url',
  1165. key: url,
  1166. deleteKey: 'video',
  1167. fun
  1168. })
  1169. }
  1170. static loadOnlyId (id: number | string, transaction?: Transaction): Promise<MVideoId> {
  1171. const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
  1172. return queryBuilder.queryVideo({ id, transaction, type: 'id' })
  1173. }
  1174. static loadWithFiles (id: number | string, transaction?: Transaction, logging?: boolean): Promise<MVideoWithAllFiles> {
  1175. const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
  1176. return queryBuilder.queryVideo({ id, transaction, type: 'all-files', logging })
  1177. }
  1178. static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoThumbnail> {
  1179. const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
  1180. return queryBuilder.queryVideo({ url, transaction, type: 'thumbnails' })
  1181. }
  1182. static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise<MVideoAccountLightBlacklistAllFiles> {
  1183. const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
  1184. return queryBuilder.queryVideo({ url, transaction, type: 'account-blacklist-files' })
  1185. }
  1186. static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Promise<MVideoFullLight> {
  1187. const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
  1188. return queryBuilder.queryVideo({ id, transaction: t, type: 'full-light', userId })
  1189. }
  1190. static loadForGetAPI (parameters: {
  1191. id: number | string
  1192. transaction?: Transaction
  1193. userId?: number
  1194. }): Promise<MVideoDetails> {
  1195. const { id, transaction, userId } = parameters
  1196. const queryBuilder = new VideoModelGetQueryBuilder(VideoModel.sequelize)
  1197. return queryBuilder.queryVideo({ id, transaction, type: 'api', userId })
  1198. }
  1199. static async getStats () {
  1200. const totalLocalVideos = await VideoModel.count({
  1201. where: {
  1202. remote: false
  1203. }
  1204. })
  1205. let totalLocalVideoViews = await VideoModel.sum('views', {
  1206. where: {
  1207. remote: false
  1208. }
  1209. })
  1210. // Sequelize could return null...
  1211. if (!totalLocalVideoViews) totalLocalVideoViews = 0
  1212. const serverActor = await getServerActor()
  1213. const { total: totalVideos } = await VideoModel.listForApi({
  1214. start: 0,
  1215. count: 0,
  1216. sort: '-publishedAt',
  1217. nsfw: buildNSFWFilter(),
  1218. displayOnlyForFollower: {
  1219. actorId: serverActor.id,
  1220. orLocalVideos: true
  1221. }
  1222. })
  1223. return {
  1224. totalLocalVideos,
  1225. totalLocalVideoViews,
  1226. totalVideos
  1227. }
  1228. }
  1229. static incrementViews (id: number, views: number) {
  1230. return VideoModel.increment('views', {
  1231. by: views,
  1232. where: {
  1233. id
  1234. }
  1235. })
  1236. }
  1237. static updateRatesOf (videoId: number, type: VideoRateType, count: number, t: Transaction) {
  1238. const field = type === 'like'
  1239. ? 'likes'
  1240. : 'dislikes'
  1241. const rawQuery = `UPDATE "video" SET "${field}" = :count WHERE "video"."id" = :videoId`
  1242. return AccountVideoRateModel.sequelize.query(rawQuery, {
  1243. transaction: t,
  1244. replacements: { videoId, rateType: type, count },
  1245. type: QueryTypes.UPDATE
  1246. })
  1247. }
  1248. static syncLocalRates (videoId: number, type: VideoRateType, t: Transaction) {
  1249. const field = type === 'like'
  1250. ? 'likes'
  1251. : 'dislikes'
  1252. const rawQuery = `UPDATE "video" SET "${field}" = ` +
  1253. '(' +
  1254. 'SELECT COUNT(id) FROM "accountVideoRate" WHERE "accountVideoRate"."videoId" = "video"."id" AND type = :rateType' +
  1255. ') ' +
  1256. 'WHERE "video"."id" = :videoId'
  1257. return AccountVideoRateModel.sequelize.query(rawQuery, {
  1258. transaction: t,
  1259. replacements: { videoId, rateType: type },
  1260. type: QueryTypes.UPDATE
  1261. })
  1262. }
  1263. static checkVideoHasInstanceFollow (videoId: number, followerActorId: number) {
  1264. // Instances only share videos
  1265. const query = 'SELECT 1 FROM "videoShare" ' +
  1266. 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
  1267. 'WHERE "actorFollow"."actorId" = $followerActorId AND "actorFollow"."state" = \'accepted\' AND "videoShare"."videoId" = $videoId ' +
  1268. 'LIMIT 1'
  1269. const options = {
  1270. type: QueryTypes.SELECT as QueryTypes.SELECT,
  1271. bind: { followerActorId, videoId },
  1272. raw: true
  1273. }
  1274. return VideoModel.sequelize.query(query, options)
  1275. .then(results => results.length === 1)
  1276. }
  1277. static bulkUpdateSupportField (ofChannel: MChannel, t: Transaction) {
  1278. const options = {
  1279. where: {
  1280. channelId: ofChannel.id
  1281. },
  1282. transaction: t
  1283. }
  1284. return VideoModel.update({ support: ofChannel.support }, options)
  1285. }
  1286. static getAllIdsFromChannel (videoChannel: MChannelId): Promise<number[]> {
  1287. const query = {
  1288. attributes: [ 'id' ],
  1289. where: {
  1290. channelId: videoChannel.id
  1291. }
  1292. }
  1293. return VideoModel.findAll(query)
  1294. .then(videos => videos.map(v => v.id))
  1295. }
  1296. // threshold corresponds to how many video the field should have to be returned
  1297. static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
  1298. const serverActor = await getServerActor()
  1299. const queryOptions: BuildVideosListQueryOptions = {
  1300. attributes: [ `"${field}"` ],
  1301. group: `GROUP BY "${field}"`,
  1302. having: `HAVING COUNT("${field}") >= ${threshold}`,
  1303. start: 0,
  1304. sort: 'random',
  1305. count,
  1306. serverAccountIdForBlock: serverActor.Account.id,
  1307. displayOnlyForFollower: {
  1308. actorId: serverActor.id,
  1309. orLocalVideos: true
  1310. }
  1311. }
  1312. const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
  1313. return queryBuilder.queryVideoIds(queryOptions)
  1314. .then(rows => rows.map(r => r[field]))
  1315. }
  1316. static buildTrendingQuery (trendingDays: number) {
  1317. return {
  1318. attributes: [],
  1319. subQuery: false,
  1320. model: VideoViewModel,
  1321. required: false,
  1322. where: {
  1323. startDate: {
  1324. // FIXME: ts error
  1325. [Op.gte as any]: new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
  1326. }
  1327. }
  1328. }
  1329. }
  1330. private static async getAvailableForApi (
  1331. options: BuildVideosListQueryOptions,
  1332. countVideos = true
  1333. ): Promise<ResultList<VideoModel>> {
  1334. function getCount () {
  1335. if (countVideos !== true) return Promise.resolve(undefined)
  1336. const countOptions = Object.assign({}, options, { isCount: true })
  1337. const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
  1338. return queryBuilder.countVideoIds(countOptions)
  1339. }
  1340. function getModels () {
  1341. if (options.count === 0) return Promise.resolve([])
  1342. const queryBuilder = new VideosModelListQueryBuilder(VideoModel.sequelize)
  1343. return queryBuilder.queryVideos(options)
  1344. }
  1345. const [ count, rows ] = await Promise.all([ getCount(), getModels() ])
  1346. return {
  1347. data: rows,
  1348. total: count
  1349. }
  1350. }
  1351. private static throwIfPrivateIncludeWithoutUser (include: VideoInclude, user: MUserAccountId) {
  1352. if (VideoModel.isPrivateInclude(include) && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
  1353. throw new Error('Try to filter all-local but user cannot see all videos')
  1354. }
  1355. }
  1356. private static throwIfPrivacyOneOfWithoutUser (privacyOneOf: VideoPrivacy[], user: MUserAccountId) {
  1357. if (privacyOneOf && !user?.hasRight(UserRight.SEE_ALL_VIDEOS)) {
  1358. throw new Error('Try to choose video privacies but user cannot see all videos')
  1359. }
  1360. }
  1361. private static isPrivateInclude (include: VideoInclude) {
  1362. return include & VideoInclude.BLACKLISTED ||
  1363. include & VideoInclude.BLOCKED_OWNER ||
  1364. include & VideoInclude.NOT_PUBLISHED_STATE
  1365. }
  1366. isBlacklisted () {
  1367. return !!this.VideoBlacklist
  1368. }
  1369. isBlocked () {
  1370. return this.VideoChannel.Account.Actor.Server?.isBlocked() || this.VideoChannel.Account.isBlocked()
  1371. }
  1372. getQualityFileBy<T extends MVideoWithFile> (this: T, fun: (files: MVideoFile[], it: (file: MVideoFile) => number) => MVideoFile) {
  1373. // We first transcode to WebTorrent format, so try this array first
  1374. if (Array.isArray(this.VideoFiles) && this.VideoFiles.length !== 0) {
  1375. const file = fun(this.VideoFiles, file => file.resolution)
  1376. return Object.assign(file, { Video: this })
  1377. }
  1378. // No webtorrent files, try with streaming playlist files
  1379. if (Array.isArray(this.VideoStreamingPlaylists) && this.VideoStreamingPlaylists.length !== 0) {
  1380. const streamingPlaylistWithVideo = Object.assign(this.VideoStreamingPlaylists[0], { Video: this })
  1381. const file = fun(streamingPlaylistWithVideo.VideoFiles, file => file.resolution)
  1382. return Object.assign(file, { VideoStreamingPlaylist: streamingPlaylistWithVideo })
  1383. }
  1384. return undefined
  1385. }
  1386. getMaxQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
  1387. return this.getQualityFileBy(maxBy)
  1388. }
  1389. getMinQualityFile<T extends MVideoWithFile> (this: T): MVideoFileVideo | MVideoFileStreamingPlaylistVideo {
  1390. return this.getQualityFileBy(minBy)
  1391. }
  1392. getWebTorrentFile<T extends MVideoWithFile> (this: T, resolution: number): MVideoFileVideo {
  1393. if (Array.isArray(this.VideoFiles) === false) return undefined
  1394. const file = this.VideoFiles.find(f => f.resolution === resolution)
  1395. if (!file) return undefined
  1396. return Object.assign(file, { Video: this })
  1397. }
  1398. hasWebTorrentFiles () {
  1399. return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0
  1400. }
  1401. async addAndSaveThumbnail (thumbnail: MThumbnail, transaction?: Transaction) {
  1402. thumbnail.videoId = this.id
  1403. const savedThumbnail = await thumbnail.save({ transaction })
  1404. if (Array.isArray(this.Thumbnails) === false) this.Thumbnails = []
  1405. this.Thumbnails = this.Thumbnails.filter(t => t.id !== savedThumbnail.id)
  1406. this.Thumbnails.push(savedThumbnail)
  1407. }
  1408. getMiniature () {
  1409. if (Array.isArray(this.Thumbnails) === false) return undefined
  1410. return this.Thumbnails.find(t => t.type === ThumbnailType.MINIATURE)
  1411. }
  1412. hasPreview () {
  1413. return !!this.getPreview()
  1414. }
  1415. getPreview () {
  1416. if (Array.isArray(this.Thumbnails) === false) return undefined
  1417. return this.Thumbnails.find(t => t.type === ThumbnailType.PREVIEW)
  1418. }
  1419. isOwned () {
  1420. return this.remote === false
  1421. }
  1422. getWatchStaticPath () {
  1423. return buildVideoWatchPath({ shortUUID: uuidToShort(this.uuid) })
  1424. }
  1425. getEmbedStaticPath () {
  1426. return buildVideoEmbedPath(this)
  1427. }
  1428. getMiniatureStaticPath () {
  1429. const thumbnail = this.getMiniature()
  1430. if (!thumbnail) return null
  1431. return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
  1432. }
  1433. getPreviewStaticPath () {
  1434. const preview = this.getPreview()
  1435. if (!preview) return null
  1436. // We use a local cache, so specify our cache endpoint instead of potential remote URL
  1437. return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename)
  1438. }
  1439. toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
  1440. return videoModelToFormattedJSON(this, options)
  1441. }
  1442. toFormattedDetailsJSON (this: MVideoFormattableDetails): VideoDetails {
  1443. return videoModelToFormattedDetailsJSON(this)
  1444. }
  1445. getFormattedVideoFilesJSON (includeMagnet = true): VideoFile[] {
  1446. let files: VideoFile[] = []
  1447. if (Array.isArray(this.VideoFiles)) {
  1448. const result = videoFilesModelToFormattedJSON(this, this.VideoFiles, includeMagnet)
  1449. files = files.concat(result)
  1450. }
  1451. for (const p of (this.VideoStreamingPlaylists || [])) {
  1452. const result = videoFilesModelToFormattedJSON(this, p.VideoFiles, includeMagnet)
  1453. files = files.concat(result)
  1454. }
  1455. return files
  1456. }
  1457. toActivityPubObject (this: MVideoAP): VideoObject {
  1458. return videoModelToActivityPubObject(this)
  1459. }
  1460. getTruncatedDescription () {
  1461. if (!this.description) return null
  1462. const maxLength = CONSTRAINTS_FIELDS.VIDEOS.TRUNCATED_DESCRIPTION.max
  1463. return peertubeTruncate(this.description, { length: maxLength })
  1464. }
  1465. getAllFiles () {
  1466. let files: MVideoFile[] = []
  1467. if (Array.isArray(this.VideoFiles)) {
  1468. files = files.concat(this.VideoFiles)
  1469. }
  1470. if (Array.isArray(this.VideoStreamingPlaylists)) {
  1471. for (const p of this.VideoStreamingPlaylists) {
  1472. if (Array.isArray(p.VideoFiles)) {
  1473. files = files.concat(p.VideoFiles)
  1474. }
  1475. }
  1476. }
  1477. return files
  1478. }
  1479. probeMaxQualityFile () {
  1480. const file = this.getMaxQualityFile()
  1481. const videoOrPlaylist = file.getVideoOrStreamingPlaylist()
  1482. return VideoPathManager.Instance.makeAvailableVideoFile(file.withVideoOrPlaylist(videoOrPlaylist), async originalFilePath => {
  1483. const probe = await ffprobePromise(originalFilePath)
  1484. const { audioStream } = await getAudioStream(originalFilePath, probe)
  1485. return {
  1486. audioStream,
  1487. ...await getVideoStreamDimensionsInfo(originalFilePath, probe)
  1488. }
  1489. })
  1490. }
  1491. getDescriptionAPIPath () {
  1492. return `/api/${API_VERSION}/videos/${this.uuid}/description`
  1493. }
  1494. getHLSPlaylist (): MStreamingPlaylistFilesVideo {
  1495. if (!this.VideoStreamingPlaylists) return undefined
  1496. const playlist = this.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
  1497. if (!playlist) return undefined
  1498. playlist.Video = this
  1499. return playlist
  1500. }
  1501. setHLSPlaylist (playlist: MStreamingPlaylist) {
  1502. const toAdd = [ playlist ] as [ VideoStreamingPlaylistModel ]
  1503. if (Array.isArray(this.VideoStreamingPlaylists) === false || this.VideoStreamingPlaylists.length === 0) {
  1504. this.VideoStreamingPlaylists = toAdd
  1505. return
  1506. }
  1507. this.VideoStreamingPlaylists = this.VideoStreamingPlaylists
  1508. .filter(s => s.type !== VideoStreamingPlaylistType.HLS)
  1509. .concat(toAdd)
  1510. }
  1511. removeWebTorrentFileAndTorrent (videoFile: MVideoFile, isRedundancy = false) {
  1512. const filePath = isRedundancy
  1513. ? VideoPathManager.Instance.getFSRedundancyVideoFilePath(this, videoFile)
  1514. : VideoPathManager.Instance.getFSVideoFileOutputPath(this, videoFile)
  1515. const promises: Promise<any>[] = [ remove(filePath) ]
  1516. if (!isRedundancy) promises.push(videoFile.removeTorrent())
  1517. if (videoFile.storage === VideoStorage.OBJECT_STORAGE) {
  1518. promises.push(removeWebTorrentObjectStorage(videoFile))
  1519. }
  1520. return Promise.all(promises)
  1521. }
  1522. async removeStreamingPlaylistFiles (streamingPlaylist: MStreamingPlaylist, isRedundancy = false) {
  1523. const directoryPath = isRedundancy
  1524. ? getHLSRedundancyDirectory(this)
  1525. : getHLSDirectory(this)
  1526. await remove(directoryPath)
  1527. if (isRedundancy !== true) {
  1528. const streamingPlaylistWithFiles = streamingPlaylist as MStreamingPlaylistFilesVideo
  1529. streamingPlaylistWithFiles.Video = this
  1530. if (!Array.isArray(streamingPlaylistWithFiles.VideoFiles)) {
  1531. streamingPlaylistWithFiles.VideoFiles = await streamingPlaylistWithFiles.$get('VideoFiles')
  1532. }
  1533. // Remove physical files and torrents
  1534. await Promise.all(
  1535. streamingPlaylistWithFiles.VideoFiles.map(file => file.removeTorrent())
  1536. )
  1537. if (streamingPlaylist.storage === VideoStorage.OBJECT_STORAGE) {
  1538. await removeHLSObjectStorage(streamingPlaylist.withVideo(this))
  1539. }
  1540. }
  1541. }
  1542. isOutdated () {
  1543. if (this.isOwned()) return false
  1544. return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL)
  1545. }
  1546. hasPrivacyForFederation () {
  1547. return isPrivacyForFederation(this.privacy)
  1548. }
  1549. hasStateForFederation () {
  1550. return isStateForFederation(this.state)
  1551. }
  1552. isNewVideo (newPrivacy: VideoPrivacy) {
  1553. return this.hasPrivacyForFederation() === false && isPrivacyForFederation(newPrivacy) === true
  1554. }
  1555. setAsRefreshed (transaction?: Transaction) {
  1556. return setAsUpdated('video', this.id, transaction)
  1557. }
  1558. requiresAuth () {
  1559. return this.privacy === VideoPrivacy.PRIVATE || this.privacy === VideoPrivacy.INTERNAL || !!this.VideoBlacklist
  1560. }
  1561. setPrivacy (newPrivacy: VideoPrivacy) {
  1562. if (this.privacy === VideoPrivacy.PRIVATE && newPrivacy !== VideoPrivacy.PRIVATE) {
  1563. this.publishedAt = new Date()
  1564. }
  1565. this.privacy = newPrivacy
  1566. }
  1567. isConfidential () {
  1568. return this.privacy === VideoPrivacy.PRIVATE ||
  1569. this.privacy === VideoPrivacy.UNLISTED ||
  1570. this.privacy === VideoPrivacy.INTERNAL
  1571. }
  1572. async setNewState (newState: VideoState, isNewVideo: boolean, transaction: Transaction) {
  1573. if (this.state === newState) throw new Error('Cannot use same state ' + newState)
  1574. this.state = newState
  1575. if (this.state === VideoState.PUBLISHED && isNewVideo) {
  1576. this.publishedAt = new Date()
  1577. }
  1578. await this.save({ transaction })
  1579. }
  1580. getBandwidthBits (this: MVideo, videoFile: MVideoFile) {
  1581. return Math.ceil((videoFile.size * 8) / this.duration)
  1582. }
  1583. getTrackerUrls () {
  1584. if (this.isOwned()) {
  1585. return [
  1586. WEBSERVER.URL + '/tracker/announce',
  1587. WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket'
  1588. ]
  1589. }
  1590. return this.Trackers.map(t => t.url)
  1591. }
  1592. }