aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/controllers/feeds/video-podcast-feeds.ts
diff options
context:
space:
mode:
Diffstat (limited to 'server/controllers/feeds/video-podcast-feeds.ts')
-rw-r--r--server/controllers/feeds/video-podcast-feeds.ts301
1 files changed, 301 insertions, 0 deletions
diff --git a/server/controllers/feeds/video-podcast-feeds.ts b/server/controllers/feeds/video-podcast-feeds.ts
new file mode 100644
index 000000000..45d31c781
--- /dev/null
+++ b/server/controllers/feeds/video-podcast-feeds.ts
@@ -0,0 +1,301 @@
1import express from 'express'
2import { extname } from 'path'
3import { Feed } from '@peertube/feed'
4import { CustomTag, CustomXMLNS, LiveItemStatus } from '@peertube/feed/lib/typings'
5import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
6import { Hooks } from '@server/lib/plugins/hooks'
7import { buildPodcastGroupsCache, cacheRouteFactory, videoFeedsPodcastSetCacheKey } from '@server/middlewares'
8import { MVideo, MVideoCaptionVideo, MVideoFullLight } from '@server/types/models'
9import { sortObjectComparator } from '@shared/core-utils'
10import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@shared/models'
11import { buildNSFWFilter } from '../../helpers/express-utils'
12import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
13import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares'
14import { VideoModel } from '../../models/video/video'
15import { VideoCaptionModel } from '../../models/video/video-caption'
16import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed } from './shared'
17
18const videoPodcastFeedsRouter = express.Router()
19
20// ---------------------------------------------------------------------------
21
22const { middleware: podcastCacheRouteMiddleware, instance: podcastApiCache } = cacheRouteFactory({
23 headerBlacklist: [ 'Content-Type' ]
24})
25
26for (const event of ([ 'video-created', 'video-updated', 'video-deleted' ] as const)) {
27 InternalEventEmitter.Instance.on(event, ({ video }) => {
28 if (video.remote) return
29
30 podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: video.channelId }))
31 })
32}
33
34for (const event of ([ 'channel-updated', 'channel-deleted' ] as const)) {
35 InternalEventEmitter.Instance.on(event, ({ channel }) => {
36 podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: channel.id }))
37 })
38}
39
40// ---------------------------------------------------------------------------
41
42videoPodcastFeedsRouter.get('/feeds/podcast/videos.xml',
43 setFeedPodcastContentType,
44 videoFeedsPodcastSetCacheKey,
45 podcastCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
46 asyncMiddleware(videoFeedsPodcastValidator),
47 asyncMiddleware(generateVideoPodcastFeed)
48)
49
50// ---------------------------------------------------------------------------
51
52export {
53 videoPodcastFeedsRouter
54}
55
56// ---------------------------------------------------------------------------
57
58async function generateVideoPodcastFeed (req: express.Request, res: express.Response) {
59 const videoChannel = res.locals.videoChannel
60
61 const { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink } = await buildFeedMetadata({ videoChannel })
62
63 const data = await getVideosForFeeds({
64 sort: '-publishedAt',
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
68 isLocal: true,
69 include: VideoInclude.FILES,
70 videoChannelId: videoChannel?.id
71 })
72
73 const customTags: CustomTag[] = await Hooks.wrapObject(
74 [],
75 'filter:feed.podcast.channel.create-custom-tags.result',
76 { videoChannel }
77 )
78
79 const customXMLNS: CustomXMLNS[] = await Hooks.wrapObject(
80 [],
81 'filter:feed.podcast.rss.create-custom-xmlns.result'
82 )
83
84 const feed = initFeed({
85 name,
86 description,
87 link,
88 isPodcast: true,
89 imageUrl,
90
91 locked: email
92 ? { isLocked: true, email } // Default to true because we have no way of offering a redirect yet
93 : undefined,
94
95 person: [ { name: userName, href: accountLink, img: accountImageUrl } ],
96 resourceType: 'videos',
97 queryString: new URL(WEBSERVER.URL + req.url).search,
98 medium: 'video',
99 customXMLNS,
100 customTags
101 })
102
103 await addVideosToPodcastFeed(feed, data)
104
105 // Now the feed generation is done, let's send it!
106 return res.send(feed.podcast()).end()
107}
108
109type PodcastMedia =
110 {
111 type: string
112 length: number
113 bitrate: number
114 sources: { uri: string, contentType?: string }[]
115 title: string
116 language?: string
117 } |
118 {
119 sources: { uri: string }[]
120 type: string
121 title: string
122 }
123
124async function generatePodcastItem (options: {
125 video: VideoModel
126 liveItem: boolean
127 media: PodcastMedia[]
128}) {
129 const { video, liveItem, media } = options
130
131 const customTags: CustomTag[] = await Hooks.wrapObject(
132 [],
133 'filter:feed.podcast.video.create-custom-tags.result',
134 { video, liveItem }
135 )
136
137 const account = video.VideoChannel.Account
138
139 const author = {
140 name: account.getDisplayName(),
141 href: account.getClientUrl()
142 }
143
144 return {
145 ...getCommonVideoFeedAttributes(video),
146
147 trackers: video.getTrackerUrls(),
148
149 author: [ author ],
150 person: [
151 {
152 ...author,
153
154 img: account.Actor.hasImage(ActorImageType.AVATAR)
155 ? WEBSERVER.URL + account.Actor.Avatars[0].getStaticPath()
156 : undefined
157 }
158 ],
159
160 media,
161
162 socialInteract: [
163 {
164 uri: video.url,
165 protocol: 'activitypub',
166 accountUrl: account.getClientUrl()
167 }
168 ],
169
170 customTags
171 }
172}
173
174async function addVideosToPodcastFeed (feed: Feed, videos: VideoModel[]) {
175 const captionsGroup = await VideoCaptionModel.listCaptionsOfMultipleVideos(videos.map(v => v.id))
176
177 for (const video of videos) {
178 if (!video.isLive) {
179 await addVODPodcastItem({ feed, video, captionsGroup })
180 } else if (video.isLive && video.state !== VideoState.LIVE_ENDED) {
181 await addLivePodcastItem({ feed, video })
182 }
183 }
184}
185
186async function addVODPodcastItem (options: {
187 feed: Feed
188 video: VideoModel
189 captionsGroup: { [ id: number ]: MVideoCaptionVideo[] }
190}) {
191 const { feed, video, captionsGroup } = options
192
193 const webVideos = video.getFormattedWebVideoFilesJSON(true)
194 .map(f => buildVODWebVideoFile(video, f))
195 .sort(sortObjectComparator('bitrate', 'desc'))
196
197 const streamingPlaylistFiles = buildVODStreamingPlaylists(video)
198
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 ]
202
203 const videoCaptions = buildVODCaptions(video, captionsGroup[video.id])
204 const item = await generatePodcastItem({ video, liveItem: false, media })
205
206 feed.addPodcastItem({ ...item, subTitle: videoCaptions })
207}
208
209async function addLivePodcastItem (options: {
210 feed: Feed
211 video: VideoModel
212}) {
213 const { feed, video } = options
214
215 let status: LiveItemStatus
216
217 switch (video.state) {
218 case VideoState.WAITING_FOR_LIVE:
219 status = LiveItemStatus.pending
220 break
221 case VideoState.PUBLISHED:
222 status = LiveItemStatus.live
223 break
224 }
225
226 const item = await generatePodcastItem({ video, liveItem: true, media: buildLiveStreamingPlaylists(video) })
227
228 feed.addPodcastLiveItem({ ...item, status, start: video.updatedAt.toISOString() })
229}
230
231// ---------------------------------------------------------------------------
232
233function buildVODWebVideoFile (video: MVideo, videoFile: VideoFile) {
234 const isAudio = videoFile.resolution.id === VideoResolution.H_NOVIDEO
235 const type = isAudio
236 ? MIMETYPES.AUDIO.EXT_MIMETYPE[extname(videoFile.fileUrl)]
237 : MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)]
238
239 const sources = [
240 { uri: videoFile.fileUrl },
241 { uri: videoFile.torrentUrl, contentType: 'application/x-bittorrent' }
242 ]
243
244 if (videoFile.magnetUri) {
245 sources.push({ uri: videoFile.magnetUri })
246 }
247
248 return {
249 type,
250 title: videoFile.resolution.label,
251 length: videoFile.size,
252 bitrate: videoFile.size / video.duration * 8,
253 language: video.language,
254 sources
255 }
256}
257
258function buildVODStreamingPlaylists (video: MVideoFullLight) {
259 const hls = video.getHLSPlaylist()
260 if (!hls) return []
261
262 return [
263 {
264 type: 'application/x-mpegURL',
265 title: 'HLS',
266 sources: [
267 { uri: hls.getMasterPlaylistUrl(video) }
268 ],
269 language: video.language
270 }
271 ]
272}
273
274function buildLiveStreamingPlaylists (video: MVideoFullLight) {
275 const hls = video.getHLSPlaylist()
276
277 return [
278 {
279 type: 'application/x-mpegURL',
280 title: `HLS live stream`,
281 sources: [
282 { uri: hls.getMasterPlaylistUrl(video) }
283 ],
284 language: video.language
285 }
286 ]
287}
288
289function 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
293
294 return {
295 url: caption.getFileUrl(video),
296 language: caption.language,
297 type,
298 rel: 'captions'
299 }
300 }).filter(c => c)
301}