X-Git-Url: https://git.immae.eu/?a=blobdiff_plain;f=server%2Fcontrollers%2Ffeeds.ts;h=772fe734deda1ebcfb8b7639ab45cf23695010ba;hb=bd09dfaf8dcb0ca4cd5dac9f13e3117486f3bcce;hp=ba5a476e7acbc7831137d109902cd161acd010ad;hpb=1de1c112482233bd70ba53fdacbc0cfb9b816ce7;p=github%2FChocobozzz%2FPeerTube.git diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts index ba5a476e7..772fe734d 100644 --- a/server/controllers/feeds.ts +++ b/server/controllers/feeds.ts @@ -1,37 +1,66 @@ -import * as express from 'express' -import { FEEDS, ROUTE_CACHE_LIFETIME, THUMBNAILS_SIZE, WEBSERVER } from '../initializers/constants' +import express from 'express' +import { extname } from 'path' +import { Feed } from '@peertube/feed' +import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown' +import { getServerActor } from '@server/models/application/application' +import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils' +import { MAccountDefault, MChannelBannerAccountDefault, MVideoFullLight } from '@server/types/models' +import { ActorImageType, VideoInclude } from '@shared/models' +import { buildNSFWFilter } from '../helpers/express-utils' +import { CONFIG } from '../initializers/config' +import { MIMETYPES, PREVIEWS_SIZE, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../initializers/constants' import { asyncMiddleware, commonVideosFiltersValidator, - setDefaultSort, + feedsFormatValidator, + setDefaultVideosSort, + setFeedFormatContentType, videoCommentsFeedsValidator, videoFeedsValidator, - videosSortValidator + videosSortValidator, + videoSubscriptionFeedsValidator } from '../middlewares' +import { cacheRouteFactory } from '../middlewares/cache/cache' import { VideoModel } from '../models/video/video' -import * as Feed from 'pfeed' -import { cacheRoute } from '../middlewares/cache' import { VideoCommentModel } from '../models/video/video-comment' -import { buildNSFWFilter } from '../helpers/express-utils' -import { CONFIG } from '../initializers/config' const feedsRouter = express.Router() +const cacheRoute = cacheRouteFactory({ + headerBlacklist: [ 'Content-Type' ] +}) + feedsRouter.get('/feeds/video-comments.:format', - asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS)), + feedsFormatValidator, + setFeedFormatContentType, + cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS), + asyncMiddleware(videoFeedsValidator), asyncMiddleware(videoCommentsFeedsValidator), asyncMiddleware(generateVideoCommentsFeed) ) feedsRouter.get('/feeds/videos.:format', videosSortValidator, - setDefaultSort, - asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS)), + setDefaultVideosSort, + feedsFormatValidator, + setFeedFormatContentType, + cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS), commonVideosFiltersValidator, asyncMiddleware(videoFeedsValidator), asyncMiddleware(generateVideoFeed) ) +feedsRouter.get('/feeds/subscriptions.:format', + videosSortValidator, + setDefaultVideosSort, + feedsFormatValidator, + setFeedFormatContentType, + cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS), + commonVideosFiltersValidator, + asyncMiddleware(videoSubscriptionFeedsValidator), + asyncMiddleware(generateVideoFeedForSubscriptions) +) + // --------------------------------------------------------------------------- export { @@ -42,37 +71,52 @@ export { async function generateVideoCommentsFeed (req: express.Request, res: express.Response) { const start = 0 - const video = res.locals.videoAll - const videoId: number = video ? video.id : undefined + const account = res.locals.account + const videoChannel = res.locals.videoChannel - const comments = await VideoCommentModel.listForFeed(start, FEEDS.COUNT, videoId) + const comments = await VideoCommentModel.listForFeed({ + start, + count: CONFIG.FEEDS.COMMENTS.COUNT, + videoId: video ? video.id : undefined, + accountId: account ? account.id : undefined, + videoChannelId: videoChannel ? videoChannel.id : undefined + }) + + const { name, description, imageUrl } = buildFeedMetadata({ video, account, videoChannel }) - const name = video ? video.name : CONFIG.INSTANCE.NAME - const description = video ? video.description : CONFIG.INSTANCE.DESCRIPTION - const feed = initFeed(name, description) + const feed = initFeed({ + name, + description, + imageUrl, + resourceType: 'video-comments', + queryString: new URL(WEBSERVER.URL + req.originalUrl).search + }) // Adding video items to the feed, one at a time - comments.forEach(comment => { - const link = WEBSERVER.URL + comment.getCommentStaticPath() + for (const comment of comments) { + const localLink = WEBSERVER.URL + comment.getCommentStaticPath() let title = comment.Video.name - if (comment.Account) title += ` - ${comment.Account.getDisplayName()}` + const author: { name: string, link: string }[] = [] + + if (comment.Account) { + title += ` - ${comment.Account.getDisplayName()}` + author.push({ + name: comment.Account.getDisplayName(), + link: comment.Account.Actor.url + }) + } feed.addItem({ title, - id: comment.url, - link, - content: comment.text, - author: [ - { - name: comment.Account.getDisplayName(), - link: comment.Account.Actor.url - } - ], + id: localLink, + link: localLink, + content: toSafeHtml(comment.text), + author, date: comment.createdAt }) - }) + } // Now the feed generation is done, let's send it! return sendFeed(feed, req, res) @@ -80,42 +124,126 @@ async function generateVideoCommentsFeed (req: express.Request, res: express.Res async function generateVideoFeed (req: express.Request, res: express.Response) { const start = 0 - const account = res.locals.account const videoChannel = res.locals.videoChannel const nsfw = buildNSFWFilter(res, req.query.nsfw) - let name: string - let description: string + const { name, description, imageUrl } = buildFeedMetadata({ videoChannel, account }) - if (videoChannel) { - name = videoChannel.getDisplayName() - description = videoChannel.description - } else if (account) { - name = account.getDisplayName() - description = account.description - } else { - name = CONFIG.INSTANCE.NAME - description = CONFIG.INSTANCE.DESCRIPTION + const feed = initFeed({ + name, + description, + imageUrl, + resourceType: 'videos', + queryString: new URL(WEBSERVER.URL + req.url).search + }) + + const options = { + accountId: account ? account.id : null, + videoChannelId: videoChannel ? videoChannel.id : null } - const feed = initFeed(name, description) + const server = await getServerActor() + const { data } = await VideoModel.listForApi({ + start, + count: CONFIG.FEEDS.VIDEOS.COUNT, + sort: req.query.sort, + displayOnlyForFollower: { + actorId: server.id, + orLocalVideos: true + }, + nsfw, + isLocal: req.query.isLocal, + include: req.query.include | VideoInclude.FILES, + hasFiles: true, + countVideos: false, + ...options + }) + + addVideosToFeed(feed, data) - const resultList = await VideoModel.listForApi({ + // Now the feed generation is done, let's send it! + return sendFeed(feed, req, res) +} + +async function generateVideoFeedForSubscriptions (req: express.Request, res: express.Response) { + const start = 0 + const account = res.locals.account + const nsfw = buildNSFWFilter(res, req.query.nsfw) + + const { name, description, imageUrl } = buildFeedMetadata({ account }) + + const feed = initFeed({ + name, + description, + imageUrl, + resourceType: 'videos', + queryString: new URL(WEBSERVER.URL + req.url).search + }) + + const { data } = await VideoModel.listForApi({ start, - count: FEEDS.COUNT, + count: CONFIG.FEEDS.VIDEOS.COUNT, sort: req.query.sort, - includeLocalVideos: true, nsfw, - filter: req.query.filter, - withFiles: true, - accountId: account ? account.id : null, - videoChannelId: videoChannel ? videoChannel.id : null + + isLocal: req.query.isLocal, + + hasFiles: true, + include: req.query.include | VideoInclude.FILES, + + countVideos: false, + + displayOnlyForFollower: { + actorId: res.locals.user.Account.Actor.id, + orLocalVideos: false + }, + user: res.locals.user }) - // Adding video items to the feed, one at a time - resultList.data.forEach(video => { - const formattedVideoFiles = video.getFormattedVideoFilesJSON() + addVideosToFeed(feed, data) + + // Now the feed generation is done, let's send it! + return sendFeed(feed, req, res) +} + +function initFeed (parameters: { + name: string + description: string + imageUrl: string + resourceType?: 'videos' | 'video-comments' + queryString?: string +}) { + const webserverUrl = WEBSERVER.URL + const { name, description, resourceType, queryString, imageUrl } = parameters + + return new Feed({ + title: name, + description: mdToOneLinePlainText(description), + // updated: TODO: somehowGetLatestUpdate, // optional, default = today + id: webserverUrl, + link: webserverUrl, + image: imageUrl, + favicon: webserverUrl + '/client/assets/images/favicon.png', + copyright: `All rights reserved, unless otherwise specified in the terms specified at ${webserverUrl}/about` + + ` and potential licenses granted by each content's rightholder.`, + generator: `Toraifōsu`, // ^.~ + feedLinks: { + json: `${webserverUrl}/feeds/${resourceType}.json${queryString}`, + atom: `${webserverUrl}/feeds/${resourceType}.atom${queryString}`, + rss: `${webserverUrl}/feeds/${resourceType}.xml${queryString}` + }, + author: { + name: 'Instance admin of ' + CONFIG.INSTANCE.NAME, + email: CONFIG.ADMIN.EMAIL, + link: `${webserverUrl}/about` + } + }) +} + +function addVideosToFeed (feed: Feed, videos: VideoModel[]) { + for (const video of videos) { + const formattedVideoFiles = video.getFormattedVideoFilesJSON(false) const torrents = formattedVideoFiles.map(videoFile => ({ title: video.name, @@ -123,11 +251,11 @@ async function generateVideoFeed (req: express.Request, res: express.Response) { size_in_bytes: videoFile.size })) - const videos = formattedVideoFiles.map(videoFile => { + const videoFiles = formattedVideoFiles.map(videoFile => { const result = { - type: 'video/mp4', + type: MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)], medium: 'video', - height: videoFile.resolution.label.replace('p', ''), + height: videoFile.resolution.id, fileSize: videoFile.size, url: videoFile.fileUrl, framerate: videoFile.fps, @@ -143,16 +271,18 @@ async function generateVideoFeed (req: express.Request, res: express.Response) { if (video.category) { categories.push({ value: video.category, - label: VideoModel.getCategoryLabel(video.category) + label: getCategoryLabel(video.category) }) } + const localLink = WEBSERVER.URL + video.getWatchStaticPath() + feed.addItem({ title: video.name, - id: video.url, - link: WEBSERVER.URL + '/videos/watch/' + video.uuid, - description: video.getTruncatedDescription(), - content: video.description, + id: localLink, + link: localLink, + description: mdToOneLinePlainText(video.getTruncatedDescription()), + content: toSafeHtml(video.description), author: [ { name: video.VideoChannel.Account.getDisplayName(), @@ -161,14 +291,26 @@ async function generateVideoFeed (req: express.Request, res: express.Response) { ], date: video.publishedAt, nsfw: video.nsfw, - torrent: torrents, - videos, + torrents, + + // Enclosure + video: videoFiles.length !== 0 + ? { + url: videoFiles[0].url, + length: videoFiles[0].fileSize, + type: videoFiles[0].type + } + : undefined, + + // Media RSS + videos: videoFiles, + embed: { - url: video.getEmbedStaticPath(), + url: WEBSERVER.URL + video.getEmbedStaticPath(), allowFullscreen: true }, player: { - url: video.getWatchStaticPath() + url: WEBSERVER.URL + video.getWatchStaticPath() }, categories, community: { @@ -176,71 +318,72 @@ async function generateVideoFeed (req: express.Request, res: express.Response) { views: video.views } }, - thumbnail: [ + thumbnails: [ { - url: WEBSERVER.URL + video.getMiniatureStaticPath(), - height: THUMBNAILS_SIZE.height, - width: THUMBNAILS_SIZE.width + url: WEBSERVER.URL + video.getPreviewStaticPath(), + height: PREVIEWS_SIZE.height, + width: PREVIEWS_SIZE.width } ] }) - }) - - // Now the feed generation is done, let's send it! - return sendFeed(feed, req, res) -} - -function initFeed (name: string, description: string) { - const webserverUrl = WEBSERVER.URL - - return new Feed({ - title: name, - description, - // updated: TODO: somehowGetLatestUpdate, // optional, default = today - id: webserverUrl, - link: webserverUrl, - image: webserverUrl + '/client/assets/images/icons/icon-96x96.png', - favicon: webserverUrl + '/client/assets/images/favicon.png', - copyright: `All rights reserved, unless otherwise specified in the terms specified at ${webserverUrl}/about` + - ` and potential licenses granted by each content's rightholder.`, - generator: `Toraifōsu`, // ^.~ - feedLinks: { - json: `${webserverUrl}/feeds/videos.json`, - atom: `${webserverUrl}/feeds/videos.atom`, - rss: `${webserverUrl}/feeds/videos.xml` - }, - author: { - name: 'Instance admin of ' + CONFIG.INSTANCE.NAME, - email: CONFIG.ADMIN.EMAIL, - link: `${webserverUrl}/about` - } - }) + } } -function sendFeed (feed, req: express.Request, res: express.Response) { +function sendFeed (feed: Feed, req: express.Request, res: express.Response) { const format = req.params.format if (format === 'atom' || format === 'atom1') { - res.set('Content-Type', 'application/atom+xml') return res.send(feed.atom1()).end() } if (format === 'json' || format === 'json1') { - res.set('Content-Type', 'application/json') return res.send(feed.json1()).end() } if (format === 'rss' || format === 'rss2') { - res.set('Content-Type', 'application/rss+xml') return res.send(feed.rss2()).end() } // We're in the ambiguous '.xml' case and we look at the format query parameter if (req.query.format === 'atom' || req.query.format === 'atom1') { - res.set('Content-Type', 'application/atom+xml') return res.send(feed.atom1()).end() } - res.set('Content-Type', 'application/rss+xml') return res.send(feed.rss2()).end() } + +function buildFeedMetadata (options: { + videoChannel?: MChannelBannerAccountDefault + account?: MAccountDefault + video?: MVideoFullLight +}) { + const { video, videoChannel, account } = options + + let imageUrl = WEBSERVER.URL + '/client/assets/images/icons/icon-96x96.png' + let name: string + let description: string + + if (videoChannel) { + name = videoChannel.getDisplayName() + description = videoChannel.description + + if (videoChannel.Actor.hasImage(ActorImageType.AVATAR)) { + imageUrl = WEBSERVER.URL + videoChannel.Actor.Avatars[0].getStaticPath() + } + } else if (account) { + name = account.getDisplayName() + description = account.description + + if (account.Actor.hasImage(ActorImageType.AVATAR)) { + imageUrl = WEBSERVER.URL + account.Actor.Avatars[0].getStaticPath() + } + } else if (video) { + name = video.name + description = video.description + } else { + name = CONFIG.INSTANCE.NAME + description = CONFIG.INSTANCE.DESCRIPTION + } + + return { name, description, imageUrl } +}