PeerTube/server/controllers/feeds/video-podcast-feeds.ts

302 lines
8.5 KiB
TypeScript
Raw Normal View History

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 09:00:05 -05:00
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)
}