ニジカ投稿局 https://tv.nizika.tv
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

2140 lines
57 KiB

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