aboutsummaryrefslogtreecommitdiffhomepage
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
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.
-rw-r--r--client/src/app/account/account-videos/account-videos.component.ts6
-rw-r--r--client/src/app/shared/misc/object-length.pipe.ts8
-rw-r--r--client/src/app/shared/shared.module.ts9
-rw-r--r--client/src/app/shared/video/abstract-video-list.html2
-rw-r--r--client/src/app/shared/video/abstract-video-list.scss7
-rw-r--r--client/src/app/shared/video/abstract-video-list.ts6
-rw-r--r--client/src/app/shared/video/video-feed.component.html14
-rw-r--r--client/src/app/shared/video/video-feed.component.scss19
-rw-r--r--client/src/app/shared/video/video-feed.component.ts14
-rw-r--r--client/src/app/shared/video/video.service.ts43
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.html1
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.scss5
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.ts23
-rw-r--r--client/src/app/videos/video-list/video-local.component.ts11
-rw-r--r--client/src/app/videos/video-list/video-recently-added.component.ts10
-rw-r--r--client/src/app/videos/video-list/video-search.component.ts5
-rw-r--r--client/src/app/videos/video-list/video-trending.component.ts10
-rw-r--r--client/src/assets/images/global/syndication.svg58
-rw-r--r--client/src/sass/include/_bootstrap.scss2
-rw-r--r--package.json1
-rw-r--r--server.ts13
-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
-rw-r--r--shared/models/feeds/feed-format.enum.ts5
-rw-r--r--shared/models/feeds/index.ts1
-rw-r--r--shared/models/index.ts1
-rw-r--r--support/doc/api/openapi.yaml32
-rw-r--r--yarn.lock10
33 files changed, 606 insertions, 82 deletions
diff --git a/client/src/app/account/account-videos/account-videos.component.ts b/client/src/app/account/account-videos/account-videos.component.ts
index 2664d59d8..b9a3bea3f 100644
--- a/client/src/app/account/account-videos/account-videos.component.ts
+++ b/client/src/app/account/account-videos/account-videos.component.ts
@@ -27,6 +27,8 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit,
27 totalItems: null 27 totalItems: null
28 } 28 }
29 29
30 syndicationItems = {}
31
30 protected baseVideoWidth = -1 32 protected baseVideoWidth = -1
31 protected baseVideoHeight = 155 33 protected baseVideoHeight = 155
32 34
@@ -61,6 +63,10 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit,
61 return this.videoService.getMyVideos(newPagination, this.sort) 63 return this.videoService.getMyVideos(newPagination, this.sort)
62 } 64 }
63 65
66 generateSyndicationList () {
67 throw new Error('Method not implemented.')
68 }
69
64 async deleteSelectedVideos () { 70 async deleteSelectedVideos () {
65 const toDeleteVideosIds = Object.keys(this.checkedVideos) 71 const toDeleteVideosIds = Object.keys(this.checkedVideos)
66 .filter(k => this.checkedVideos[k] === true) 72 .filter(k => this.checkedVideos[k] === true)
diff --git a/client/src/app/shared/misc/object-length.pipe.ts b/client/src/app/shared/misc/object-length.pipe.ts
new file mode 100644
index 000000000..84d182052
--- /dev/null
+++ b/client/src/app/shared/misc/object-length.pipe.ts
@@ -0,0 +1,8 @@
1import { Pipe, PipeTransform } from '@angular/core'
2
3@Pipe({ name: 'myObjectLength' })
4export class ObjectLengthPipe implements PipeTransform {
5 transform (value: Object) {
6 return Object.keys(value).length
7 }
8}
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index eb50d45a9..74730e2aa 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -10,6 +10,7 @@ import { MarkdownService } from '@app/videos/shared'
10 10
11import { BsDropdownModule } from 'ngx-bootstrap/dropdown' 11import { BsDropdownModule } from 'ngx-bootstrap/dropdown'
12import { ModalModule } from 'ngx-bootstrap/modal' 12import { ModalModule } from 'ngx-bootstrap/modal'
13import { PopoverModule } from 'ngx-bootstrap/popover'
13import { TabsModule } from 'ngx-bootstrap/tabs' 14import { TabsModule } from 'ngx-bootstrap/tabs'
14import { TooltipModule } from 'ngx-bootstrap/tooltip' 15import { TooltipModule } from 'ngx-bootstrap/tooltip'
15import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' 16import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes'
@@ -21,11 +22,13 @@ import { EditButtonComponent } from './misc/edit-button.component'
21import { FromNowPipe } from './misc/from-now.pipe' 22import { FromNowPipe } from './misc/from-now.pipe'
22import { LoaderComponent } from './misc/loader.component' 23import { LoaderComponent } from './misc/loader.component'
23import { NumberFormatterPipe } from './misc/number-formatter.pipe' 24import { NumberFormatterPipe } from './misc/number-formatter.pipe'
25import { ObjectLengthPipe } from './misc/object-length.pipe'
24import { RestExtractor, RestService } from './rest' 26import { RestExtractor, RestService } from './rest'
25import { UserService } from './users' 27import { UserService } from './users'
26import { VideoAbuseService } from './video-abuse' 28import { VideoAbuseService } from './video-abuse'
27import { VideoBlacklistService } from './video-blacklist' 29import { VideoBlacklistService } from './video-blacklist'
28import { VideoMiniatureComponent } from './video/video-miniature.component' 30import { VideoMiniatureComponent } from './video/video-miniature.component'
31import { VideoFeedComponent } from './video/video-feed.component'
29import { VideoThumbnailComponent } from './video/video-thumbnail.component' 32import { VideoThumbnailComponent } from './video/video-thumbnail.component'
30import { VideoService } from './video/video.service' 33import { VideoService } from './video/video.service'
31 34
@@ -39,6 +42,7 @@ import { VideoService } from './video/video.service'
39 42
40 BsDropdownModule.forRoot(), 43 BsDropdownModule.forRoot(),
41 ModalModule.forRoot(), 44 ModalModule.forRoot(),
45 PopoverModule.forRoot(),
42 TabsModule.forRoot(), 46 TabsModule.forRoot(),
43 TooltipModule.forRoot(), 47 TooltipModule.forRoot(),
44 48
@@ -50,9 +54,11 @@ import { VideoService } from './video/video.service'
50 LoaderComponent, 54 LoaderComponent,
51 VideoThumbnailComponent, 55 VideoThumbnailComponent,
52 VideoMiniatureComponent, 56 VideoMiniatureComponent,
57 VideoFeedComponent,
53 DeleteButtonComponent, 58 DeleteButtonComponent,
54 EditButtonComponent, 59 EditButtonComponent,
55 NumberFormatterPipe, 60 NumberFormatterPipe,
61 ObjectLengthPipe,
56 FromNowPipe, 62 FromNowPipe,
57 MarkdownTextareaComponent, 63 MarkdownTextareaComponent,
58 InfiniteScrollerDirective, 64 InfiniteScrollerDirective,
@@ -68,6 +74,7 @@ import { VideoService } from './video/video.service'
68 74
69 BsDropdownModule, 75 BsDropdownModule,
70 ModalModule, 76 ModalModule,
77 PopoverModule,
71 TabsModule, 78 TabsModule,
72 TooltipModule, 79 TooltipModule,
73 PrimeSharedModule, 80 PrimeSharedModule,
@@ -77,6 +84,7 @@ import { VideoService } from './video/video.service'
77 LoaderComponent, 84 LoaderComponent,
78 VideoThumbnailComponent, 85 VideoThumbnailComponent,
79 VideoMiniatureComponent, 86 VideoMiniatureComponent,
87 VideoFeedComponent,
80 DeleteButtonComponent, 88 DeleteButtonComponent,
81 EditButtonComponent, 89 EditButtonComponent,
82 MarkdownTextareaComponent, 90 MarkdownTextareaComponent,
@@ -84,6 +92,7 @@ import { VideoService } from './video/video.service'
84 HelpComponent, 92 HelpComponent,
85 93
86 NumberFormatterPipe, 94 NumberFormatterPipe,
95 ObjectLengthPipe,
87 FromNowPipe 96 FromNowPipe
88 ], 97 ],
89 98
diff --git a/client/src/app/shared/video/abstract-video-list.html b/client/src/app/shared/video/abstract-video-list.html
index 94a38019d..cb04e07b4 100644
--- a/client/src/app/shared/video/abstract-video-list.html
+++ b/client/src/app/shared/video/abstract-video-list.html
@@ -2,9 +2,9 @@
2 <div class="title-page title-page-single"> 2 <div class="title-page title-page-single">
3 {{ titlePage }} 3 {{ titlePage }}
4 </div> 4 </div>
5 <my-video-feed [syndicationItems]="syndicationItems"></my-video-feed>
5 6
6 <div *ngIf="pagination.totalItems === 0">No results.</div> 7 <div *ngIf="pagination.totalItems === 0">No results.</div>
7
8 <div 8 <div
9 myInfiniteScroller 9 myInfiniteScroller
10 [pageHeight]="pageHeight" 10 [pageHeight]="pageHeight"
diff --git a/client/src/app/shared/video/abstract-video-list.scss b/client/src/app/shared/video/abstract-video-list.scss
index 63538a089..b75907dc9 100644
--- a/client/src/app/shared/video/abstract-video-list.scss
+++ b/client/src/app/shared/video/abstract-video-list.scss
@@ -1,3 +1,5 @@
1@import '_mixins';
2
1.videos { 3.videos {
2 text-align: center; 4 text-align: center;
3 5
@@ -6,6 +8,11 @@
6 } 8 }
7} 9}
8 10
11my-video-feed {
12 display: inline-block;
13 margin-left: -45px;
14}
15
9@media screen and (max-width: 500px) { 16@media screen and (max-width: 500px) {
10 .videos { 17 .videos {
11 text-align: center; 18 text-align: center;
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts
index 1b9a519bd..024834dfc 100644
--- a/client/src/app/shared/video/abstract-video-list.ts
+++ b/client/src/app/shared/video/abstract-video-list.ts
@@ -3,6 +3,7 @@ import { ActivatedRoute, Router } from '@angular/router'
3import { isInMobileView } from '@app/shared/misc/utils' 3import { isInMobileView } from '@app/shared/misc/utils'
4import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' 4import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive'
5import { NotificationsService } from 'angular2-notifications' 5import { NotificationsService } from 'angular2-notifications'
6import { PopoverModule } from 'ngx-bootstrap/popover'
6import 'rxjs/add/operator/debounceTime' 7import 'rxjs/add/operator/debounceTime'
7import { Observable } from 'rxjs/Observable' 8import { Observable } from 'rxjs/Observable'
8import { fromEvent } from 'rxjs/observable/fromEvent' 9import { fromEvent } from 'rxjs/observable/fromEvent'
@@ -11,6 +12,8 @@ import { AuthService } from '../../core/auth'
11import { ComponentPagination } from '../rest/component-pagination.model' 12import { ComponentPagination } from '../rest/component-pagination.model'
12import { SortField } from './sort-field.type' 13import { SortField } from './sort-field.type'
13import { Video } from './video.model' 14import { Video } from './video.model'
15import { FeedFormat } from '../../../../../shared'
16import { VideoFeedComponent } from '@app/shared/video/video-feed.component'
14 17
15export abstract class AbstractVideoList implements OnInit, OnDestroy { 18export abstract class AbstractVideoList implements OnInit, OnDestroy {
16 private static LINES_PER_PAGE = 4 19 private static LINES_PER_PAGE = 4
@@ -25,6 +28,8 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
25 } 28 }
26 sort: SortField = '-createdAt' 29 sort: SortField = '-createdAt'
27 defaultSort: SortField = '-createdAt' 30 defaultSort: SortField = '-createdAt'
31 syndicationItems = {}
32
28 loadOnInit = true 33 loadOnInit = true
29 pageHeight: number 34 pageHeight: number
30 videoWidth: number 35 videoWidth: number
@@ -47,6 +52,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
47 private resizeSubscription: Subscription 52 private resizeSubscription: Subscription
48 53
49 abstract getVideosObservable (page: number): Observable<{ videos: Video[], totalVideos: number}> 54 abstract getVideosObservable (page: number): Observable<{ videos: Video[], totalVideos: number}>
55 abstract generateSyndicationList ()
50 56
51 get user () { 57 get user () {
52 return this.authService.getUser() 58 return this.authService.getUser()
diff --git a/client/src/app/shared/video/video-feed.component.html b/client/src/app/shared/video/video-feed.component.html
new file mode 100644
index 000000000..7733ef221
--- /dev/null
+++ b/client/src/app/shared/video/video-feed.component.html
@@ -0,0 +1,14 @@
1<div class="video-feed">
2 <span *ngIf="(syndicationItems | myObjectLength) >= 1" class="icon icon-syndication"
3 [popover]="feedsList"
4 placement="bottom"
5 [outsideClick]="true">
6 </span>
7
8 <ng-template #feedsList>
9 <div *ngFor="let key of syndicationItems | keys">
10 <a [href]="syndicationItems[key]">{{ key }}</a>
11 </div>
12 </ng-template>
13</div>
14 \ No newline at end of file
diff --git a/client/src/app/shared/video/video-feed.component.scss b/client/src/app/shared/video/video-feed.component.scss
new file mode 100644
index 000000000..2efeb405e
--- /dev/null
+++ b/client/src/app/shared/video/video-feed.component.scss
@@ -0,0 +1,19 @@
1@import '_mixins';
2
3.video-feed {
4 a {
5 @include disable-default-a-behaviour;
6
7 color: black;
8 }
9
10 .icon {
11 @include icon(12px);
12
13 &.icon-syndication {
14 position: relative;
15 top: -2px;
16 background-image: url('../../../assets/images/global/syndication.svg');
17 }
18 }
19} \ No newline at end of file
diff --git a/client/src/app/shared/video/video-feed.component.ts b/client/src/app/shared/video/video-feed.component.ts
new file mode 100644
index 000000000..41257ca99
--- /dev/null
+++ b/client/src/app/shared/video/video-feed.component.ts
@@ -0,0 +1,14 @@
1import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'
2
3@Component({
4 selector: 'my-video-feed',
5 styleUrls: [ './video-feed.component.scss' ],
6 templateUrl: './video-feed.component.html'
7})
8export class VideoFeedComponent implements OnChanges {
9 @Input() syndicationItems
10
11 ngOnChanges (changes: SimpleChanges) {
12 this.syndicationItems = changes.syndicationItems.currentValue
13 }
14}
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts
index 0a8894fd9..009155410 100644
--- a/client/src/app/shared/video/video.service.ts
+++ b/client/src/app/shared/video/video.service.ts
@@ -8,6 +8,7 @@ import { ResultList } from '../../../../../shared/models/result-list.model'
8import { UserVideoRateUpdate } from '../../../../../shared/models/videos/user-video-rate-update.model' 8import { UserVideoRateUpdate } from '../../../../../shared/models/videos/user-video-rate-update.model'
9import { UserVideoRate } from '../../../../../shared/models/videos/user-video-rate.model' 9import { UserVideoRate } from '../../../../../shared/models/videos/user-video-rate.model'
10import { VideoFilter } from '../../../../../shared/models/videos/video-query.type' 10import { VideoFilter } from '../../../../../shared/models/videos/video-query.type'
11import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum'
11import { VideoRateType } from '../../../../../shared/models/videos/video-rate.type' 12import { VideoRateType } from '../../../../../shared/models/videos/video-rate.type'
12import { VideoUpdate } from '../../../../../shared/models/videos/video-update.model' 13import { VideoUpdate } from '../../../../../shared/models/videos/video-update.model'
13import { environment } from '../../../environments/environment' 14import { environment } from '../../../environments/environment'
@@ -24,6 +25,7 @@ import { objectToFormData } from '@app/shared/misc/utils'
24@Injectable() 25@Injectable()
25export class VideoService { 26export class VideoService {
26 private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' 27 private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
28 private static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
27 29
28 constructor ( 30 constructor (
29 private authHttp: HttpClient, 31 private authHttp: HttpClient,
@@ -115,6 +117,47 @@ export class VideoService {
115 .catch((res) => this.restExtractor.handleError(res)) 117 .catch((res) => this.restExtractor.handleError(res))
116 } 118 }
117 119
120 baseFeed () {
121 const feed = {}
122
123 for (let item in FeedFormat) {
124 feed[FeedFormat[item]] = VideoService.BASE_FEEDS_URL + item.toLowerCase()
125 }
126
127 return feed
128 }
129
130 getFeed (
131 filter?: VideoFilter
132 ) {
133 let params = this.restService.addRestGetParams(new HttpParams())
134 const feed = this.baseFeed()
135
136 if (filter) {
137 params = params.set('filter', filter)
138 }
139 for (let item in feed) {
140 feed[item] = feed[item] + ((params.toString().length === 0) ? '' : '?') + params.toString()
141 }
142
143 return feed
144 }
145
146 getAccountFeed (
147 accountId: number,
148 host?: string
149 ) {
150 let params = this.restService.addRestGetParams(new HttpParams())
151 const feed = this.baseFeed()
152
153 params = params.set('accountId', accountId.toString())
154 for (let item in feed) {
155 feed[item] = feed[item] + ((params.toString().length === 0) ? '' : '?') + params.toString()
156 }
157
158 return feed
159 }
160
118 searchVideos ( 161 searchVideos (
119 search: string, 162 search: string,
120 videoPagination: ComponentPagination, 163 videoPagination: ComponentPagination,
diff --git a/client/src/app/videos/+video-watch/video-watch.component.html b/client/src/app/videos/+video-watch/video-watch.component.html
index 03f64bd12..52e3e429a 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.html
+++ b/client/src/app/videos/+video-watch/video-watch.component.html
@@ -24,6 +24,7 @@
24 <div class="video-info-by"> 24 <div class="video-info-by">
25 By {{ video.by }} 25 By {{ video.by }}
26 <img [src]="getAvatarPath()" alt="Account avatar" /> 26 <img [src]="getAvatarPath()" alt="Account avatar" />
27 <my-video-feed [syndicationItems]="syndicationItems"></my-video-feed>
27 </div> 28 </div>
28 </div> 29 </div>
29 30
diff --git a/client/src/app/videos/+video-watch/video-watch.component.scss b/client/src/app/videos/+video-watch/video-watch.component.scss
index 03f960339..8a3e2584b 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.scss
+++ b/client/src/app/videos/+video-watch/video-watch.component.scss
@@ -80,6 +80,11 @@
80 } 80 }
81 } 81 }
82 82
83 my-video-feed {
84 margin-left: 5px;
85 margin-top: 1px;
86 }
87
83 .video-actions-rates { 88 .video-actions-rates {
84 display: flex; 89 display: flex;
85 flex-direction: column; 90 flex-direction: column;
diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts
index df5b8d02d..b3ebe3e4b 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch.component.ts
@@ -1,4 +1,4 @@
1import { Component, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' 1import { Component, ElementRef, NgZone, OnDestroy, OnInit, ViewChild, OnChanges } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 2import { ActivatedRoute, Router } from '@angular/router'
3import { RedirectService } from '@app/core/routing/redirect.service' 3import { RedirectService } from '@app/core/routing/redirect.service'
4import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage' 4import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage'
@@ -9,18 +9,20 @@ import { Subscription } from 'rxjs/Subscription'
9import * as videojs from 'video.js' 9import * as videojs from 'video.js'
10import 'videojs-hotkeys' 10import 'videojs-hotkeys'
11import * as WebTorrent from 'webtorrent' 11import * as WebTorrent from 'webtorrent'
12import { UserVideoRateType, VideoRateType } from '../../../../../shared' 12import { UserVideoRateType, VideoRateType, FeedFormat } from '../../../../../shared'
13import '../../../assets/player/peertube-videojs-plugin' 13import '../../../assets/player/peertube-videojs-plugin'
14import { AuthService, ConfirmService } from '../../core' 14import { AuthService, ConfirmService } from '../../core'
15import { VideoBlacklistService } from '../../shared' 15import { VideoBlacklistService } from '../../shared'
16import { Account } from '../../shared/account/account.model' 16import { Account } from '../../shared/account/account.model'
17import { VideoDetails } from '../../shared/video/video-details.model' 17import { VideoDetails } from '../../shared/video/video-details.model'
18import { VideoFeedComponent } from '../../shared/video/video-feed.component'
18import { Video } from '../../shared/video/video.model' 19import { Video } from '../../shared/video/video.model'
19import { VideoService } from '../../shared/video/video.service' 20import { VideoService } from '../../shared/video/video.service'
20import { MarkdownService } from '../shared' 21import { MarkdownService } from '../shared'
21import { VideoDownloadComponent } from './modal/video-download.component' 22import { VideoDownloadComponent } from './modal/video-download.component'
22import { VideoReportComponent } from './modal/video-report.component' 23import { VideoReportComponent } from './modal/video-report.component'
23import { VideoShareComponent } from './modal/video-share.component' 24import { VideoShareComponent } from './modal/video-share.component'
25import { environment } from '../../../environments/environment'
24import { getVideojsOptions } from '../../../assets/player/peertube-player' 26import { getVideojsOptions } from '../../../assets/player/peertube-player'
25 27
26@Component({ 28@Component({
@@ -38,6 +40,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
38 40
39 otherVideosDisplayed: Video[] = [] 41 otherVideosDisplayed: Video[] = []
40 42
43 syndicationItems = {}
44
41 player: videojs.Player 45 player: videojs.Player
42 playerElement: HTMLVideoElement 46 playerElement: HTMLVideoElement
43 userRating: UserVideoRateType = null 47 userRating: UserVideoRateType = null
@@ -98,14 +102,15 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
98 } 102 }
99 103
100 const uuid = routeParams['uuid'] 104 const uuid = routeParams['uuid']
101 // Video did not changed 105 // Video did not change
102 if (this.video && this.video.uuid === uuid) return 106 if (this.video && this.video.uuid === uuid) return
103 107 // Video did change
104 this.videoService.getVideo(uuid).subscribe( 108 this.videoService.getVideo(uuid).subscribe(
105 video => { 109 video => {
106 const startTime = this.route.snapshot.queryParams.start 110 const startTime = this.route.snapshot.queryParams.start
107 this.onVideoFetched(video, startTime) 111 this.onVideoFetched(video, startTime)
108 .catch(err => this.handleError(err)) 112 .catch(err => this.handleError(err))
113 this.generateSyndicationList()
109 }, 114 },
110 115
111 error => { 116 error => {
@@ -242,6 +247,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
242 return this.video.tags.join(', ') 247 return this.video.tags.join(', ')
243 } 248 }
244 249
250 generateSyndicationList () {
251 const feeds = this.videoService.getAccountFeed(
252 this.video.account.id,
253 (this.video.isLocal) ? environment.apiUrl : this.video.account.host
254 )
255 this.syndicationItems['rss 2.0'] = feeds[FeedFormat.RSS]
256 this.syndicationItems['atom 1.0'] = feeds[FeedFormat.ATOM]
257 this.syndicationItems['json 1.0'] = feeds[FeedFormat.JSON]
258 }
259
245 isVideoRemovable () { 260 isVideoRemovable () {
246 return this.video.isRemovableBy(this.authService.getUser()) 261 return this.video.isRemovableBy(this.authService.getUser())
247 } 262 }
diff --git a/client/src/app/videos/video-list/video-local.component.ts b/client/src/app/videos/video-list/video-local.component.ts
index 8f9d50a7b..9d626abd1 100644
--- a/client/src/app/videos/video-list/video-local.component.ts
+++ b/client/src/app/videos/video-list/video-local.component.ts
@@ -3,9 +3,12 @@ import { ActivatedRoute, Router } from '@angular/router'
3import { immutableAssign } from '@app/shared/misc/utils' 3import { immutableAssign } from '@app/shared/misc/utils'
4import { NotificationsService } from 'angular2-notifications' 4import { NotificationsService } from 'angular2-notifications'
5import { AuthService } from '../../core/auth' 5import { AuthService } from '../../core/auth'
6import { PopoverModule } from 'ngx-bootstrap/popover'
6import { AbstractVideoList } from '../../shared/video/abstract-video-list' 7import { AbstractVideoList } from '../../shared/video/abstract-video-list'
7import { SortField } from '../../shared/video/sort-field.type' 8import { SortField } from '../../shared/video/sort-field.type'
8import { VideoService } from '../../shared/video/video.service' 9import { VideoService } from '../../shared/video/video.service'
10import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum'
11import * as url from 'url'
9 12
10@Component({ 13@Component({
11 selector: 'my-videos-local', 14 selector: 'my-videos-local',
@@ -27,6 +30,7 @@ export class VideoLocalComponent extends AbstractVideoList implements OnInit, On
27 30
28 ngOnInit () { 31 ngOnInit () {
29 super.ngOnInit() 32 super.ngOnInit()
33 this.generateSyndicationList()
30 } 34 }
31 35
32 ngOnDestroy () { 36 ngOnDestroy () {
@@ -38,4 +42,11 @@ export class VideoLocalComponent extends AbstractVideoList implements OnInit, On
38 42
39 return this.videoService.getVideos(newPagination, this.sort, 'local') 43 return this.videoService.getVideos(newPagination, this.sort, 'local')
40 } 44 }
45
46 generateSyndicationList () {
47 const feeds = this.videoService.getFeed('local')
48 this.syndicationItems['rss 2.0'] = feeds[FeedFormat.RSS]
49 this.syndicationItems['atom 1.0'] = feeds[FeedFormat.ATOM]
50 this.syndicationItems['json 1.0'] = feeds[FeedFormat.JSON]
51 }
41} 52}
diff --git a/client/src/app/videos/video-list/video-recently-added.component.ts b/client/src/app/videos/video-list/video-recently-added.component.ts
index 1cecd14a0..2bdc20d92 100644
--- a/client/src/app/videos/video-list/video-recently-added.component.ts
+++ b/client/src/app/videos/video-list/video-recently-added.component.ts
@@ -6,6 +6,8 @@ import { AuthService } from '../../core/auth'
6import { AbstractVideoList } from '../../shared/video/abstract-video-list' 6import { AbstractVideoList } from '../../shared/video/abstract-video-list'
7import { SortField } from '../../shared/video/sort-field.type' 7import { SortField } from '../../shared/video/sort-field.type'
8import { VideoService } from '../../shared/video/video.service' 8import { VideoService } from '../../shared/video/video.service'
9import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum'
10import * as url from 'url'
9 11
10@Component({ 12@Component({
11 selector: 'my-videos-recently-added', 13 selector: 'my-videos-recently-added',
@@ -27,6 +29,7 @@ export class VideoRecentlyAddedComponent extends AbstractVideoList implements On
27 29
28 ngOnInit () { 30 ngOnInit () {
29 super.ngOnInit() 31 super.ngOnInit()
32 this.generateSyndicationList()
30 } 33 }
31 34
32 ngOnDestroy () { 35 ngOnDestroy () {
@@ -38,4 +41,11 @@ export class VideoRecentlyAddedComponent extends AbstractVideoList implements On
38 41
39 return this.videoService.getVideos(newPagination, this.sort) 42 return this.videoService.getVideos(newPagination, this.sort)
40 } 43 }
44
45 generateSyndicationList () {
46 const feeds = this.videoService.getFeed('local')
47 this.syndicationItems['rss 2.0'] = feeds[FeedFormat.RSS]
48 this.syndicationItems['atom 1.0'] = feeds[FeedFormat.ATOM]
49 this.syndicationItems['json 1.0'] = feeds[FeedFormat.JSON]
50 }
41} 51}
diff --git a/client/src/app/videos/video-list/video-search.component.ts b/client/src/app/videos/video-list/video-search.component.ts
index b94be8e11..ef9afa757 100644
--- a/client/src/app/videos/video-list/video-search.component.ts
+++ b/client/src/app/videos/video-list/video-search.component.ts
@@ -7,6 +7,7 @@ import { Subscription } from 'rxjs/Subscription'
7import { AuthService } from '../../core/auth' 7import { AuthService } from '../../core/auth'
8import { AbstractVideoList } from '../../shared/video/abstract-video-list' 8import { AbstractVideoList } from '../../shared/video/abstract-video-list'
9import { VideoService } from '../../shared/video/video.service' 9import { VideoService } from '../../shared/video/video.service'
10import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum'
10 11
11@Component({ 12@Component({
12 selector: 'my-videos-search', 13 selector: 'my-videos-search',
@@ -61,4 +62,8 @@ export class VideoSearchComponent extends AbstractVideoList implements OnInit, O
61 const newPagination = immutableAssign(this.pagination, { currentPage: page }) 62 const newPagination = immutableAssign(this.pagination, { currentPage: page })
62 return this.videoService.searchVideos(this.otherRouteParams.search, newPagination, this.sort) 63 return this.videoService.searchVideos(this.otherRouteParams.search, newPagination, this.sort)
63 } 64 }
65
66 generateSyndicationList () {
67 throw new Error('Method not implemented.')
68 }
64} 69}
diff --git a/client/src/app/videos/video-list/video-trending.component.ts b/client/src/app/videos/video-list/video-trending.component.ts
index 1dd1ad23b..905c75ab0 100644
--- a/client/src/app/videos/video-list/video-trending.component.ts
+++ b/client/src/app/videos/video-list/video-trending.component.ts
@@ -6,6 +6,8 @@ import { AuthService } from '../../core/auth'
6import { AbstractVideoList } from '../../shared/video/abstract-video-list' 6import { AbstractVideoList } from '../../shared/video/abstract-video-list'
7import { SortField } from '../../shared/video/sort-field.type' 7import { SortField } from '../../shared/video/sort-field.type'
8import { VideoService } from '../../shared/video/video.service' 8import { VideoService } from '../../shared/video/video.service'
9import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum'
10import * as url from 'url'
9 11
10@Component({ 12@Component({
11 selector: 'my-videos-trending', 13 selector: 'my-videos-trending',
@@ -27,6 +29,7 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit,
27 29
28 ngOnInit () { 30 ngOnInit () {
29 super.ngOnInit() 31 super.ngOnInit()
32 this.generateSyndicationList()
30 } 33 }
31 34
32 ngOnDestroy () { 35 ngOnDestroy () {
@@ -37,4 +40,11 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit,
37 const newPagination = immutableAssign(this.pagination, { currentPage: page }) 40 const newPagination = immutableAssign(this.pagination, { currentPage: page })
38 return this.videoService.getVideos(newPagination, this.sort) 41 return this.videoService.getVideos(newPagination, this.sort)
39 } 42 }
43
44 generateSyndicationList () {
45 const feeds = this.videoService.getFeed('local')
46 this.syndicationItems['rss 2.0'] = feeds[FeedFormat.RSS]
47 this.syndicationItems['atom 1.0'] = feeds[FeedFormat.ATOM]
48 this.syndicationItems['json 1.0'] = feeds[FeedFormat.JSON]
49 }
40} 50}
diff --git a/client/src/assets/images/global/syndication.svg b/client/src/assets/images/global/syndication.svg
new file mode 100644
index 000000000..cb74cf81b
--- /dev/null
+++ b/client/src/assets/images/global/syndication.svg
@@ -0,0 +1,58 @@
1<?xml version="1.0" encoding="iso-8859-1"?>
2<!-- Generator: Adobe Illustrator 18.1.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
3<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
4 viewBox="0 0 559.372 559.372" style="enable-background:new 0 0 559.372 559.372;" xml:space="preserve">
5<g>
6 <g>
7 <path style="fill:#010002;" d="M53.244,0.002c46.512,0,91.29,6.018,134.334,18.054s83.334,29.07,120.869,51.102
8 c37.537,22.032,71.707,48.45,102.514,79.254c30.803,30.804,57.221,64.974,79.254,102.51
9 c22.029,37.539,39.063,77.828,51.102,120.873c12.037,43.043,18.055,87.818,18.055,134.334c0,14.688-5.201,27.23-15.605,37.637
10 c-10.404,10.407-22.949,15.604-37.637,15.604c-14.689,0-27.234-5.199-37.641-15.604c-10.402-10.404-15.604-22.949-15.604-37.637
11 c0-36.723-4.795-72.115-14.383-106.186c-9.588-34.064-23.055-65.891-40.395-95.471c-17.34-29.581-38.145-56.509-62.424-80.785
12 c-24.277-24.276-51.203-45.084-80.784-62.424c-29.58-17.34-61.404-30.804-95.472-40.392s-69.462-14.382-106.182-14.382
13 c-14.688,0-27.234-5.202-37.638-15.606S0.001,67.933,0.001,53.245s5.202-27.234,15.606-37.638
14 C26.01,5.204,38.556,0.002,53.244,0.002z M53.244,201.35c42.024,0,81.498,8.058,118.422,24.174s69.156,37.944,96.696,65.484
15 c27.541,27.541,49.369,59.771,65.484,96.693c16.117,36.928,24.174,76.398,24.174,118.426c0,14.688-5.201,27.23-15.604,37.637
16 c-10.404,10.404-22.949,15.604-37.641,15.604c-14.688,0-27.233-5.199-37.637-15.604c-10.404-10.404-15.606-22.949-15.606-37.637
17 c0-27.338-5.202-53.041-15.606-77.113c-10.404-24.072-24.582-45.084-42.534-63.035c-17.952-17.953-38.964-32.131-63.036-42.535
18 c-24.072-10.402-49.776-15.604-77.112-15.604c-14.688,0-27.234-5.201-37.638-15.605C5.202,281.83,0,269.284,0,254.596
19 s5.202-27.234,15.606-37.638C26.01,206.552,38.556,201.35,53.244,201.35z M151.164,481.033c0,10.609-1.938,20.4-5.814,29.377
20 c-3.876,8.979-9.18,16.83-15.912,23.563c-6.732,6.729-14.688,12.035-23.868,15.912c-9.18,3.875-18.87,5.811-29.07,5.811
21 c-10.608,0-20.4-1.938-29.376-5.811c-8.976-3.875-16.83-9.184-23.562-15.912c-6.732-6.732-12.036-14.586-15.912-23.563
22 c-3.876-8.977-5.814-18.768-5.814-29.377c0-10.197,1.938-19.889,5.814-29.066c3.876-9.184,9.18-17.139,15.912-23.869
23 c6.732-6.732,14.586-12.035,23.562-15.912c8.976-3.875,18.768-5.814,29.376-5.814c10.2,0,19.89,1.939,29.07,5.814
24 c9.18,3.877,17.136,9.18,23.868,15.912c6.732,6.73,12.036,14.688,15.912,23.869C149.226,461.145,151.164,470.834,151.164,481.033z
25 "/>
26 </g>
27</g>
28<g>
29</g>
30<g>
31</g>
32<g>
33</g>
34<g>
35</g>
36<g>
37</g>
38<g>
39</g>
40<g>
41</g>
42<g>
43</g>
44<g>
45</g>
46<g>
47</g>
48<g>
49</g>
50<g>
51</g>
52<g>
53</g>
54<g>
55</g>
56<g>
57</g>
58</svg>
diff --git a/client/src/sass/include/_bootstrap.scss b/client/src/sass/include/_bootstrap.scss
index bbf0fda22..f15b8966e 100644
--- a/client/src/sass/include/_bootstrap.scss
+++ b/client/src/sass/include/_bootstrap.scss
@@ -42,7 +42,7 @@
42// Components w/ JavaScript 42// Components w/ JavaScript
43@import "~bootstrap-sass/assets/stylesheets/bootstrap/modals"; 43@import "~bootstrap-sass/assets/stylesheets/bootstrap/modals";
44@import "~bootstrap-sass/assets/stylesheets/bootstrap/tooltip"; 44@import "~bootstrap-sass/assets/stylesheets/bootstrap/tooltip";
45//@import "~bootstrap-sass/assets/stylesheets/bootstrap/popovers"; 45@import "~bootstrap-sass/assets/stylesheets/bootstrap/popovers";
46//@import "~bootstrap-sass/assets/stylesheets/bootstrap/carousel"; 46//@import "~bootstrap-sass/assets/stylesheets/bootstrap/carousel";
47 47
48//// Utility classes 48//// Utility classes
diff --git a/package.json b/package.json
index 6941b913b..e3007bea9 100644
--- a/package.json
+++ b/package.json
@@ -81,6 +81,7 @@
81 "parse-torrent": "^5.8.0", 81 "parse-torrent": "^5.8.0",
82 "password-generator": "^2.0.2", 82 "password-generator": "^2.0.2",
83 "pem": "^1.12.3", 83 "pem": "^1.12.3",
84 "pfeed": "^1.1.5",
84 "pg": "^7.4.1", 85 "pg": "^7.4.1",
85 "pg-hstore": "^2.3.2", 86 "pg-hstore": "^2.3.2",
86 "redis": "^2.8.0", 87 "redis": "^2.8.0",
diff --git a/server.ts b/server.ts
index 97941c958..06d575c86 100644
--- a/server.ts
+++ b/server.ts
@@ -69,7 +69,15 @@ import { installApplication } from './server/initializers'
69import { Emailer } from './server/lib/emailer' 69import { Emailer } from './server/lib/emailer'
70import { JobQueue } from './server/lib/job-queue' 70import { JobQueue } from './server/lib/job-queue'
71import { VideosPreviewCache } from './server/lib/cache' 71import { VideosPreviewCache } from './server/lib/cache'
72import { apiRouter, clientsRouter, staticRouter, servicesRouter, webfingerRouter, activityPubRouter } from './server/controllers' 72import {
73 activityPubRouter,
74 apiRouter,
75 clientsRouter,
76 feedsRouter,
77 staticRouter,
78 servicesRouter,
79 webfingerRouter
80} from './server/controllers'
73import { Redis } from './server/lib/redis' 81import { Redis } from './server/lib/redis'
74import { BadActorFollowScheduler } from './server/lib/schedulers/bad-actor-follow-scheduler' 82import { BadActorFollowScheduler } from './server/lib/schedulers/bad-actor-follow-scheduler'
75import { RemoveOldJobsScheduler } from './server/lib/schedulers/remove-old-jobs-scheduler' 83import { RemoveOldJobsScheduler } from './server/lib/schedulers/remove-old-jobs-scheduler'
@@ -144,8 +152,9 @@ app.use(apiRoute, apiRouter)
144// Services (oembed...) 152// Services (oembed...)
145app.use('/services', servicesRouter) 153app.use('/services', servicesRouter)
146 154
147app.use('/', webfingerRouter)
148app.use('/', activityPubRouter) 155app.use('/', activityPubRouter)
156app.use('/', feedsRouter)
157app.use('/', webfingerRouter)
149 158
150// Client files 159// Client files
151app.use('/', clientsRouter) 160app.use('/', clientsRouter)
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 {
diff --git a/shared/models/feeds/feed-format.enum.ts b/shared/models/feeds/feed-format.enum.ts
new file mode 100644
index 000000000..f3173a781
--- /dev/null
+++ b/shared/models/feeds/feed-format.enum.ts
@@ -0,0 +1,5 @@
1export enum FeedFormat {
2 RSS = 'xml',
3 ATOM = 'atom',
4 JSON = 'json'
5}
diff --git a/shared/models/feeds/index.ts b/shared/models/feeds/index.ts
new file mode 100644
index 000000000..d56c8458c
--- /dev/null
+++ b/shared/models/feeds/index.ts
@@ -0,0 +1 @@
export * from './feed-format.enum'
diff --git a/shared/models/index.ts b/shared/models/index.ts
index ae3a44777..95bc402d6 100644
--- a/shared/models/index.ts
+++ b/shared/models/index.ts
@@ -2,6 +2,7 @@ export * from './actors'
2export * from './activitypub' 2export * from './activitypub'
3export * from './users' 3export * from './users'
4export * from './videos' 4export * from './videos'
5export * from './feeds'
5export * from './server/job.model' 6export * from './server/job.model'
6export * from './oauth-client-local.model' 7export * from './oauth-client-local.model'
7export * from './result-list.model' 8export * from './result-list.model'
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index c3efa512d..0729ac8ec 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -78,6 +78,38 @@ paths:
78 description: successful operation 78 description: successful operation
79 schema: 79 schema:
80 $ref: '#/definitions/ServerConfig' 80 $ref: '#/definitions/ServerConfig'
81 /feeds/videos.{format}:
82 get:
83 tags:
84 - Feeds
85 consumes:
86 - application/json
87 produces:
88 - application/json
89 parameters:
90 - name: format
91 in: path
92 required: true
93 type: string
94 enum: ['xml', 'atom' 'json']
95 default: 'xml'
96 description: 'The format expected (xml defaults to RSS 2.0, atom to ATOM 1.0 and json to JSON FEED 1.0'
97 - name: accountId
98 in: query
99 required: false
100 type: number
101 description: 'The id of the local account to filter to (beware, users IDs and not actors IDs which will return empty feeds'
102 - name: accountName
103 in: query
104 required: false
105 type: string
106 description: 'The name of the local account to filter to'
107 responses:
108 '200':
109 description: successful operation
110 content:
111 application/json:
112 application/xml:
81 /jobs: 113 /jobs:
82 get: 114 get:
83 security: 115 security:
diff --git a/yarn.lock b/yarn.lock
index b4c3b7bcc..2b445860f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4585,6 +4585,12 @@ performance-now@^2.1.0:
4585 version "2.1.0" 4585 version "2.1.0"
4586 resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" 4586 resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
4587 4587
4588pfeed@^1.1.2:
4589 version "1.1.5"
4590 resolved "https://registry.yarnpkg.com/pfeed/-/pfeed-1.1.5.tgz#6d0ab54209c60b45de03a15efaab7be867a3f71a"
4591 dependencies:
4592 xml "^1.0.1"
4593
4588pg-connection-string@0.1.3: 4594pg-connection-string@0.1.3:
4589 version "0.1.3" 4595 version "0.1.3"
4590 resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-0.1.3.tgz#da1847b20940e42ee1492beaf65d49d91b245df7" 4596 resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-0.1.3.tgz#da1847b20940e42ee1492beaf65d49d91b245df7"
@@ -6792,6 +6798,10 @@ xhr2@^0.1.4:
6792 version "0.1.4" 6798 version "0.1.4"
6793 resolved "https://registry.yarnpkg.com/xhr2/-/xhr2-0.1.4.tgz#7f87658847716db5026323812f818cadab387a5f" 6799 resolved "https://registry.yarnpkg.com/xhr2/-/xhr2-0.1.4.tgz#7f87658847716db5026323812f818cadab387a5f"
6794 6800
6801xml@^1.0.1:
6802 version "1.0.1"
6803 resolved "https://registry.yarnpkg.com/xml/-/xml-1.0.1.tgz#78ba72020029c5bc87b8a81a3cfcd74b4a2fc1e5"
6804
6795xmldom@0.1.19: 6805xmldom@0.1.19:
6796 version "0.1.19" 6806 version "0.1.19"
6797 resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.19.tgz#631fc07776efd84118bf25171b37ed4d075a0abc" 6807 resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.19.tgz#631fc07776efd84118bf25171b37ed4d075a0abc"