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

860 lines
22 KiB

  1. import { forceNumber, pick } from '@peertube/peertube-core-utils'
  2. import { ActivityPubActor, VideoChannel, VideoChannelSummary } from '@peertube/peertube-models'
  3. import { CONFIG } from '@server/initializers/config.js'
  4. import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
  5. import { MAccountHost } from '@server/types/models/index.js'
  6. import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
  7. import {
  8. AfterCreate,
  9. AfterDestroy,
  10. AfterUpdate,
  11. AllowNull,
  12. BeforeDestroy,
  13. BelongsTo,
  14. Column,
  15. CreatedAt,
  16. DataType,
  17. Default,
  18. DefaultScope,
  19. ForeignKey,
  20. HasMany,
  21. Is, Scopes,
  22. Sequelize,
  23. Table,
  24. UpdatedAt
  25. } from 'sequelize-typescript'
  26. import {
  27. isVideoChannelDescriptionValid,
  28. isVideoChannelDisplayNameValid,
  29. isVideoChannelSupportValid
  30. } from '../../helpers/custom-validators/video-channels.js'
  31. import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants.js'
  32. import { sendDeleteActor } from '../../lib/activitypub/send/index.js'
  33. import {
  34. MChannelAP,
  35. MChannelBannerAccountDefault,
  36. MChannelFormattable,
  37. MChannelHost,
  38. MChannelSummaryFormattable,
  39. type MChannel, MChannelDefault
  40. } from '../../types/models/video/index.js'
  41. import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account.js'
  42. import { ActorFollowModel } from '../actor/actor-follow.js'
  43. import { ActorImageModel } from '../actor/actor-image.js'
  44. import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor.js'
  45. import { ServerModel } from '../server/server.js'
  46. import {
  47. SequelizeModel,
  48. buildServerIdsFollowedBy,
  49. buildTrigramSearchIndex,
  50. createSimilarityAttribute,
  51. getSort,
  52. setAsUpdated,
  53. throwIfNotValid
  54. } from '../shared/index.js'
  55. import { VideoPlaylistModel } from './video-playlist.js'
  56. import { VideoModel } from './video.js'
  57. export enum ScopeNames {
  58. FOR_API = 'FOR_API',
  59. SUMMARY = 'SUMMARY',
  60. WITH_ACCOUNT = 'WITH_ACCOUNT',
  61. WITH_ACTOR = 'WITH_ACTOR',
  62. WITH_ACTOR_BANNER = 'WITH_ACTOR_BANNER',
  63. WITH_VIDEOS = 'WITH_VIDEOS',
  64. WITH_STATS = 'WITH_STATS'
  65. }
  66. type AvailableForListOptions = {
  67. actorId: number
  68. search?: string
  69. host?: string
  70. handles?: string[]
  71. forCount?: boolean
  72. }
  73. type AvailableWithStatsOptions = {
  74. daysPrior: number
  75. }
  76. export type SummaryOptions = {
  77. actorRequired?: boolean // Default: true
  78. withAccount?: boolean // Default: false
  79. withAccountBlockerIds?: number[]
  80. }
  81. @DefaultScope(() => ({
  82. include: [
  83. {
  84. model: ActorModel,
  85. required: true
  86. }
  87. ]
  88. }))
  89. @Scopes(() => ({
  90. [ScopeNames.FOR_API]: (options: AvailableForListOptions) => {
  91. // Only list local channels OR channels that are on an instance followed by actorId
  92. const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
  93. const whereActorAnd: WhereOptions[] = [
  94. {
  95. [Op.or]: [
  96. {
  97. serverId: null
  98. },
  99. {
  100. serverId: {
  101. [Op.in]: Sequelize.literal(inQueryInstanceFollow)
  102. }
  103. }
  104. ]
  105. }
  106. ]
  107. let serverRequired = false
  108. let whereServer: WhereOptions
  109. if (options.host && options.host !== WEBSERVER.HOST) {
  110. serverRequired = true
  111. whereServer = { host: options.host }
  112. }
  113. if (options.host === WEBSERVER.HOST) {
  114. whereActorAnd.push({
  115. serverId: null
  116. })
  117. }
  118. if (Array.isArray(options.handles) && options.handles.length !== 0) {
  119. const or: string[] = []
  120. for (const handle of options.handles || []) {
  121. const [ preferredUsername, host ] = handle.split('@')
  122. const sanitizedPreferredUsername = VideoChannelModel.sequelize.escape(preferredUsername.toLowerCase())
  123. const sanitizedHost = VideoChannelModel.sequelize.escape(host)
  124. if (!host || host === WEBSERVER.HOST) {
  125. or.push(`(LOWER("preferredUsername") = ${sanitizedPreferredUsername} AND "serverId" IS NULL)`)
  126. } else {
  127. or.push(
  128. `(` +
  129. `LOWER("preferredUsername") = ${sanitizedPreferredUsername} ` +
  130. `AND "host" = ${sanitizedHost}` +
  131. `)`
  132. )
  133. }
  134. }
  135. whereActorAnd.push({
  136. id: {
  137. [Op.in]: literal(`(SELECT "actor".id FROM actor LEFT JOIN server on server.id = actor."serverId" WHERE ${or.join(' OR ')})`)
  138. }
  139. })
  140. }
  141. const channelActorInclude: Includeable[] = []
  142. const accountActorInclude: Includeable[] = []
  143. if (options.forCount !== true) {
  144. accountActorInclude.push({
  145. model: ServerModel,
  146. required: false
  147. })
  148. accountActorInclude.push({
  149. model: ActorImageModel,
  150. as: 'Avatars',
  151. required: false
  152. })
  153. channelActorInclude.push({
  154. model: ActorImageModel,
  155. as: 'Avatars',
  156. required: false
  157. })
  158. channelActorInclude.push({
  159. model: ActorImageModel,
  160. as: 'Banners',
  161. required: false
  162. })
  163. }
  164. if (options.forCount !== true || serverRequired) {
  165. channelActorInclude.push({
  166. model: ServerModel,
  167. duplicating: false,
  168. required: serverRequired,
  169. where: whereServer
  170. })
  171. }
  172. return {
  173. include: [
  174. {
  175. attributes: {
  176. exclude: unusedActorAttributesForAPI
  177. },
  178. model: ActorModel.unscoped(),
  179. where: {
  180. [Op.and]: whereActorAnd
  181. },
  182. include: channelActorInclude
  183. },
  184. {
  185. model: AccountModel.unscoped(),
  186. required: true,
  187. include: [
  188. {
  189. attributes: {
  190. exclude: unusedActorAttributesForAPI
  191. },
  192. model: ActorModel.unscoped(),
  193. required: true,
  194. include: accountActorInclude
  195. }
  196. ]
  197. }
  198. ]
  199. }
  200. },
  201. [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => {
  202. const include: Includeable[] = [
  203. {
  204. attributes: [ 'id', 'preferredUsername', 'url', 'serverId' ],
  205. model: ActorModel.unscoped(),
  206. required: options.actorRequired ?? true,
  207. include: [
  208. {
  209. attributes: [ 'host' ],
  210. model: ServerModel.unscoped(),
  211. required: false
  212. },
  213. {
  214. model: ActorImageModel,
  215. as: 'Avatars',
  216. required: false
  217. }
  218. ]
  219. }
  220. ]
  221. const base: FindOptions = {
  222. attributes: [ 'id', 'name', 'description', 'actorId' ]
  223. }
  224. if (options.withAccount === true) {
  225. include.push({
  226. model: AccountModel.scope({
  227. method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ]
  228. }),
  229. required: true
  230. })
  231. }
  232. base.include = include
  233. return base
  234. },
  235. [ScopeNames.WITH_ACCOUNT]: {
  236. include: [
  237. {
  238. model: AccountModel,
  239. required: true
  240. }
  241. ]
  242. },
  243. [ScopeNames.WITH_ACTOR]: {
  244. include: [
  245. ActorModel
  246. ]
  247. },
  248. [ScopeNames.WITH_ACTOR_BANNER]: {
  249. include: [
  250. {
  251. model: ActorModel,
  252. include: [
  253. {
  254. model: ActorImageModel,
  255. required: false,
  256. as: 'Banners'
  257. }
  258. ]
  259. }
  260. ]
  261. },
  262. [ScopeNames.WITH_VIDEOS]: {
  263. include: [
  264. VideoModel
  265. ]
  266. },
  267. [ScopeNames.WITH_STATS]: (options: AvailableWithStatsOptions = { daysPrior: 30 }) => {
  268. const daysPrior = forceNumber(options.daysPrior)
  269. return {
  270. attributes: {
  271. include: [
  272. [
  273. literal('(SELECT COUNT(*) FROM "video" WHERE "channelId" = "VideoChannelModel"."id")'),
  274. 'videosCount'
  275. ],
  276. [
  277. literal(
  278. '(' +
  279. `SELECT string_agg(concat_ws('|', t.day, t.views), ',') ` +
  280. 'FROM ( ' +
  281. 'WITH ' +
  282. 'days AS ( ' +
  283. `SELECT generate_series(date_trunc('day', now()) - '${daysPrior} day'::interval, ` +
  284. `date_trunc('day', now()), '1 day'::interval) AS day ` +
  285. ') ' +
  286. 'SELECT days.day AS day, COALESCE(SUM("videoView".views), 0) AS views ' +
  287. 'FROM days ' +
  288. 'LEFT JOIN (' +
  289. '"videoView" INNER JOIN "video" ON "videoView"."videoId" = "video"."id" ' +
  290. 'AND "video"."channelId" = "VideoChannelModel"."id"' +
  291. `) ON date_trunc('day', "videoView"."startDate") = date_trunc('day', days.day) ` +
  292. 'GROUP BY day ' +
  293. 'ORDER BY day ' +
  294. ') t' +
  295. ')'
  296. ),
  297. 'viewsPerDay'
  298. ],
  299. [
  300. literal(
  301. '(' +
  302. 'SELECT COALESCE(SUM("video".views), 0) AS totalViews ' +
  303. 'FROM "video" ' +
  304. 'WHERE "video"."channelId" = "VideoChannelModel"."id"' +
  305. ')'
  306. ),
  307. 'totalViews'
  308. ]
  309. ]
  310. }
  311. }
  312. }
  313. }))
  314. @Table({
  315. tableName: 'videoChannel',
  316. indexes: [
  317. buildTrigramSearchIndex('video_channel_name_trigram', 'name'),
  318. {
  319. fields: [ 'accountId' ]
  320. },
  321. {
  322. fields: [ 'actorId' ]
  323. }
  324. ]
  325. })
  326. export class VideoChannelModel extends SequelizeModel<VideoChannelModel> {
  327. @AllowNull(false)
  328. @Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'name'))
  329. @Column
  330. name: string
  331. @AllowNull(true)
  332. @Default(null)
  333. @Is('VideoChannelDescription', value => throwIfNotValid(value, isVideoChannelDescriptionValid, 'description', true))
  334. @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.DESCRIPTION.max))
  335. description: string
  336. @AllowNull(true)
  337. @Default(null)
  338. @Is('VideoChannelSupport', value => throwIfNotValid(value, isVideoChannelSupportValid, 'support', true))
  339. @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.SUPPORT.max))
  340. support: string
  341. @CreatedAt
  342. createdAt: Date
  343. @UpdatedAt
  344. updatedAt: Date
  345. @ForeignKey(() => ActorModel)
  346. @Column
  347. actorId: number
  348. @BelongsTo(() => ActorModel, {
  349. foreignKey: {
  350. allowNull: false
  351. },
  352. onDelete: 'cascade'
  353. })
  354. Actor: Awaited<ActorModel>
  355. @ForeignKey(() => AccountModel)
  356. @Column
  357. accountId: number
  358. @BelongsTo(() => AccountModel, {
  359. foreignKey: {
  360. allowNull: false
  361. }
  362. })
  363. Account: Awaited<AccountModel>
  364. @HasMany(() => VideoModel, {
  365. foreignKey: {
  366. name: 'channelId',
  367. allowNull: false
  368. },
  369. onDelete: 'CASCADE',
  370. hooks: true
  371. })
  372. Videos: Awaited<VideoModel>[]
  373. @HasMany(() => VideoPlaylistModel, {
  374. foreignKey: {
  375. allowNull: true
  376. },
  377. onDelete: 'CASCADE',
  378. hooks: true
  379. })
  380. VideoPlaylists: Awaited<VideoPlaylistModel>[]
  381. @AfterCreate
  382. static notifyCreate (channel: MChannel) {
  383. InternalEventEmitter.Instance.emit('channel-created', { channel })
  384. }
  385. @AfterUpdate
  386. static notifyUpdate (channel: MChannel) {
  387. InternalEventEmitter.Instance.emit('channel-updated', { channel })
  388. }
  389. @AfterDestroy
  390. static notifyDestroy (channel: MChannel) {
  391. InternalEventEmitter.Instance.emit('channel-deleted', { channel })
  392. }
  393. @BeforeDestroy
  394. static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
  395. if (!instance.Actor) {
  396. instance.Actor = await instance.$get('Actor', { transaction: options.transaction })
  397. }
  398. await ActorFollowModel.removeFollowsOf(instance.Actor.id, options.transaction)
  399. if (instance.Actor.isOwned()) {
  400. return sendDeleteActor(instance.Actor, options.transaction)
  401. }
  402. return undefined
  403. }
  404. static countByAccount (accountId: number) {
  405. const query = {
  406. where: {
  407. accountId
  408. }
  409. }
  410. return VideoChannelModel.unscoped().count(query)
  411. }
  412. static async getStats () {
  413. function getLocalVideoChannelStats (days?: number) {
  414. const options = {
  415. type: QueryTypes.SELECT as QueryTypes.SELECT,
  416. raw: true
  417. }
  418. const videoJoin = days
  419. ? `INNER JOIN "video" AS "Videos" ON "VideoChannelModel"."id" = "Videos"."channelId" ` +
  420. `AND ("Videos"."publishedAt" > Now() - interval '${days}d')`
  421. : ''
  422. const query = `
  423. SELECT COUNT(DISTINCT("VideoChannelModel"."id")) AS "count"
  424. FROM "videoChannel" AS "VideoChannelModel"
  425. ${videoJoin}
  426. INNER JOIN "account" AS "Account" ON "VideoChannelModel"."accountId" = "Account"."id"
  427. INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id"
  428. AND "Account->Actor"."serverId" IS NULL`
  429. return VideoChannelModel.sequelize.query<{ count: string }>(query, options)
  430. .then(r => parseInt(r[0].count, 10))
  431. }
  432. const totalLocalVideoChannels = await getLocalVideoChannelStats()
  433. const totalLocalDailyActiveVideoChannels = await getLocalVideoChannelStats(1)
  434. const totalLocalWeeklyActiveVideoChannels = await getLocalVideoChannelStats(7)
  435. const totalLocalMonthlyActiveVideoChannels = await getLocalVideoChannelStats(30)
  436. const totalLocalHalfYearActiveVideoChannels = await getLocalVideoChannelStats(180)
  437. return {
  438. totalLocalVideoChannels,
  439. totalLocalDailyActiveVideoChannels,
  440. totalLocalWeeklyActiveVideoChannels,
  441. totalLocalMonthlyActiveVideoChannels,
  442. totalLocalHalfYearActiveVideoChannels
  443. }
  444. }
  445. static listLocalsForSitemap (sort: string): Promise<MChannelHost[]> {
  446. const query = {
  447. attributes: [ ],
  448. offset: 0,
  449. order: getSort(sort),
  450. include: [
  451. {
  452. attributes: [ 'preferredUsername', 'serverId' ],
  453. model: ActorModel.unscoped(),
  454. where: {
  455. serverId: null
  456. }
  457. }
  458. ]
  459. }
  460. return VideoChannelModel
  461. .unscoped()
  462. .findAll(query)
  463. }
  464. static listForApi (parameters: Pick<AvailableForListOptions, 'actorId'> & {
  465. start: number
  466. count: number
  467. sort: string
  468. }) {
  469. const { actorId } = parameters
  470. const query = {
  471. offset: parameters.start,
  472. limit: parameters.count,
  473. order: getSort(parameters.sort)
  474. }
  475. const getScope = (forCount: boolean) => {
  476. return { method: [ ScopeNames.FOR_API, { actorId, forCount } as AvailableForListOptions ] }
  477. }
  478. return Promise.all([
  479. VideoChannelModel.scope(getScope(true)).count(),
  480. VideoChannelModel.scope(getScope(false)).findAll(query)
  481. ]).then(([ total, data ]) => ({ total, data }))
  482. }
  483. static searchForApi (options: Pick<AvailableForListOptions, 'actorId' | 'search' | 'host' | 'handles'> & {
  484. start: number
  485. count: number
  486. sort: string
  487. }) {
  488. let attributesInclude: any[] = [ literal('0 as similarity') ]
  489. let where: WhereOptions
  490. if (options.search) {
  491. const escapedSearch = VideoChannelModel.sequelize.escape(options.search)
  492. const escapedLikeSearch = VideoChannelModel.sequelize.escape('%' + options.search + '%')
  493. attributesInclude = [ createSimilarityAttribute('VideoChannelModel.name', options.search) ]
  494. where = {
  495. [Op.or]: [
  496. Sequelize.literal(
  497. 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
  498. ),
  499. Sequelize.literal(
  500. 'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
  501. )
  502. ]
  503. }
  504. }
  505. const query = {
  506. attributes: {
  507. include: attributesInclude
  508. },
  509. offset: options.start,
  510. limit: options.count,
  511. order: getSort(options.sort),
  512. where
  513. }
  514. const getScope = (forCount: boolean) => {
  515. return {
  516. method: [
  517. ScopeNames.FOR_API, {
  518. ...pick(options, [ 'actorId', 'host', 'handles' ]),
  519. forCount
  520. } as AvailableForListOptions
  521. ]
  522. }
  523. }
  524. return Promise.all([
  525. VideoChannelModel.scope(getScope(true)).count(query),
  526. VideoChannelModel.scope(getScope(false)).findAll(query)
  527. ]).then(([ total, data ]) => ({ total, data }))
  528. }
  529. static listByAccountForAPI (options: {
  530. accountId: number
  531. start: number
  532. count: number
  533. sort: string
  534. withStats?: boolean
  535. search?: string
  536. }) {
  537. const escapedSearch = VideoModel.sequelize.escape(options.search)
  538. const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
  539. const where = options.search
  540. ? {
  541. [Op.or]: [
  542. Sequelize.literal(
  543. 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
  544. ),
  545. Sequelize.literal(
  546. 'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
  547. )
  548. ]
  549. }
  550. : null
  551. const getQuery = (forCount: boolean) => {
  552. const accountModel = forCount
  553. ? AccountModel.unscoped()
  554. : AccountModel
  555. return {
  556. offset: options.start,
  557. limit: options.count,
  558. order: getSort(options.sort),
  559. include: [
  560. {
  561. model: accountModel,
  562. where: {
  563. id: options.accountId
  564. },
  565. required: true
  566. }
  567. ],
  568. where
  569. }
  570. }
  571. const findScopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR_BANNER ]
  572. if (options.withStats === true) {
  573. findScopes.push({
  574. method: [ ScopeNames.WITH_STATS, { daysPrior: 30 } as AvailableWithStatsOptions ]
  575. })
  576. }
  577. return Promise.all([
  578. VideoChannelModel.unscoped().count(getQuery(true)),
  579. VideoChannelModel.scope(findScopes).findAll(getQuery(false))
  580. ]).then(([ total, data ]) => ({ total, data }))
  581. }
  582. static listAllByAccount (accountId: number): Promise<MChannelDefault[]> {
  583. const query = {
  584. limit: CONFIG.VIDEO_CHANNELS.MAX_PER_USER,
  585. include: [
  586. {
  587. attributes: [],
  588. model: AccountModel.unscoped(),
  589. where: {
  590. id: accountId
  591. },
  592. required: true
  593. }
  594. ]
  595. }
  596. return VideoChannelModel.findAll(query)
  597. }
  598. static loadAndPopulateAccount (id: number, transaction?: Transaction): Promise<MChannelBannerAccountDefault> {
  599. return VideoChannelModel.unscoped()
  600. .scope([ ScopeNames.WITH_ACTOR_BANNER, ScopeNames.WITH_ACCOUNT ])
  601. .findByPk(id, { transaction })
  602. }
  603. static loadByUrlAndPopulateAccount (url: string): Promise<MChannelBannerAccountDefault> {
  604. const query = {
  605. include: [
  606. {
  607. model: ActorModel,
  608. required: true,
  609. where: {
  610. url
  611. },
  612. include: [
  613. {
  614. model: ActorImageModel,
  615. required: false,
  616. as: 'Banners'
  617. }
  618. ]
  619. }
  620. ]
  621. }
  622. return VideoChannelModel
  623. .scope([ ScopeNames.WITH_ACCOUNT ])
  624. .findOne(query)
  625. }
  626. static loadByNameWithHostAndPopulateAccount (nameWithHost: string) {
  627. const [ name, host ] = nameWithHost.split('@')
  628. if (!host || host === WEBSERVER.HOST) return VideoChannelModel.loadLocalByNameAndPopulateAccount(name)
  629. return VideoChannelModel.loadByNameAndHostAndPopulateAccount(name, host)
  630. }
  631. static loadLocalByNameAndPopulateAccount (name: string): Promise<MChannelBannerAccountDefault> {
  632. const query = {
  633. include: [
  634. {
  635. model: ActorModel,
  636. required: true,
  637. where: {
  638. [Op.and]: [
  639. ActorModel.wherePreferredUsername(name, 'Actor.preferredUsername'),
  640. { serverId: null }
  641. ]
  642. },
  643. include: [
  644. {
  645. model: ActorImageModel,
  646. required: false,
  647. as: 'Banners'
  648. }
  649. ]
  650. }
  651. ]
  652. }
  653. return VideoChannelModel.unscoped()
  654. .scope([ ScopeNames.WITH_ACCOUNT ])
  655. .findOne(query)
  656. }
  657. static loadByNameAndHostAndPopulateAccount (name: string, host: string): Promise<MChannelBannerAccountDefault> {
  658. const query = {
  659. include: [
  660. {
  661. model: ActorModel,
  662. required: true,
  663. where: ActorModel.wherePreferredUsername(name, 'Actor.preferredUsername'),
  664. include: [
  665. {
  666. model: ServerModel,
  667. required: true,
  668. where: { host }
  669. },
  670. {
  671. model: ActorImageModel,
  672. required: false,
  673. as: 'Banners'
  674. }
  675. ]
  676. }
  677. ]
  678. }
  679. return VideoChannelModel.unscoped()
  680. .scope([ ScopeNames.WITH_ACCOUNT ])
  681. .findOne(query)
  682. }
  683. toFormattedSummaryJSON (this: MChannelSummaryFormattable): VideoChannelSummary {
  684. const actor = this.Actor.toFormattedSummaryJSON()
  685. return {
  686. id: this.id,
  687. name: actor.name,
  688. displayName: this.getDisplayName(),
  689. url: actor.url,
  690. host: actor.host,
  691. avatars: actor.avatars
  692. }
  693. }
  694. toFormattedJSON (this: MChannelFormattable): VideoChannel {
  695. const viewsPerDayString = this.get('viewsPerDay') as string
  696. const videosCount = this.get('videosCount') as number
  697. let viewsPerDay: { date: Date, views: number }[]
  698. if (viewsPerDayString) {
  699. viewsPerDay = viewsPerDayString.split(',')
  700. .map(v => {
  701. const [ dateString, amount ] = v.split('|')
  702. return {
  703. date: new Date(dateString),
  704. views: +amount
  705. }
  706. })
  707. }
  708. const totalViews = this.get('totalViews') as number
  709. const actor = this.Actor.toFormattedJSON()
  710. const videoChannel = {
  711. id: this.id,
  712. displayName: this.getDisplayName(),
  713. description: this.description,
  714. support: this.support,
  715. isLocal: this.Actor.isOwned(),
  716. updatedAt: this.updatedAt,
  717. ownerAccount: undefined,
  718. videosCount,
  719. viewsPerDay,
  720. totalViews,
  721. avatars: actor.avatars
  722. }
  723. if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON()
  724. return Object.assign(actor, videoChannel)
  725. }
  726. async toActivityPubObject (this: MChannelAP): Promise<ActivityPubActor> {
  727. const obj = await this.Actor.toActivityPubObject(this.name)
  728. return {
  729. ...obj,
  730. summary: this.description,
  731. support: this.support,
  732. postingRestrictedToMods: true,
  733. attributedTo: [
  734. {
  735. type: 'Person' as 'Person',
  736. id: this.Account.Actor.url
  737. }
  738. ]
  739. }
  740. }
  741. // Avoid error when running this method on MAccount... | MChannel...
  742. getClientUrl (this: MAccountHost | MChannelHost) {
  743. return WEBSERVER.URL + '/c/' + this.Actor.getIdentifier() + '/videos'
  744. }
  745. getDisplayName () {
  746. return this.name
  747. }
  748. isOutdated () {
  749. return this.Actor.isOutdated()
  750. }
  751. setAsUpdated (transaction?: Transaction) {
  752. return setAsUpdated({ sequelize: this.sequelize, table: 'videoChannel', id: this.id, transaction })
  753. }
  754. }