]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/controllers/feeds/video-podcast-feeds.ts
45d31c7812b14d3055d6d2391cc207bf9fed60e2
[github/Chocobozzz/PeerTube.git] / server / controllers / feeds / video-podcast-feeds.ts
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'
17
18 const videoPodcastFeedsRouter = express.Router()
19
20 // ---------------------------------------------------------------------------
21
22 const { middleware: podcastCacheRouteMiddleware, instance: podcastApiCache } = cacheRouteFactory({
23 headerBlacklist: [ 'Content-Type' ]
24 })
25
26 for (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
34 for (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
42 videoPodcastFeedsRouter.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
52 export {
53 videoPodcastFeedsRouter
54 }
55
56 // ---------------------------------------------------------------------------
57
58 async 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
109 type 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
124 async 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
174 async 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
186 async 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
209 async 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
233 function 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
258 function 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
274 function 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
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
293
294 return {
295 url: caption.getFileUrl(video),
296 language: caption.language,
297 type,
298 rel: 'captions'
299 }
300 }).filter(c => c)
301 }