309 lines
8.9 KiB
TypeScript
309 lines
8.9 KiB
TypeScript
import express from 'express'
|
|
import { extname } from 'path'
|
|
import { Feed } from '@peertube/feed'
|
|
import { CustomTag, CustomXMLNS, LiveItemStatus } from '@peertube/feed/lib/typings/index.js'
|
|
import { getBiggestActorImage } from '@server/lib/actor-image.js'
|
|
import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
|
|
import { Hooks } from '@server/lib/plugins/hooks.js'
|
|
import { buildPodcastGroupsCache, cacheRouteFactory, videoFeedsPodcastSetCacheKey } from '@server/middlewares/index.js'
|
|
import { MVideo, MVideoCaptionVideo, MVideoFullLight } from '@server/types/models/index.js'
|
|
import { sortObjectComparator } from '@peertube/peertube-core-utils'
|
|
import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@peertube/peertube-models'
|
|
import { buildNSFWFilter } from '../../helpers/express-utils.js'
|
|
import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants.js'
|
|
import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares/index.js'
|
|
import { VideoModel } from '../../models/video/video.js'
|
|
import { VideoCaptionModel } from '../../models/video/video-caption.js'
|
|
import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed } from './shared/index.js'
|
|
import { getVideoFileMimeType } from '@server/lib/video-file.js'
|
|
|
|
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('/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()
|
|
}
|
|
|
|
const commonAttributes = getCommonVideoFeedAttributes(video)
|
|
const guid = liveItem
|
|
? `${video.uuid}_${video.publishedAt.toISOString()}`
|
|
: commonAttributes.link
|
|
|
|
let personImage: string
|
|
|
|
if (account.Actor.hasImage(ActorImageType.AVATAR)) {
|
|
const avatar = getBiggestActorImage(account.Actor.Avatars)
|
|
personImage = WEBSERVER.URL + avatar.getStaticPath()
|
|
}
|
|
|
|
return {
|
|
guid,
|
|
...commonAttributes,
|
|
|
|
trackers: video.getTrackerUrls(),
|
|
|
|
author: [ author ],
|
|
person: [
|
|
{
|
|
...author,
|
|
|
|
img: personImage
|
|
}
|
|
],
|
|
|
|
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 sources = [
|
|
{ uri: videoFile.fileUrl },
|
|
{ uri: videoFile.torrentUrl, contentType: 'application/x-bittorrent' }
|
|
]
|
|
|
|
if (videoFile.magnetUri) {
|
|
sources.push({ uri: videoFile.magnetUri })
|
|
}
|
|
|
|
return {
|
|
type: getVideoFileMimeType(extname(videoFile.fileUrl), videoFile.resolution.id === VideoResolution.H_NOVIDEO),
|
|
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)
|
|
}
|