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