aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/controllers
diff options
context:
space:
mode:
Diffstat (limited to 'server/controllers')
-rw-r--r--server/controllers/api/users/me.ts3
-rw-r--r--server/controllers/api/videos/update.ts4
-rw-r--r--server/controllers/feeds.ts389
-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
10 files changed, 819 insertions, 392 deletions
diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts
index 00f580ee9..218091d91 100644
--- a/server/controllers/api/users/me.ts
+++ b/server/controllers/api/users/me.ts
@@ -212,7 +212,8 @@ async function updateMe (req: express.Request, res: express.Response) {
212 'theme', 212 'theme',
213 'noInstanceConfigWarningModal', 213 'noInstanceConfigWarningModal',
214 'noAccountSetupWarningModal', 214 'noAccountSetupWarningModal',
215 'noWelcomeModal' 215 'noWelcomeModal',
216 'emailPublic'
216 ] 217 ]
217 218
218 for (const key of keysToUpdate) { 219 for (const key of keysToUpdate) {
diff --git a/server/controllers/api/videos/update.ts b/server/controllers/api/videos/update.ts
index 5ab54a006..ddab428d4 100644
--- a/server/controllers/api/videos/update.ts
+++ b/server/controllers/api/videos/update.ts
@@ -2,10 +2,12 @@ import express from 'express'
2import { Transaction } from 'sequelize/types' 2import { Transaction } from 'sequelize/types'
3import { changeVideoChannelShare } from '@server/lib/activitypub/share' 3import { changeVideoChannelShare } from '@server/lib/activitypub/share'
4import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' 4import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
5import { VideoPathManager } from '@server/lib/video-path-manager'
5import { setVideoPrivacy } from '@server/lib/video-privacy' 6import { setVideoPrivacy } from '@server/lib/video-privacy'
6import { openapiOperationDoc } from '@server/middlewares/doc' 7import { openapiOperationDoc } from '@server/middlewares/doc'
7import { FilteredModelAttributes } from '@server/types' 8import { FilteredModelAttributes } from '@server/types'
8import { MVideoFullLight } from '@server/types/models' 9import { MVideoFullLight } from '@server/types/models'
10import { forceNumber } from '@shared/core-utils'
9import { HttpStatusCode, VideoUpdate } from '@shared/models' 11import { HttpStatusCode, VideoUpdate } from '@shared/models'
10import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 12import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
11import { resetSequelizeInstance } from '../../../helpers/database-utils' 13import { resetSequelizeInstance } from '../../../helpers/database-utils'
@@ -18,8 +20,6 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
18import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares' 20import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares'
19import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' 21import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
20import { VideoModel } from '../../../models/video/video' 22import { VideoModel } from '../../../models/video/video'
21import { VideoPathManager } from '@server/lib/video-path-manager'
22import { forceNumber } from '@shared/core-utils'
23 23
24const lTags = loggerTagsFactory('api', 'video') 24const lTags = loggerTagsFactory('api', 'video')
25const auditLogger = auditLoggerFactory('videos') 25const auditLogger = auditLoggerFactory('videos')
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 @@
1import express from 'express'
2import { extname } from 'path'
3import { Feed } from '@peertube/feed'
4import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown'
5import { getServerActor } from '@server/models/application/application'
6import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils'
7import { MAccountDefault, MChannelBannerAccountDefault, MVideoFullLight } from '@server/types/models'
8import { ActorImageType, VideoInclude } from '@shared/models'
9import { buildNSFWFilter } from '../helpers/express-utils'
10import { CONFIG } from '../initializers/config'
11import { MIMETYPES, PREVIEWS_SIZE, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../initializers/constants'
12import {
13 asyncMiddleware,
14 commonVideosFiltersValidator,
15 feedsFormatValidator,
16 setDefaultVideosSort,
17 setFeedFormatContentType,
18 videoCommentsFeedsValidator,
19 videoFeedsValidator,
20 videosSortValidator,
21 videoSubscriptionFeedsValidator
22} from '../middlewares'
23import { cacheRouteFactory } from '../middlewares/cache/cache'
24import { VideoModel } from '../models/video/video'
25import { VideoCommentModel } from '../models/video/video-comment'
26
27const feedsRouter = express.Router()
28
29const cacheRoute = cacheRouteFactory({
30 headerBlacklist: [ 'Content-Type' ]
31})
32
33feedsRouter.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
42feedsRouter.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
53feedsRouter.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
66export {
67 feedsRouter
68}
69
70// ---------------------------------------------------------------------------
71
72async 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
125async 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
169async 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
210function 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
244function 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
332function 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
355function 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}
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}