feeds.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  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. const feedsRouter = express.Router()
  27. const cacheRoute = cacheRouteFactory({
  28. headerBlacklist: [ 'Content-Type' ]
  29. })
  30. feedsRouter.get('/feeds/video-comments.:format',
  31. feedsFormatValidator,
  32. setFeedFormatContentType,
  33. cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS),
  34. asyncMiddleware(videoFeedsValidator),
  35. asyncMiddleware(videoCommentsFeedsValidator),
  36. asyncMiddleware(generateVideoCommentsFeed)
  37. )
  38. feedsRouter.get('/feeds/videos.:format',
  39. videosSortValidator,
  40. setDefaultVideosSort,
  41. feedsFormatValidator,
  42. setFeedFormatContentType,
  43. cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS),
  44. commonVideosFiltersValidator,
  45. asyncMiddleware(videoFeedsValidator),
  46. asyncMiddleware(generateVideoFeed)
  47. )
  48. feedsRouter.get('/feeds/subscriptions.:format',
  49. videosSortValidator,
  50. setDefaultVideosSort,
  51. feedsFormatValidator,
  52. setFeedFormatContentType,
  53. cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS),
  54. commonVideosFiltersValidator,
  55. asyncMiddleware(videoSubscriptionFeedsValidator),
  56. asyncMiddleware(generateVideoFeedForSubscriptions)
  57. )
  58. // ---------------------------------------------------------------------------
  59. export {
  60. feedsRouter
  61. }
  62. // ---------------------------------------------------------------------------
  63. async function generateVideoCommentsFeed (req: express.Request, res: express.Response) {
  64. const start = 0
  65. const video = res.locals.videoAll
  66. const account = res.locals.account
  67. const videoChannel = res.locals.videoChannel
  68. const comments = await VideoCommentModel.listForFeed({
  69. start,
  70. count: CONFIG.FEEDS.COMMENTS.COUNT,
  71. videoId: video ? video.id : undefined,
  72. accountId: account ? account.id : undefined,
  73. videoChannelId: videoChannel ? videoChannel.id : undefined
  74. })
  75. const { name, description, imageUrl } = buildFeedMetadata({ video, account, videoChannel })
  76. const feed = initFeed({
  77. name,
  78. description,
  79. imageUrl,
  80. resourceType: 'video-comments',
  81. queryString: new URL(WEBSERVER.URL + req.originalUrl).search
  82. })
  83. // Adding video items to the feed, one at a time
  84. for (const comment of comments) {
  85. const localLink = WEBSERVER.URL + comment.getCommentStaticPath()
  86. let title = comment.Video.name
  87. const author: { name: string, link: string }[] = []
  88. if (comment.Account) {
  89. title += ` - ${comment.Account.getDisplayName()}`
  90. author.push({
  91. name: comment.Account.getDisplayName(),
  92. link: comment.Account.Actor.url
  93. })
  94. }
  95. feed.addItem({
  96. title,
  97. id: localLink,
  98. link: localLink,
  99. content: toSafeHtml(comment.text),
  100. author,
  101. date: comment.createdAt
  102. })
  103. }
  104. // Now the feed generation is done, let's send it!
  105. return sendFeed(feed, req, res)
  106. }
  107. async function generateVideoFeed (req: express.Request, res: express.Response) {
  108. const start = 0
  109. const account = res.locals.account
  110. const videoChannel = res.locals.videoChannel
  111. const nsfw = buildNSFWFilter(res, req.query.nsfw)
  112. const { name, description, imageUrl } = buildFeedMetadata({ videoChannel, account })
  113. const feed = initFeed({
  114. name,
  115. description,
  116. imageUrl,
  117. resourceType: 'videos',
  118. queryString: new URL(WEBSERVER.URL + req.url).search
  119. })
  120. const options = {
  121. accountId: account ? account.id : null,
  122. videoChannelId: videoChannel ? videoChannel.id : null
  123. }
  124. const server = await getServerActor()
  125. const { data } = await VideoModel.listForApi({
  126. start,
  127. count: CONFIG.FEEDS.VIDEOS.COUNT,
  128. sort: req.query.sort,
  129. displayOnlyForFollower: {
  130. actorId: server.id,
  131. orLocalVideos: true
  132. },
  133. nsfw,
  134. isLocal: req.query.isLocal,
  135. include: req.query.include | VideoInclude.FILES,
  136. hasFiles: true,
  137. countVideos: false,
  138. ...options
  139. })
  140. addVideosToFeed(feed, data)
  141. // Now the feed generation is done, let's send it!
  142. return sendFeed(feed, req, res)
  143. }
  144. async function generateVideoFeedForSubscriptions (req: express.Request, res: express.Response) {
  145. const start = 0
  146. const account = res.locals.account
  147. const nsfw = buildNSFWFilter(res, req.query.nsfw)
  148. const { name, description, imageUrl } = buildFeedMetadata({ account })
  149. const feed = initFeed({
  150. name,
  151. description,
  152. imageUrl,
  153. resourceType: 'videos',
  154. queryString: new URL(WEBSERVER.URL + req.url).search
  155. })
  156. const { data } = await VideoModel.listForApi({
  157. start,
  158. count: CONFIG.FEEDS.VIDEOS.COUNT,
  159. sort: req.query.sort,
  160. nsfw,
  161. isLocal: req.query.isLocal,
  162. hasFiles: true,
  163. include: req.query.include | VideoInclude.FILES,
  164. countVideos: false,
  165. displayOnlyForFollower: {
  166. actorId: res.locals.user.Account.Actor.id,
  167. orLocalVideos: false
  168. },
  169. user: res.locals.user
  170. })
  171. addVideosToFeed(feed, data)
  172. // Now the feed generation is done, let's send it!
  173. return sendFeed(feed, req, res)
  174. }
  175. function initFeed (parameters: {
  176. name: string
  177. description: string
  178. imageUrl: string
  179. resourceType?: 'videos' | 'video-comments'
  180. queryString?: string
  181. }) {
  182. const webserverUrl = WEBSERVER.URL
  183. const { name, description, resourceType, queryString, imageUrl } = parameters
  184. return new Feed({
  185. title: name,
  186. description: mdToOneLinePlainText(description),
  187. // updated: TODO: somehowGetLatestUpdate, // optional, default = today
  188. id: webserverUrl,
  189. link: webserverUrl,
  190. image: imageUrl,
  191. favicon: webserverUrl + '/client/assets/images/favicon.png',
  192. copyright: `All rights reserved, unless otherwise specified in the terms specified at ${webserverUrl}/about` +
  193. ` and potential licenses granted by each content's rightholder.`,
  194. generator: `Toraifōsu`, // ^.~
  195. feedLinks: {
  196. json: `${webserverUrl}/feeds/${resourceType}.json${queryString}`,
  197. atom: `${webserverUrl}/feeds/${resourceType}.atom${queryString}`,
  198. rss: `${webserverUrl}/feeds/${resourceType}.xml${queryString}`
  199. },
  200. author: {
  201. name: 'Instance admin of ' + CONFIG.INSTANCE.NAME,
  202. email: CONFIG.ADMIN.EMAIL,
  203. link: `${webserverUrl}/about`
  204. }
  205. })
  206. }
  207. function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
  208. for (const video of videos) {
  209. const formattedVideoFiles = video.getFormattedVideoFilesJSON(false)
  210. const torrents = formattedVideoFiles.map(videoFile => ({
  211. title: video.name,
  212. url: videoFile.torrentUrl,
  213. size_in_bytes: videoFile.size
  214. }))
  215. const videoFiles = formattedVideoFiles.map(videoFile => {
  216. const result = {
  217. type: MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)],
  218. medium: 'video',
  219. height: videoFile.resolution.id,
  220. fileSize: videoFile.size,
  221. url: videoFile.fileUrl,
  222. framerate: videoFile.fps,
  223. duration: video.duration
  224. }
  225. if (video.language) Object.assign(result, { lang: video.language })
  226. return result
  227. })
  228. const categories: { value: number, label: string }[] = []
  229. if (video.category) {
  230. categories.push({
  231. value: video.category,
  232. label: getCategoryLabel(video.category)
  233. })
  234. }
  235. const localLink = WEBSERVER.URL + video.getWatchStaticPath()
  236. feed.addItem({
  237. title: video.name,
  238. id: localLink,
  239. link: localLink,
  240. description: mdToOneLinePlainText(video.getTruncatedDescription()),
  241. content: toSafeHtml(video.description),
  242. author: [
  243. {
  244. name: video.VideoChannel.getDisplayName(),
  245. link: video.VideoChannel.Actor.url
  246. }
  247. ],
  248. date: video.publishedAt,
  249. nsfw: video.nsfw,
  250. torrents,
  251. // Enclosure
  252. video: videoFiles.length !== 0
  253. ? {
  254. url: videoFiles[0].url,
  255. length: videoFiles[0].fileSize,
  256. type: videoFiles[0].type
  257. }
  258. : undefined,
  259. // Media RSS
  260. videos: videoFiles,
  261. embed: {
  262. url: WEBSERVER.URL + video.getEmbedStaticPath(),
  263. allowFullscreen: true
  264. },
  265. player: {
  266. url: WEBSERVER.URL + video.getWatchStaticPath()
  267. },
  268. categories,
  269. community: {
  270. statistics: {
  271. views: video.views
  272. }
  273. },
  274. thumbnails: [
  275. {
  276. url: WEBSERVER.URL + video.getPreviewStaticPath(),
  277. height: PREVIEWS_SIZE.height,
  278. width: PREVIEWS_SIZE.width
  279. }
  280. ]
  281. })
  282. }
  283. }
  284. function sendFeed (feed: Feed, req: express.Request, res: express.Response) {
  285. const format = req.params.format
  286. if (format === 'atom' || format === 'atom1') {
  287. return res.send(feed.atom1()).end()
  288. }
  289. if (format === 'json' || format === 'json1') {
  290. return res.send(feed.json1()).end()
  291. }
  292. if (format === 'rss' || format === 'rss2') {
  293. return res.send(feed.rss2()).end()
  294. }
  295. // We're in the ambiguous '.xml' case and we look at the format query parameter
  296. if (req.query.format === 'atom' || req.query.format === 'atom1') {
  297. return res.send(feed.atom1()).end()
  298. }
  299. return res.send(feed.rss2()).end()
  300. }
  301. function buildFeedMetadata (options: {
  302. videoChannel?: MChannelBannerAccountDefault
  303. account?: MAccountDefault
  304. video?: MVideoFullLight
  305. }) {
  306. const { video, videoChannel, account } = options
  307. let imageUrl = WEBSERVER.URL + '/client/assets/images/icons/icon-96x96.png'
  308. let name: string
  309. let description: string
  310. if (videoChannel) {
  311. name = videoChannel.getDisplayName()
  312. description = videoChannel.description
  313. if (videoChannel.Actor.hasImage(ActorImageType.AVATAR)) {
  314. imageUrl = WEBSERVER.URL + videoChannel.Actor.Avatars[0].getStaticPath()
  315. }
  316. } else if (account) {
  317. name = account.getDisplayName()
  318. description = account.description
  319. if (account.Actor.hasImage(ActorImageType.AVATAR)) {
  320. imageUrl = WEBSERVER.URL + account.Actor.Avatars[0].getStaticPath()
  321. }
  322. } else if (video) {
  323. name = video.name
  324. description = video.description
  325. } else {
  326. name = CONFIG.INSTANCE.NAME
  327. description = CONFIG.INSTANCE.DESCRIPTION
  328. }
  329. return { name, description, imageUrl }
  330. }