1
0
Fork 0

Support <podcast:txt purpose="p20url"> element

This commit is contained in:
Chocobozzz 2025-03-04 13:49:01 +01:00
parent 888273a1d7
commit cb91056514
No known key found for this signature in database
GPG key ID: 583A612D890159BE
10 changed files with 212 additions and 103 deletions

View file

@ -10,10 +10,14 @@ export function getDefaultRSSFeeds (url: string, instanceName: string) {
]
}
export function getChannelPodcastFeed (url: string, channel: { id: number }) {
return `${url}/feeds/podcast/videos.xml?videoChannelId=${channel.id}`
}
export function getChannelRSSFeeds (url: string, instanceName: string, channel: { name: string, id: number }) {
return [
{
url: `${url}/feeds/podcast/videos.xml?videoChannelId=${channel.id}`,
url: getChannelPodcastFeed(url, channel),
// TODO: translate
title: `${channel.name} podcast feed`
},

View file

@ -5,35 +5,39 @@ import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
type FeedType = 'videos' | 'video-comments' | 'subscriptions'
export class FeedCommand extends AbstractCommand {
getXML (options: OverrideCommandOptions & {
feed: FeedType
ignoreCache: boolean
format?: string
}) {
const { feed, format, ignoreCache } = options
getXML (
options: OverrideCommandOptions & {
feed: FeedType
ignoreCache: boolean
format?: string
query?: { [id: string]: any }
}
) {
const { feed, format, ignoreCache, query = {} } = options
const path = '/feeds/' + feed + '.xml'
const query: { [id: string]: string } = {}
const internalQuery: { [id: string]: string } = {}
if (ignoreCache) query.v = buildUUID()
if (format) query.format = format
if (ignoreCache) internalQuery.v = buildUUID()
if (format) internalQuery.format = format
return this.getRequestText({
...options,
path,
query,
query: { ...internalQuery, ...query },
accept: 'application/xml',
implicitToken: false,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
getPodcastXML (options: OverrideCommandOptions & {
ignoreCache: boolean
channelId: number
}) {
getPodcastXML (
options: OverrideCommandOptions & {
ignoreCache: boolean
channelId: number
}
) {
const { ignoreCache, channelId } = options
const path = `/feeds/podcast/videos.xml`
@ -53,11 +57,13 @@ export class FeedCommand extends AbstractCommand {
})
}
getJSON (options: OverrideCommandOptions & {
feed: FeedType
ignoreCache: boolean
query?: { [ id: string ]: any }
}) {
getJSON (
options: OverrideCommandOptions & {
feed: FeedType
ignoreCache: boolean
query?: { [id: string]: any }
}
) {
const { feed, query = {}, ignoreCache } = options
const path = '/feeds/' + feed + '.json'

View file

@ -13,7 +13,6 @@ import { unwrapBody } from '../requests/index.js'
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
export class ChannelsCommand extends AbstractCommand {
list (options: OverrideCommandOptions & {
start?: number
count?: number
@ -32,14 +31,16 @@ export class ChannelsCommand extends AbstractCommand {
})
}
listByAccount (options: OverrideCommandOptions & {
accountName: string
start?: number
count?: number
sort?: string
withStats?: boolean
search?: string
}) {
listByAccount (
options: OverrideCommandOptions & {
accountName: string
start?: number
count?: number
sort?: string
withStats?: boolean
search?: string
}
) {
const { accountName, sort = 'createdAt' } = options
const path = '/api/v1/accounts/' + accountName + '/video-channels'
@ -53,9 +54,11 @@ export class ChannelsCommand extends AbstractCommand {
})
}
async create (options: OverrideCommandOptions & {
attributes: Partial<VideoChannelCreate>
}) {
async create (
options: OverrideCommandOptions & {
attributes: Partial<VideoChannelCreate>
}
) {
const path = '/api/v1/video-channels/'
// Default attributes
@ -78,10 +81,12 @@ export class ChannelsCommand extends AbstractCommand {
return body.videoChannel
}
update (options: OverrideCommandOptions & {
channelName: string
attributes: VideoChannelUpdate
}) {
update (
options: OverrideCommandOptions & {
channelName: string
attributes: VideoChannelUpdate
}
) {
const { channelName, attributes } = options
const path = '/api/v1/video-channels/' + channelName
@ -95,9 +100,11 @@ export class ChannelsCommand extends AbstractCommand {
})
}
delete (options: OverrideCommandOptions & {
channelName: string
}) {
delete (
options: OverrideCommandOptions & {
channelName: string
}
) {
const path = '/api/v1/video-channels/' + options.channelName
return this.deleteRequest({
@ -109,9 +116,13 @@ export class ChannelsCommand extends AbstractCommand {
})
}
get (options: OverrideCommandOptions & {
channelName: string
}) {
// ---------------------------------------------------------------------------
get (
options: OverrideCommandOptions & {
channelName: string
}
) {
const path = '/api/v1/video-channels/' + options.channelName
return this.getRequestBody<VideoChannel>({
@ -123,11 +134,25 @@ export class ChannelsCommand extends AbstractCommand {
})
}
updateImage (options: OverrideCommandOptions & {
fixture: string
channelName: string | number
type: 'avatar' | 'banner'
}) {
async getIdOf (
options: OverrideCommandOptions & {
channelName: string
}
) {
const { id } = await this.get(options)
return id
}
// ---------------------------------------------------------------------------
updateImage (
options: OverrideCommandOptions & {
fixture: string
channelName: string | number
type: 'avatar' | 'banner'
}
) {
const { channelName, fixture, type } = options
const path = `/api/v1/video-channels/${channelName}/${type}/pick`
@ -144,10 +169,12 @@ export class ChannelsCommand extends AbstractCommand {
})
}
deleteImage (options: OverrideCommandOptions & {
channelName: string | number
type: 'avatar' | 'banner'
}) {
deleteImage (
options: OverrideCommandOptions & {
channelName: string | number
type: 'avatar' | 'banner'
}
) {
const { channelName, type } = options
const path = `/api/v1/video-channels/${channelName}/${type}`
@ -161,13 +188,15 @@ export class ChannelsCommand extends AbstractCommand {
})
}
listFollowers (options: OverrideCommandOptions & {
channelName: string
start?: number
count?: number
sort?: string
search?: string
}) {
listFollowers (
options: OverrideCommandOptions & {
channelName: string
start?: number
count?: number
sort?: string
search?: string
}
) {
const { channelName, start, count, sort, search } = options
const path = '/api/v1/video-channels/' + channelName + '/followers'
@ -183,9 +212,11 @@ export class ChannelsCommand extends AbstractCommand {
})
}
importVideos (options: OverrideCommandOptions & VideosImportInChannelCreate & {
channelName: string
}) {
importVideos (
options: OverrideCommandOptions & VideosImportInChannelCreate & {
channelName: string
}
) {
const { channelName, externalChannelUrl, videoChannelSyncId } = options
const path = `/api/v1/video-channels/${channelName}/import-videos`

View file

@ -34,7 +34,7 @@ describe('Test syndication feeds', () => {
let userAccessToken: string
let rootAccountId: number
let rootChannelId: number
let rootChannelIdServer1: number
let userAccountId: number
let userChannelId: number
@ -53,7 +53,7 @@ describe('Test syndication feeds', () => {
await setAccessTokensToServers([ ...servers, serverHLSOnly ])
await setDefaultChannelAvatar([ servers[0], serverHLSOnly ])
await setDefaultVideoChannel(servers)
await setDefaultVideoChannel([ ...servers, serverHLSOnly ])
await doubleFollow(servers[0], servers[1])
await servers[0].config.enableLive({ allowReplay: false, transcoding: false })
@ -62,7 +62,7 @@ describe('Test syndication feeds', () => {
{
const user = await servers[0].users.getMyInfo()
rootAccountId = user.account.id
rootChannelId = user.videoChannels[0].id
rootChannelIdServer1 = user.videoChannels[0].id
}
{
@ -116,7 +116,6 @@ describe('Test syndication feeds', () => {
})
describe('All feed', function () {
it('Should be well formed XML (covers RSS 2.0 and ATOM 1.0 endpoints)', async function () {
for (const feed of [ 'video-comments' as 'video-comments', 'videos' as 'videos' ]) {
const rss = await servers[0].feed.getXML({ feed, ignoreCache: true })
@ -128,7 +127,7 @@ describe('Test syndication feeds', () => {
})
it('Should be well formed XML (covers Podcast endpoint)', async function () {
const podcast = await servers[0].feed.getPodcastXML({ ignoreCache: true, channelId: rootChannelId })
const podcast = await servers[0].feed.getPodcastXML({ ignoreCache: true, channelId: rootChannelIdServer1 })
expect(podcast).xml.to.be.valid()
})
@ -154,13 +153,11 @@ describe('Test syndication feeds', () => {
})
describe('Videos feed', function () {
describe('Podcast feed', function () {
it('Should contain a valid podcast enclosures', async function () {
// Since podcast feeds should only work on the server they originate on,
// only test the first server where the videos reside
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: servers[0].store.channel.id })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
@ -192,7 +189,7 @@ describe('Test syndication feeds', () => {
})
it('Should contain a valid podcast enclosures with HLS only', async function () {
const rss = await serverHLSOnly.feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
const rss = await serverHLSOnly.feed.getPodcastXML({ ignoreCache: false, channelId: serverHLSOnly.store.channel.id })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
@ -230,7 +227,7 @@ describe('Test syndication feeds', () => {
})
it('Should contain a valid podcast:socialInteract', async function () {
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: servers[0].store.channel.id })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
@ -284,7 +281,7 @@ describe('Test syndication feeds', () => {
fields: {
name: 'live-0',
privacy: VideoPrivacy.PUBLIC,
channelId: rootChannelId,
channelId: rootChannelIdServer1,
permanentLive: false
}
})
@ -293,7 +290,7 @@ describe('Test syndication feeds', () => {
const ffmpeg = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveId, copyCodecs: true, fixtureName: 'video_short.mp4' })
await servers[0].live.waitUntilPublished({ videoId: liveId })
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: servers[0].store.channel.id })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
@ -321,7 +318,7 @@ describe('Test syndication feeds', () => {
})
it('Should have valid itunes metadata', async function () {
const rss = await serverHLSOnly.feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
const rss = await serverHLSOnly.feed.getPodcastXML({ ignoreCache: false, channelId: serverHLSOnly.store.channel.id })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
@ -345,10 +342,49 @@ describe('Test syndication feeds', () => {
expect(item['itunes:duration']).to.equal(5)
})
it('Should have p20url podcast txt attribute with local podcast feed', async function () {
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: servers[0].store.channel.id })
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const podcastUrlEl = xmlDoc.rss.channel['podcast:txt']
expect(podcastUrlEl).to.exist
expect(podcastUrlEl['@_purpose']).to.equal('p20url')
expect(podcastUrlEl['#text']).to.equal(
servers[0].url + '/feeds/podcast/videos.xml?videoChannelId=' + servers[0].store.channel.id
)
})
it('Should have p20url podcast txt attribute with remote classic RSS feed with channel', async function () {
const videoChannelId = await servers[1].channels.getIdOf({ channelName: 'root_channel@' + servers[0].host })
const rss = await servers[1].feed.getXML({
feed: 'videos',
ignoreCache: true,
query: { videoChannelId }
})
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const podcastUrlEl = xmlDoc.rss.channel['podcast:txt']
expect(podcastUrlEl).to.exist
expect(podcastUrlEl['@_purpose']).to.equal('p20url')
expect(podcastUrlEl['#text']).to.equal(servers[0].url + '/feeds/podcast/videos.xml?videoChannelId=' + videoChannelId)
})
it('Should not have p20url podcast txt attribute with classic RSS feed without channel', async function () {
const rss = await serverHLSOnly.feed.getXML({ feed: 'videos', ignoreCache: true })
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const podcastUrlEl = xmlDoc.rss.channel['podcast:txt']
expect(podcastUrlEl).to.not.exist
})
})
describe('JSON feed', function () {
it('Should contain a valid \'attachments\' object', async function () {
for (const server of servers) {
const json = await server.feed.getJSON({ feed: 'videos', ignoreCache: true })
@ -398,7 +434,7 @@ describe('Test syndication feeds', () => {
it('Should filter by video channel', async function () {
{
const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: rootChannelId }, ignoreCache: true })
const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: rootChannelIdServer1 }, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('my super name for server 1')
@ -453,7 +489,7 @@ describe('Test syndication feeds', () => {
fields: {
name: 'live',
privacy: VideoPrivacy.PUBLIC,
channelId: rootChannelId
channelId: rootChannelIdServer1
}
})
liveId = uuid
@ -484,7 +520,7 @@ describe('Test syndication feeds', () => {
})
it('Should have the channel avatar as feed icon', async function () {
const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: rootChannelId }, ignoreCache: true })
const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: rootChannelIdServer1 }, ignoreCache: true })
const jsonObj = JSON.parse(json)
const imageUrl = jsonObj.icon
@ -494,7 +530,6 @@ describe('Test syndication feeds', () => {
})
describe('XML feed', function () {
it('Should correctly have video mime types feed with HLS only', async function () {
this.timeout(120000)
@ -514,7 +549,6 @@ describe('Test syndication feeds', () => {
})
describe('Video comments feed', function () {
it('Should contain valid comments (covers JSON feed 1.0 endpoint) and not from unlisted/password protected videos', async function () {
for (const server of servers) {
const json = await server.feed.getJSON({ feed: 'video-comments', ignoreCache: true })
@ -544,7 +578,11 @@ describe('Test syndication feeds', () => {
it('Should filter by videoChannelId/videoChannelName', async function () {
{
const json = await servers[0].feed.getJSON({ feed: 'video-comments', query: { videoChannelId: rootChannelId }, ignoreCache: true })
const json = await servers[0].feed.getJSON({
feed: 'video-comments',
query: { videoChannelId: rootChannelIdServer1 },
ignoreCache: true
})
expect(JSON.parse(json).items.length).to.be.equal(2)
}
@ -744,7 +782,6 @@ describe('Test syndication feeds', () => {
const query = { accountId: userAccountId, token: userFeedToken }
await servers[0].feed.getJSON({ feed: 'subscriptions', query, ignoreCache: true })
})
})
describe('Cache', function () {
@ -830,7 +867,6 @@ describe('Test syndication feeds', () => {
const res = await doPodcastRequest()
expect(res.headers['x-api-cache-cached']).to.not.exist
})
})
after(async function () {

View file

@ -1,12 +1,13 @@
import { getChannelPodcastFeed } from '@peertube/peertube-core-utils'
import { VideoIncludeType } from '@peertube/peertube-models'
import { mdToPlainText, toSafeHtml } from '@server/helpers/markdown.js'
import { CONFIG } from '@server/initializers/config.js'
import { WEBSERVER } from '@server/initializers/constants.js'
import { REMOTE_SCHEME, WEBSERVER } from '@server/initializers/constants.js'
import { getServerActor } from '@server/models/application/application.js'
import { getCategoryLabel } from '@server/models/video/formatter/index.js'
import { DisplayOnlyForFollowerOptions } from '@server/models/video/sql/video/index.js'
import { VideoModel } from '@server/models/video/video.js'
import { MThumbnail, MUserDefault } from '@server/types/models/index.js'
import { MChannelHostOnly, MThumbnail, MUserDefault } from '@server/types/models/index.js'
export async function getVideosForFeeds (options: {
sort: string
@ -64,3 +65,16 @@ export function getCommonVideoFeedAttributes (video: VideoModel) {
}))
}
}
export function getPodcastFeedUrlCustomTag (videoChannel: MChannelHostOnly) {
const rootHost = videoChannel.Actor.getHost()
const originUrl = `${REMOTE_SCHEME.HTTP}://${rootHost}`
return {
name: 'podcast:txt',
attributes: {
purpose: 'p20url'
},
value: getChannelPodcastFeed(originUrl, videoChannel)
}
}

View file

@ -18,7 +18,14 @@ import {
videosSortValidator,
videoSubscriptionFeedsValidator
} from '../../middlewares/index.js'
import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed, sendFeed } from './shared/index.js'
import {
buildFeedMetadata,
getCommonVideoFeedAttributes,
getPodcastFeedUrlCustomTag,
getVideosForFeeds,
initFeed,
sendFeed
} from './shared/index.js'
const videoFeedsRouter = express.Router()
@ -28,7 +35,8 @@ const { middleware: cacheRouteMiddleware } = cacheRouteFactory({
// ---------------------------------------------------------------------------
videoFeedsRouter.get('/videos.:format',
videoFeedsRouter.get(
'/videos.:format',
videosSortValidator,
setDefaultVideosSort,
feedsFormatValidator,
@ -39,7 +47,8 @@ videoFeedsRouter.get('/videos.:format',
asyncMiddleware(generateVideoFeed)
)
videoFeedsRouter.get('/subscriptions.:format',
videoFeedsRouter.get(
'/subscriptions.:format',
videosSortValidator,
setDefaultVideosSort,
feedsFormatValidator,
@ -72,7 +81,10 @@ async function generateVideoFeed (req: express.Request, res: express.Response) {
imageUrl: ownerImageUrl || imageUrl,
author: { name, link: ownerLink },
resourceType: 'videos',
queryString: new URL(WEBSERVER.URL + req.url).search
queryString: new URL(WEBSERVER.URL + req.url).search,
customTags: videoChannel
? [ getPodcastFeedUrlCustomTag(videoChannel) ]
: []
})
const data = await getVideosForFeeds({

View file

@ -16,7 +16,7 @@ import { MIMETYPES, ROUTE_CACHE_LIFETIME, VIDEO_CATEGORIES, WEBSERVER } from '..
import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares/index.js'
import { VideoCaptionModel } from '../../models/video/video-caption.js'
import { VideoModel } from '../../models/video/video.js'
import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed } from './shared/index.js'
import { buildFeedMetadata, getCommonVideoFeedAttributes, getPodcastFeedUrlCustomTag, getVideosForFeeds, initFeed } from './shared/index.js'
const videoPodcastFeedsRouter = express.Router()
@ -42,7 +42,8 @@ for (const event of ([ 'channel-updated', 'channel-deleted' ] as const)) {
// ---------------------------------------------------------------------------
videoPodcastFeedsRouter.get('/podcast/videos.xml',
videoPodcastFeedsRouter.get(
'/podcast/videos.xml',
setFeedPodcastContentType,
videoFeedsPodcastSetCacheKey,
podcastCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
@ -85,7 +86,7 @@ async function generateVideoPodcastFeed (req: express.Request, res: express.Resp
: false
const customTags: CustomTag[] = await Hooks.wrapObject(
[],
[ getPodcastFeedUrlCustomTag(videoChannel) ],
'filter:feed.podcast.channel.create-custom-tags.result',
{ videoChannel }
)
@ -128,15 +129,15 @@ async function generateVideoPodcastFeed (req: express.Request, res: express.Resp
}
type PodcastMedia =
{
| {
type: string
length: number
bitrate: number
sources: { uri: string, contentType?: string }[]
title: string
language?: string
} |
{
}
| {
sources: { uri: string }[]
type: string
title: string
@ -209,7 +210,7 @@ async function addVideosToPodcastFeed (feed: Feed, videos: VideoModel[]) {
async function addVODPodcastItem (options: {
feed: Feed
video: VideoModel
captionsGroup: { [ id: number ]: MVideoCaptionVideo[] }
captionsGroup: { [id: number]: MVideoCaptionVideo[] }
}) {
const { feed, video, captionsGroup } = options
@ -350,7 +351,7 @@ function buildVODCaptions (video: MVideo, videoCaptions: MVideoCaptionVideo[]) {
}
function categoryToItunes (category: number) {
const itunesMap: { [ id in keyof typeof VIDEO_CATEGORIES ]: string } = {
const itunesMap: { [id in keyof typeof VIDEO_CATEGORIES]: string } = {
1: 'Music',
2: 'TV &amp; Film',
3: 'Leisure',

View file

@ -664,6 +664,8 @@ export class ActorModel extends SequelizeModel<ActorModel> {
}
getHost (this: MActorHostOnly) {
if (this.serverId && !this.Server) throw new Error('Server is not loaded in the object')
return this.Server ? this.Server.host : WEBSERVER.HOST
}

View file

@ -29,7 +29,10 @@ export type MActorLight = Omit<MActor, 'privateKey' | 'privateKey'>
// Some association attributes
export type MActorHostOnly = Use<'Server', MServerHost>
export type MActorHostOnly =
& Pick<ActorModel, 'serverId' | 'getHost'>
& Use<'Server', MServerHost>
export type MActorHost =
& MActorLight
& Use<'Server', MServerHost>
@ -157,7 +160,7 @@ export type MActorAPI = Omit<
export type MActorSummaryFormattable =
& FunctionProperties<MActor>
& Pick<MActor, 'url' | 'preferredUsername'>
& Pick<MActor, 'url' | 'preferredUsername' | 'serverId'>
& Use<'Server', MServerHost>
& Use<'Avatars', MActorImageFormattable[]>

View file

@ -31,7 +31,7 @@ export module UserNotificationIncludes {
& PickWith<VideoModel, 'VideoChannel', VideoChannelIncludeActor>
export type ActorInclude =
& Pick<ActorModel, 'preferredUsername' | 'getHost'>
& Pick<ActorModel, 'preferredUsername' | 'getHost' | 'serverId'>
& PickWith<ActorModel, 'Avatars', ActorImageInclude[]>
& PickWith<ActorModel, 'Server', Pick<ServerModel, 'host'>>
@ -78,13 +78,13 @@ export module UserNotificationIncludes {
& PickWith<VideoImportModel, 'Video', VideoInclude>
export type ActorFollower =
& Pick<ActorModel, 'preferredUsername' | 'getHost'>
& Pick<ActorModel, 'preferredUsername' | 'getHost' | 'serverId'>
& PickWith<ActorModel, 'Account', AccountInclude>
& PickWith<ActorModel, 'Server', Pick<ServerModel, 'host'>>
& PickWithOpt<ActorModel, 'Avatars', ActorImageInclude[]>
export type ActorFollowing =
& Pick<ActorModel, 'preferredUsername' | 'type' | 'getHost'>
& Pick<ActorModel, 'preferredUsername' | 'type' | 'getHost' | 'serverId'>
& PickWith<ActorModel, 'VideoChannel', VideoChannelInclude>
& PickWith<ActorModel, 'Account', AccountInclude>
& PickWith<ActorModel, 'Server', Pick<ServerModel, 'host'>>