aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/controllers/feeds
diff options
context:
space:
mode:
authorAlecks Gates <agates@mail.agates.io>2023-05-22 09:00:05 -0500
committerGitHub <noreply@github.com>2023-05-22 16:00:05 +0200
commitcb0eda5602a21d1626a7face32de6153ed07b5f9 (patch)
treed6a7a4e31c7267c130871ac8e3beb42994271c20 /server/controllers/feeds
parent3f0ceab06e5320f62f593c49daa30d963dbc36f9 (diff)
downloadPeerTube-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')
-rw-r--r--server/controllers/feeds/comment-feeds.ts96
-rw-r--r--server/controllers/feeds/index.ts16
-rw-r--r--server/controllers/feeds/shared/common-feed-utils.ts145
-rw-r--r--server/controllers/feeds/shared/index.ts2
-rw-r--r--server/controllers/feeds/shared/video-feed-utils.ts66
-rw-r--r--server/controllers/feeds/video-feeds.ts189
-rw-r--r--server/controllers/feeds/video-podcast-feeds.ts301
7 files changed, 815 insertions, 0 deletions
diff --git a/server/controllers/feeds/comment-feeds.ts b/server/controllers/feeds/comment-feeds.ts
new file mode 100644
index 000000000..bdc53b51f
--- /dev/null
+++ b/server/controllers/feeds/comment-feeds.ts
@@ -0,0 +1,96 @@
1import express from 'express'
2import { toSafeHtml } from '@server/helpers/markdown'
3import { cacheRouteFactory } from '@server/middlewares'
4import { CONFIG } from '../../initializers/config'
5import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
6import {
7 asyncMiddleware,
8 feedsFormatValidator,
9 setFeedFormatContentType,
10 videoCommentsFeedsValidator,
11 videoFeedsValidator
12} from '../../middlewares'
13import { VideoCommentModel } from '../../models/video/video-comment'
14import { buildFeedMetadata, initFeed, sendFeed } from './shared'
15
16const commentFeedsRouter = express.Router()
17
18// ---------------------------------------------------------------------------
19
20const { middleware: cacheRouteMiddleware } = cacheRouteFactory({
21 headerBlacklist: [ 'Content-Type' ]
22})
23
24// ---------------------------------------------------------------------------
25
26commentFeedsRouter.get('/feeds/video-comments.:format',
27 feedsFormatValidator,
28 setFeedFormatContentType,
29 cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
30 asyncMiddleware(videoFeedsValidator),
31 asyncMiddleware(videoCommentsFeedsValidator),
32 asyncMiddleware(generateVideoCommentsFeed)
33)
34
35// ---------------------------------------------------------------------------
36
37export {
38 commentFeedsRouter
39}
40
41// ---------------------------------------------------------------------------
42
43async function generateVideoCommentsFeed (req: express.Request, res: express.Response) {
44 const start = 0
45 const video = res.locals.videoAll
46 const account = res.locals.account
47 const videoChannel = res.locals.videoChannel
48
49 const comments = await VideoCommentModel.listForFeed({
50 start,
51 count: CONFIG.FEEDS.COMMENTS.COUNT,
52 videoId: video ? video.id : undefined,
53 accountId: account ? account.id : undefined,
54 videoChannelId: videoChannel ? videoChannel.id : undefined
55 })
56
57 const { name, description, imageUrl, link } = await buildFeedMetadata({ video, account, videoChannel })
58
59 const feed = initFeed({
60 name,
61 description,
62 imageUrl,
63 isPodcast: false,
64 link,
65 resourceType: 'video-comments',
66 queryString: new URL(WEBSERVER.URL + req.originalUrl).search
67 })
68
69 // Adding video items to the feed, one at a time
70 for (const comment of comments) {
71 const localLink = WEBSERVER.URL + comment.getCommentStaticPath()
72
73 let title = comment.Video.name
74 const author: { name: string, link: string }[] = []
75
76 if (comment.Account) {
77 title += ` - ${comment.Account.getDisplayName()}`
78 author.push({
79 name: comment.Account.getDisplayName(),
80 link: comment.Account.Actor.url
81 })
82 }
83
84 feed.addItem({
85 title,
86 id: localLink,
87 link: localLink,
88 content: toSafeHtml(comment.text),
89 author,
90 date: comment.createdAt
91 })
92 }
93
94 // Now the feed generation is done, let's send it!
95 return sendFeed(feed, req, res)
96}
diff --git a/server/controllers/feeds/index.ts b/server/controllers/feeds/index.ts
new file mode 100644
index 000000000..e344a1448
--- /dev/null
+++ b/server/controllers/feeds/index.ts
@@ -0,0 +1,16 @@
1import express from 'express'
2import { commentFeedsRouter } from './comment-feeds'
3import { videoFeedsRouter } from './video-feeds'
4import { videoPodcastFeedsRouter } from './video-podcast-feeds'
5
6const feedsRouter = express.Router()
7
8feedsRouter.use('/', commentFeedsRouter)
9feedsRouter.use('/', videoFeedsRouter)
10feedsRouter.use('/', videoPodcastFeedsRouter)
11
12// ---------------------------------------------------------------------------
13
14export {
15 feedsRouter
16}
diff --git a/server/controllers/feeds/shared/common-feed-utils.ts b/server/controllers/feeds/shared/common-feed-utils.ts
new file mode 100644
index 000000000..375c2814b
--- /dev/null
+++ b/server/controllers/feeds/shared/common-feed-utils.ts
@@ -0,0 +1,145 @@
1import express from 'express'
2import { Feed } from '@peertube/feed'
3import { CustomTag, CustomXMLNS, Person } from '@peertube/feed/lib/typings'
4import { mdToOneLinePlainText } from '@server/helpers/markdown'
5import { CONFIG } from '@server/initializers/config'
6import { WEBSERVER } from '@server/initializers/constants'
7import { UserModel } from '@server/models/user/user'
8import { MAccountDefault, MChannelBannerAccountDefault, MUser, MVideoFullLight } from '@server/types/models'
9import { pick } from '@shared/core-utils'
10import { ActorImageType } from '@shared/models'
11
12export function initFeed (parameters: {
13 name: string
14 description: string
15 imageUrl: string
16 isPodcast: boolean
17 link?: string
18 locked?: { isLocked: boolean, email: string }
19 author?: {
20 name: string
21 link: string
22 imageUrl: string
23 }
24 person?: Person[]
25 resourceType?: 'videos' | 'video-comments'
26 queryString?: string
27 medium?: string
28 stunServers?: string[]
29 trackers?: string[]
30 customXMLNS?: CustomXMLNS[]
31 customTags?: CustomTag[]
32}) {
33 const webserverUrl = WEBSERVER.URL
34 const { name, description, link, imageUrl, isPodcast, resourceType, queryString, medium } = parameters
35
36 return new Feed({
37 title: name,
38 description: mdToOneLinePlainText(description),
39 // updated: TODO: somehowGetLatestUpdate, // optional, default = today
40 id: link || webserverUrl,
41 link: link || webserverUrl,
42 image: imageUrl,
43 favicon: webserverUrl + '/client/assets/images/favicon.png',
44 copyright: `All rights reserved, unless otherwise specified in the terms specified at ${webserverUrl}/about` +
45 ` and potential licenses granted by each content's rightholder.`,
46 generator: `Toraifōsu`, // ^.~
47 medium: medium || 'video',
48 feedLinks: {
49 json: `${webserverUrl}/feeds/${resourceType}.json${queryString}`,
50 atom: `${webserverUrl}/feeds/${resourceType}.atom${queryString}`,
51 rss: isPodcast
52 ? `${webserverUrl}/feeds/podcast/videos.xml${queryString}`
53 : `${webserverUrl}/feeds/${resourceType}.xml${queryString}`
54 },
55
56 ...pick(parameters, [ 'stunServers', 'trackers', 'customXMLNS', 'customTags', 'author', 'person', 'locked' ])
57 })
58}
59
60export function sendFeed (feed: Feed, req: express.Request, res: express.Response) {
61 const format = req.params.format
62
63 if (format === 'atom' || format === 'atom1') {
64 return res.send(feed.atom1()).end()
65 }
66
67 if (format === 'json' || format === 'json1') {
68 return res.send(feed.json1()).end()
69 }
70
71 if (format === 'rss' || format === 'rss2') {
72 return res.send(feed.rss2()).end()
73 }
74
75 // We're in the ambiguous '.xml' case and we look at the format query parameter
76 if (req.query.format === 'atom' || req.query.format === 'atom1') {
77 return res.send(feed.atom1()).end()
78 }
79
80 return res.send(feed.rss2()).end()
81}
82
83export async function buildFeedMetadata (options: {
84 videoChannel?: MChannelBannerAccountDefault
85 account?: MAccountDefault
86 video?: MVideoFullLight
87}) {
88 const { video, videoChannel, account } = options
89
90 let imageUrl = WEBSERVER.URL + '/client/assets/images/icons/icon-96x96.png'
91 let accountImageUrl: string
92 let name: string
93 let userName: string
94 let description: string
95 let email: string
96 let link: string
97 let accountLink: string
98 let user: MUser
99
100 if (videoChannel) {
101 name = videoChannel.getDisplayName()
102 description = videoChannel.description
103 link = videoChannel.getClientUrl()
104 accountLink = videoChannel.Account.getClientUrl()
105
106 if (videoChannel.Actor.hasImage(ActorImageType.AVATAR)) {
107 imageUrl = WEBSERVER.URL + videoChannel.Actor.Avatars[0].getStaticPath()
108 }
109
110 if (videoChannel.Account.Actor.hasImage(ActorImageType.AVATAR)) {
111 accountImageUrl = WEBSERVER.URL + videoChannel.Account.Actor.Avatars[0].getStaticPath()
112 }
113
114 user = await UserModel.loadById(videoChannel.Account.userId)
115 userName = videoChannel.Account.getDisplayName()
116 } else if (account) {
117 name = account.getDisplayName()
118 description = account.description
119 link = account.getClientUrl()
120 accountLink = link
121
122 if (account.Actor.hasImage(ActorImageType.AVATAR)) {
123 imageUrl = WEBSERVER.URL + account.Actor.Avatars[0].getStaticPath()
124 accountImageUrl = imageUrl
125 }
126
127 user = await UserModel.loadById(account.userId)
128 } else if (video) {
129 name = video.name
130 description = video.description
131 link = video.url
132 } else {
133 name = CONFIG.INSTANCE.NAME
134 description = CONFIG.INSTANCE.DESCRIPTION
135 link = WEBSERVER.URL
136 }
137
138 // If the user is local, has a verified email address, and allows it to be publicly displayed
139 // Return it so the owner can prove ownership of their feed
140 if (user && !user.pluginAuth && user.emailVerified && user.emailPublic) {
141 email = user.email
142 }
143
144 return { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink }
145}
diff --git a/server/controllers/feeds/shared/index.ts b/server/controllers/feeds/shared/index.ts
new file mode 100644
index 000000000..0136c8477
--- /dev/null
+++ b/server/controllers/feeds/shared/index.ts
@@ -0,0 +1,2 @@
1export * from './video-feed-utils'
2export * from './common-feed-utils'
diff --git a/server/controllers/feeds/shared/video-feed-utils.ts b/server/controllers/feeds/shared/video-feed-utils.ts
new file mode 100644
index 000000000..3175cea59
--- /dev/null
+++ b/server/controllers/feeds/shared/video-feed-utils.ts
@@ -0,0 +1,66 @@
1import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown'
2import { CONFIG } from '@server/initializers/config'
3import { WEBSERVER } from '@server/initializers/constants'
4import { getServerActor } from '@server/models/application/application'
5import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils'
6import { DisplayOnlyForFollowerOptions } from '@server/models/video/sql/video'
7import { VideoModel } from '@server/models/video/video'
8import { MThumbnail, MUserDefault } from '@server/types/models'
9import { VideoInclude } from '@shared/models'
10
11export async function getVideosForFeeds (options: {
12 sort: string
13 nsfw: boolean
14 isLocal: boolean
15 include: VideoInclude
16
17 accountId?: number
18 videoChannelId?: number
19 displayOnlyForFollower?: DisplayOnlyForFollowerOptions
20 user?: MUserDefault
21}) {
22 const server = await getServerActor()
23
24 const { data } = await VideoModel.listForApi({
25 start: 0,
26 count: CONFIG.FEEDS.VIDEOS.COUNT,
27 displayOnlyForFollower: {
28 actorId: server.id,
29 orLocalVideos: true
30 },
31 hasFiles: true,
32 countVideos: false,
33
34 ...options
35 })
36
37 return data
38}
39
40export function getCommonVideoFeedAttributes (video: VideoModel) {
41 const localLink = WEBSERVER.URL + video.getWatchStaticPath()
42
43 const thumbnailModels: MThumbnail[] = []
44 if (video.hasPreview()) thumbnailModels.push(video.getPreview())
45 thumbnailModels.push(video.getMiniature())
46
47 return {
48 title: video.name,
49 link: localLink,
50 description: mdToOneLinePlainText(video.getTruncatedDescription()),
51 content: toSafeHtml(video.description),
52
53 date: video.publishedAt,
54 nsfw: video.nsfw,
55
56 category: video.category
57 ? [ { name: getCategoryLabel(video.category) } ]
58 : undefined,
59
60 thumbnails: thumbnailModels.map(t => ({
61 url: WEBSERVER.URL + t.getLocalStaticPath(),
62 width: t.width,
63 height: t.height
64 }))
65 }
66}
diff --git a/server/controllers/feeds/video-feeds.ts b/server/controllers/feeds/video-feeds.ts
new file mode 100644
index 000000000..b6e0663eb
--- /dev/null
+++ b/server/controllers/feeds/video-feeds.ts
@@ -0,0 +1,189 @@
1import express from 'express'
2import { extname } from 'path'
3import { Feed } from '@peertube/feed'
4import { cacheRouteFactory } from '@server/middlewares'
5import { VideoModel } from '@server/models/video/video'
6import { VideoInclude } from '@shared/models'
7import { buildNSFWFilter } from '../../helpers/express-utils'
8import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
9import {
10 asyncMiddleware,
11 commonVideosFiltersValidator,
12 feedsFormatValidator,
13 setDefaultVideosSort,
14 setFeedFormatContentType,
15 videoFeedsValidator,
16 videosSortValidator,
17 videoSubscriptionFeedsValidator
18} from '../../middlewares'
19import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed, sendFeed } from './shared'
20
21const videoFeedsRouter = express.Router()
22
23const { middleware: cacheRouteMiddleware } = cacheRouteFactory({
24 headerBlacklist: [ 'Content-Type' ]
25})
26
27// ---------------------------------------------------------------------------
28
29videoFeedsRouter.get('/feeds/videos.:format',
30 videosSortValidator,
31 setDefaultVideosSort,
32 feedsFormatValidator,
33 setFeedFormatContentType,
34 cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
35 commonVideosFiltersValidator,
36 asyncMiddleware(videoFeedsValidator),
37 asyncMiddleware(generateVideoFeed)
38)
39
40videoFeedsRouter.get('/feeds/subscriptions.:format',
41 videosSortValidator,
42 setDefaultVideosSort,
43 feedsFormatValidator,
44 setFeedFormatContentType,
45 cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
46 commonVideosFiltersValidator,
47 asyncMiddleware(videoSubscriptionFeedsValidator),
48 asyncMiddleware(generateVideoFeedForSubscriptions)
49)
50
51// ---------------------------------------------------------------------------
52
53export {
54 videoFeedsRouter
55}
56
57// ---------------------------------------------------------------------------
58
59async function generateVideoFeed (req: express.Request, res: express.Response) {
60 const account = res.locals.account
61 const videoChannel = res.locals.videoChannel
62
63 const { name, description, imageUrl, accountImageUrl, link, accountLink } = await buildFeedMetadata({ videoChannel, account })
64
65 const feed = initFeed({
66 name,
67 description,
68 link,
69 isPodcast: false,
70 imageUrl,
71 author: { name, link: accountLink, imageUrl: accountImageUrl },
72 resourceType: 'videos',
73 queryString: new URL(WEBSERVER.URL + req.url).search
74 })
75
76 const data = await getVideosForFeeds({
77 sort: req.query.sort,
78 nsfw: buildNSFWFilter(res, req.query.nsfw),
79 isLocal: req.query.isLocal,
80 include: req.query.include | VideoInclude.FILES,
81 accountId: account?.id,
82 videoChannelId: videoChannel?.id
83 })
84
85 addVideosToFeed(feed, data)
86
87 // Now the feed generation is done, let's send it!
88 return sendFeed(feed, req, res)
89}
90
91async function generateVideoFeedForSubscriptions (req: express.Request, res: express.Response) {
92 const account = res.locals.account
93 const { name, description, imageUrl, link } = await buildFeedMetadata({ account })
94
95 const feed = initFeed({
96 name,
97 description,
98 link,
99 isPodcast: false,
100 imageUrl,
101 resourceType: 'videos',
102 queryString: new URL(WEBSERVER.URL + req.url).search
103 })
104
105 const data = await getVideosForFeeds({
106 sort: req.query.sort,
107 nsfw: buildNSFWFilter(res, req.query.nsfw),
108 isLocal: req.query.isLocal,
109 include: req.query.include | VideoInclude.FILES,
110 displayOnlyForFollower: {
111 actorId: res.locals.user.Account.Actor.id,
112 orLocalVideos: false
113 },
114 user: res.locals.user
115 })
116
117 addVideosToFeed(feed, data)
118
119 // Now the feed generation is done, let's send it!
120 return sendFeed(feed, req, res)
121}
122
123// ---------------------------------------------------------------------------
124
125function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
126 /**
127 * Adding video items to the feed object, one at a time
128 */
129 for (const video of videos) {
130 const formattedVideoFiles = video.getFormattedAllVideoFilesJSON(false)
131
132 const torrents = formattedVideoFiles.map(videoFile => ({
133 title: video.name,
134 url: videoFile.torrentUrl,
135 size_in_bytes: videoFile.size
136 }))
137
138 const videoFiles = formattedVideoFiles.map(videoFile => {
139 return {
140 type: MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)],
141 medium: 'video',
142 height: videoFile.resolution.id,
143 fileSize: videoFile.size,
144 url: videoFile.fileUrl,
145 framerate: videoFile.fps,
146 duration: video.duration,
147 lang: video.language
148 }
149 })
150
151 feed.addItem({
152 ...getCommonVideoFeedAttributes(video),
153
154 id: WEBSERVER.URL + video.getWatchStaticPath(),
155 author: [
156 {
157 name: video.VideoChannel.getDisplayName(),
158 link: video.VideoChannel.getClientUrl()
159 }
160 ],
161 torrents,
162
163 // Enclosure
164 video: videoFiles.length !== 0
165 ? {
166 url: videoFiles[0].url,
167 length: videoFiles[0].fileSize,
168 type: videoFiles[0].type
169 }
170 : undefined,
171
172 // Media RSS
173 videos: videoFiles,
174
175 embed: {
176 url: WEBSERVER.URL + video.getEmbedStaticPath(),
177 allowFullscreen: true
178 },
179 player: {
180 url: WEBSERVER.URL + video.getWatchStaticPath()
181 },
182 community: {
183 statistics: {
184 views: video.views
185 }
186 }
187 })
188 }
189}
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}