1 import express from 'express'
2 import { extname } from 'path'
3 import { Feed } from '@peertube/feed'
4 import { CustomTag, CustomXMLNS, LiveItemStatus } from '@peertube/feed/lib/typings'
5 import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
6 import { Hooks } from '@server/lib/plugins/hooks'
7 import { buildPodcastGroupsCache, cacheRouteFactory, videoFeedsPodcastSetCacheKey } from '@server/middlewares'
8 import { MVideo, MVideoCaptionVideo, MVideoFullLight } from '@server/types/models'
9 import { sortObjectComparator } from '@shared/core-utils'
10 import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@shared/models'
11 import { buildNSFWFilter } from '../../helpers/express-utils'
12 import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
13 import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares'
14 import { VideoModel } from '../../models/video/video'
15 import { VideoCaptionModel } from '../../models/video/video-caption'
16 import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed } from './shared'
18 const videoPodcastFeedsRouter = express.Router()
20 // ---------------------------------------------------------------------------
22 const { middleware: podcastCacheRouteMiddleware, instance: podcastApiCache } = cacheRouteFactory({
23 headerBlacklist: [ 'Content-Type' ]
26 for (const event of ([ 'video-created', 'video-updated', 'video-deleted' ] as const)) {
27 InternalEventEmitter.Instance.on(event, ({ video }) => {
28 if (video.remote) return
30 podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: video.channelId }))
34 for (const event of ([ 'channel-updated', 'channel-deleted' ] as const)) {
35 InternalEventEmitter.Instance.on(event, ({ channel }) => {
36 podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: channel.id }))
40 // ---------------------------------------------------------------------------
42 videoPodcastFeedsRouter.get('/feeds/podcast/videos.xml',
43 setFeedPodcastContentType,
44 videoFeedsPodcastSetCacheKey,
45 podcastCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
46 asyncMiddleware(videoFeedsPodcastValidator),
47 asyncMiddleware(generateVideoPodcastFeed)
50 // ---------------------------------------------------------------------------
53 videoPodcastFeedsRouter
56 // ---------------------------------------------------------------------------
58 async function generateVideoPodcastFeed (req: express.Request, res: express.Response) {
59 const videoChannel = res.locals.videoChannel
61 const { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink } = await buildFeedMetadata({ videoChannel })
63 const data = await getVideosForFeeds({
65 nsfw: buildNSFWFilter(),
66 // Prevent podcast feeds from listing videos in other instances
67 // helps prevent duplicates when they are indexed -- only the author should control them
69 include: VideoInclude.FILES,
70 videoChannelId: videoChannel?.id
73 const customTags: CustomTag[] = await Hooks.wrapObject(
75 'filter:feed.podcast.channel.create-custom-tags.result',
79 const customXMLNS: CustomXMLNS[] = await Hooks.wrapObject(
81 'filter:feed.podcast.rss.create-custom-xmlns.result'
84 const feed = initFeed({
92 ? { isLocked: true, email } // Default to true because we have no way of offering a redirect yet
95 person: [ { name: userName, href: accountLink, img: accountImageUrl } ],
96 resourceType: 'videos',
97 queryString: new URL(WEBSERVER.URL + req.url).search,
103 await addVideosToPodcastFeed(feed, data)
105 // Now the feed generation is done, let's send it!
106 return res.send(feed.podcast()).end()
114 sources: { uri: string, contentType?: string }[]
119 sources: { uri: string }[]
124 async function generatePodcastItem (options: {
127 media: PodcastMedia[]
129 const { video, liveItem, media } = options
131 const customTags: CustomTag[] = await Hooks.wrapObject(
133 'filter:feed.podcast.video.create-custom-tags.result',
137 const account = video.VideoChannel.Account
140 name: account.getDisplayName(),
141 href: account.getClientUrl()
145 ...getCommonVideoFeedAttributes(video),
147 trackers: video.getTrackerUrls(),
154 img: account.Actor.hasImage(ActorImageType.AVATAR)
155 ? WEBSERVER.URL + account.Actor.Avatars[0].getStaticPath()
165 protocol: 'activitypub',
166 accountUrl: account.getClientUrl()
174 async function addVideosToPodcastFeed (feed: Feed, videos: VideoModel[]) {
175 const captionsGroup = await VideoCaptionModel.listCaptionsOfMultipleVideos(videos.map(v => v.id))
177 for (const video of videos) {
179 await addVODPodcastItem({ feed, video, captionsGroup })
180 } else if (video.isLive && video.state !== VideoState.LIVE_ENDED) {
181 await addLivePodcastItem({ feed, video })
186 async function addVODPodcastItem (options: {
189 captionsGroup: { [ id: number ]: MVideoCaptionVideo[] }
191 const { feed, video, captionsGroup } = options
193 const webVideos = video.getFormattedWebVideoFilesJSON(true)
194 .map(f => buildVODWebVideoFile(video, f))
195 .sort(sortObjectComparator('bitrate', 'desc'))
197 const streamingPlaylistFiles = buildVODStreamingPlaylists(video)
199 // Order matters here, the first media URI will be the "default"
200 // So web videos are default if enabled
201 const media = [ ...webVideos, ...streamingPlaylistFiles ]
203 const videoCaptions = buildVODCaptions(video, captionsGroup[video.id])
204 const item = await generatePodcastItem({ video, liveItem: false, media })
206 feed.addPodcastItem({ ...item, subTitle: videoCaptions })
209 async function addLivePodcastItem (options: {
213 const { feed, video } = options
215 let status: LiveItemStatus
217 switch (video.state) {
218 case VideoState.WAITING_FOR_LIVE:
219 status = LiveItemStatus.pending
221 case VideoState.PUBLISHED:
222 status = LiveItemStatus.live
226 const item = await generatePodcastItem({ video, liveItem: true, media: buildLiveStreamingPlaylists(video) })
228 feed.addPodcastLiveItem({ ...item, status, start: video.updatedAt.toISOString() })
231 // ---------------------------------------------------------------------------
233 function buildVODWebVideoFile (video: MVideo, videoFile: VideoFile) {
234 const isAudio = videoFile.resolution.id === VideoResolution.H_NOVIDEO
236 ? MIMETYPES.AUDIO.EXT_MIMETYPE[extname(videoFile.fileUrl)]
237 : MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)]
240 { uri: videoFile.fileUrl },
241 { uri: videoFile.torrentUrl, contentType: 'application/x-bittorrent' }
244 if (videoFile.magnetUri) {
245 sources.push({ uri: videoFile.magnetUri })
250 title: videoFile.resolution.label,
251 length: videoFile.size,
252 bitrate: videoFile.size / video.duration * 8,
253 language: video.language,
258 function buildVODStreamingPlaylists (video: MVideoFullLight) {
259 const hls = video.getHLSPlaylist()
264 type: 'application/x-mpegURL',
267 { uri: hls.getMasterPlaylistUrl(video) }
269 language: video.language
274 function buildLiveStreamingPlaylists (video: MVideoFullLight) {
275 const hls = video.getHLSPlaylist()
279 type: 'application/x-mpegURL',
280 title: `HLS live stream`,
282 { uri: hls.getMasterPlaylistUrl(video) }
284 language: video.language
289 function buildVODCaptions (video: MVideo, videoCaptions: MVideoCaptionVideo[]) {
290 return videoCaptions.map(caption => {
291 const type = MIMETYPES.VIDEO_CAPTIONS.EXT_MIMETYPE[extname(caption.filename)]
292 if (!type) return null
295 url: caption.getFileUrl(video),
296 language: caption.language,