video-channel.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. import { FindOptions, Includeable, literal, Op, ScopeOptions } from 'sequelize'
  2. import {
  3. AllowNull,
  4. BeforeDestroy,
  5. BelongsTo,
  6. Column,
  7. CreatedAt,
  8. DataType,
  9. Default,
  10. DefaultScope,
  11. ForeignKey,
  12. HasMany,
  13. Is,
  14. Model,
  15. Scopes,
  16. Sequelize,
  17. Table,
  18. UpdatedAt
  19. } from 'sequelize-typescript'
  20. import { MAccountActor } from '@server/types/models'
  21. import { ActivityPubActor } from '../../../shared/models/activitypub'
  22. import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
  23. import {
  24. isVideoChannelDescriptionValid,
  25. isVideoChannelNameValid,
  26. isVideoChannelSupportValid
  27. } from '../../helpers/custom-validators/video-channels'
  28. import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
  29. import { sendDeleteActor } from '../../lib/activitypub/send'
  30. import {
  31. MChannelAccountDefault,
  32. MChannelActor,
  33. MChannelActorAccountDefaultVideos,
  34. MChannelAP,
  35. MChannelFormattable,
  36. MChannelSummaryFormattable
  37. } from '../../types/models/video'
  38. import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
  39. import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
  40. import { ActorFollowModel } from '../activitypub/actor-follow'
  41. import { AvatarModel } from '../avatar/avatar'
  42. import { ServerModel } from '../server/server'
  43. import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
  44. import { VideoModel } from './video'
  45. import { VideoPlaylistModel } from './video-playlist'
  46. export enum ScopeNames {
  47. FOR_API = 'FOR_API',
  48. SUMMARY = 'SUMMARY',
  49. WITH_ACCOUNT = 'WITH_ACCOUNT',
  50. WITH_ACTOR = 'WITH_ACTOR',
  51. WITH_VIDEOS = 'WITH_VIDEOS',
  52. WITH_STATS = 'WITH_STATS'
  53. }
  54. type AvailableForListOptions = {
  55. actorId: number
  56. search?: string
  57. }
  58. type AvailableWithStatsOptions = {
  59. daysPrior: number
  60. }
  61. export type SummaryOptions = {
  62. actorRequired?: boolean // Default: true
  63. withAccount?: boolean // Default: false
  64. withAccountBlockerIds?: number[]
  65. }
  66. @DefaultScope(() => ({
  67. include: [
  68. {
  69. model: ActorModel,
  70. required: true
  71. }
  72. ]
  73. }))
  74. @Scopes(() => ({
  75. [ScopeNames.FOR_API]: (options: AvailableForListOptions) => {
  76. // Only list local channels OR channels that are on an instance followed by actorId
  77. const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
  78. return {
  79. include: [
  80. {
  81. attributes: {
  82. exclude: unusedActorAttributesForAPI
  83. },
  84. model: ActorModel,
  85. where: {
  86. [Op.or]: [
  87. {
  88. serverId: null
  89. },
  90. {
  91. serverId: {
  92. [Op.in]: Sequelize.literal(inQueryInstanceFollow)
  93. }
  94. }
  95. ]
  96. }
  97. },
  98. {
  99. model: AccountModel,
  100. required: true,
  101. include: [
  102. {
  103. attributes: {
  104. exclude: unusedActorAttributesForAPI
  105. },
  106. model: ActorModel, // Default scope includes avatar and server
  107. required: true
  108. }
  109. ]
  110. }
  111. ]
  112. }
  113. },
  114. [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => {
  115. const include: Includeable[] = [
  116. {
  117. attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
  118. model: ActorModel.unscoped(),
  119. required: options.actorRequired ?? true,
  120. include: [
  121. {
  122. attributes: [ 'host' ],
  123. model: ServerModel.unscoped(),
  124. required: false
  125. },
  126. {
  127. model: AvatarModel.unscoped(),
  128. required: false
  129. }
  130. ]
  131. }
  132. ]
  133. const base: FindOptions = {
  134. attributes: [ 'id', 'name', 'description', 'actorId' ]
  135. }
  136. if (options.withAccount === true) {
  137. include.push({
  138. model: AccountModel.scope({
  139. method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ]
  140. }),
  141. required: true
  142. })
  143. }
  144. base.include = include
  145. return base
  146. },
  147. [ScopeNames.WITH_ACCOUNT]: {
  148. include: [
  149. {
  150. model: AccountModel,
  151. required: true
  152. }
  153. ]
  154. },
  155. [ScopeNames.WITH_ACTOR]: {
  156. include: [
  157. ActorModel
  158. ]
  159. },
  160. [ScopeNames.WITH_VIDEOS]: {
  161. include: [
  162. VideoModel
  163. ]
  164. },
  165. [ScopeNames.WITH_STATS]: (options: AvailableWithStatsOptions = { daysPrior: 30 }) => {
  166. const daysPrior = parseInt(options.daysPrior + '', 10)
  167. return {
  168. attributes: {
  169. include: [
  170. [
  171. literal('(SELECT COUNT(*) FROM "video" WHERE "channelId" = "VideoChannelModel"."id")'),
  172. 'videosCount'
  173. ],
  174. [
  175. literal(
  176. '(' +
  177. `SELECT string_agg(concat_ws('|', t.day, t.views), ',') ` +
  178. 'FROM ( ' +
  179. 'WITH ' +
  180. 'days AS ( ' +
  181. `SELECT generate_series(date_trunc('day', now()) - '${daysPrior} day'::interval, ` +
  182. `date_trunc('day', now()), '1 day'::interval) AS day ` +
  183. ') ' +
  184. 'SELECT days.day AS day, COALESCE(SUM("videoView".views), 0) AS views ' +
  185. 'FROM days ' +
  186. 'LEFT JOIN (' +
  187. '"videoView" INNER JOIN "video" ON "videoView"."videoId" = "video"."id" ' +
  188. 'AND "video"."channelId" = "VideoChannelModel"."id"' +
  189. `) ON date_trunc('day', "videoView"."startDate") = date_trunc('day', days.day) ` +
  190. 'GROUP BY day ' +
  191. 'ORDER BY day ' +
  192. ') t' +
  193. ')'
  194. ),
  195. 'viewsPerDay'
  196. ]
  197. ]
  198. }
  199. }
  200. }
  201. }))
  202. @Table({
  203. tableName: 'videoChannel',
  204. indexes: [
  205. buildTrigramSearchIndex('video_channel_name_trigram', 'name'),
  206. {
  207. fields: [ 'accountId' ]
  208. },
  209. {
  210. fields: [ 'actorId' ]
  211. }
  212. ]
  213. })
  214. export class VideoChannelModel extends Model {
  215. @AllowNull(false)
  216. @Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelNameValid, 'name'))
  217. @Column
  218. name: string
  219. @AllowNull(true)
  220. @Default(null)
  221. @Is('VideoChannelDescription', value => throwIfNotValid(value, isVideoChannelDescriptionValid, 'description', true))
  222. @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.DESCRIPTION.max))
  223. description: string
  224. @AllowNull(true)
  225. @Default(null)
  226. @Is('VideoChannelSupport', value => throwIfNotValid(value, isVideoChannelSupportValid, 'support', true))
  227. @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.SUPPORT.max))
  228. support: string
  229. @CreatedAt
  230. createdAt: Date
  231. @UpdatedAt
  232. updatedAt: Date
  233. @ForeignKey(() => ActorModel)
  234. @Column
  235. actorId: number
  236. @BelongsTo(() => ActorModel, {
  237. foreignKey: {
  238. allowNull: false
  239. },
  240. onDelete: 'cascade'
  241. })
  242. Actor: ActorModel
  243. @ForeignKey(() => AccountModel)
  244. @Column
  245. accountId: number
  246. @BelongsTo(() => AccountModel, {
  247. foreignKey: {
  248. allowNull: false
  249. },
  250. hooks: true
  251. })
  252. Account: AccountModel
  253. @HasMany(() => VideoModel, {
  254. foreignKey: {
  255. name: 'channelId',
  256. allowNull: false
  257. },
  258. onDelete: 'CASCADE',
  259. hooks: true
  260. })
  261. Videos: VideoModel[]
  262. @HasMany(() => VideoPlaylistModel, {
  263. foreignKey: {
  264. allowNull: true
  265. },
  266. onDelete: 'CASCADE',
  267. hooks: true
  268. })
  269. VideoPlaylists: VideoPlaylistModel[]
  270. @BeforeDestroy
  271. static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
  272. if (!instance.Actor) {
  273. instance.Actor = await instance.$get('Actor', { transaction: options.transaction })
  274. }
  275. await ActorFollowModel.removeFollowsOf(instance.Actor.id, options.transaction)
  276. if (instance.Actor.isOwned()) {
  277. return sendDeleteActor(instance.Actor, options.transaction)
  278. }
  279. return undefined
  280. }
  281. static countByAccount (accountId: number) {
  282. const query = {
  283. where: {
  284. accountId
  285. }
  286. }
  287. return VideoChannelModel.count(query)
  288. }
  289. static listForApi (parameters: {
  290. actorId: number
  291. start: number
  292. count: number
  293. sort: string
  294. }) {
  295. const { actorId } = parameters
  296. const query = {
  297. offset: parameters.start,
  298. limit: parameters.count,
  299. order: getSort(parameters.sort)
  300. }
  301. return VideoChannelModel
  302. .scope({
  303. method: [ ScopeNames.FOR_API, { actorId } as AvailableForListOptions ]
  304. })
  305. .findAndCountAll(query)
  306. .then(({ rows, count }) => {
  307. return { total: count, data: rows }
  308. })
  309. }
  310. static listLocalsForSitemap (sort: string): Promise<MChannelActor[]> {
  311. const query = {
  312. attributes: [ ],
  313. offset: 0,
  314. order: getSort(sort),
  315. include: [
  316. {
  317. attributes: [ 'preferredUsername', 'serverId' ],
  318. model: ActorModel.unscoped(),
  319. where: {
  320. serverId: null
  321. }
  322. }
  323. ]
  324. }
  325. return VideoChannelModel
  326. .unscoped()
  327. .findAll(query)
  328. }
  329. static searchForApi (options: {
  330. actorId: number
  331. search: string
  332. start: number
  333. count: number
  334. sort: string
  335. }) {
  336. const attributesInclude = []
  337. const escapedSearch = VideoModel.sequelize.escape(options.search)
  338. const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
  339. attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search))
  340. const query = {
  341. attributes: {
  342. include: attributesInclude
  343. },
  344. offset: options.start,
  345. limit: options.count,
  346. order: getSort(options.sort),
  347. where: {
  348. [Op.or]: [
  349. Sequelize.literal(
  350. 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
  351. ),
  352. Sequelize.literal(
  353. 'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
  354. )
  355. ]
  356. }
  357. }
  358. return VideoChannelModel
  359. .scope({
  360. method: [ ScopeNames.FOR_API, { actorId: options.actorId } as AvailableForListOptions ]
  361. })
  362. .findAndCountAll(query)
  363. .then(({ rows, count }) => {
  364. return { total: count, data: rows }
  365. })
  366. }
  367. static listByAccount (options: {
  368. accountId: number
  369. start: number
  370. count: number
  371. sort: string
  372. withStats?: boolean
  373. search?: string
  374. }) {
  375. const escapedSearch = VideoModel.sequelize.escape(options.search)
  376. const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
  377. const where = options.search
  378. ? {
  379. [Op.or]: [
  380. Sequelize.literal(
  381. 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
  382. ),
  383. Sequelize.literal(
  384. 'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
  385. )
  386. ]
  387. }
  388. : null
  389. const query = {
  390. offset: options.start,
  391. limit: options.count,
  392. order: getSort(options.sort),
  393. include: [
  394. {
  395. model: AccountModel,
  396. where: {
  397. id: options.accountId
  398. },
  399. required: true
  400. }
  401. ],
  402. where
  403. }
  404. const scopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR ]
  405. if (options.withStats === true) {
  406. scopes.push({
  407. method: [ ScopeNames.WITH_STATS, { daysPrior: 30 } as AvailableWithStatsOptions ]
  408. })
  409. }
  410. return VideoChannelModel
  411. .scope(scopes)
  412. .findAndCountAll(query)
  413. .then(({ rows, count }) => {
  414. return { total: count, data: rows }
  415. })
  416. }
  417. static loadByIdAndPopulateAccount (id: number): Promise<MChannelAccountDefault> {
  418. return VideoChannelModel.unscoped()
  419. .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
  420. .findByPk(id)
  421. }
  422. static loadByIdAndAccount (id: number, accountId: number): Promise<MChannelAccountDefault> {
  423. const query = {
  424. where: {
  425. id,
  426. accountId
  427. }
  428. }
  429. return VideoChannelModel.unscoped()
  430. .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
  431. .findOne(query)
  432. }
  433. static loadAndPopulateAccount (id: number): Promise<MChannelAccountDefault> {
  434. return VideoChannelModel.unscoped()
  435. .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
  436. .findByPk(id)
  437. }
  438. static loadByUrlAndPopulateAccount (url: string): Promise<MChannelAccountDefault> {
  439. const query = {
  440. include: [
  441. {
  442. model: ActorModel,
  443. required: true,
  444. where: {
  445. url
  446. }
  447. }
  448. ]
  449. }
  450. return VideoChannelModel
  451. .scope([ ScopeNames.WITH_ACCOUNT ])
  452. .findOne(query)
  453. }
  454. static loadByNameWithHostAndPopulateAccount (nameWithHost: string) {
  455. const [ name, host ] = nameWithHost.split('@')
  456. if (!host || host === WEBSERVER.HOST) return VideoChannelModel.loadLocalByNameAndPopulateAccount(name)
  457. return VideoChannelModel.loadByNameAndHostAndPopulateAccount(name, host)
  458. }
  459. static loadLocalByNameAndPopulateAccount (name: string): Promise<MChannelAccountDefault> {
  460. const query = {
  461. include: [
  462. {
  463. model: ActorModel,
  464. required: true,
  465. where: {
  466. preferredUsername: name,
  467. serverId: null
  468. }
  469. }
  470. ]
  471. }
  472. return VideoChannelModel.unscoped()
  473. .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
  474. .findOne(query)
  475. }
  476. static loadByNameAndHostAndPopulateAccount (name: string, host: string): Promise<MChannelAccountDefault> {
  477. const query = {
  478. include: [
  479. {
  480. model: ActorModel,
  481. required: true,
  482. where: {
  483. preferredUsername: name
  484. },
  485. include: [
  486. {
  487. model: ServerModel,
  488. required: true,
  489. where: { host }
  490. }
  491. ]
  492. }
  493. ]
  494. }
  495. return VideoChannelModel.unscoped()
  496. .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
  497. .findOne(query)
  498. }
  499. static loadAndPopulateAccountAndVideos (id: number): Promise<MChannelActorAccountDefaultVideos> {
  500. const options = {
  501. include: [
  502. VideoModel
  503. ]
  504. }
  505. return VideoChannelModel.unscoped()
  506. .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEOS ])
  507. .findByPk(id, options)
  508. }
  509. toFormattedSummaryJSON (this: MChannelSummaryFormattable): VideoChannelSummary {
  510. const actor = this.Actor.toFormattedSummaryJSON()
  511. return {
  512. id: this.id,
  513. name: actor.name,
  514. displayName: this.getDisplayName(),
  515. url: actor.url,
  516. host: actor.host,
  517. avatar: actor.avatar
  518. }
  519. }
  520. toFormattedJSON (this: MChannelFormattable): VideoChannel {
  521. const viewsPerDayString = this.get('viewsPerDay') as string
  522. const videosCount = this.get('videosCount') as number
  523. let viewsPerDay: { date: Date, views: number }[]
  524. if (viewsPerDayString) {
  525. viewsPerDay = viewsPerDayString.split(',')
  526. .map(v => {
  527. const [ dateString, amount ] = v.split('|')
  528. return {
  529. date: new Date(dateString),
  530. views: +amount
  531. }
  532. })
  533. }
  534. const actor = this.Actor.toFormattedJSON()
  535. const videoChannel = {
  536. id: this.id,
  537. displayName: this.getDisplayName(),
  538. description: this.description,
  539. support: this.support,
  540. isLocal: this.Actor.isOwned(),
  541. createdAt: this.createdAt,
  542. updatedAt: this.updatedAt,
  543. ownerAccount: undefined,
  544. videosCount,
  545. viewsPerDay
  546. }
  547. if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON()
  548. return Object.assign(actor, videoChannel)
  549. }
  550. toActivityPubObject (this: MChannelAP): ActivityPubActor {
  551. const obj = this.Actor.toActivityPubObject(this.name)
  552. return Object.assign(obj, {
  553. summary: this.description,
  554. support: this.support,
  555. attributedTo: [
  556. {
  557. type: 'Person' as 'Person',
  558. id: this.Account.Actor.url
  559. }
  560. ]
  561. })
  562. }
  563. getLocalUrl (this: MAccountActor | MChannelActor) {
  564. return WEBSERVER.URL + `/video-channels/` + this.Actor.preferredUsername
  565. }
  566. getDisplayName () {
  567. return this.name
  568. }
  569. isOutdated () {
  570. return this.Actor.isOutdated()
  571. }
  572. }