2023-07-31 14:34:36 +02:00
|
|
|
import { forceNumber, pick } from '@peertube/peertube-core-utils'
|
|
|
|
import { ActivityPubActor, VideoChannel, VideoChannelSummary } from '@peertube/peertube-models'
|
|
|
|
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
|
|
|
|
import { CONFIG } from '@server/initializers/config.js'
|
|
|
|
import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
|
|
|
|
import { MAccountHost } from '@server/types/models/index.js'
|
2021-07-28 10:32:40 +02:00
|
|
|
import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
|
2017-12-12 17:53:50 +01:00
|
|
|
import {
|
Add Podcast RSS feeds (#5487)
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Add correct feed image to RSS channel
* Prefer HLS videos for podcast RSS
Remove video/stream titles, add optional height attribute to podcast RSS
* Prefix podcast RSS images with root server URL
* Add optional video query support to include captions
* Add transcripts & person images to podcast RSS feed
* Prefer webseed/webtorrent files over HLS fragmented mp4s
* Experimentally adding podcast fields to basic config page
* Add validation for new basic config fields
* Don't include "content" in podcast feed, use full description for "description"
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Add correct feed image to RSS channel
* Prefer HLS videos for podcast RSS
Remove video/stream titles, add optional height attribute to podcast RSS
* Prefix podcast RSS images with root server URL
* Add optional video query support to include captions
* Add transcripts & person images to podcast RSS feed
* Prefer webseed/webtorrent files over HLS fragmented mp4s
* Experimentally adding podcast fields to basic config page
* Add validation for new basic config fields
* Don't include "content" in podcast feed, use full description for "description"
* Add medium/socialInteract to podcast RSS feeds. Use HTML for description
* Change base production image to bullseye, install prosody in image
* Add liveItem and trackers to Podcast RSS feeds
Remove height from alternateEnclosure, replaced with title.
* Clear Podcast RSS feed cache when live streams start/end
* Upgrade to Node 16
* Refactor clearCacheRoute to use ApiCache
* Remove unnecessary type hint
* Update dockerfile to node 16, install python-is-python2
* Use new file paths for captions/playlists
* Fix legacy videos in RSS after migration to object storage
* Improve method of identifying non-fragmented mp4s in podcast RSS feeds
* Don't include fragmented MP4s in podcast RSS feeds
* Add experimental support for podcast:categories on the podcast RSS item
* Fix undefined category when no videos exist
Allows for empty feeds to exist (important for feeds that might only go live)
* Add support for podcast:locked -- user has to opt in to show their email
* Use comma for podcast:categories delimiter
* Make cache clearing async
* Fix merge, temporarily test with pfeed-podcast
* Syntax changes
* Add EXT_MIMETYPE constants for captions
* Update & fix tests, fix enclosure mimetypes, remove admin email
* Add test for podacst:socialInteract
* Add filters hooks for podcast customTags
* Remove showdown, updated to pfeed-podcast 6.1.2
* Add 'action:api.live-video.state.updated' hook
* Avoid assigning undefined category to podcast feeds
* Remove nvmrc
* Remove comment
* Remove unused podcast config
* Remove more unused podcast config
* Fix MChannelAccountDefault type hint missed in merge
* Remove extra line
* Re-add newline in config
* Fix lint errors for isEmailPublic
* Fix thumbnails in podcast feeds
* Requested changes based on review
* Provide podcast rss 2.0 only on video channels
* Misc cleanup for a less messy PR
* Lint fixes
* Remove pfeed-podcast
* Add peertube version to new hooks
* Don't use query include, remove TODO
* Remove film medium hack
* Clear podcast rss cache before video/channel update hooks
* Clear podcast rss cache before video uploaded/deleted hooks
* Refactor podcast feed cache clearing
* Set correct person name from video channel
* Styling
* Fix tests
---------
Co-authored-by: Chocobozzz <me@florianbigard.com>
2023-05-22 16:00:05 +02:00
|
|
|
AfterCreate,
|
|
|
|
AfterDestroy,
|
|
|
|
AfterUpdate,
|
2018-08-16 15:25:20 +02:00
|
|
|
AllowNull,
|
|
|
|
BeforeDestroy,
|
|
|
|
BelongsTo,
|
|
|
|
Column,
|
|
|
|
CreatedAt,
|
|
|
|
DataType,
|
|
|
|
Default,
|
|
|
|
DefaultScope,
|
|
|
|
ForeignKey,
|
2019-04-11 11:33:44 +02:00
|
|
|
HasMany,
|
2018-08-16 15:25:20 +02:00
|
|
|
Is,
|
|
|
|
Model,
|
|
|
|
Scopes,
|
2018-08-23 17:58:39 +02:00
|
|
|
Sequelize,
|
2018-08-16 15:25:20 +02:00
|
|
|
Table,
|
|
|
|
UpdatedAt
|
2017-12-12 17:53:50 +01:00
|
|
|
} from 'sequelize-typescript'
|
2018-02-15 14:46:26 +01:00
|
|
|
import {
|
2018-08-16 15:25:20 +02:00
|
|
|
isVideoChannelDescriptionValid,
|
2021-08-05 13:54:35 +02:00
|
|
|
isVideoChannelDisplayNameValid,
|
2018-02-15 14:46:26 +01:00
|
|
|
isVideoChannelSupportValid
|
2023-07-31 14:34:36 +02:00
|
|
|
} from '../../helpers/custom-validators/video-channels.js'
|
|
|
|
import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants.js'
|
|
|
|
import { sendDeleteActor } from '../../lib/activitypub/send/index.js'
|
2019-08-15 11:53:26 +02:00
|
|
|
import {
|
2019-08-21 14:31:57 +02:00
|
|
|
MChannelAP,
|
2021-04-06 17:01:35 +02:00
|
|
|
MChannelBannerAccountDefault,
|
2019-08-21 14:31:57 +02:00
|
|
|
MChannelFormattable,
|
Add Podcast RSS feeds (#5487)
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Add correct feed image to RSS channel
* Prefer HLS videos for podcast RSS
Remove video/stream titles, add optional height attribute to podcast RSS
* Prefix podcast RSS images with root server URL
* Add optional video query support to include captions
* Add transcripts & person images to podcast RSS feed
* Prefer webseed/webtorrent files over HLS fragmented mp4s
* Experimentally adding podcast fields to basic config page
* Add validation for new basic config fields
* Don't include "content" in podcast feed, use full description for "description"
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Add correct feed image to RSS channel
* Prefer HLS videos for podcast RSS
Remove video/stream titles, add optional height attribute to podcast RSS
* Prefix podcast RSS images with root server URL
* Add optional video query support to include captions
* Add transcripts & person images to podcast RSS feed
* Prefer webseed/webtorrent files over HLS fragmented mp4s
* Experimentally adding podcast fields to basic config page
* Add validation for new basic config fields
* Don't include "content" in podcast feed, use full description for "description"
* Add medium/socialInteract to podcast RSS feeds. Use HTML for description
* Change base production image to bullseye, install prosody in image
* Add liveItem and trackers to Podcast RSS feeds
Remove height from alternateEnclosure, replaced with title.
* Clear Podcast RSS feed cache when live streams start/end
* Upgrade to Node 16
* Refactor clearCacheRoute to use ApiCache
* Remove unnecessary type hint
* Update dockerfile to node 16, install python-is-python2
* Use new file paths for captions/playlists
* Fix legacy videos in RSS after migration to object storage
* Improve method of identifying non-fragmented mp4s in podcast RSS feeds
* Don't include fragmented MP4s in podcast RSS feeds
* Add experimental support for podcast:categories on the podcast RSS item
* Fix undefined category when no videos exist
Allows for empty feeds to exist (important for feeds that might only go live)
* Add support for podcast:locked -- user has to opt in to show their email
* Use comma for podcast:categories delimiter
* Make cache clearing async
* Fix merge, temporarily test with pfeed-podcast
* Syntax changes
* Add EXT_MIMETYPE constants for captions
* Update & fix tests, fix enclosure mimetypes, remove admin email
* Add test for podacst:socialInteract
* Add filters hooks for podcast customTags
* Remove showdown, updated to pfeed-podcast 6.1.2
* Add 'action:api.live-video.state.updated' hook
* Avoid assigning undefined category to podcast feeds
* Remove nvmrc
* Remove comment
* Remove unused podcast config
* Remove more unused podcast config
* Fix MChannelAccountDefault type hint missed in merge
* Remove extra line
* Re-add newline in config
* Fix lint errors for isEmailPublic
* Fix thumbnails in podcast feeds
* Requested changes based on review
* Provide podcast rss 2.0 only on video channels
* Misc cleanup for a less messy PR
* Lint fixes
* Remove pfeed-podcast
* Add peertube version to new hooks
* Don't use query include, remove TODO
* Remove film medium hack
* Clear podcast rss cache before video/channel update hooks
* Clear podcast rss cache before video uploaded/deleted hooks
* Refactor podcast feed cache clearing
* Set correct person name from video channel
* Styling
* Fix tests
---------
Co-authored-by: Chocobozzz <me@florianbigard.com>
2023-05-22 16:00:05 +02:00
|
|
|
MChannelHost,
|
2023-07-31 14:34:36 +02:00
|
|
|
MChannelSummaryFormattable,
|
|
|
|
type MChannel
|
|
|
|
} from '../../types/models/video/index.js'
|
|
|
|
import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account.js'
|
|
|
|
import { ActorFollowModel } from '../actor/actor-follow.js'
|
|
|
|
import { ActorImageModel } from '../actor/actor-image.js'
|
|
|
|
import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor.js'
|
|
|
|
import { ServerModel } from '../server/server.js'
|
2023-01-10 11:09:30 +01:00
|
|
|
import {
|
|
|
|
buildServerIdsFollowedBy,
|
|
|
|
buildTrigramSearchIndex,
|
|
|
|
createSimilarityAttribute,
|
|
|
|
getSort,
|
|
|
|
setAsUpdated,
|
|
|
|
throwIfNotValid
|
2023-07-31 14:34:36 +02:00
|
|
|
} from '../shared/index.js'
|
|
|
|
import { VideoPlaylistModel } from './video-playlist.js'
|
|
|
|
import { VideoModel } from './video.js'
|
2018-08-23 17:58:39 +02:00
|
|
|
|
2019-02-26 10:55:40 +01:00
|
|
|
export enum ScopeNames {
|
2019-08-15 11:53:26 +02:00
|
|
|
FOR_API = 'FOR_API',
|
2020-03-23 10:14:05 +01:00
|
|
|
SUMMARY = 'SUMMARY',
|
2017-12-14 10:07:57 +01:00
|
|
|
WITH_ACCOUNT = 'WITH_ACCOUNT',
|
2017-12-14 17:38:41 +01:00
|
|
|
WITH_ACTOR = 'WITH_ACTOR',
|
2021-04-06 17:01:35 +02:00
|
|
|
WITH_ACTOR_BANNER = 'WITH_ACTOR_BANNER',
|
2019-02-26 10:55:40 +01:00
|
|
|
WITH_VIDEOS = 'WITH_VIDEOS',
|
2020-03-23 10:14:05 +01:00
|
|
|
WITH_STATS = 'WITH_STATS'
|
2017-12-14 10:07:57 +01:00
|
|
|
}
|
|
|
|
|
2018-08-23 17:58:39 +02:00
|
|
|
type AvailableForListOptions = {
|
|
|
|
actorId: number
|
2020-07-15 11:17:03 +02:00
|
|
|
search?: string
|
2021-07-28 10:32:40 +02:00
|
|
|
host?: string
|
2021-07-29 10:27:24 +02:00
|
|
|
handles?: string[]
|
2022-02-28 08:34:43 +01:00
|
|
|
forCount?: boolean
|
2018-08-23 17:58:39 +02:00
|
|
|
}
|
|
|
|
|
2020-03-23 10:14:05 +01:00
|
|
|
type AvailableWithStatsOptions = {
|
|
|
|
daysPrior: number
|
|
|
|
}
|
|
|
|
|
2019-07-31 15:57:32 +02:00
|
|
|
export type SummaryOptions = {
|
2020-07-07 14:34:16 +02:00
|
|
|
actorRequired?: boolean // Default: true
|
2019-07-31 15:57:32 +02:00
|
|
|
withAccount?: boolean // Default: false
|
|
|
|
withAccountBlockerIds?: number[]
|
|
|
|
}
|
|
|
|
|
2019-04-23 09:50:57 +02:00
|
|
|
@DefaultScope(() => ({
|
2017-12-14 17:38:41 +01:00
|
|
|
include: [
|
|
|
|
{
|
2019-04-23 09:50:57 +02:00
|
|
|
model: ActorModel,
|
2017-12-14 17:38:41 +01:00
|
|
|
required: true
|
|
|
|
}
|
|
|
|
]
|
2019-04-23 09:50:57 +02:00
|
|
|
}))
|
|
|
|
@Scopes(() => ({
|
2019-08-15 11:53:26 +02:00
|
|
|
[ScopeNames.FOR_API]: (options: AvailableForListOptions) => {
|
2018-08-23 17:58:39 +02:00
|
|
|
// Only list local channels OR channels that are on an instance followed by actorId
|
2019-02-26 10:55:40 +01:00
|
|
|
const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
|
2018-08-23 17:58:39 +02:00
|
|
|
|
2021-07-28 16:40:21 +02:00
|
|
|
const whereActorAnd: WhereOptions[] = [
|
|
|
|
{
|
|
|
|
[Op.or]: [
|
|
|
|
{
|
|
|
|
serverId: null
|
|
|
|
},
|
|
|
|
{
|
|
|
|
serverId: {
|
|
|
|
[Op.in]: Sequelize.literal(inQueryInstanceFollow)
|
|
|
|
}
|
2021-07-28 10:32:40 +02:00
|
|
|
}
|
2021-07-28 16:40:21 +02:00
|
|
|
]
|
|
|
|
}
|
|
|
|
]
|
2021-07-28 10:32:40 +02:00
|
|
|
|
|
|
|
let serverRequired = false
|
|
|
|
let whereServer: WhereOptions
|
|
|
|
|
|
|
|
if (options.host && options.host !== WEBSERVER.HOST) {
|
|
|
|
serverRequired = true
|
|
|
|
whereServer = { host: options.host }
|
|
|
|
}
|
|
|
|
|
|
|
|
if (options.host === WEBSERVER.HOST) {
|
2021-07-28 16:40:21 +02:00
|
|
|
whereActorAnd.push({
|
|
|
|
serverId: null
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2022-02-28 08:34:43 +01:00
|
|
|
if (Array.isArray(options.handles) && options.handles.length !== 0) {
|
|
|
|
const or: string[] = []
|
2021-07-29 10:27:24 +02:00
|
|
|
|
|
|
|
for (const handle of options.handles || []) {
|
|
|
|
const [ preferredUsername, host ] = handle.split('@')
|
|
|
|
|
2023-05-11 16:16:27 +02:00
|
|
|
const sanitizedPreferredUsername = VideoChannelModel.sequelize.escape(preferredUsername.toLowerCase())
|
|
|
|
const sanitizedHost = VideoChannelModel.sequelize.escape(host)
|
|
|
|
|
2021-10-08 11:15:06 +02:00
|
|
|
if (!host || host === WEBSERVER.HOST) {
|
2023-05-11 16:16:27 +02:00
|
|
|
or.push(`(LOWER("preferredUsername") = ${sanitizedPreferredUsername} AND "serverId" IS NULL)`)
|
2021-07-29 10:27:24 +02:00
|
|
|
} else {
|
2022-02-28 08:34:43 +01:00
|
|
|
or.push(
|
|
|
|
`(` +
|
2023-05-11 16:16:27 +02:00
|
|
|
`LOWER("preferredUsername") = ${sanitizedPreferredUsername} ` +
|
|
|
|
`AND "host" = ${sanitizedHost}` +
|
2022-02-28 08:34:43 +01:00
|
|
|
`)`
|
|
|
|
)
|
2021-07-28 16:40:21 +02:00
|
|
|
}
|
2021-07-29 10:27:24 +02:00
|
|
|
}
|
|
|
|
|
2022-02-28 08:34:43 +01:00
|
|
|
whereActorAnd.push({
|
|
|
|
id: {
|
|
|
|
[Op.in]: literal(`(SELECT "actor".id FROM actor LEFT JOIN server on server.id = actor."serverId" WHERE ${or.join(' OR ')})`)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2022-03-21 09:26:48 +01:00
|
|
|
const channelActorInclude: Includeable[] = []
|
|
|
|
const accountActorInclude: Includeable[] = []
|
2022-02-28 08:34:43 +01:00
|
|
|
|
|
|
|
if (options.forCount !== true) {
|
2022-03-21 09:26:48 +01:00
|
|
|
accountActorInclude.push({
|
2022-02-28 08:34:43 +01:00
|
|
|
model: ServerModel,
|
|
|
|
required: false
|
|
|
|
})
|
|
|
|
|
2022-03-21 09:26:48 +01:00
|
|
|
accountActorInclude.push({
|
2022-02-28 08:34:43 +01:00
|
|
|
model: ActorImageModel,
|
|
|
|
as: 'Avatars',
|
|
|
|
required: false
|
|
|
|
})
|
|
|
|
|
2022-03-21 09:26:48 +01:00
|
|
|
channelActorInclude.push({
|
2022-02-28 08:34:43 +01:00
|
|
|
model: ActorImageModel,
|
|
|
|
as: 'Avatars',
|
|
|
|
required: false
|
|
|
|
})
|
|
|
|
|
2022-03-21 09:26:48 +01:00
|
|
|
channelActorInclude.push({
|
2022-02-28 08:34:43 +01:00
|
|
|
model: ActorImageModel,
|
|
|
|
as: 'Banners',
|
|
|
|
required: false
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
if (options.forCount !== true || serverRequired) {
|
2022-03-21 09:26:48 +01:00
|
|
|
channelActorInclude.push({
|
2022-02-28 08:34:43 +01:00
|
|
|
model: ServerModel,
|
|
|
|
duplicating: false,
|
|
|
|
required: serverRequired,
|
|
|
|
where: whereServer
|
|
|
|
})
|
2021-07-28 10:32:40 +02:00
|
|
|
}
|
|
|
|
|
2018-08-23 17:58:39 +02:00
|
|
|
return {
|
|
|
|
include: [
|
|
|
|
{
|
|
|
|
attributes: {
|
|
|
|
exclude: unusedActorAttributesForAPI
|
|
|
|
},
|
2022-02-28 08:34:43 +01:00
|
|
|
model: ActorModel.unscoped(),
|
2021-07-28 16:40:21 +02:00
|
|
|
where: {
|
|
|
|
[Op.and]: whereActorAnd
|
|
|
|
},
|
2022-03-21 09:26:48 +01:00
|
|
|
include: channelActorInclude
|
2018-08-23 17:58:39 +02:00
|
|
|
},
|
|
|
|
{
|
2022-02-28 08:34:43 +01:00
|
|
|
model: AccountModel.unscoped(),
|
2018-08-23 17:58:39 +02:00
|
|
|
required: true,
|
|
|
|
include: [
|
|
|
|
{
|
|
|
|
attributes: {
|
|
|
|
exclude: unusedActorAttributesForAPI
|
|
|
|
},
|
2022-02-28 08:34:43 +01:00
|
|
|
model: ActorModel.unscoped(),
|
|
|
|
required: true,
|
2022-03-21 09:26:48 +01:00
|
|
|
include: accountActorInclude
|
2018-08-23 17:58:39 +02:00
|
|
|
}
|
|
|
|
]
|
|
|
|
}
|
|
|
|
]
|
|
|
|
}
|
|
|
|
},
|
2020-03-23 10:14:05 +01:00
|
|
|
[ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => {
|
2020-12-08 14:30:29 +01:00
|
|
|
const include: Includeable[] = [
|
|
|
|
{
|
2022-02-28 08:34:43 +01:00
|
|
|
attributes: [ 'id', 'preferredUsername', 'url', 'serverId' ],
|
2020-12-08 14:30:29 +01:00
|
|
|
model: ActorModel.unscoped(),
|
|
|
|
required: options.actorRequired ?? true,
|
|
|
|
include: [
|
|
|
|
{
|
|
|
|
attributes: [ 'host' ],
|
|
|
|
model: ServerModel.unscoped(),
|
|
|
|
required: false
|
|
|
|
},
|
|
|
|
{
|
2022-02-28 08:34:43 +01:00
|
|
|
model: ActorImageModel,
|
|
|
|
as: 'Avatars',
|
2020-12-08 14:30:29 +01:00
|
|
|
required: false
|
|
|
|
}
|
|
|
|
]
|
|
|
|
}
|
|
|
|
]
|
|
|
|
|
2020-03-23 10:14:05 +01:00
|
|
|
const base: FindOptions = {
|
2020-12-08 14:30:29 +01:00
|
|
|
attributes: [ 'id', 'name', 'description', 'actorId' ]
|
2020-03-23 10:14:05 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
if (options.withAccount === true) {
|
2020-12-08 14:30:29 +01:00
|
|
|
include.push({
|
2020-03-23 10:14:05 +01:00
|
|
|
model: AccountModel.scope({
|
|
|
|
method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ]
|
|
|
|
}),
|
|
|
|
required: true
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2020-12-08 14:30:29 +01:00
|
|
|
base.include = include
|
|
|
|
|
2020-03-23 10:14:05 +01:00
|
|
|
return base
|
|
|
|
},
|
2018-08-23 17:58:39 +02:00
|
|
|
[ScopeNames.WITH_ACCOUNT]: {
|
|
|
|
include: [
|
|
|
|
{
|
2019-04-23 09:50:57 +02:00
|
|
|
model: AccountModel,
|
2018-08-23 17:58:39 +02:00
|
|
|
required: true
|
2017-12-14 10:07:57 +01:00
|
|
|
}
|
|
|
|
]
|
|
|
|
},
|
2020-03-23 10:14:05 +01:00
|
|
|
[ScopeNames.WITH_ACTOR]: {
|
2017-12-14 10:07:57 +01:00
|
|
|
include: [
|
2020-03-23 10:14:05 +01:00
|
|
|
ActorModel
|
2017-12-14 10:07:57 +01:00
|
|
|
]
|
2017-12-14 17:38:41 +01:00
|
|
|
},
|
2021-04-06 17:01:35 +02:00
|
|
|
[ScopeNames.WITH_ACTOR_BANNER]: {
|
|
|
|
include: [
|
|
|
|
{
|
|
|
|
model: ActorModel,
|
|
|
|
include: [
|
|
|
|
{
|
|
|
|
model: ActorImageModel,
|
|
|
|
required: false,
|
2022-02-28 08:34:43 +01:00
|
|
|
as: 'Banners'
|
2021-04-06 17:01:35 +02:00
|
|
|
}
|
|
|
|
]
|
|
|
|
}
|
|
|
|
]
|
|
|
|
},
|
2020-03-23 10:14:05 +01:00
|
|
|
[ScopeNames.WITH_VIDEOS]: {
|
2017-12-14 17:38:41 +01:00
|
|
|
include: [
|
2020-03-23 10:14:05 +01:00
|
|
|
VideoModel
|
2017-12-14 17:38:41 +01:00
|
|
|
]
|
2020-03-23 10:14:05 +01:00
|
|
|
},
|
2020-03-30 12:06:46 +02:00
|
|
|
[ScopeNames.WITH_STATS]: (options: AvailableWithStatsOptions = { daysPrior: 30 }) => {
|
2022-11-15 14:41:55 +01:00
|
|
|
const daysPrior = forceNumber(options.daysPrior)
|
2020-03-30 12:06:46 +02:00
|
|
|
|
|
|
|
return {
|
|
|
|
attributes: {
|
|
|
|
include: [
|
2020-06-16 14:13:01 +02:00
|
|
|
[
|
|
|
|
literal('(SELECT COUNT(*) FROM "video" WHERE "channelId" = "VideoChannelModel"."id")'),
|
|
|
|
'videosCount'
|
|
|
|
],
|
2020-03-30 12:06:46 +02:00
|
|
|
[
|
|
|
|
literal(
|
|
|
|
'(' +
|
|
|
|
`SELECT string_agg(concat_ws('|', t.day, t.views), ',') ` +
|
|
|
|
'FROM ( ' +
|
|
|
|
'WITH ' +
|
|
|
|
'days AS ( ' +
|
|
|
|
`SELECT generate_series(date_trunc('day', now()) - '${daysPrior} day'::interval, ` +
|
|
|
|
`date_trunc('day', now()), '1 day'::interval) AS day ` +
|
2020-03-23 10:14:05 +01:00
|
|
|
') ' +
|
2020-06-12 16:01:42 +02:00
|
|
|
'SELECT days.day AS day, COALESCE(SUM("videoView".views), 0) AS views ' +
|
|
|
|
'FROM days ' +
|
|
|
|
'LEFT JOIN (' +
|
|
|
|
'"videoView" INNER JOIN "video" ON "videoView"."videoId" = "video"."id" ' +
|
|
|
|
'AND "video"."channelId" = "VideoChannelModel"."id"' +
|
|
|
|
`) ON date_trunc('day', "videoView"."startDate") = date_trunc('day', days.day) ` +
|
|
|
|
'GROUP BY day ' +
|
|
|
|
'ORDER BY day ' +
|
|
|
|
') t' +
|
2020-03-30 12:06:46 +02:00
|
|
|
')'
|
|
|
|
),
|
|
|
|
'viewsPerDay'
|
2022-05-31 16:01:11 +02:00
|
|
|
],
|
|
|
|
[
|
|
|
|
literal(
|
|
|
|
'(' +
|
|
|
|
'SELECT COALESCE(SUM("video".views), 0) AS totalViews ' +
|
|
|
|
'FROM "video" ' +
|
|
|
|
'WHERE "video"."channelId" = "VideoChannelModel"."id"' +
|
|
|
|
')'
|
|
|
|
),
|
|
|
|
'totalViews'
|
2020-03-30 12:06:46 +02:00
|
|
|
]
|
2020-03-23 10:14:05 +01:00
|
|
|
]
|
2020-03-30 12:06:46 +02:00
|
|
|
}
|
2020-03-23 10:14:05 +01:00
|
|
|
}
|
2020-03-30 12:06:46 +02:00
|
|
|
}
|
2019-04-23 09:50:57 +02:00
|
|
|
}))
|
2017-12-12 17:53:50 +01:00
|
|
|
@Table({
|
|
|
|
tableName: 'videoChannel',
|
2020-01-28 14:45:17 +01:00
|
|
|
indexes: [
|
|
|
|
buildTrigramSearchIndex('video_channel_name_trigram', 'name'),
|
|
|
|
|
|
|
|
{
|
|
|
|
fields: [ 'accountId' ]
|
|
|
|
},
|
|
|
|
{
|
|
|
|
fields: [ 'actorId' ]
|
|
|
|
}
|
|
|
|
]
|
2017-12-12 17:53:50 +01:00
|
|
|
})
|
2021-05-12 14:09:04 +02:00
|
|
|
export class VideoChannelModel extends Model<Partial<AttributesOnly<VideoChannelModel>>> {
|
2017-10-24 19:41:09 +02:00
|
|
|
|
2017-12-12 17:53:50 +01:00
|
|
|
@AllowNull(false)
|
2021-08-05 13:54:35 +02:00
|
|
|
@Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'name'))
|
2017-12-12 17:53:50 +01:00
|
|
|
@Column
|
|
|
|
name: string
|
2017-10-24 19:41:09 +02:00
|
|
|
|
2017-12-12 17:53:50 +01:00
|
|
|
@AllowNull(true)
|
2018-02-15 14:46:26 +01:00
|
|
|
@Default(null)
|
2019-04-18 11:28:17 +02:00
|
|
|
@Is('VideoChannelDescription', value => throwIfNotValid(value, isVideoChannelDescriptionValid, 'description', true))
|
2018-05-09 13:32:44 +02:00
|
|
|
@Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.DESCRIPTION.max))
|
2017-12-12 17:53:50 +01:00
|
|
|
description: string
|
2017-10-24 19:41:09 +02:00
|
|
|
|
2018-02-15 14:46:26 +01:00
|
|
|
@AllowNull(true)
|
|
|
|
@Default(null)
|
2019-04-18 11:28:17 +02:00
|
|
|
@Is('VideoChannelSupport', value => throwIfNotValid(value, isVideoChannelSupportValid, 'support', true))
|
2018-05-09 13:32:44 +02:00
|
|
|
@Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.SUPPORT.max))
|
2018-02-15 14:46:26 +01:00
|
|
|
support: string
|
|
|
|
|
2017-12-12 17:53:50 +01:00
|
|
|
@CreatedAt
|
|
|
|
createdAt: Date
|
2017-10-24 19:41:09 +02:00
|
|
|
|
2017-12-12 17:53:50 +01:00
|
|
|
@UpdatedAt
|
|
|
|
updatedAt: Date
|
2017-11-27 14:44:51 +01:00
|
|
|
|
2017-12-14 11:18:49 +01:00
|
|
|
@ForeignKey(() => ActorModel)
|
|
|
|
@Column
|
|
|
|
actorId: number
|
|
|
|
|
|
|
|
@BelongsTo(() => ActorModel, {
|
|
|
|
foreignKey: {
|
|
|
|
allowNull: false
|
|
|
|
},
|
|
|
|
onDelete: 'cascade'
|
|
|
|
})
|
2023-07-31 14:34:36 +02:00
|
|
|
Actor: Awaited<ActorModel>
|
2017-12-14 11:18:49 +01:00
|
|
|
|
2017-12-12 17:53:50 +01:00
|
|
|
@ForeignKey(() => AccountModel)
|
|
|
|
@Column
|
|
|
|
accountId: number
|
2017-11-27 14:44:51 +01:00
|
|
|
|
2017-12-12 17:53:50 +01:00
|
|
|
@BelongsTo(() => AccountModel, {
|
|
|
|
foreignKey: {
|
|
|
|
allowNull: false
|
2021-07-01 16:47:14 +02:00
|
|
|
}
|
2017-12-12 17:53:50 +01:00
|
|
|
})
|
2023-07-31 14:34:36 +02:00
|
|
|
Account: Awaited<AccountModel>
|
2017-10-24 19:41:09 +02:00
|
|
|
|
2017-12-12 17:53:50 +01:00
|
|
|
@HasMany(() => VideoModel, {
|
2017-10-24 19:41:09 +02:00
|
|
|
foreignKey: {
|
2017-12-12 17:53:50 +01:00
|
|
|
name: 'channelId',
|
2017-10-24 19:41:09 +02:00
|
|
|
allowNull: false
|
|
|
|
},
|
2018-01-18 10:53:54 +01:00
|
|
|
onDelete: 'CASCADE',
|
|
|
|
hooks: true
|
2017-10-24 19:41:09 +02:00
|
|
|
})
|
2023-07-31 14:34:36 +02:00
|
|
|
Videos: Awaited<VideoModel>[]
|
2017-10-24 19:41:09 +02:00
|
|
|
|
2019-02-26 10:55:40 +01:00
|
|
|
@HasMany(() => VideoPlaylistModel, {
|
|
|
|
foreignKey: {
|
2019-02-28 11:14:26 +01:00
|
|
|
allowNull: true
|
2019-02-26 10:55:40 +01:00
|
|
|
},
|
2019-03-05 10:58:44 +01:00
|
|
|
onDelete: 'CASCADE',
|
2019-02-26 10:55:40 +01:00
|
|
|
hooks: true
|
|
|
|
})
|
2023-07-31 14:34:36 +02:00
|
|
|
VideoPlaylists: Awaited<VideoPlaylistModel>[]
|
2019-02-26 10:55:40 +01:00
|
|
|
|
Add Podcast RSS feeds (#5487)
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Add correct feed image to RSS channel
* Prefer HLS videos for podcast RSS
Remove video/stream titles, add optional height attribute to podcast RSS
* Prefix podcast RSS images with root server URL
* Add optional video query support to include captions
* Add transcripts & person images to podcast RSS feed
* Prefer webseed/webtorrent files over HLS fragmented mp4s
* Experimentally adding podcast fields to basic config page
* Add validation for new basic config fields
* Don't include "content" in podcast feed, use full description for "description"
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Add correct feed image to RSS channel
* Prefer HLS videos for podcast RSS
Remove video/stream titles, add optional height attribute to podcast RSS
* Prefix podcast RSS images with root server URL
* Add optional video query support to include captions
* Add transcripts & person images to podcast RSS feed
* Prefer webseed/webtorrent files over HLS fragmented mp4s
* Experimentally adding podcast fields to basic config page
* Add validation for new basic config fields
* Don't include "content" in podcast feed, use full description for "description"
* Add medium/socialInteract to podcast RSS feeds. Use HTML for description
* Change base production image to bullseye, install prosody in image
* Add liveItem and trackers to Podcast RSS feeds
Remove height from alternateEnclosure, replaced with title.
* Clear Podcast RSS feed cache when live streams start/end
* Upgrade to Node 16
* Refactor clearCacheRoute to use ApiCache
* Remove unnecessary type hint
* Update dockerfile to node 16, install python-is-python2
* Use new file paths for captions/playlists
* Fix legacy videos in RSS after migration to object storage
* Improve method of identifying non-fragmented mp4s in podcast RSS feeds
* Don't include fragmented MP4s in podcast RSS feeds
* Add experimental support for podcast:categories on the podcast RSS item
* Fix undefined category when no videos exist
Allows for empty feeds to exist (important for feeds that might only go live)
* Add support for podcast:locked -- user has to opt in to show their email
* Use comma for podcast:categories delimiter
* Make cache clearing async
* Fix merge, temporarily test with pfeed-podcast
* Syntax changes
* Add EXT_MIMETYPE constants for captions
* Update & fix tests, fix enclosure mimetypes, remove admin email
* Add test for podacst:socialInteract
* Add filters hooks for podcast customTags
* Remove showdown, updated to pfeed-podcast 6.1.2
* Add 'action:api.live-video.state.updated' hook
* Avoid assigning undefined category to podcast feeds
* Remove nvmrc
* Remove comment
* Remove unused podcast config
* Remove more unused podcast config
* Fix MChannelAccountDefault type hint missed in merge
* Remove extra line
* Re-add newline in config
* Fix lint errors for isEmailPublic
* Fix thumbnails in podcast feeds
* Requested changes based on review
* Provide podcast rss 2.0 only on video channels
* Misc cleanup for a less messy PR
* Lint fixes
* Remove pfeed-podcast
* Add peertube version to new hooks
* Don't use query include, remove TODO
* Remove film medium hack
* Clear podcast rss cache before video/channel update hooks
* Clear podcast rss cache before video uploaded/deleted hooks
* Refactor podcast feed cache clearing
* Set correct person name from video channel
* Styling
* Fix tests
---------
Co-authored-by: Chocobozzz <me@florianbigard.com>
2023-05-22 16:00:05 +02:00
|
|
|
@AfterCreate
|
|
|
|
static notifyCreate (channel: MChannel) {
|
|
|
|
InternalEventEmitter.Instance.emit('channel-created', { channel })
|
|
|
|
}
|
|
|
|
|
|
|
|
@AfterUpdate
|
|
|
|
static notifyUpdate (channel: MChannel) {
|
|
|
|
InternalEventEmitter.Instance.emit('channel-updated', { channel })
|
|
|
|
}
|
|
|
|
|
|
|
|
@AfterDestroy
|
|
|
|
static notifyDestroy (channel: MChannel) {
|
|
|
|
InternalEventEmitter.Instance.emit('channel-deleted', { channel })
|
|
|
|
}
|
|
|
|
|
2018-01-18 10:53:54 +01:00
|
|
|
@BeforeDestroy
|
|
|
|
static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
|
|
|
|
if (!instance.Actor) {
|
2020-01-08 15:11:38 +01:00
|
|
|
instance.Actor = await instance.$get('Actor', { transaction: options.transaction })
|
2018-01-18 10:53:54 +01:00
|
|
|
}
|
|
|
|
|
2020-11-10 16:29:35 +01:00
|
|
|
await ActorFollowModel.removeFollowsOf(instance.Actor.id, options.transaction)
|
|
|
|
|
2018-07-30 13:39:20 +02:00
|
|
|
if (instance.Actor.isOwned()) {
|
|
|
|
return sendDeleteActor(instance.Actor, options.transaction)
|
|
|
|
}
|
|
|
|
|
|
|
|
return undefined
|
2017-12-12 17:53:50 +01:00
|
|
|
}
|
2017-10-24 19:41:09 +02:00
|
|
|
|
2017-12-12 17:53:50 +01:00
|
|
|
static countByAccount (accountId: number) {
|
|
|
|
const query = {
|
|
|
|
where: {
|
|
|
|
accountId
|
|
|
|
}
|
2017-10-24 19:41:09 +02:00
|
|
|
}
|
2017-12-12 17:53:50 +01:00
|
|
|
|
2022-03-21 09:27:49 +01:00
|
|
|
return VideoChannelModel.unscoped().count(query)
|
2017-10-24 19:41:09 +02:00
|
|
|
}
|
|
|
|
|
2021-04-12 11:19:07 +02:00
|
|
|
static async getStats () {
|
|
|
|
|
2022-12-21 10:46:55 +01:00
|
|
|
function getLocalVideoChannelStats (days?: number) {
|
2021-04-12 11:19:07 +02:00
|
|
|
const options = {
|
|
|
|
type: QueryTypes.SELECT as QueryTypes.SELECT,
|
|
|
|
raw: true
|
|
|
|
}
|
|
|
|
|
2022-12-21 10:46:55 +01:00
|
|
|
const videoJoin = days
|
|
|
|
? `INNER JOIN "video" AS "Videos" ON "VideoChannelModel"."id" = "Videos"."channelId" ` +
|
|
|
|
`AND ("Videos"."publishedAt" > Now() - interval '${days}d')`
|
|
|
|
: ''
|
|
|
|
|
2021-04-12 11:19:07 +02:00
|
|
|
const query = `
|
2022-12-21 10:46:55 +01:00
|
|
|
SELECT COUNT(DISTINCT("VideoChannelModel"."id")) AS "count"
|
|
|
|
FROM "videoChannel" AS "VideoChannelModel"
|
|
|
|
${videoJoin}
|
|
|
|
INNER JOIN "account" AS "Account" ON "VideoChannelModel"."accountId" = "Account"."id"
|
|
|
|
INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id"
|
|
|
|
AND "Account->Actor"."serverId" IS NULL`
|
2021-04-12 11:19:07 +02:00
|
|
|
|
|
|
|
return VideoChannelModel.sequelize.query<{ count: string }>(query, options)
|
|
|
|
.then(r => parseInt(r[0].count, 10))
|
|
|
|
}
|
|
|
|
|
2022-12-21 10:46:55 +01:00
|
|
|
const totalLocalVideoChannels = await getLocalVideoChannelStats()
|
|
|
|
const totalLocalDailyActiveVideoChannels = await getLocalVideoChannelStats(1)
|
|
|
|
const totalLocalWeeklyActiveVideoChannels = await getLocalVideoChannelStats(7)
|
|
|
|
const totalLocalMonthlyActiveVideoChannels = await getLocalVideoChannelStats(30)
|
|
|
|
const totalLocalHalfYearActiveVideoChannels = await getLocalVideoChannelStats(180)
|
2021-04-12 11:19:07 +02:00
|
|
|
|
|
|
|
return {
|
|
|
|
totalLocalVideoChannels,
|
|
|
|
totalLocalDailyActiveVideoChannels,
|
|
|
|
totalLocalWeeklyActiveVideoChannels,
|
|
|
|
totalLocalMonthlyActiveVideoChannels,
|
2022-12-21 10:46:55 +01:00
|
|
|
totalLocalHalfYearActiveVideoChannels
|
2021-04-12 11:19:07 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-10-19 16:13:22 +02:00
|
|
|
static listLocalsForSitemap (sort: string): Promise<MChannelHost[]> {
|
2018-12-05 17:27:24 +01:00
|
|
|
const query = {
|
|
|
|
attributes: [ ],
|
|
|
|
offset: 0,
|
|
|
|
order: getSort(sort),
|
|
|
|
include: [
|
|
|
|
{
|
|
|
|
attributes: [ 'preferredUsername', 'serverId' ],
|
|
|
|
model: ActorModel.unscoped(),
|
|
|
|
where: {
|
|
|
|
serverId: null
|
|
|
|
}
|
|
|
|
}
|
|
|
|
]
|
|
|
|
}
|
|
|
|
|
|
|
|
return VideoChannelModel
|
|
|
|
.unscoped()
|
|
|
|
.findAll(query)
|
|
|
|
}
|
|
|
|
|
2021-07-29 14:17:03 +02:00
|
|
|
static listForApi (parameters: Pick<AvailableForListOptions, 'actorId'> & {
|
2018-08-23 17:58:39 +02:00
|
|
|
start: number
|
|
|
|
count: number
|
|
|
|
sort: string
|
2021-07-29 14:17:03 +02:00
|
|
|
}) {
|
|
|
|
const { actorId } = parameters
|
2021-07-28 10:32:40 +02:00
|
|
|
|
2021-07-29 14:17:03 +02:00
|
|
|
const query = {
|
|
|
|
offset: parameters.start,
|
|
|
|
limit: parameters.count,
|
|
|
|
order: getSort(parameters.sort)
|
|
|
|
}
|
|
|
|
|
2022-02-28 08:34:43 +01:00
|
|
|
const getScope = (forCount: boolean) => {
|
|
|
|
return { method: [ ScopeNames.FOR_API, { actorId, forCount } as AvailableForListOptions ] }
|
|
|
|
}
|
|
|
|
|
|
|
|
return Promise.all([
|
|
|
|
VideoChannelModel.scope(getScope(true)).count(),
|
|
|
|
VideoChannelModel.scope(getScope(false)).findAll(query)
|
|
|
|
]).then(([ total, data ]) => ({ total, data }))
|
2021-07-29 14:17:03 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
static searchForApi (options: Pick<AvailableForListOptions, 'actorId' | 'search' | 'host' | 'handles'> & {
|
|
|
|
start: number
|
|
|
|
count: number
|
|
|
|
sort: string
|
2018-08-23 17:58:39 +02:00
|
|
|
}) {
|
2021-07-28 16:40:21 +02:00
|
|
|
let attributesInclude: any[] = [ literal('0 as similarity') ]
|
|
|
|
let where: WhereOptions
|
2018-08-23 17:58:39 +02:00
|
|
|
|
2021-07-28 16:40:21 +02:00
|
|
|
if (options.search) {
|
|
|
|
const escapedSearch = VideoChannelModel.sequelize.escape(options.search)
|
|
|
|
const escapedLikeSearch = VideoChannelModel.sequelize.escape('%' + options.search + '%')
|
|
|
|
attributesInclude = [ createSimilarityAttribute('VideoChannelModel.name', options.search) ]
|
|
|
|
|
|
|
|
where = {
|
2019-04-18 11:28:17 +02:00
|
|
|
[Op.or]: [
|
2018-08-28 15:16:04 +02:00
|
|
|
Sequelize.literal(
|
|
|
|
'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
|
|
|
|
),
|
|
|
|
Sequelize.literal(
|
|
|
|
'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
|
2018-08-23 17:58:39 +02:00
|
|
|
)
|
2018-08-28 15:16:04 +02:00
|
|
|
]
|
2018-08-23 17:58:39 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-07-28 16:40:21 +02:00
|
|
|
const query = {
|
|
|
|
attributes: {
|
|
|
|
include: attributesInclude
|
|
|
|
},
|
|
|
|
offset: options.start,
|
|
|
|
limit: options.count,
|
|
|
|
order: getSort(options.sort),
|
|
|
|
where
|
|
|
|
}
|
|
|
|
|
2022-02-28 08:34:43 +01:00
|
|
|
const getScope = (forCount: boolean) => {
|
|
|
|
return {
|
|
|
|
method: [
|
|
|
|
ScopeNames.FOR_API, {
|
|
|
|
...pick(options, [ 'actorId', 'host', 'handles' ]),
|
|
|
|
|
|
|
|
forCount
|
|
|
|
} as AvailableForListOptions
|
|
|
|
]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return Promise.all([
|
|
|
|
VideoChannelModel.scope(getScope(true)).count(query),
|
|
|
|
VideoChannelModel.scope(getScope(false)).findAll(query)
|
|
|
|
]).then(([ total, data ]) => ({ total, data }))
|
2017-10-24 19:41:09 +02:00
|
|
|
}
|
|
|
|
|
2021-10-19 09:44:43 +02:00
|
|
|
static listByAccountForAPI (options: {
|
2020-01-31 16:56:52 +01:00
|
|
|
accountId: number
|
|
|
|
start: number
|
|
|
|
count: number
|
2019-05-29 15:09:38 +02:00
|
|
|
sort: string
|
2020-03-23 10:14:05 +01:00
|
|
|
withStats?: boolean
|
2020-07-23 21:30:04 +02:00
|
|
|
search?: string
|
2019-05-29 15:09:38 +02:00
|
|
|
}) {
|
2020-07-23 21:30:04 +02:00
|
|
|
const escapedSearch = VideoModel.sequelize.escape(options.search)
|
|
|
|
const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
|
|
|
|
const where = options.search
|
|
|
|
? {
|
|
|
|
[Op.or]: [
|
|
|
|
Sequelize.literal(
|
|
|
|
'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
|
|
|
|
),
|
|
|
|
Sequelize.literal(
|
|
|
|
'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
|
|
|
|
)
|
|
|
|
]
|
|
|
|
}
|
|
|
|
: null
|
|
|
|
|
2022-02-28 08:34:43 +01:00
|
|
|
const getQuery = (forCount: boolean) => {
|
|
|
|
const accountModel = forCount
|
|
|
|
? AccountModel.unscoped()
|
|
|
|
: AccountModel
|
|
|
|
|
|
|
|
return {
|
|
|
|
offset: options.start,
|
|
|
|
limit: options.count,
|
|
|
|
order: getSort(options.sort),
|
|
|
|
include: [
|
|
|
|
{
|
|
|
|
model: accountModel,
|
|
|
|
where: {
|
|
|
|
id: options.accountId
|
|
|
|
},
|
|
|
|
required: true
|
|
|
|
}
|
|
|
|
],
|
|
|
|
where
|
|
|
|
}
|
2017-12-12 17:53:50 +01:00
|
|
|
}
|
2017-10-24 19:41:09 +02:00
|
|
|
|
2022-03-21 09:26:48 +01:00
|
|
|
const findScopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR_BANNER ]
|
2020-03-23 10:14:05 +01:00
|
|
|
|
2020-06-12 16:01:42 +02:00
|
|
|
if (options.withStats === true) {
|
2022-03-21 09:26:48 +01:00
|
|
|
findScopes.push({
|
2020-03-23 10:14:05 +01:00
|
|
|
method: [ ScopeNames.WITH_STATS, { daysPrior: 30 } as AvailableWithStatsOptions ]
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2022-02-28 08:34:43 +01:00
|
|
|
return Promise.all([
|
2022-03-21 09:26:48 +01:00
|
|
|
VideoChannelModel.unscoped().count(getQuery(true)),
|
|
|
|
VideoChannelModel.scope(findScopes).findAll(getQuery(false))
|
2022-02-28 08:34:43 +01:00
|
|
|
]).then(([ total, data ]) => ({ total, data }))
|
2017-10-24 19:41:09 +02:00
|
|
|
}
|
|
|
|
|
2022-02-28 08:34:43 +01:00
|
|
|
static listAllByAccount (accountId: number): Promise<MChannel[]> {
|
2021-10-19 09:44:43 +02:00
|
|
|
const query = {
|
2021-10-26 16:42:10 +02:00
|
|
|
limit: CONFIG.VIDEO_CHANNELS.MAX_PER_USER,
|
2021-10-19 09:44:43 +02:00
|
|
|
include: [
|
|
|
|
{
|
|
|
|
attributes: [],
|
2022-02-28 08:34:43 +01:00
|
|
|
model: AccountModel.unscoped(),
|
2021-10-19 09:44:43 +02:00
|
|
|
where: {
|
|
|
|
id: accountId
|
|
|
|
},
|
|
|
|
required: true
|
|
|
|
}
|
|
|
|
]
|
|
|
|
}
|
|
|
|
|
|
|
|
return VideoChannelModel.findAll(query)
|
|
|
|
}
|
|
|
|
|
2021-06-15 09:17:19 +02:00
|
|
|
static loadAndPopulateAccount (id: number, transaction?: Transaction): Promise<MChannelBannerAccountDefault> {
|
2018-09-04 10:22:10 +02:00
|
|
|
return VideoChannelModel.unscoped()
|
2021-04-06 17:01:35 +02:00
|
|
|
.scope([ ScopeNames.WITH_ACTOR_BANNER, ScopeNames.WITH_ACCOUNT ])
|
2021-06-15 09:17:19 +02:00
|
|
|
.findByPk(id, { transaction })
|
2017-12-12 17:53:50 +01:00
|
|
|
}
|
2017-11-10 14:34:45 +01:00
|
|
|
|
2021-04-06 17:01:35 +02:00
|
|
|
static loadByUrlAndPopulateAccount (url: string): Promise<MChannelBannerAccountDefault> {
|
2018-08-23 17:58:39 +02:00
|
|
|
const query = {
|
|
|
|
include: [
|
|
|
|
{
|
|
|
|
model: ActorModel,
|
|
|
|
required: true,
|
|
|
|
where: {
|
|
|
|
url
|
2021-04-06 17:01:35 +02:00
|
|
|
},
|
|
|
|
include: [
|
|
|
|
{
|
|
|
|
model: ActorImageModel,
|
|
|
|
required: false,
|
2022-02-28 08:34:43 +01:00
|
|
|
as: 'Banners'
|
2021-04-06 17:01:35 +02:00
|
|
|
}
|
|
|
|
]
|
2018-08-23 17:58:39 +02:00
|
|
|
}
|
|
|
|
]
|
|
|
|
}
|
|
|
|
|
|
|
|
return VideoChannelModel
|
|
|
|
.scope([ ScopeNames.WITH_ACCOUNT ])
|
2018-08-17 15:45:42 +02:00
|
|
|
.findOne(query)
|
2017-10-24 19:41:09 +02:00
|
|
|
}
|
|
|
|
|
2019-02-21 14:06:10 +01:00
|
|
|
static loadByNameWithHostAndPopulateAccount (nameWithHost: string) {
|
|
|
|
const [ name, host ] = nameWithHost.split('@')
|
|
|
|
|
2019-04-11 11:33:44 +02:00
|
|
|
if (!host || host === WEBSERVER.HOST) return VideoChannelModel.loadLocalByNameAndPopulateAccount(name)
|
2019-02-21 14:06:10 +01:00
|
|
|
|
|
|
|
return VideoChannelModel.loadByNameAndHostAndPopulateAccount(name, host)
|
|
|
|
}
|
|
|
|
|
2021-04-06 17:01:35 +02:00
|
|
|
static loadLocalByNameAndPopulateAccount (name: string): Promise<MChannelBannerAccountDefault> {
|
2018-08-17 15:45:42 +02:00
|
|
|
const query = {
|
2017-12-12 17:53:50 +01:00
|
|
|
include: [
|
2018-08-17 15:45:42 +02:00
|
|
|
{
|
|
|
|
model: ActorModel,
|
|
|
|
required: true,
|
|
|
|
where: {
|
2023-05-11 16:16:27 +02:00
|
|
|
[Op.and]: [
|
2023-05-12 09:06:16 +02:00
|
|
|
ActorModel.wherePreferredUsername(name, 'Actor.preferredUsername'),
|
2023-05-11 16:16:27 +02:00
|
|
|
{ serverId: null }
|
|
|
|
]
|
2021-04-06 17:01:35 +02:00
|
|
|
},
|
|
|
|
include: [
|
|
|
|
{
|
|
|
|
model: ActorImageModel,
|
|
|
|
required: false,
|
2022-02-28 08:34:43 +01:00
|
|
|
as: 'Banners'
|
2021-04-06 17:01:35 +02:00
|
|
|
}
|
|
|
|
]
|
2018-08-17 15:45:42 +02:00
|
|
|
}
|
2017-12-12 17:53:50 +01:00
|
|
|
]
|
|
|
|
}
|
2017-10-24 19:41:09 +02:00
|
|
|
|
2018-09-04 10:22:10 +02:00
|
|
|
return VideoChannelModel.unscoped()
|
2021-04-06 17:01:35 +02:00
|
|
|
.scope([ ScopeNames.WITH_ACCOUNT ])
|
2018-08-17 15:45:42 +02:00
|
|
|
.findOne(query)
|
2017-10-24 19:41:09 +02:00
|
|
|
}
|
|
|
|
|
2021-04-06 17:01:35 +02:00
|
|
|
static loadByNameAndHostAndPopulateAccount (name: string, host: string): Promise<MChannelBannerAccountDefault> {
|
2018-08-16 15:25:20 +02:00
|
|
|
const query = {
|
|
|
|
include: [
|
|
|
|
{
|
|
|
|
model: ActorModel,
|
|
|
|
required: true,
|
2023-05-12 09:06:16 +02:00
|
|
|
where: ActorModel.wherePreferredUsername(name, 'Actor.preferredUsername'),
|
2018-08-17 15:45:42 +02:00
|
|
|
include: [
|
|
|
|
{
|
|
|
|
model: ServerModel,
|
|
|
|
required: true,
|
|
|
|
where: { host }
|
2021-04-06 17:01:35 +02:00
|
|
|
},
|
|
|
|
{
|
|
|
|
model: ActorImageModel,
|
|
|
|
required: false,
|
2022-02-28 08:34:43 +01:00
|
|
|
as: 'Banners'
|
2018-08-17 15:45:42 +02:00
|
|
|
}
|
|
|
|
]
|
2018-08-16 15:25:20 +02:00
|
|
|
}
|
|
|
|
]
|
|
|
|
}
|
|
|
|
|
2018-09-04 10:22:10 +02:00
|
|
|
return VideoChannelModel.unscoped()
|
2021-04-06 17:01:35 +02:00
|
|
|
.scope([ ScopeNames.WITH_ACCOUNT ])
|
2018-08-17 15:45:42 +02:00
|
|
|
.findOne(query)
|
|
|
|
}
|
|
|
|
|
2019-08-20 19:05:31 +02:00
|
|
|
toFormattedSummaryJSON (this: MChannelSummaryFormattable): VideoChannelSummary {
|
|
|
|
const actor = this.Actor.toFormattedSummaryJSON()
|
|
|
|
|
|
|
|
return {
|
|
|
|
id: this.id,
|
|
|
|
name: actor.name,
|
|
|
|
displayName: this.getDisplayName(),
|
|
|
|
url: actor.url,
|
|
|
|
host: actor.host,
|
2023-07-28 11:28:07 +02:00
|
|
|
avatars: actor.avatars
|
2019-08-20 19:05:31 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
toFormattedJSON (this: MChannelFormattable): VideoChannel {
|
2020-06-16 14:13:01 +02:00
|
|
|
const viewsPerDayString = this.get('viewsPerDay') as string
|
|
|
|
const videosCount = this.get('videosCount') as number
|
|
|
|
|
|
|
|
let viewsPerDay: { date: Date, views: number }[]
|
|
|
|
|
|
|
|
if (viewsPerDayString) {
|
|
|
|
viewsPerDay = viewsPerDayString.split(',')
|
|
|
|
.map(v => {
|
|
|
|
const [ dateString, amount ] = v.split('|')
|
|
|
|
|
|
|
|
return {
|
|
|
|
date: new Date(dateString),
|
|
|
|
views: +amount
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
2020-03-23 10:14:05 +01:00
|
|
|
|
2022-05-31 16:01:11 +02:00
|
|
|
const totalViews = this.get('totalViews') as number
|
|
|
|
|
2017-12-14 17:38:41 +01:00
|
|
|
const actor = this.Actor.toFormattedJSON()
|
2018-04-25 10:21:38 +02:00
|
|
|
const videoChannel = {
|
2017-12-12 17:53:50 +01:00
|
|
|
id: this.id,
|
2018-06-13 15:07:25 +02:00
|
|
|
displayName: this.getDisplayName(),
|
2017-12-12 17:53:50 +01:00
|
|
|
description: this.description,
|
2018-02-15 14:46:26 +01:00
|
|
|
support: this.support,
|
2017-12-14 17:38:41 +01:00
|
|
|
isLocal: this.Actor.isOwned(),
|
2021-05-07 17:14:39 +02:00
|
|
|
updatedAt: this.updatedAt,
|
2022-02-28 08:34:43 +01:00
|
|
|
|
2020-03-23 10:14:05 +01:00
|
|
|
ownerAccount: undefined,
|
2022-02-28 08:34:43 +01:00
|
|
|
|
2020-06-16 14:13:01 +02:00
|
|
|
videosCount,
|
2022-02-28 08:34:43 +01:00
|
|
|
viewsPerDay,
|
2022-05-31 16:01:11 +02:00
|
|
|
totalViews,
|
2022-02-28 08:34:43 +01:00
|
|
|
|
2023-07-28 11:28:07 +02:00
|
|
|
avatars: actor.avatars
|
2018-04-25 10:21:38 +02:00
|
|
|
}
|
|
|
|
|
2018-05-23 11:38:00 +02:00
|
|
|
if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON()
|
2017-10-24 19:41:09 +02:00
|
|
|
|
2018-04-25 10:21:38 +02:00
|
|
|
return Object.assign(actor, videoChannel)
|
2017-10-24 19:41:09 +02:00
|
|
|
}
|
|
|
|
|
2023-03-10 12:01:21 +01:00
|
|
|
async toActivityPubObject (this: MChannelAP): Promise<ActivityPubActor> {
|
|
|
|
const obj = await this.Actor.toActivityPubObject(this.name)
|
2017-12-14 17:38:41 +01:00
|
|
|
|
|
|
|
return Object.assign(obj, {
|
|
|
|
summary: this.description,
|
2018-02-15 14:46:26 +01:00
|
|
|
support: this.support,
|
2017-12-14 17:38:41 +01:00
|
|
|
attributedTo: [
|
|
|
|
{
|
|
|
|
type: 'Person' as 'Person',
|
|
|
|
id: this.Account.Actor.url
|
|
|
|
}
|
|
|
|
]
|
|
|
|
})
|
2017-10-24 19:41:09 +02:00
|
|
|
}
|
2018-06-13 15:07:25 +02:00
|
|
|
|
Add Podcast RSS feeds (#5487)
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Add correct feed image to RSS channel
* Prefer HLS videos for podcast RSS
Remove video/stream titles, add optional height attribute to podcast RSS
* Prefix podcast RSS images with root server URL
* Add optional video query support to include captions
* Add transcripts & person images to podcast RSS feed
* Prefer webseed/webtorrent files over HLS fragmented mp4s
* Experimentally adding podcast fields to basic config page
* Add validation for new basic config fields
* Don't include "content" in podcast feed, use full description for "description"
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Add correct feed image to RSS channel
* Prefer HLS videos for podcast RSS
Remove video/stream titles, add optional height attribute to podcast RSS
* Prefix podcast RSS images with root server URL
* Add optional video query support to include captions
* Add transcripts & person images to podcast RSS feed
* Prefer webseed/webtorrent files over HLS fragmented mp4s
* Experimentally adding podcast fields to basic config page
* Add validation for new basic config fields
* Don't include "content" in podcast feed, use full description for "description"
* Add medium/socialInteract to podcast RSS feeds. Use HTML for description
* Change base production image to bullseye, install prosody in image
* Add liveItem and trackers to Podcast RSS feeds
Remove height from alternateEnclosure, replaced with title.
* Clear Podcast RSS feed cache when live streams start/end
* Upgrade to Node 16
* Refactor clearCacheRoute to use ApiCache
* Remove unnecessary type hint
* Update dockerfile to node 16, install python-is-python2
* Use new file paths for captions/playlists
* Fix legacy videos in RSS after migration to object storage
* Improve method of identifying non-fragmented mp4s in podcast RSS feeds
* Don't include fragmented MP4s in podcast RSS feeds
* Add experimental support for podcast:categories on the podcast RSS item
* Fix undefined category when no videos exist
Allows for empty feeds to exist (important for feeds that might only go live)
* Add support for podcast:locked -- user has to opt in to show their email
* Use comma for podcast:categories delimiter
* Make cache clearing async
* Fix merge, temporarily test with pfeed-podcast
* Syntax changes
* Add EXT_MIMETYPE constants for captions
* Update & fix tests, fix enclosure mimetypes, remove admin email
* Add test for podacst:socialInteract
* Add filters hooks for podcast customTags
* Remove showdown, updated to pfeed-podcast 6.1.2
* Add 'action:api.live-video.state.updated' hook
* Avoid assigning undefined category to podcast feeds
* Remove nvmrc
* Remove comment
* Remove unused podcast config
* Remove more unused podcast config
* Fix MChannelAccountDefault type hint missed in merge
* Remove extra line
* Re-add newline in config
* Fix lint errors for isEmailPublic
* Fix thumbnails in podcast feeds
* Requested changes based on review
* Provide podcast rss 2.0 only on video channels
* Misc cleanup for a less messy PR
* Lint fixes
* Remove pfeed-podcast
* Add peertube version to new hooks
* Don't use query include, remove TODO
* Remove film medium hack
* Clear podcast rss cache before video/channel update hooks
* Clear podcast rss cache before video uploaded/deleted hooks
* Refactor podcast feed cache clearing
* Set correct person name from video channel
* Styling
* Fix tests
---------
Co-authored-by: Chocobozzz <me@florianbigard.com>
2023-05-22 16:00:05 +02:00
|
|
|
// Avoid error when running this method on MAccount... | MChannel...
|
|
|
|
getClientUrl (this: MAccountHost | MChannelHost) {
|
|
|
|
return WEBSERVER.URL + '/c/' + this.Actor.getIdentifier()
|
2020-12-08 10:53:41 +01:00
|
|
|
}
|
|
|
|
|
2018-06-13 15:07:25 +02:00
|
|
|
getDisplayName () {
|
|
|
|
return this.name
|
|
|
|
}
|
2019-01-14 11:30:15 +01:00
|
|
|
|
|
|
|
isOutdated () {
|
|
|
|
return this.Actor.isOutdated()
|
|
|
|
}
|
2021-05-07 17:14:39 +02:00
|
|
|
|
2021-08-30 16:24:25 +02:00
|
|
|
setAsUpdated (transaction?: Transaction) {
|
2023-01-10 11:09:30 +01:00
|
|
|
return setAsUpdated({ sequelize: this.sequelize, table: 'videoChannel', id: this.id, transaction })
|
2021-05-07 17:14:39 +02:00
|
|
|
}
|
2017-10-24 19:41:09 +02:00
|
|
|
}
|