import express from 'express'
import { extname } from 'path'
import { Feed } from '@peertube/feed'
import { CustomTag, CustomXMLNS, LiveItemStatus } from '@peertube/feed/lib/typings'
import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
import { Hooks } from '@server/lib/plugins/hooks'
import { buildPodcastGroupsCache, cacheRouteFactory, videoFeedsPodcastSetCacheKey } from '@server/middlewares'
import { MVideo, MVideoCaptionVideo, MVideoFullLight } from '@server/types/models'
import { sortObjectComparator } from '@shared/core-utils'
import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@shared/models'
import { buildNSFWFilter } from '../../helpers/express-utils'
import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares'
import { VideoModel } from '../../models/video/video'
import { VideoCaptionModel } from '../../models/video/video-caption'
import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed } from './shared'
const videoPodcastFeedsRouter = express.Router()
// ---------------------------------------------------------------------------
const { middleware: podcastCacheRouteMiddleware, instance: podcastApiCache } = cacheRouteFactory({
headerBlacklist: [ 'Content-Type' ]
})
for (const event of ([ 'video-created', 'video-updated', 'video-deleted' ] as const)) {
InternalEventEmitter.Instance.on(event, ({ video }) => {
if (video.remote) return
podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: video.channelId }))
})
}
for (const event of ([ 'channel-updated', 'channel-deleted' ] as const)) {
InternalEventEmitter.Instance.on(event, ({ channel }) => {
podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: channel.id }))
})
}
// ---------------------------------------------------------------------------
videoPodcastFeedsRouter.get('/feeds/podcast/videos.xml',
setFeedPodcastContentType,
videoFeedsPodcastSetCacheKey,
podcastCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
asyncMiddleware(videoFeedsPodcastValidator),
asyncMiddleware(generateVideoPodcastFeed)
)
// ---------------------------------------------------------------------------
export {
videoPodcastFeedsRouter
}
// ---------------------------------------------------------------------------
async function generateVideoPodcastFeed (req: express.Request, res: express.Response) {
const videoChannel = res.locals.videoChannel
const { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink } = await buildFeedMetadata({ videoChannel })
const data = await getVideosForFeeds({
sort: '-publishedAt',
nsfw: buildNSFWFilter(),
// Prevent podcast feeds from listing videos in other instances
// helps prevent duplicates when they are indexed -- only the author should control them
isLocal: true,
include: VideoInclude.FILES,
videoChannelId: videoChannel?.id
})
const customTags: CustomTag[] = await Hooks.wrapObject(
[],
'filter:feed.podcast.channel.create-custom-tags.result',
{ videoChannel }
)
const customXMLNS: CustomXMLNS[] = await Hooks.wrapObject(
[],
'filter:feed.podcast.rss.create-custom-xmlns.result'
)
const feed = initFeed({
name,
description,
link,
isPodcast: true,
imageUrl,
locked: email
? { isLocked: true, email } // Default to true because we have no way of offering a redirect yet
: undefined,
person: [ { name: userName, href: accountLink, img: accountImageUrl } ],
resourceType: 'videos',
queryString: new URL(WEBSERVER.URL + req.url).search,
medium: 'video',
customXMLNS,
customTags
})
await addVideosToPodcastFeed(feed, data)
// Now the feed generation is done, let's send it!
return res.send(feed.podcast()).end()
}
type PodcastMedia =
{
type: string
length: number
bitrate: number
sources: { uri: string, contentType?: string }[]
title: string
language?: string
} |
{
sources: { uri: string }[]
type: string
title: string
}
async function generatePodcastItem (options: {
video: VideoModel
liveItem: boolean
media: PodcastMedia[]
}) {
const { video, liveItem, media } = options
const customTags: CustomTag[] = await Hooks.wrapObject(
[],
'filter:feed.podcast.video.create-custom-tags.result',
{ video, liveItem }
)
const account = video.VideoChannel.Account
const author = {
name: account.getDisplayName(),
href: account.getClientUrl()
}
return {
...getCommonVideoFeedAttributes(video),
trackers: video.getTrackerUrls(),
author: [ author ],
person: [
{
...author,
img: account.Actor.hasImage(ActorImageType.AVATAR)
? WEBSERVER.URL + account.Actor.Avatars[0].getStaticPath()
: undefined
}
],
media,
socialInteract: [
{
uri: video.url,
protocol: 'activitypub',
accountUrl: account.getClientUrl()
}
],
customTags
}
}
async function addVideosToPodcastFeed (feed: Feed, videos: VideoModel[]) {
const captionsGroup = await VideoCaptionModel.listCaptionsOfMultipleVideos(videos.map(v => v.id))
for (const video of videos) {
if (!video.isLive) {
await addVODPodcastItem({ feed, video, captionsGroup })
} else if (video.isLive && video.state !== VideoState.LIVE_ENDED) {
await addLivePodcastItem({ feed, video })
}
}
}
async function addVODPodcastItem (options: {
feed: Feed
video: VideoModel
captionsGroup: { [ id: number ]: MVideoCaptionVideo[] }
}) {
const { feed, video, captionsGroup } = options
const webVideos = video.getFormattedWebVideoFilesJSON(true)
.map(f => buildVODWebVideoFile(video, f))
.sort(sortObjectComparator('bitrate', 'desc'))
const streamingPlaylistFiles = buildVODStreamingPlaylists(video)
// Order matters here, the first media URI will be the "default"
// So web videos are default if enabled
const media = [ ...webVideos, ...streamingPlaylistFiles ]
const videoCaptions = buildVODCaptions(video, captionsGroup[video.id])
const item = await generatePodcastItem({ video, liveItem: false, media })
feed.addPodcastItem({ ...item, subTitle: videoCaptions })
}
async function addLivePodcastItem (options: {
feed: Feed
video: VideoModel
}) {
const { feed, video } = options
let status: LiveItemStatus
switch (video.state) {
case VideoState.WAITING_FOR_LIVE:
status = LiveItemStatus.pending
break
case VideoState.PUBLISHED:
status = LiveItemStatus.live
break
}
const item = await generatePodcastItem({ video, liveItem: true, media: buildLiveStreamingPlaylists(video) })
feed.addPodcastLiveItem({ ...item, status, start: video.updatedAt.toISOString() })
}
// ---------------------------------------------------------------------------
function buildVODWebVideoFile (video: MVideo, videoFile: VideoFile) {
const isAudio = videoFile.resolution.id === VideoResolution.H_NOVIDEO
const type = isAudio
? MIMETYPES.AUDIO.EXT_MIMETYPE[extname(videoFile.fileUrl)]
: MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)]
const sources = [
{ uri: videoFile.fileUrl },
{ uri: videoFile.torrentUrl, contentType: 'application/x-bittorrent' }
]
if (videoFile.magnetUri) {
sources.push({ uri: videoFile.magnetUri })
}
return {
type,
title: videoFile.resolution.label,
length: videoFile.size,
bitrate: videoFile.size / video.duration * 8,
language: video.language,
sources
}
}
function buildVODStreamingPlaylists (video: MVideoFullLight) {
const hls = video.getHLSPlaylist()
if (!hls) return []
return [
{
type: 'application/x-mpegURL',
title: 'HLS',
sources: [
{ uri: hls.getMasterPlaylistUrl(video) }
],
language: video.language
}
]
}
function buildLiveStreamingPlaylists (video: MVideoFullLight) {
const hls = video.getHLSPlaylist()
return [
{
type: 'application/x-mpegURL',
title: `HLS live stream`,
sources: [
{ uri: hls.getMasterPlaylistUrl(video) }
],
language: video.language
}
]
}
function buildVODCaptions (video: MVideo, videoCaptions: MVideoCaptionVideo[]) {
return videoCaptions.map(caption => {
const type = MIMETYPES.VIDEO_CAPTIONS.EXT_MIMETYPE[extname(caption.filename)]
if (!type) return null
return {
url: caption.getFileUrl(video),
language: caption.language,
type,
rel: 'captions'
}
}).filter(c => c)
}