video.ts 51 KB

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