import express from 'express' import { maxBy } from 'lodash' 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() } 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 = maxBy(account.Actor.Avatars, 'width') 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 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) }