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 { getBiggestActorImage } from '@server/lib/actor-image'
6 import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
7 import { Hooks } from '@server/lib/plugins/hooks'
8 import { buildPodcastGroupsCache, cacheRouteFactory, videoFeedsPodcastSetCacheKey } from '@server/middlewares'
9 import { MVideo, MVideoCaptionVideo, MVideoFullLight } from '@server/types/models'
10 import { sortObjectComparator } from '@shared/core-utils'
11 import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@shared/models'
12 import { buildNSFWFilter } from '../../helpers/express-utils'
13 import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
14 import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares'
15 import { VideoModel } from '../../models/video/video'
16 import { VideoCaptionModel } from '../../models/video/video-caption'
17 import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed } from './shared'
19 const videoPodcastFeedsRouter = express.Router()
21 // ---------------------------------------------------------------------------
23 const { middleware: podcastCacheRouteMiddleware, instance: podcastApiCache } = cacheRouteFactory({
24 headerBlacklist: [ 'Content-Type' ]
27 for (const event of ([ 'video-created', 'video-updated', 'video-deleted' ] as const)) {
28 InternalEventEmitter.Instance.on(event, ({ video }) => {
29 if (video.remote) return
31 podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: video.channelId }))
35 for (const event of ([ 'channel-updated', 'channel-deleted' ] as const)) {
36 InternalEventEmitter.Instance.on(event, ({ channel }) => {
37 podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: channel.id }))
41 // ---------------------------------------------------------------------------
43 videoPodcastFeedsRouter.get('/feeds/podcast/videos.xml',
44 setFeedPodcastContentType,
45 videoFeedsPodcastSetCacheKey,
46 podcastCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
47 asyncMiddleware(videoFeedsPodcastValidator),
48 asyncMiddleware(generateVideoPodcastFeed)
51 // ---------------------------------------------------------------------------
54 videoPodcastFeedsRouter
57 // ---------------------------------------------------------------------------
59 async function generateVideoPodcastFeed (req: express.Request, res: express.Response) {
60 const videoChannel = res.locals.videoChannel
62 const { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink } = await buildFeedMetadata({ videoChannel })
64 const data = await getVideosForFeeds({
66 nsfw: buildNSFWFilter(),
67 // Prevent podcast feeds from listing videos in other instances
68 // helps prevent duplicates when they are indexed -- only the author should control them
70 include: VideoInclude.FILES,
71 videoChannelId: videoChannel?.id
74 const customTags: CustomTag[] = await Hooks.wrapObject(
76 'filter:feed.podcast.channel.create-custom-tags.result',
80 const customXMLNS: CustomXMLNS[] = await Hooks.wrapObject(
82 'filter:feed.podcast.rss.create-custom-xmlns.result'
85 const feed = initFeed({
93 ? { isLocked: true, email } // Default to true because we have no way of offering a redirect yet
96 person: [ { name: userName, href: accountLink, img: accountImageUrl } ],
97 resourceType: 'videos',
98 queryString: new URL(WEBSERVER.URL + req.url).search,
104 await addVideosToPodcastFeed(feed, data)
106 // Now the feed generation is done, let's send it!
107 return res.send(feed.podcast()).end()
115 sources: { uri: string, contentType?: string }[]
120 sources: { uri: string }[]
125 async function generatePodcastItem (options: {
128 media: PodcastMedia[]
130 const { video, liveItem, media } = options
132 const customTags: CustomTag[] = await Hooks.wrapObject(
134 'filter:feed.podcast.video.create-custom-tags.result',
138 const account = video.VideoChannel.Account
141 name: account.getDisplayName(),
142 href: account.getClientUrl()
145 const commonAttributes = getCommonVideoFeedAttributes(video)
146 const guid = liveItem
147 ? `${video.uuid}_${video.publishedAt.toISOString()}`
148 : commonAttributes.link
150 let personImage: string
152 if (account.Actor.hasImage(ActorImageType.AVATAR)) {
153 const avatar = getBiggestActorImage(account.Actor.Avatars, 'width')
154 personImage = WEBSERVER.URL + avatar.getStaticPath()
161 trackers: video.getTrackerUrls(),
177 protocol: 'activitypub',
178 accountUrl: account.getClientUrl()
186 async function addVideosToPodcastFeed (feed: Feed, videos: VideoModel[]) {
187 const captionsGroup = await VideoCaptionModel.listCaptionsOfMultipleVideos(videos.map(v => v.id))
189 for (const video of videos) {
191 await addVODPodcastItem({ feed, video, captionsGroup })
192 } else if (video.isLive && video.state !== VideoState.LIVE_ENDED) {
193 await addLivePodcastItem({ feed, video })
198 async function addVODPodcastItem (options: {
201 captionsGroup: { [ id: number ]: MVideoCaptionVideo[] }
203 const { feed, video, captionsGroup } = options
205 const webVideos = video.getFormattedWebVideoFilesJSON(true)
206 .map(f => buildVODWebVideoFile(video, f))
207 .sort(sortObjectComparator('bitrate', 'desc'))
209 const streamingPlaylistFiles = buildVODStreamingPlaylists(video)
211 // Order matters here, the first media URI will be the "default"
212 // So web videos are default if enabled
213 const media = [ ...webVideos, ...streamingPlaylistFiles ]
215 const videoCaptions = buildVODCaptions(video, captionsGroup[video.id])
216 const item = await generatePodcastItem({ video, liveItem: false, media })
218 feed.addPodcastItem({ ...item, subTitle: videoCaptions })
221 async function addLivePodcastItem (options: {
225 const { feed, video } = options
227 let status: LiveItemStatus
229 switch (video.state) {
230 case VideoState.WAITING_FOR_LIVE:
231 status = LiveItemStatus.pending
233 case VideoState.PUBLISHED:
234 status = LiveItemStatus.live
238 const item = await generatePodcastItem({ video, liveItem: true, media: buildLiveStreamingPlaylists(video) })
240 feed.addPodcastLiveItem({ ...item, status, start: video.updatedAt.toISOString() })
243 // ---------------------------------------------------------------------------
245 function buildVODWebVideoFile (video: MVideo, videoFile: VideoFile) {
246 const isAudio = videoFile.resolution.id === VideoResolution.H_NOVIDEO
248 ? MIMETYPES.AUDIO.EXT_MIMETYPE[extname(videoFile.fileUrl)]
249 : MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)]
252 { uri: videoFile.fileUrl },
253 { uri: videoFile.torrentUrl, contentType: 'application/x-bittorrent' }
256 if (videoFile.magnetUri) {
257 sources.push({ uri: videoFile.magnetUri })
262 title: videoFile.resolution.label,
263 length: videoFile.size,
264 bitrate: videoFile.size / video.duration * 8,
265 language: video.language,
270 function buildVODStreamingPlaylists (video: MVideoFullLight) {
271 const hls = video.getHLSPlaylist()
276 type: 'application/x-mpegURL',
279 { uri: hls.getMasterPlaylistUrl(video) }
281 language: video.language
286 function buildLiveStreamingPlaylists (video: MVideoFullLight) {
287 const hls = video.getHLSPlaylist()
291 type: 'application/x-mpegURL',
292 title: `HLS live stream`,
294 { uri: hls.getMasterPlaylistUrl(video) }
296 language: video.language
301 function buildVODCaptions (video: MVideo, videoCaptions: MVideoCaptionVideo[]) {
302 return videoCaptions.map(caption => {
303 const type = MIMETYPES.VIDEO_CAPTIONS.EXT_MIMETYPE[extname(caption.filename)]
304 if (!type) return null
307 url: caption.getFileUrl(video),
308 language: caption.language,