diff options
author | Rigel Kent <sendmemail@rigelk.eu> | 2018-04-17 00:49:04 +0200 |
---|---|---|
committer | Rigel <sendmemail@rigelk.eu> | 2018-04-17 01:09:06 +0200 |
commit | 244e76a552ef05a5067134b1065d26dd89246d8c (patch) | |
tree | a15fcd52bce99797fc9366572fea62a7a44aaabe /server | |
parent | c36d5a6b98056ef7fec3db43fbee880ee7332dcf (diff) | |
download | PeerTube-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.ts | 136 | ||||
-rw-r--r-- | server/controllers/index.ts | 5 | ||||
-rw-r--r-- | server/helpers/custom-validators/feeds.ts | 23 | ||||
-rw-r--r-- | server/middlewares/validators/feeds.ts | 35 | ||||
-rw-r--r-- | server/middlewares/validators/index.ts | 1 | ||||
-rw-r--r-- | server/models/account/account.ts | 6 | ||||
-rw-r--r-- | server/models/video/video.ts | 166 |
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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { CONFIG } from '../initializers' | ||
3 | import { asyncMiddleware, feedsValidator } from '../middlewares' | ||
4 | import { VideoModel } from '../models/video/video' | ||
5 | import * as Feed from 'pfeed' | ||
6 | import { ResultList } from '../../shared/models' | ||
7 | import { AccountModel } from '../models/account/account' | ||
8 | |||
9 | const feedsRouter = express.Router() | ||
10 | |||
11 | feedsRouter.get('/feeds/videos.:format', | ||
12 | asyncMiddleware(feedsValidator), | ||
13 | asyncMiddleware(generateFeed) | ||
14 | ) | ||
15 | |||
16 | // --------------------------------------------------------------------------- | ||
17 | |||
18 | export { | ||
19 | feedsRouter | ||
20 | } | ||
21 | |||
22 | // --------------------------------------------------------------------------- | ||
23 | |||
24 | async 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 | |||
83 | function 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 | |||
110 | function 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 @@ | |||
1 | export * from './activitypub' | 1 | export * from './activitypub' |
2 | export * from './static' | 2 | export * from './api' |
3 | export * from './client' | 3 | export * from './client' |
4 | export * from './feeds' | ||
4 | export * from './services' | 5 | export * from './services' |
5 | export * from './api' | 6 | export * from './static' |
6 | export * from './webfinger' | 7 | export * 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 @@ | |||
1 | import { exists } from './misc' | ||
2 | |||
3 | function 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 | |||
21 | export { | ||
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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { param, query } from 'express-validator/check' | ||
3 | import { isAccountIdExist, isAccountNameValid, isLocalAccountNameExist } from '../../helpers/custom-validators/accounts' | ||
4 | import { join } from 'path' | ||
5 | import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' | ||
6 | import { logger } from '../../helpers/logger' | ||
7 | import { areValidationErrors } from './utils' | ||
8 | import { isValidRSSFeed } from '../../helpers/custom-validators/feeds' | ||
9 | |||
10 | const 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 | |||
33 | export { | ||
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' | |||
3 | export * from './activitypub' | 3 | export * from './activitypub' |
4 | export * from './pagination' | 4 | export * from './pagination' |
5 | export * from './follows' | 5 | export * from './follows' |
6 | export * from './feeds' | ||
6 | export * from './sort' | 7 | export * from './sort' |
7 | export * from './users' | 8 | export * from './users' |
8 | export * from './videos' | 9 | export * 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 { |