aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
authorRigel Kent <sendmemail@rigelk.eu>2018-04-17 00:49:04 +0200
committerRigel <sendmemail@rigelk.eu>2018-04-17 01:09:06 +0200
commit244e76a552ef05a5067134b1065d26dd89246d8c (patch)
treea15fcd52bce99797fc9366572fea62a7a44aaabe /server
parentc36d5a6b98056ef7fec3db43fbee880ee7332dcf (diff)
downloadPeerTube-244e76a552ef05a5067134b1065d26dd89246d8c.tar.gz
PeerTube-244e76a552ef05a5067134b1065d26dd89246d8c.tar.zst
PeerTube-244e76a552ef05a5067134b1065d26dd89246d8c.zip
feature: initial syndication feeds support
Provides rss 2.0, atom 1.0 and json 1.0 feeds for videos (instance and account-wide) on listings and video-watch views. * still lacks redis caching * still lacks lastBuildDate support * still lacks channel-wide support * still lacks semantic annotation (for licenses, NSFW warnings, etc.) * still lacks love ( ˘ ³˘) * RSS: has MRSS support for torrent lists! * RSS: includes the first torrent in an enclosure * JSON: lists all torrents in the 'attachments' object * ATOM: lacking torrent listing support Advances #23 Partial implementation for the accountId generation in the client, which will need a hotfix to add a way to get the proper account id.
Diffstat (limited to 'server')
-rw-r--r--server/controllers/feeds.ts136
-rw-r--r--server/controllers/index.ts5
-rw-r--r--server/helpers/custom-validators/feeds.ts23
-rw-r--r--server/middlewares/validators/feeds.ts35
-rw-r--r--server/middlewares/validators/index.ts1
-rw-r--r--server/models/account/account.ts6
-rw-r--r--server/models/video/video.ts166
7 files changed, 298 insertions, 74 deletions
diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts
new file mode 100644
index 000000000..b9d4c5d50
--- /dev/null
+++ b/server/controllers/feeds.ts
@@ -0,0 +1,136 @@
1import * as express from 'express'
2import { CONFIG } from '../initializers'
3import { asyncMiddleware, feedsValidator } from '../middlewares'
4import { VideoModel } from '../models/video/video'
5import * as Feed from 'pfeed'
6import { ResultList } from '../../shared/models'
7import { AccountModel } from '../models/account/account'
8
9const feedsRouter = express.Router()
10
11feedsRouter.get('/feeds/videos.:format',
12 asyncMiddleware(feedsValidator),
13 asyncMiddleware(generateFeed)
14)
15
16// ---------------------------------------------------------------------------
17
18export {
19 feedsRouter
20}
21
22// ---------------------------------------------------------------------------
23
24async function generateFeed (req: express.Request, res: express.Response, next: express.NextFunction) {
25 let feed = initFeed()
26 let feedStart = 0
27 let feedCount = 10
28 let feedSort = '-createdAt'
29
30 let resultList: ResultList<VideoModel>
31 const account: AccountModel = res.locals.account
32
33 if (account) {
34 resultList = await VideoModel.listUserVideosForApi(
35 account.id,
36 feedStart,
37 feedCount,
38 feedSort,
39 true
40 )
41 } else {
42 resultList = await VideoModel.listForApi(
43 feedStart,
44 feedCount,
45 feedSort,
46 req.query.filter,
47 true
48 )
49 }
50
51 // Adding video items to the feed, one at a time
52 resultList.data.forEach(video => {
53 const formattedVideoFiles = video.getFormattedVideoFilesJSON()
54 const torrents = formattedVideoFiles.map(videoFile => ({
55 title: video.name,
56 url: videoFile.torrentUrl,
57 size_in_bytes: videoFile.size
58 }))
59
60 feed.addItem({
61 title: video.name,
62 id: video.url,
63 link: video.url,
64 description: video.getTruncatedDescription(),
65 content: video.description,
66 author: [
67 {
68 name: video.VideoChannel.Account.getDisplayName(),
69 link: video.VideoChannel.Account.Actor.url
70 }
71 ],
72 date: video.publishedAt,
73 language: video.language,
74 nsfw: video.nsfw,
75 torrent: torrents
76 })
77 })
78
79 // Now the feed generation is done, let's send it!
80 return sendFeed(feed, req, res)
81}
82
83function initFeed () {
84 const webserverUrl = CONFIG.WEBSERVER.URL
85
86 return new Feed({
87 title: CONFIG.INSTANCE.NAME,
88 description: CONFIG.INSTANCE.SHORT_DESCRIPTION,
89 // updated: TODO: somehowGetLatestUpdate, // optional, default = today
90 id: webserverUrl,
91 link: webserverUrl,
92 image: webserverUrl + '/client/assets/images/icons/icon-96x96.png',
93 favicon: webserverUrl + '/client/assets/images/favicon.png',
94 copyright: `All rights reserved, unless otherwise specified in the terms specified at ${webserverUrl}/about` +
95 ` and potential licenses granted by each content's rightholder.`,
96 generator: `Toraifōsu`, // ^.~
97 feedLinks: {
98 json: `${webserverUrl}/feeds/videos.json`,
99 atom: `${webserverUrl}/feeds/videos.atom`,
100 rss: `${webserverUrl}/feeds/videos.xml`
101 },
102 author: {
103 name: 'instance admin of ' + CONFIG.INSTANCE.NAME,
104 email: CONFIG.ADMIN.EMAIL,
105 link: `${webserverUrl}/about`
106 }
107 })
108}
109
110function sendFeed (feed, req: express.Request, res: express.Response) {
111 const format = req.params.format
112
113 if (format === 'atom' || format === 'atom1') {
114 res.set('Content-Type', 'application/atom+xml')
115 return res.send(feed.atom1()).end()
116 }
117
118 if (format === 'json' || format === 'json1') {
119 res.set('Content-Type', 'application/json')
120 return res.send(feed.json1()).end()
121 }
122
123 if (format === 'rss' || format === 'rss2') {
124 res.set('Content-Type', 'application/rss+xml')
125 return res.send(feed.rss2()).end()
126 }
127
128 // We're in the ambiguous '.xml' case and we look at the format query parameter
129 if (req.query.format === 'atom' || req.query.format === 'atom1') {
130 res.set('Content-Type', 'application/atom+xml')
131 return res.send(feed.atom1()).end()
132 }
133
134 res.set('Content-Type', 'application/rss+xml')
135 return res.send(feed.rss2()).end()
136}
diff --git a/server/controllers/index.ts b/server/controllers/index.ts
index 457d0a12e..ff7928312 100644
--- a/server/controllers/index.ts
+++ b/server/controllers/index.ts
@@ -1,6 +1,7 @@
1export * from './activitypub' 1export * from './activitypub'
2export * from './static' 2export * from './api'
3export * from './client' 3export * from './client'
4export * from './feeds'
4export * from './services' 5export * from './services'
5export * from './api' 6export * from './static'
6export * from './webfinger' 7export * from './webfinger'
diff --git a/server/helpers/custom-validators/feeds.ts b/server/helpers/custom-validators/feeds.ts
new file mode 100644
index 000000000..638e814f0
--- /dev/null
+++ b/server/helpers/custom-validators/feeds.ts
@@ -0,0 +1,23 @@
1import { exists } from './misc'
2
3function isValidRSSFeed (value: string) {
4 if (!exists(value)) return false
5
6 const feedExtensions = [
7 'xml',
8 'json',
9 'json1',
10 'rss',
11 'rss2',
12 'atom',
13 'atom1'
14 ]
15
16 return feedExtensions.indexOf(value) !== -1
17}
18
19// ---------------------------------------------------------------------------
20
21export {
22 isValidRSSFeed
23}
diff --git a/server/middlewares/validators/feeds.ts b/server/middlewares/validators/feeds.ts
new file mode 100644
index 000000000..6a8cfce86
--- /dev/null
+++ b/server/middlewares/validators/feeds.ts
@@ -0,0 +1,35 @@
1import * as express from 'express'
2import { param, query } from 'express-validator/check'
3import { isAccountIdExist, isAccountNameValid, isLocalAccountNameExist } from '../../helpers/custom-validators/accounts'
4import { join } from 'path'
5import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
6import { logger } from '../../helpers/logger'
7import { areValidationErrors } from './utils'
8import { isValidRSSFeed } from '../../helpers/custom-validators/feeds'
9
10const feedsValidator = [
11 param('format').optional().custom(isValidRSSFeed).withMessage('Should have a valid format (rss, atom, json)'),
12 query('format').optional().custom(isValidRSSFeed).withMessage('Should have a valid format (rss, atom, json)'),
13 query('accountId').optional().custom(isIdOrUUIDValid),
14 query('accountName').optional().custom(isAccountNameValid),
15
16 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
17 logger.debug('Checking feeds parameters', { parameters: req.query })
18
19 if (areValidationErrors(req, res)) return
20
21 if (req.query.accountId) {
22 if (!await isAccountIdExist(req.query.accountId, res)) return
23 } else if (req.query.accountName) {
24 if (!await isLocalAccountNameExist(req.query.accountName, res)) return
25 }
26
27 return next()
28 }
29]
30
31// ---------------------------------------------------------------------------
32
33export {
34 feedsValidator
35}
diff --git a/server/middlewares/validators/index.ts b/server/middlewares/validators/index.ts
index 9840e8f65..b69e1f14b 100644
--- a/server/middlewares/validators/index.ts
+++ b/server/middlewares/validators/index.ts
@@ -3,6 +3,7 @@ export * from './oembed'
3export * from './activitypub' 3export * from './activitypub'
4export * from './pagination' 4export * from './pagination'
5export * from './follows' 5export * from './follows'
6export * from './feeds'
6export * from './sort' 7export * from './sort'
7export * from './users' 8export * from './users'
8export * from './videos' 9export * from './videos'
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index c5955ef3b..3ff59887d 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -246,7 +246,7 @@ export class AccountModel extends Model<AccountModel> {
246 const actor = this.Actor.toFormattedJSON() 246 const actor = this.Actor.toFormattedJSON()
247 const account = { 247 const account = {
248 id: this.id, 248 id: this.id,
249 displayName: this.name, 249 displayName: this.getDisplayName(),
250 description: this.description, 250 description: this.description,
251 createdAt: this.createdAt, 251 createdAt: this.createdAt,
252 updatedAt: this.updatedAt 252 updatedAt: this.updatedAt
@@ -266,4 +266,8 @@ export class AccountModel extends Model<AccountModel> {
266 isOwned () { 266 isOwned () {
267 return this.Actor.isOwned() 267 return this.Actor.isOwned()
268 } 268 }
269
270 getDisplayName () {
271 return this.name
272 }
269} 273}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 54fe54535..240a2b5a2 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -95,14 +95,15 @@ enum ScopeNames {
95} 95}
96 96
97@Scopes({ 97@Scopes({
98 [ScopeNames.AVAILABLE_FOR_LIST]: (actorId: number, filter?: VideoFilter) => ({ 98 [ScopeNames.AVAILABLE_FOR_LIST]: (actorId: number, filter?: VideoFilter, withFiles?: boolean) => {
99 where: { 99 const query: IFindOptions<VideoModel> = {
100 id: { 100 where: {
101 [Sequelize.Op.notIn]: Sequelize.literal( 101 id: {
102 '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")' 102 [Sequelize.Op.notIn]: Sequelize.literal(
103 ), 103 '(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
104 [ Sequelize.Op.in ]: Sequelize.literal( 104 ),
105 '(' + 105 [ Sequelize.Op.in ]: Sequelize.literal(
106 '(' +
106 'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' + 107 'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' +
107 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' + 108 'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
108 'WHERE "actorFollow"."actorId" = ' + parseInt(actorId.toString(), 10) + 109 'WHERE "actorFollow"."actorId" = ' + parseInt(actorId.toString(), 10) +
@@ -113,45 +114,55 @@ enum ScopeNames {
113 'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' + 114 'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
114 'LEFT JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' + 115 'LEFT JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' +
115 'WHERE "actor"."serverId" IS NULL OR "actorFollow"."actorId" = ' + parseInt(actorId.toString(), 10) + 116 'WHERE "actor"."serverId" IS NULL OR "actorFollow"."actorId" = ' + parseInt(actorId.toString(), 10) +
116 ')' 117 ')'
117 ) 118 )
119 },
120 privacy: VideoPrivacy.PUBLIC
118 }, 121 },
119 privacy: VideoPrivacy.PUBLIC 122 include: [
120 }, 123 {
121 include: [ 124 attributes: [ 'name', 'description' ],
122 { 125 model: VideoChannelModel.unscoped(),
123 attributes: [ 'name', 'description' ], 126 required: true,
124 model: VideoChannelModel.unscoped(), 127 include: [
125 required: true, 128 {
126 include: [ 129 attributes: [ 'name' ],
127 { 130 model: AccountModel.unscoped(),
128 attributes: [ 'name' ], 131 required: true,
129 model: AccountModel.unscoped(), 132 include: [
130 required: true, 133 {
131 include: [ 134 attributes: [ 'preferredUsername', 'url', 'serverId', 'avatarId' ],
132 { 135 model: ActorModel.unscoped(),
133 attributes: [ 'preferredUsername', 'url', 'serverId' ], 136 required: true,
134 model: ActorModel.unscoped(), 137 where: VideoModel.buildActorWhereWithFilter(filter),
135 required: true, 138 include: [
136 where: VideoModel.buildActorWhereWithFilter(filter), 139 {
137 include: [ 140 attributes: [ 'host' ],
138 { 141 model: ServerModel.unscoped(),
139 attributes: [ 'host' ], 142 required: false
140 model: ServerModel.unscoped(), 143 },
141 required: false 144 {
142 }, 145 model: AvatarModel.unscoped(),
143 { 146 required: false
144 model: AvatarModel.unscoped(), 147 }
145 required: false 148 ]
146 } 149 }
147 ] 150 ]
148 } 151 }
149 ] 152 ]
150 } 153 }
151 ] 154 ]
152 } 155 }
153 ] 156
154 }), 157 if (withFiles === true) {
158 query.include.push({
159 model: VideoFileModel.unscoped(),
160 required: true
161 })
162 }
163
164 return query
165 },
155 [ScopeNames.WITH_ACCOUNT_DETAILS]: { 166 [ScopeNames.WITH_ACCOUNT_DETAILS]: {
156 include: [ 167 include: [
157 { 168 {
@@ -629,8 +640,8 @@ export class VideoModel extends Model<VideoModel> {
629 }) 640 })
630 } 641 }
631 642
632 static listUserVideosForApi (userId: number, start: number, count: number, sort: string) { 643 static listUserVideosForApi (userId: number, start: number, count: number, sort: string, withFiles = false) {
633 const query = { 644 const query: IFindOptions<VideoModel> = {
634 offset: start, 645 offset: start,
635 limit: count, 646 limit: count,
636 order: getSort(sort), 647 order: getSort(sort),
@@ -651,6 +662,13 @@ export class VideoModel extends Model<VideoModel> {
651 ] 662 ]
652 } 663 }
653 664
665 if (withFiles === true) {
666 query.include.push({
667 model: VideoFileModel.unscoped(),
668 required: true
669 })
670 }
671
654 return VideoModel.findAndCountAll(query).then(({ rows, count }) => { 672 return VideoModel.findAndCountAll(query).then(({ rows, count }) => {
655 return { 673 return {
656 data: rows, 674 data: rows,
@@ -659,7 +677,7 @@ export class VideoModel extends Model<VideoModel> {
659 }) 677 })
660 } 678 }
661 679
662 static async listForApi (start: number, count: number, sort: string, filter?: VideoFilter) { 680 static async listForApi (start: number, count: number, sort: string, filter?: VideoFilter, withFiles = false) {
663 const query = { 681 const query = {
664 offset: start, 682 offset: start,
665 limit: count, 683 limit: count,
@@ -668,7 +686,7 @@ export class VideoModel extends Model<VideoModel> {
668 686
669 const serverActor = await getServerActor() 687 const serverActor = await getServerActor()
670 688
671 return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST, serverActor.id, filter ] }) 689 return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST, serverActor.id, filter, withFiles ] })
672 .findAndCountAll(query) 690 .findAndCountAll(query)
673 .then(({ rows, count }) => { 691 .then(({ rows, count }) => {
674 return { 692 return {
@@ -707,7 +725,8 @@ export class VideoModel extends Model<VideoModel> {
707 const serverActor = await getServerActor() 725 const serverActor = await getServerActor()
708 726
709 return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST, serverActor.id ] }) 727 return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST, serverActor.id ] })
710 .findAndCountAll(query).then(({ rows, count }) => { 728 .findAndCountAll(query)
729 .then(({ rows, count }) => {
711 return { 730 return {
712 data: rows, 731 data: rows,
713 total: count 732 total: count
@@ -1006,29 +1025,34 @@ export class VideoModel extends Model<VideoModel> {
1006 } 1025 }
1007 1026
1008 // Format and sort video files 1027 // Format and sort video files
1028 detailsJson.files = this.getFormattedVideoFilesJSON()
1029
1030 return Object.assign(formattedJson, detailsJson)
1031 }
1032
1033 getFormattedVideoFilesJSON (): VideoFile[] {
1009 const { baseUrlHttp, baseUrlWs } = this.getBaseUrls() 1034 const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
1010 detailsJson.files = this.VideoFiles
1011 .map(videoFile => {
1012 let resolutionLabel = videoFile.resolution + 'p'
1013 1035
1014 return { 1036 return this.VideoFiles
1015 resolution: { 1037 .map(videoFile => {
1016 id: videoFile.resolution, 1038 let resolutionLabel = videoFile.resolution + 'p'
1017 label: resolutionLabel
1018 },
1019 magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
1020 size: videoFile.size,
1021 torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
1022 fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp)
1023 } as VideoFile
1024 })
1025 .sort((a, b) => {
1026 if (a.resolution.id < b.resolution.id) return 1
1027 if (a.resolution.id === b.resolution.id) return 0
1028 return -1
1029 })
1030 1039
1031 return Object.assign(formattedJson, detailsJson) 1040 return {
1041 resolution: {
1042 id: videoFile.resolution,
1043 label: resolutionLabel
1044 },
1045 magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
1046 size: videoFile.size,
1047 torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
1048 fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp)
1049 } as VideoFile
1050 })
1051 .sort((a, b) => {
1052 if (a.resolution.id < b.resolution.id) return 1
1053 if (a.resolution.id === b.resolution.id) return 0
1054 return -1
1055 })
1032 } 1056 }
1033 1057
1034 toActivityPubObject (): VideoTorrentObject { 1058 toActivityPubObject (): VideoTorrentObject {