ニジカ投稿局 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.
 
 
 
 
 

796 lines
21 KiB

  1. import {
  2. ActivityVideoUrlObject,
  3. CacheFileObject,
  4. FileRedundancyInformation,
  5. StreamingPlaylistRedundancyInformation,
  6. VideoPrivacy,
  7. VideoRedundanciesTarget,
  8. VideoRedundancy,
  9. VideoRedundancyStrategy,
  10. VideoRedundancyStrategyWithManual
  11. } from '@peertube/peertube-models'
  12. import { isTestInstance } from '@peertube/peertube-node-utils'
  13. import { getVideoFileMimeType } from '@server/lib/video-file.js'
  14. import { getServerActor } from '@server/models/application/application.js'
  15. import { MActor, MVideoForRedundancyAPI, MVideoRedundancy, MVideoRedundancyAP, MVideoRedundancyVideo } from '@server/types/models/index.js'
  16. import sample from 'lodash-es/sample.js'
  17. import { literal, Op, QueryTypes, Transaction, WhereOptions } from 'sequelize'
  18. import {
  19. AllowNull,
  20. BeforeDestroy,
  21. BelongsTo,
  22. Column,
  23. CreatedAt,
  24. DataType,
  25. ForeignKey,
  26. Is, Scopes,
  27. Table,
  28. UpdatedAt
  29. } from 'sequelize-typescript'
  30. import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc.js'
  31. import { logger } from '../../helpers/logger.js'
  32. import { CONFIG } from '../../initializers/config.js'
  33. import { CONSTRAINTS_FIELDS } from '../../initializers/constants.js'
  34. import { ActorModel } from '../actor/actor.js'
  35. import { ServerModel } from '../server/server.js'
  36. import { getSort, getVideoSort, parseAggregateResult, SequelizeModel, throwIfNotValid } from '../shared/index.js'
  37. import { ScheduleVideoUpdateModel } from '../video/schedule-video-update.js'
  38. import { VideoChannelModel } from '../video/video-channel.js'
  39. import { VideoFileModel } from '../video/video-file.js'
  40. import { VideoStreamingPlaylistModel } from '../video/video-streaming-playlist.js'
  41. import { VideoModel } from '../video/video.js'
  42. export enum ScopeNames {
  43. WITH_VIDEO = 'WITH_VIDEO'
  44. }
  45. @Scopes(() => ({
  46. [ScopeNames.WITH_VIDEO]: {
  47. include: [
  48. {
  49. model: VideoFileModel,
  50. required: false,
  51. include: [
  52. {
  53. model: VideoModel,
  54. required: true
  55. }
  56. ]
  57. },
  58. {
  59. model: VideoStreamingPlaylistModel,
  60. required: false,
  61. include: [
  62. {
  63. model: VideoModel,
  64. required: true
  65. }
  66. ]
  67. }
  68. ]
  69. }
  70. }))
  71. @Table({
  72. tableName: 'videoRedundancy',
  73. indexes: [
  74. {
  75. fields: [ 'videoFileId' ]
  76. },
  77. {
  78. fields: [ 'actorId' ]
  79. },
  80. {
  81. fields: [ 'expiresOn' ]
  82. },
  83. {
  84. fields: [ 'url' ],
  85. unique: true
  86. }
  87. ]
  88. })
  89. export class VideoRedundancyModel extends SequelizeModel<VideoRedundancyModel> {
  90. @CreatedAt
  91. createdAt: Date
  92. @UpdatedAt
  93. updatedAt: Date
  94. @AllowNull(true)
  95. @Column
  96. expiresOn: Date
  97. @AllowNull(false)
  98. @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS_REDUNDANCY.URL.max))
  99. fileUrl: string
  100. @AllowNull(false)
  101. @Is('VideoRedundancyUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
  102. @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS_REDUNDANCY.URL.max))
  103. url: string
  104. @AllowNull(true)
  105. @Column
  106. strategy: string // Only used by us
  107. @ForeignKey(() => VideoFileModel)
  108. @Column
  109. videoFileId: number
  110. @BelongsTo(() => VideoFileModel, {
  111. foreignKey: {
  112. allowNull: true
  113. },
  114. onDelete: 'cascade'
  115. })
  116. VideoFile: Awaited<VideoFileModel>
  117. @ForeignKey(() => VideoStreamingPlaylistModel)
  118. @Column
  119. videoStreamingPlaylistId: number
  120. @BelongsTo(() => VideoStreamingPlaylistModel, {
  121. foreignKey: {
  122. allowNull: true
  123. },
  124. onDelete: 'cascade'
  125. })
  126. VideoStreamingPlaylist: Awaited<VideoStreamingPlaylistModel>
  127. @ForeignKey(() => ActorModel)
  128. @Column
  129. actorId: number
  130. @BelongsTo(() => ActorModel, {
  131. foreignKey: {
  132. allowNull: false
  133. },
  134. onDelete: 'cascade'
  135. })
  136. Actor: Awaited<ActorModel>
  137. @BeforeDestroy
  138. static async removeFile (instance: VideoRedundancyModel) {
  139. if (!instance.isOwned()) return
  140. if (instance.videoFileId) {
  141. const videoFile = await VideoFileModel.loadWithVideo(instance.videoFileId)
  142. const logIdentifier = `${videoFile.Video.uuid}-${videoFile.resolution}`
  143. logger.info('Removing duplicated video file %s.', logIdentifier)
  144. videoFile.Video.removeWebVideoFile(videoFile, true)
  145. .catch(err => logger.error('Cannot delete %s files.', logIdentifier, { err }))
  146. }
  147. if (instance.videoStreamingPlaylistId) {
  148. const videoStreamingPlaylist = await VideoStreamingPlaylistModel.loadWithVideo(instance.videoStreamingPlaylistId)
  149. const videoUUID = videoStreamingPlaylist.Video.uuid
  150. logger.info('Removing duplicated video streaming playlist %s.', videoUUID)
  151. videoStreamingPlaylist.Video.removeStreamingPlaylistFiles(videoStreamingPlaylist, true)
  152. .catch(err => logger.error('Cannot delete video streaming playlist files of %s.', videoUUID, { err }))
  153. }
  154. return undefined
  155. }
  156. static async loadLocalByFileId (videoFileId: number): Promise<MVideoRedundancyVideo> {
  157. const actor = await getServerActor()
  158. const query = {
  159. where: {
  160. actorId: actor.id,
  161. videoFileId
  162. }
  163. }
  164. return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
  165. }
  166. static async listLocalByVideoId (videoId: number): Promise<MVideoRedundancyVideo[]> {
  167. const actor = await getServerActor()
  168. const queryStreamingPlaylist = {
  169. where: {
  170. actorId: actor.id
  171. },
  172. include: [
  173. {
  174. model: VideoStreamingPlaylistModel.unscoped(),
  175. required: true,
  176. include: [
  177. {
  178. model: VideoModel.unscoped(),
  179. required: true,
  180. where: {
  181. id: videoId
  182. }
  183. }
  184. ]
  185. }
  186. ]
  187. }
  188. const queryFiles = {
  189. where: {
  190. actorId: actor.id
  191. },
  192. include: [
  193. {
  194. model: VideoFileModel,
  195. required: true,
  196. include: [
  197. {
  198. model: VideoModel,
  199. required: true,
  200. where: {
  201. id: videoId
  202. }
  203. }
  204. ]
  205. }
  206. ]
  207. }
  208. return Promise.all([
  209. VideoRedundancyModel.findAll(queryStreamingPlaylist),
  210. VideoRedundancyModel.findAll(queryFiles)
  211. ]).then(([ r1, r2 ]) => r1.concat(r2))
  212. }
  213. static async loadLocalByStreamingPlaylistId (videoStreamingPlaylistId: number): Promise<MVideoRedundancyVideo> {
  214. const actor = await getServerActor()
  215. const query = {
  216. where: {
  217. actorId: actor.id,
  218. videoStreamingPlaylistId
  219. }
  220. }
  221. return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
  222. }
  223. static loadByIdWithVideo (id: number, transaction?: Transaction): Promise<MVideoRedundancyVideo> {
  224. const query = {
  225. where: { id },
  226. transaction
  227. }
  228. return VideoRedundancyModel.scope(ScopeNames.WITH_VIDEO).findOne(query)
  229. }
  230. static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoRedundancy> {
  231. const query = {
  232. where: {
  233. url
  234. },
  235. transaction
  236. }
  237. return VideoRedundancyModel.findOne(query)
  238. }
  239. static async isLocalByVideoUUIDExists (uuid: string) {
  240. const actor = await getServerActor()
  241. const query = {
  242. raw: true,
  243. attributes: [ 'id' ],
  244. where: {
  245. actorId: actor.id
  246. },
  247. include: [
  248. {
  249. attributes: [],
  250. model: VideoFileModel,
  251. required: true,
  252. include: [
  253. {
  254. attributes: [],
  255. model: VideoModel,
  256. required: true,
  257. where: {
  258. uuid
  259. }
  260. }
  261. ]
  262. }
  263. ]
  264. }
  265. return VideoRedundancyModel.findOne(query)
  266. .then(r => !!r)
  267. }
  268. static async getVideoSample (p: Promise<VideoModel[]>) {
  269. const rows = await p
  270. if (rows.length === 0) return undefined
  271. const ids = rows.map(r => r.id)
  272. const id = sample(ids)
  273. return VideoModel.loadWithFiles(id, undefined, !isTestInstance())
  274. }
  275. static async findMostViewToDuplicate (randomizedFactor: number) {
  276. const peertubeActor = await getServerActor()
  277. // On VideoModel!
  278. const query = {
  279. attributes: [ 'id', 'views' ],
  280. limit: randomizedFactor,
  281. order: getVideoSort('-views'),
  282. where: {
  283. privacy: VideoPrivacy.PUBLIC,
  284. isLive: false,
  285. ...this.buildVideoIdsForDuplication(peertubeActor)
  286. },
  287. include: [
  288. VideoRedundancyModel.buildServerRedundancyInclude()
  289. ]
  290. }
  291. return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
  292. }
  293. static async findTrendingToDuplicate (randomizedFactor: number) {
  294. const peertubeActor = await getServerActor()
  295. // On VideoModel!
  296. const query = {
  297. attributes: [ 'id', 'views' ],
  298. subQuery: false,
  299. group: 'VideoModel.id',
  300. limit: randomizedFactor,
  301. order: getVideoSort('-trending'),
  302. where: {
  303. privacy: VideoPrivacy.PUBLIC,
  304. isLive: false,
  305. ...this.buildVideoIdsForDuplication(peertubeActor)
  306. },
  307. include: [
  308. VideoRedundancyModel.buildServerRedundancyInclude(),
  309. VideoModel.buildTrendingQuery(CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS)
  310. ]
  311. }
  312. return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
  313. }
  314. static async findRecentlyAddedToDuplicate (randomizedFactor: number, minViews: number) {
  315. const peertubeActor = await getServerActor()
  316. // On VideoModel!
  317. const query = {
  318. attributes: [ 'id', 'publishedAt' ],
  319. limit: randomizedFactor,
  320. order: getVideoSort('-publishedAt'),
  321. where: {
  322. privacy: VideoPrivacy.PUBLIC,
  323. isLive: false,
  324. views: {
  325. [Op.gte]: minViews
  326. },
  327. ...this.buildVideoIdsForDuplication(peertubeActor)
  328. },
  329. include: [
  330. VideoRedundancyModel.buildServerRedundancyInclude(),
  331. // Required by publishedAt sort
  332. {
  333. model: ScheduleVideoUpdateModel.unscoped(),
  334. required: false
  335. }
  336. ]
  337. }
  338. return VideoRedundancyModel.getVideoSample(VideoModel.unscoped().findAll(query))
  339. }
  340. static async loadOldestLocalExpired (strategy: VideoRedundancyStrategy, expiresAfterMs: number): Promise<MVideoRedundancyVideo> {
  341. const expiredDate = new Date()
  342. expiredDate.setMilliseconds(expiredDate.getMilliseconds() - expiresAfterMs)
  343. const actor = await getServerActor()
  344. const query = {
  345. where: {
  346. actorId: actor.id,
  347. strategy,
  348. createdAt: {
  349. [Op.lt]: expiredDate
  350. }
  351. }
  352. }
  353. return VideoRedundancyModel.scope([ ScopeNames.WITH_VIDEO ]).findOne(query)
  354. }
  355. static async listLocalExpired (): Promise<MVideoRedundancyVideo[]> {
  356. const actor = await getServerActor()
  357. const query = {
  358. where: {
  359. actorId: actor.id,
  360. expiresOn: {
  361. [Op.lt]: new Date()
  362. }
  363. }
  364. }
  365. return VideoRedundancyModel.scope([ ScopeNames.WITH_VIDEO ]).findAll(query)
  366. }
  367. static async listRemoteExpired () {
  368. const actor = await getServerActor()
  369. const query = {
  370. where: {
  371. actorId: {
  372. [Op.ne]: actor.id
  373. },
  374. expiresOn: {
  375. [Op.lt]: new Date(),
  376. [Op.ne]: null
  377. }
  378. }
  379. }
  380. return VideoRedundancyModel.scope([ ScopeNames.WITH_VIDEO ]).findAll(query)
  381. }
  382. static async listLocalOfServer (serverId: number) {
  383. const actor = await getServerActor()
  384. const buildVideoInclude = () => ({
  385. model: VideoModel,
  386. required: true,
  387. include: [
  388. {
  389. attributes: [],
  390. model: VideoChannelModel.unscoped(),
  391. required: true,
  392. include: [
  393. {
  394. attributes: [],
  395. model: ActorModel.unscoped(),
  396. required: true,
  397. where: {
  398. serverId
  399. }
  400. }
  401. ]
  402. }
  403. ]
  404. })
  405. const query = {
  406. where: {
  407. [Op.and]: [
  408. {
  409. actorId: actor.id
  410. },
  411. {
  412. [Op.or]: [
  413. {
  414. '$VideoStreamingPlaylist.id$': {
  415. [Op.ne]: null
  416. }
  417. },
  418. {
  419. '$VideoFile.id$': {
  420. [Op.ne]: null
  421. }
  422. }
  423. ]
  424. }
  425. ]
  426. },
  427. include: [
  428. {
  429. model: VideoFileModel.unscoped(),
  430. required: false,
  431. include: [ buildVideoInclude() ]
  432. },
  433. {
  434. model: VideoStreamingPlaylistModel.unscoped(),
  435. required: false,
  436. include: [ buildVideoInclude() ]
  437. }
  438. ]
  439. }
  440. return VideoRedundancyModel.findAll(query)
  441. }
  442. static listForApi (options: {
  443. start: number
  444. count: number
  445. sort: string
  446. target: VideoRedundanciesTarget
  447. strategy?: string
  448. }) {
  449. const { start, count, sort, target, strategy } = options
  450. const redundancyWhere: WhereOptions = {}
  451. const videosWhere: WhereOptions = {}
  452. let redundancySqlSuffix = ''
  453. if (target === 'my-videos') {
  454. Object.assign(videosWhere, { remote: false })
  455. } else if (target === 'remote-videos') {
  456. Object.assign(videosWhere, { remote: true })
  457. Object.assign(redundancyWhere, { strategy: { [Op.ne]: null } })
  458. redundancySqlSuffix = ' AND "videoRedundancy"."strategy" IS NOT NULL'
  459. }
  460. if (strategy) {
  461. Object.assign(redundancyWhere, { strategy })
  462. }
  463. const videoFilterWhere = {
  464. [Op.and]: [
  465. {
  466. [Op.or]: [
  467. {
  468. id: {
  469. [Op.in]: literal(
  470. '(' +
  471. 'SELECT "videoId" FROM "videoFile" ' +
  472. 'INNER JOIN "videoRedundancy" ON "videoRedundancy"."videoFileId" = "videoFile".id' +
  473. redundancySqlSuffix +
  474. ')'
  475. )
  476. }
  477. },
  478. {
  479. id: {
  480. [Op.in]: literal(
  481. '(' +
  482. 'select "videoId" FROM "videoStreamingPlaylist" ' +
  483. 'INNER JOIN "videoRedundancy" ON "videoRedundancy"."videoStreamingPlaylistId" = "videoStreamingPlaylist".id' +
  484. redundancySqlSuffix +
  485. ')'
  486. )
  487. }
  488. }
  489. ]
  490. },
  491. videosWhere
  492. ]
  493. }
  494. // /!\ On video model /!\
  495. const findOptions = {
  496. offset: start,
  497. limit: count,
  498. order: getSort(sort),
  499. include: [
  500. {
  501. required: false,
  502. model: VideoFileModel,
  503. include: [
  504. {
  505. model: VideoRedundancyModel.unscoped(),
  506. required: false,
  507. where: redundancyWhere
  508. }
  509. ]
  510. },
  511. {
  512. required: false,
  513. model: VideoStreamingPlaylistModel.unscoped(),
  514. include: [
  515. {
  516. model: VideoRedundancyModel.unscoped(),
  517. required: false,
  518. where: redundancyWhere
  519. },
  520. {
  521. model: VideoFileModel,
  522. required: false
  523. }
  524. ]
  525. }
  526. ],
  527. where: videoFilterWhere
  528. }
  529. // /!\ On video model /!\
  530. const countOptions = {
  531. where: videoFilterWhere
  532. }
  533. return Promise.all([
  534. VideoModel.findAll(findOptions),
  535. VideoModel.count(countOptions)
  536. ]).then(([ data, total ]) => ({ total, data }))
  537. }
  538. static async getStats (strategy: VideoRedundancyStrategyWithManual) {
  539. const actor = await getServerActor()
  540. const sql = `WITH "tmp" AS ` +
  541. `(` +
  542. `SELECT "videoFile"."size" AS "videoFileSize", "videoStreamingFile"."size" AS "videoStreamingFileSize", ` +
  543. `"videoFile"."videoId" AS "videoFileVideoId", "videoStreamingPlaylist"."videoId" AS "videoStreamingVideoId"` +
  544. `FROM "videoRedundancy" AS "videoRedundancy" ` +
  545. `LEFT JOIN "videoFile" AS "videoFile" ON "videoRedundancy"."videoFileId" = "videoFile"."id" ` +
  546. `LEFT JOIN "videoStreamingPlaylist" ON "videoRedundancy"."videoStreamingPlaylistId" = "videoStreamingPlaylist"."id" ` +
  547. `LEFT JOIN "videoFile" AS "videoStreamingFile" ` +
  548. `ON "videoStreamingPlaylist"."id" = "videoStreamingFile"."videoStreamingPlaylistId" ` +
  549. `WHERE "videoRedundancy"."strategy" = :strategy AND "videoRedundancy"."actorId" = :actorId` +
  550. `), ` +
  551. `"videoIds" AS (` +
  552. `SELECT "videoFileVideoId" AS "videoId" FROM "tmp" ` +
  553. `UNION SELECT "videoStreamingVideoId" AS "videoId" FROM "tmp" ` +
  554. `) ` +
  555. `SELECT ` +
  556. `COALESCE(SUM("videoFileSize"), '0') + COALESCE(SUM("videoStreamingFileSize"), '0') AS "totalUsed", ` +
  557. `(SELECT COUNT("videoIds"."videoId") FROM "videoIds") AS "totalVideos", ` +
  558. `COUNT(*) AS "totalVideoFiles" ` +
  559. `FROM "tmp"`
  560. return VideoRedundancyModel.sequelize.query<any>(sql, {
  561. replacements: { strategy, actorId: actor.id },
  562. type: QueryTypes.SELECT
  563. }).then(([ row ]) => ({
  564. totalUsed: parseAggregateResult(row.totalUsed),
  565. totalVideos: row.totalVideos,
  566. totalVideoFiles: row.totalVideoFiles
  567. }))
  568. }
  569. static toFormattedJSONStatic (video: MVideoForRedundancyAPI): VideoRedundancy {
  570. const filesRedundancies: FileRedundancyInformation[] = []
  571. const streamingPlaylistsRedundancies: StreamingPlaylistRedundancyInformation[] = []
  572. for (const file of video.VideoFiles) {
  573. for (const redundancy of file.RedundancyVideos) {
  574. filesRedundancies.push({
  575. id: redundancy.id,
  576. fileUrl: redundancy.fileUrl,
  577. strategy: redundancy.strategy,
  578. createdAt: redundancy.createdAt,
  579. updatedAt: redundancy.updatedAt,
  580. expiresOn: redundancy.expiresOn,
  581. size: file.size
  582. })
  583. }
  584. }
  585. for (const playlist of video.VideoStreamingPlaylists) {
  586. const size = playlist.VideoFiles.reduce((a, b) => a + b.size, 0)
  587. for (const redundancy of playlist.RedundancyVideos) {
  588. streamingPlaylistsRedundancies.push({
  589. id: redundancy.id,
  590. fileUrl: redundancy.fileUrl,
  591. strategy: redundancy.strategy,
  592. createdAt: redundancy.createdAt,
  593. updatedAt: redundancy.updatedAt,
  594. expiresOn: redundancy.expiresOn,
  595. size
  596. })
  597. }
  598. }
  599. return {
  600. id: video.id,
  601. name: video.name,
  602. url: video.url,
  603. uuid: video.uuid,
  604. redundancies: {
  605. files: filesRedundancies,
  606. streamingPlaylists: streamingPlaylistsRedundancies
  607. }
  608. }
  609. }
  610. getVideo () {
  611. if (this.VideoFile?.Video) return this.VideoFile.Video
  612. if (this.VideoStreamingPlaylist?.Video) return this.VideoStreamingPlaylist.Video
  613. return undefined
  614. }
  615. getVideoUUID () {
  616. const video = this.getVideo()
  617. if (!video) return undefined
  618. return video.uuid
  619. }
  620. isOwned () {
  621. return !!this.strategy
  622. }
  623. toActivityPubObject (this: MVideoRedundancyAP): CacheFileObject {
  624. if (this.VideoStreamingPlaylist) {
  625. return {
  626. id: this.url,
  627. type: 'CacheFile' as 'CacheFile',
  628. object: this.VideoStreamingPlaylist.Video.url,
  629. expires: this.expiresOn ? this.expiresOn.toISOString() : null,
  630. url: {
  631. type: 'Link',
  632. mediaType: 'application/x-mpegURL',
  633. href: this.fileUrl
  634. }
  635. }
  636. }
  637. return {
  638. id: this.url,
  639. type: 'CacheFile' as 'CacheFile',
  640. object: this.VideoFile.Video.url,
  641. expires: this.expiresOn
  642. ? this.expiresOn.toISOString()
  643. : null,
  644. url: {
  645. type: 'Link',
  646. mediaType: getVideoFileMimeType(this.VideoFile.extname, this.VideoFile.isAudio()),
  647. href: this.fileUrl,
  648. height: this.VideoFile.resolution,
  649. size: this.VideoFile.size,
  650. fps: this.VideoFile.fps
  651. } as ActivityVideoUrlObject
  652. }
  653. }
  654. // Don't include video files we already duplicated
  655. private static buildVideoIdsForDuplication (peertubeActor: MActor) {
  656. const notIn = literal(
  657. '(' +
  658. `SELECT "videoFile"."videoId" AS "videoId" FROM "videoRedundancy" ` +
  659. `INNER JOIN "videoFile" ON "videoFile"."id" = "videoRedundancy"."videoFileId" ` +
  660. `WHERE "videoRedundancy"."actorId" = ${peertubeActor.id} ` +
  661. `UNION ` +
  662. `SELECT "videoStreamingPlaylist"."videoId" AS "videoId" FROM "videoRedundancy" ` +
  663. `INNER JOIN "videoStreamingPlaylist" ON "videoStreamingPlaylist"."id" = "videoRedundancy"."videoStreamingPlaylistId" ` +
  664. `WHERE "videoRedundancy"."actorId" = ${peertubeActor.id} ` +
  665. ')'
  666. )
  667. return {
  668. id: {
  669. [Op.notIn]: notIn
  670. }
  671. }
  672. }
  673. private static buildServerRedundancyInclude () {
  674. return {
  675. attributes: [],
  676. model: VideoChannelModel.unscoped(),
  677. required: true,
  678. include: [
  679. {
  680. attributes: [],
  681. model: ActorModel.unscoped(),
  682. required: true,
  683. include: [
  684. {
  685. attributes: [],
  686. model: ServerModel.unscoped(),
  687. required: true,
  688. where: {
  689. redundancyAllowed: true
  690. }
  691. }
  692. ]
  693. }
  694. ]
  695. }
  696. }
  697. }