]>
Commit | Line | Data |
---|---|---|
cb0eda56 AG |
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' | |
2c16f5ad | 5 | import { getBiggestActorImage } from '@server/lib/actor-image' |
cb0eda56 AG |
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 | ||
1ed1994f AG |
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)) { | |
ba278fa5 | 153 | const avatar = getBiggestActorImage(account.Actor.Avatars) |
1ed1994f AG |
154 | personImage = WEBSERVER.URL + avatar.getStaticPath() |
155 | } | |
156 | ||
cb0eda56 | 157 | return { |
1ed1994f AG |
158 | guid, |
159 | ...commonAttributes, | |
cb0eda56 AG |
160 | |
161 | trackers: video.getTrackerUrls(), | |
162 | ||
163 | author: [ author ], | |
164 | person: [ | |
165 | { | |
166 | ...author, | |
167 | ||
1ed1994f | 168 | img: personImage |
cb0eda56 AG |
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 | } |