diff options
author | Alecks Gates <agates@mail.agates.io> | 2023-05-22 09:00:05 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-05-22 16:00:05 +0200 |
commit | cb0eda5602a21d1626a7face32de6153ed07b5f9 (patch) | |
tree | d6a7a4e31c7267c130871ac8e3beb42994271c20 /server/controllers/feeds.ts | |
parent | 3f0ceab06e5320f62f593c49daa30d963dbc36f9 (diff) | |
download | PeerTube-cb0eda5602a21d1626a7face32de6153ed07b5f9.tar.gz PeerTube-cb0eda5602a21d1626a7face32de6153ed07b5f9.tar.zst PeerTube-cb0eda5602a21d1626a7face32de6153ed07b5f9.zip |
Add Podcast RSS feeds (#5487)
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Add correct feed image to RSS channel
* Prefer HLS videos for podcast RSS
Remove video/stream titles, add optional height attribute to podcast RSS
* Prefix podcast RSS images with root server URL
* Add optional video query support to include captions
* Add transcripts & person images to podcast RSS feed
* Prefer webseed/webtorrent files over HLS fragmented mp4s
* Experimentally adding podcast fields to basic config page
* Add validation for new basic config fields
* Don't include "content" in podcast feed, use full description for "description"
* Initial test implementation of Podcast RSS
This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.
I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.
* Update to pfeed-podcast 1.2.2
* Add correct feed image to RSS channel
* Prefer HLS videos for podcast RSS
Remove video/stream titles, add optional height attribute to podcast RSS
* Prefix podcast RSS images with root server URL
* Add optional video query support to include captions
* Add transcripts & person images to podcast RSS feed
* Prefer webseed/webtorrent files over HLS fragmented mp4s
* Experimentally adding podcast fields to basic config page
* Add validation for new basic config fields
* Don't include "content" in podcast feed, use full description for "description"
* Add medium/socialInteract to podcast RSS feeds. Use HTML for description
* Change base production image to bullseye, install prosody in image
* Add liveItem and trackers to Podcast RSS feeds
Remove height from alternateEnclosure, replaced with title.
* Clear Podcast RSS feed cache when live streams start/end
* Upgrade to Node 16
* Refactor clearCacheRoute to use ApiCache
* Remove unnecessary type hint
* Update dockerfile to node 16, install python-is-python2
* Use new file paths for captions/playlists
* Fix legacy videos in RSS after migration to object storage
* Improve method of identifying non-fragmented mp4s in podcast RSS feeds
* Don't include fragmented MP4s in podcast RSS feeds
* Add experimental support for podcast:categories on the podcast RSS item
* Fix undefined category when no videos exist
Allows for empty feeds to exist (important for feeds that might only go live)
* Add support for podcast:locked -- user has to opt in to show their email
* Use comma for podcast:categories delimiter
* Make cache clearing async
* Fix merge, temporarily test with pfeed-podcast
* Syntax changes
* Add EXT_MIMETYPE constants for captions
* Update & fix tests, fix enclosure mimetypes, remove admin email
* Add test for podacst:socialInteract
* Add filters hooks for podcast customTags
* Remove showdown, updated to pfeed-podcast 6.1.2
* Add 'action:api.live-video.state.updated' hook
* Avoid assigning undefined category to podcast feeds
* Remove nvmrc
* Remove comment
* Remove unused podcast config
* Remove more unused podcast config
* Fix MChannelAccountDefault type hint missed in merge
* Remove extra line
* Re-add newline in config
* Fix lint errors for isEmailPublic
* Fix thumbnails in podcast feeds
* Requested changes based on review
* Provide podcast rss 2.0 only on video channels
* Misc cleanup for a less messy PR
* Lint fixes
* Remove pfeed-podcast
* Add peertube version to new hooks
* Don't use query include, remove TODO
* Remove film medium hack
* Clear podcast rss cache before video/channel update hooks
* Clear podcast rss cache before video uploaded/deleted hooks
* Refactor podcast feed cache clearing
* Set correct person name from video channel
* Styling
* Fix tests
---------
Co-authored-by: Chocobozzz <me@florianbigard.com>
Diffstat (limited to 'server/controllers/feeds.ts')
-rw-r--r-- | server/controllers/feeds.ts | 389 |
1 files changed, 0 insertions, 389 deletions
diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts deleted file mode 100644 index ef810a842..000000000 --- a/server/controllers/feeds.ts +++ /dev/null | |||
@@ -1,389 +0,0 @@ | |||
1 | import express from 'express' | ||
2 | import { extname } from 'path' | ||
3 | import { Feed } from '@peertube/feed' | ||
4 | import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown' | ||
5 | import { getServerActor } from '@server/models/application/application' | ||
6 | import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils' | ||
7 | import { MAccountDefault, MChannelBannerAccountDefault, MVideoFullLight } from '@server/types/models' | ||
8 | import { ActorImageType, VideoInclude } from '@shared/models' | ||
9 | import { buildNSFWFilter } from '../helpers/express-utils' | ||
10 | import { CONFIG } from '../initializers/config' | ||
11 | import { MIMETYPES, PREVIEWS_SIZE, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../initializers/constants' | ||
12 | import { | ||
13 | asyncMiddleware, | ||
14 | commonVideosFiltersValidator, | ||
15 | feedsFormatValidator, | ||
16 | setDefaultVideosSort, | ||
17 | setFeedFormatContentType, | ||
18 | videoCommentsFeedsValidator, | ||
19 | videoFeedsValidator, | ||
20 | videosSortValidator, | ||
21 | videoSubscriptionFeedsValidator | ||
22 | } from '../middlewares' | ||
23 | import { cacheRouteFactory } from '../middlewares/cache/cache' | ||
24 | import { VideoModel } from '../models/video/video' | ||
25 | import { VideoCommentModel } from '../models/video/video-comment' | ||
26 | |||
27 | const feedsRouter = express.Router() | ||
28 | |||
29 | const cacheRoute = cacheRouteFactory({ | ||
30 | headerBlacklist: [ 'Content-Type' ] | ||
31 | }) | ||
32 | |||
33 | feedsRouter.get('/feeds/video-comments.:format', | ||
34 | feedsFormatValidator, | ||
35 | setFeedFormatContentType, | ||
36 | cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS), | ||
37 | asyncMiddleware(videoFeedsValidator), | ||
38 | asyncMiddleware(videoCommentsFeedsValidator), | ||
39 | asyncMiddleware(generateVideoCommentsFeed) | ||
40 | ) | ||
41 | |||
42 | feedsRouter.get('/feeds/videos.:format', | ||
43 | videosSortValidator, | ||
44 | setDefaultVideosSort, | ||
45 | feedsFormatValidator, | ||
46 | setFeedFormatContentType, | ||
47 | cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS), | ||
48 | commonVideosFiltersValidator, | ||
49 | asyncMiddleware(videoFeedsValidator), | ||
50 | asyncMiddleware(generateVideoFeed) | ||
51 | ) | ||
52 | |||
53 | feedsRouter.get('/feeds/subscriptions.:format', | ||
54 | videosSortValidator, | ||
55 | setDefaultVideosSort, | ||
56 | feedsFormatValidator, | ||
57 | setFeedFormatContentType, | ||
58 | cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS), | ||
59 | commonVideosFiltersValidator, | ||
60 | asyncMiddleware(videoSubscriptionFeedsValidator), | ||
61 | asyncMiddleware(generateVideoFeedForSubscriptions) | ||
62 | ) | ||
63 | |||
64 | // --------------------------------------------------------------------------- | ||
65 | |||
66 | export { | ||
67 | feedsRouter | ||
68 | } | ||
69 | |||
70 | // --------------------------------------------------------------------------- | ||
71 | |||
72 | async function generateVideoCommentsFeed (req: express.Request, res: express.Response) { | ||
73 | const start = 0 | ||
74 | const video = res.locals.videoAll | ||
75 | const account = res.locals.account | ||
76 | const videoChannel = res.locals.videoChannel | ||
77 | |||
78 | const comments = await VideoCommentModel.listForFeed({ | ||
79 | start, | ||
80 | count: CONFIG.FEEDS.COMMENTS.COUNT, | ||
81 | videoId: video ? video.id : undefined, | ||
82 | accountId: account ? account.id : undefined, | ||
83 | videoChannelId: videoChannel ? videoChannel.id : undefined | ||
84 | }) | ||
85 | |||
86 | const { name, description, imageUrl } = buildFeedMetadata({ video, account, videoChannel }) | ||
87 | |||
88 | const feed = initFeed({ | ||
89 | name, | ||
90 | description, | ||
91 | imageUrl, | ||
92 | resourceType: 'video-comments', | ||
93 | queryString: new URL(WEBSERVER.URL + req.originalUrl).search | ||
94 | }) | ||
95 | |||
96 | // Adding video items to the feed, one at a time | ||
97 | for (const comment of comments) { | ||
98 | const localLink = WEBSERVER.URL + comment.getCommentStaticPath() | ||
99 | |||
100 | let title = comment.Video.name | ||
101 | const author: { name: string, link: string }[] = [] | ||
102 | |||
103 | if (comment.Account) { | ||
104 | title += ` - ${comment.Account.getDisplayName()}` | ||
105 | author.push({ | ||
106 | name: comment.Account.getDisplayName(), | ||
107 | link: comment.Account.Actor.url | ||
108 | }) | ||
109 | } | ||
110 | |||
111 | feed.addItem({ | ||
112 | title, | ||
113 | id: localLink, | ||
114 | link: localLink, | ||
115 | content: toSafeHtml(comment.text), | ||
116 | author, | ||
117 | date: comment.createdAt | ||
118 | }) | ||
119 | } | ||
120 | |||
121 | // Now the feed generation is done, let's send it! | ||
122 | return sendFeed(feed, req, res) | ||
123 | } | ||
124 | |||
125 | async function generateVideoFeed (req: express.Request, res: express.Response) { | ||
126 | const start = 0 | ||
127 | const account = res.locals.account | ||
128 | const videoChannel = res.locals.videoChannel | ||
129 | const nsfw = buildNSFWFilter(res, req.query.nsfw) | ||
130 | |||
131 | const { name, description, imageUrl } = buildFeedMetadata({ videoChannel, account }) | ||
132 | |||
133 | const feed = initFeed({ | ||
134 | name, | ||
135 | description, | ||
136 | imageUrl, | ||
137 | resourceType: 'videos', | ||
138 | queryString: new URL(WEBSERVER.URL + req.url).search | ||
139 | }) | ||
140 | |||
141 | const options = { | ||
142 | accountId: account ? account.id : null, | ||
143 | videoChannelId: videoChannel ? videoChannel.id : null | ||
144 | } | ||
145 | |||
146 | const server = await getServerActor() | ||
147 | const { data } = await VideoModel.listForApi({ | ||
148 | start, | ||
149 | count: CONFIG.FEEDS.VIDEOS.COUNT, | ||
150 | sort: req.query.sort, | ||
151 | displayOnlyForFollower: { | ||
152 | actorId: server.id, | ||
153 | orLocalVideos: true | ||
154 | }, | ||
155 | nsfw, | ||
156 | isLocal: req.query.isLocal, | ||
157 | include: req.query.include | VideoInclude.FILES, | ||
158 | hasFiles: true, | ||
159 | countVideos: false, | ||
160 | ...options | ||
161 | }) | ||
162 | |||
163 | addVideosToFeed(feed, data) | ||
164 | |||
165 | // Now the feed generation is done, let's send it! | ||
166 | return sendFeed(feed, req, res) | ||
167 | } | ||
168 | |||
169 | async function generateVideoFeedForSubscriptions (req: express.Request, res: express.Response) { | ||
170 | const start = 0 | ||
171 | const account = res.locals.account | ||
172 | const nsfw = buildNSFWFilter(res, req.query.nsfw) | ||
173 | |||
174 | const { name, description, imageUrl } = buildFeedMetadata({ account }) | ||
175 | |||
176 | const feed = initFeed({ | ||
177 | name, | ||
178 | description, | ||
179 | imageUrl, | ||
180 | resourceType: 'videos', | ||
181 | queryString: new URL(WEBSERVER.URL + req.url).search | ||
182 | }) | ||
183 | |||
184 | const { data } = await VideoModel.listForApi({ | ||
185 | start, | ||
186 | count: CONFIG.FEEDS.VIDEOS.COUNT, | ||
187 | sort: req.query.sort, | ||
188 | nsfw, | ||
189 | |||
190 | isLocal: req.query.isLocal, | ||
191 | |||
192 | hasFiles: true, | ||
193 | include: req.query.include | VideoInclude.FILES, | ||
194 | |||
195 | countVideos: false, | ||
196 | |||
197 | displayOnlyForFollower: { | ||
198 | actorId: res.locals.user.Account.Actor.id, | ||
199 | orLocalVideos: false | ||
200 | }, | ||
201 | user: res.locals.user | ||
202 | }) | ||
203 | |||
204 | addVideosToFeed(feed, data) | ||
205 | |||
206 | // Now the feed generation is done, let's send it! | ||
207 | return sendFeed(feed, req, res) | ||
208 | } | ||
209 | |||
210 | function initFeed (parameters: { | ||
211 | name: string | ||
212 | description: string | ||
213 | imageUrl: string | ||
214 | resourceType?: 'videos' | 'video-comments' | ||
215 | queryString?: string | ||
216 | }) { | ||
217 | const webserverUrl = WEBSERVER.URL | ||
218 | const { name, description, resourceType, queryString, imageUrl } = parameters | ||
219 | |||
220 | return new Feed({ | ||
221 | title: name, | ||
222 | description: mdToOneLinePlainText(description), | ||
223 | // updated: TODO: somehowGetLatestUpdate, // optional, default = today | ||
224 | id: webserverUrl, | ||
225 | link: webserverUrl, | ||
226 | image: imageUrl, | ||
227 | favicon: webserverUrl + '/client/assets/images/favicon.png', | ||
228 | copyright: `All rights reserved, unless otherwise specified in the terms specified at ${webserverUrl}/about` + | ||
229 | ` and potential licenses granted by each content's rightholder.`, | ||
230 | generator: `ToraifÅsu`, // ^.~ | ||
231 | feedLinks: { | ||
232 | json: `${webserverUrl}/feeds/${resourceType}.json${queryString}`, | ||
233 | atom: `${webserverUrl}/feeds/${resourceType}.atom${queryString}`, | ||
234 | rss: `${webserverUrl}/feeds/${resourceType}.xml${queryString}` | ||
235 | }, | ||
236 | author: { | ||
237 | name: 'Instance admin of ' + CONFIG.INSTANCE.NAME, | ||
238 | email: CONFIG.ADMIN.EMAIL, | ||
239 | link: `${webserverUrl}/about` | ||
240 | } | ||
241 | }) | ||
242 | } | ||
243 | |||
244 | function addVideosToFeed (feed: Feed, videos: VideoModel[]) { | ||
245 | for (const video of videos) { | ||
246 | const formattedVideoFiles = video.getFormattedVideoFilesJSON(false) | ||
247 | |||
248 | const torrents = formattedVideoFiles.map(videoFile => ({ | ||
249 | title: video.name, | ||
250 | url: videoFile.torrentUrl, | ||
251 | size_in_bytes: videoFile.size | ||
252 | })) | ||
253 | |||
254 | const videoFiles = formattedVideoFiles.map(videoFile => { | ||
255 | const result = { | ||
256 | type: MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)], | ||
257 | medium: 'video', | ||
258 | height: videoFile.resolution.id, | ||
259 | fileSize: videoFile.size, | ||
260 | url: videoFile.fileUrl, | ||
261 | framerate: videoFile.fps, | ||
262 | duration: video.duration | ||
263 | } | ||
264 | |||
265 | if (video.language) Object.assign(result, { lang: video.language }) | ||
266 | |||
267 | return result | ||
268 | }) | ||
269 | |||
270 | const categories: { value: number, label: string }[] = [] | ||
271 | if (video.category) { | ||
272 | categories.push({ | ||
273 | value: video.category, | ||
274 | label: getCategoryLabel(video.category) | ||
275 | }) | ||
276 | } | ||
277 | |||
278 | const localLink = WEBSERVER.URL + video.getWatchStaticPath() | ||
279 | |||
280 | feed.addItem({ | ||
281 | title: video.name, | ||
282 | id: localLink, | ||
283 | link: localLink, | ||
284 | description: mdToOneLinePlainText(video.getTruncatedDescription()), | ||
285 | content: toSafeHtml(video.description), | ||
286 | author: [ | ||
287 | { | ||
288 | name: video.VideoChannel.getDisplayName(), | ||
289 | link: video.VideoChannel.Actor.url | ||
290 | } | ||
291 | ], | ||
292 | date: video.publishedAt, | ||
293 | nsfw: video.nsfw, | ||
294 | torrents, | ||
295 | |||
296 | // Enclosure | ||
297 | video: videoFiles.length !== 0 | ||
298 | ? { | ||
299 | url: videoFiles[0].url, | ||
300 | length: videoFiles[0].fileSize, | ||
301 | type: videoFiles[0].type | ||
302 | } | ||
303 | : undefined, | ||
304 | |||
305 | // Media RSS | ||
306 | videos: videoFiles, | ||
307 | |||
308 | embed: { | ||
309 | url: WEBSERVER.URL + video.getEmbedStaticPath(), | ||
310 | allowFullscreen: true | ||
311 | }, | ||
312 | player: { | ||
313 | url: WEBSERVER.URL + video.getWatchStaticPath() | ||
314 | }, | ||
315 | categories, | ||
316 | community: { | ||
317 | statistics: { | ||
318 | views: video.views | ||
319 | } | ||
320 | }, | ||
321 | thumbnails: [ | ||
322 | { | ||
323 | url: WEBSERVER.URL + video.getPreviewStaticPath(), | ||
324 | height: PREVIEWS_SIZE.height, | ||
325 | width: PREVIEWS_SIZE.width | ||
326 | } | ||
327 | ] | ||
328 | }) | ||
329 | } | ||
330 | } | ||
331 | |||
332 | function sendFeed (feed: Feed, req: express.Request, res: express.Response) { | ||
333 | const format = req.params.format | ||
334 | |||
335 | if (format === 'atom' || format === 'atom1') { | ||
336 | return res.send(feed.atom1()).end() | ||
337 | } | ||
338 | |||
339 | if (format === 'json' || format === 'json1') { | ||
340 | return res.send(feed.json1()).end() | ||
341 | } | ||
342 | |||
343 | if (format === 'rss' || format === 'rss2') { | ||
344 | return res.send(feed.rss2()).end() | ||
345 | } | ||
346 | |||
347 | // We're in the ambiguous '.xml' case and we look at the format query parameter | ||
348 | if (req.query.format === 'atom' || req.query.format === 'atom1') { | ||
349 | return res.send(feed.atom1()).end() | ||
350 | } | ||
351 | |||
352 | return res.send(feed.rss2()).end() | ||
353 | } | ||
354 | |||
355 | function buildFeedMetadata (options: { | ||
356 | videoChannel?: MChannelBannerAccountDefault | ||
357 | account?: MAccountDefault | ||
358 | video?: MVideoFullLight | ||
359 | }) { | ||
360 | const { video, videoChannel, account } = options | ||
361 | |||
362 | let imageUrl = WEBSERVER.URL + '/client/assets/images/icons/icon-96x96.png' | ||
363 | let name: string | ||
364 | let description: string | ||
365 | |||
366 | if (videoChannel) { | ||
367 | name = videoChannel.getDisplayName() | ||
368 | description = videoChannel.description | ||
369 | |||
370 | if (videoChannel.Actor.hasImage(ActorImageType.AVATAR)) { | ||
371 | imageUrl = WEBSERVER.URL + videoChannel.Actor.Avatars[0].getStaticPath() | ||
372 | } | ||
373 | } else if (account) { | ||
374 | name = account.getDisplayName() | ||
375 | description = account.description | ||
376 | |||
377 | if (account.Actor.hasImage(ActorImageType.AVATAR)) { | ||
378 | imageUrl = WEBSERVER.URL + account.Actor.Avatars[0].getStaticPath() | ||
379 | } | ||
380 | } else if (video) { | ||
381 | name = video.name | ||
382 | description = video.description | ||
383 | } else { | ||
384 | name = CONFIG.INSTANCE.NAME | ||
385 | description = CONFIG.INSTANCE.DESCRIPTION | ||
386 | } | ||
387 | |||
388 | return { name, description, imageUrl } | ||
389 | } | ||