diff options
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 @@ | |||
1 | import { Pipe, PipeTransform } from '@angular/core' | ||
2 | |||
3 | @Pipe({ name: 'myObjectLength' }) | ||
4 | export 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 | ||
11 | import { BsDropdownModule } from 'ngx-bootstrap/dropdown' | 11 | import { BsDropdownModule } from 'ngx-bootstrap/dropdown' |
12 | import { ModalModule } from 'ngx-bootstrap/modal' | 12 | import { ModalModule } from 'ngx-bootstrap/modal' |
13 | import { PopoverModule } from 'ngx-bootstrap/popover' | ||
13 | import { TabsModule } from 'ngx-bootstrap/tabs' | 14 | import { TabsModule } from 'ngx-bootstrap/tabs' |
14 | import { TooltipModule } from 'ngx-bootstrap/tooltip' | 15 | import { TooltipModule } from 'ngx-bootstrap/tooltip' |
15 | import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' | 16 | import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' |
@@ -21,11 +22,13 @@ import { EditButtonComponent } from './misc/edit-button.component' | |||
21 | import { FromNowPipe } from './misc/from-now.pipe' | 22 | import { FromNowPipe } from './misc/from-now.pipe' |
22 | import { LoaderComponent } from './misc/loader.component' | 23 | import { LoaderComponent } from './misc/loader.component' |
23 | import { NumberFormatterPipe } from './misc/number-formatter.pipe' | 24 | import { NumberFormatterPipe } from './misc/number-formatter.pipe' |
25 | import { ObjectLengthPipe } from './misc/object-length.pipe' | ||
24 | import { RestExtractor, RestService } from './rest' | 26 | import { RestExtractor, RestService } from './rest' |
25 | import { UserService } from './users' | 27 | import { UserService } from './users' |
26 | import { VideoAbuseService } from './video-abuse' | 28 | import { VideoAbuseService } from './video-abuse' |
27 | import { VideoBlacklistService } from './video-blacklist' | 29 | import { VideoBlacklistService } from './video-blacklist' |
28 | import { VideoMiniatureComponent } from './video/video-miniature.component' | 30 | import { VideoMiniatureComponent } from './video/video-miniature.component' |
31 | import { VideoFeedComponent } from './video/video-feed.component' | ||
29 | import { VideoThumbnailComponent } from './video/video-thumbnail.component' | 32 | import { VideoThumbnailComponent } from './video/video-thumbnail.component' |
30 | import { VideoService } from './video/video.service' | 33 | import { 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 | ||
11 | my-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' | |||
3 | import { isInMobileView } from '@app/shared/misc/utils' | 3 | import { isInMobileView } from '@app/shared/misc/utils' |
4 | import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' | 4 | import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' |
5 | import { NotificationsService } from 'angular2-notifications' | 5 | import { NotificationsService } from 'angular2-notifications' |
6 | import { PopoverModule } from 'ngx-bootstrap/popover' | ||
6 | import 'rxjs/add/operator/debounceTime' | 7 | import 'rxjs/add/operator/debounceTime' |
7 | import { Observable } from 'rxjs/Observable' | 8 | import { Observable } from 'rxjs/Observable' |
8 | import { fromEvent } from 'rxjs/observable/fromEvent' | 9 | import { fromEvent } from 'rxjs/observable/fromEvent' |
@@ -11,6 +12,8 @@ import { AuthService } from '../../core/auth' | |||
11 | import { ComponentPagination } from '../rest/component-pagination.model' | 12 | import { ComponentPagination } from '../rest/component-pagination.model' |
12 | import { SortField } from './sort-field.type' | 13 | import { SortField } from './sort-field.type' |
13 | import { Video } from './video.model' | 14 | import { Video } from './video.model' |
15 | import { FeedFormat } from '../../../../../shared' | ||
16 | import { VideoFeedComponent } from '@app/shared/video/video-feed.component' | ||
14 | 17 | ||
15 | export abstract class AbstractVideoList implements OnInit, OnDestroy { | 18 | export 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 @@ | |||
1 | import { 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 | }) | ||
8 | export 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' | |||
8 | import { UserVideoRateUpdate } from '../../../../../shared/models/videos/user-video-rate-update.model' | 8 | import { UserVideoRateUpdate } from '../../../../../shared/models/videos/user-video-rate-update.model' |
9 | import { UserVideoRate } from '../../../../../shared/models/videos/user-video-rate.model' | 9 | import { UserVideoRate } from '../../../../../shared/models/videos/user-video-rate.model' |
10 | import { VideoFilter } from '../../../../../shared/models/videos/video-query.type' | 10 | import { VideoFilter } from '../../../../../shared/models/videos/video-query.type' |
11 | import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum' | ||
11 | import { VideoRateType } from '../../../../../shared/models/videos/video-rate.type' | 12 | import { VideoRateType } from '../../../../../shared/models/videos/video-rate.type' |
12 | import { VideoUpdate } from '../../../../../shared/models/videos/video-update.model' | 13 | import { VideoUpdate } from '../../../../../shared/models/videos/video-update.model' |
13 | import { environment } from '../../../environments/environment' | 14 | import { environment } from '../../../environments/environment' |
@@ -24,6 +25,7 @@ import { objectToFormData } from '@app/shared/misc/utils' | |||
24 | @Injectable() | 25 | @Injectable() |
25 | export class VideoService { | 26 | export 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 @@ | |||
1 | import { Component, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' | 1 | import { Component, ElementRef, NgZone, OnDestroy, OnInit, ViewChild, OnChanges } from '@angular/core' |
2 | import { ActivatedRoute, Router } from '@angular/router' | 2 | import { ActivatedRoute, Router } from '@angular/router' |
3 | import { RedirectService } from '@app/core/routing/redirect.service' | 3 | import { RedirectService } from '@app/core/routing/redirect.service' |
4 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage' | 4 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage' |
@@ -9,18 +9,20 @@ import { Subscription } from 'rxjs/Subscription' | |||
9 | import * as videojs from 'video.js' | 9 | import * as videojs from 'video.js' |
10 | import 'videojs-hotkeys' | 10 | import 'videojs-hotkeys' |
11 | import * as WebTorrent from 'webtorrent' | 11 | import * as WebTorrent from 'webtorrent' |
12 | import { UserVideoRateType, VideoRateType } from '../../../../../shared' | 12 | import { UserVideoRateType, VideoRateType, FeedFormat } from '../../../../../shared' |
13 | import '../../../assets/player/peertube-videojs-plugin' | 13 | import '../../../assets/player/peertube-videojs-plugin' |
14 | import { AuthService, ConfirmService } from '../../core' | 14 | import { AuthService, ConfirmService } from '../../core' |
15 | import { VideoBlacklistService } from '../../shared' | 15 | import { VideoBlacklistService } from '../../shared' |
16 | import { Account } from '../../shared/account/account.model' | 16 | import { Account } from '../../shared/account/account.model' |
17 | import { VideoDetails } from '../../shared/video/video-details.model' | 17 | import { VideoDetails } from '../../shared/video/video-details.model' |
18 | import { VideoFeedComponent } from '../../shared/video/video-feed.component' | ||
18 | import { Video } from '../../shared/video/video.model' | 19 | import { Video } from '../../shared/video/video.model' |
19 | import { VideoService } from '../../shared/video/video.service' | 20 | import { VideoService } from '../../shared/video/video.service' |
20 | import { MarkdownService } from '../shared' | 21 | import { MarkdownService } from '../shared' |
21 | import { VideoDownloadComponent } from './modal/video-download.component' | 22 | import { VideoDownloadComponent } from './modal/video-download.component' |
22 | import { VideoReportComponent } from './modal/video-report.component' | 23 | import { VideoReportComponent } from './modal/video-report.component' |
23 | import { VideoShareComponent } from './modal/video-share.component' | 24 | import { VideoShareComponent } from './modal/video-share.component' |
25 | import { environment } from '../../../environments/environment' | ||
24 | import { getVideojsOptions } from '../../../assets/player/peertube-player' | 26 | import { 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' | |||
3 | import { immutableAssign } from '@app/shared/misc/utils' | 3 | import { immutableAssign } from '@app/shared/misc/utils' |
4 | import { NotificationsService } from 'angular2-notifications' | 4 | import { NotificationsService } from 'angular2-notifications' |
5 | import { AuthService } from '../../core/auth' | 5 | import { AuthService } from '../../core/auth' |
6 | import { PopoverModule } from 'ngx-bootstrap/popover' | ||
6 | import { AbstractVideoList } from '../../shared/video/abstract-video-list' | 7 | import { AbstractVideoList } from '../../shared/video/abstract-video-list' |
7 | import { SortField } from '../../shared/video/sort-field.type' | 8 | import { SortField } from '../../shared/video/sort-field.type' |
8 | import { VideoService } from '../../shared/video/video.service' | 9 | import { VideoService } from '../../shared/video/video.service' |
10 | import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum' | ||
11 | import * 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' | |||
6 | import { AbstractVideoList } from '../../shared/video/abstract-video-list' | 6 | import { AbstractVideoList } from '../../shared/video/abstract-video-list' |
7 | import { SortField } from '../../shared/video/sort-field.type' | 7 | import { SortField } from '../../shared/video/sort-field.type' |
8 | import { VideoService } from '../../shared/video/video.service' | 8 | import { VideoService } from '../../shared/video/video.service' |
9 | import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum' | ||
10 | import * 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' | |||
7 | import { AuthService } from '../../core/auth' | 7 | import { AuthService } from '../../core/auth' |
8 | import { AbstractVideoList } from '../../shared/video/abstract-video-list' | 8 | import { AbstractVideoList } from '../../shared/video/abstract-video-list' |
9 | import { VideoService } from '../../shared/video/video.service' | 9 | import { VideoService } from '../../shared/video/video.service' |
10 | import { 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' | |||
6 | import { AbstractVideoList } from '../../shared/video/abstract-video-list' | 6 | import { AbstractVideoList } from '../../shared/video/abstract-video-list' |
7 | import { SortField } from '../../shared/video/sort-field.type' | 7 | import { SortField } from '../../shared/video/sort-field.type' |
8 | import { VideoService } from '../../shared/video/video.service' | 8 | import { VideoService } from '../../shared/video/video.service' |
9 | import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum' | ||
10 | import * 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", |
@@ -69,7 +69,15 @@ import { installApplication } from './server/initializers' | |||
69 | import { Emailer } from './server/lib/emailer' | 69 | import { Emailer } from './server/lib/emailer' |
70 | import { JobQueue } from './server/lib/job-queue' | 70 | import { JobQueue } from './server/lib/job-queue' |
71 | import { VideosPreviewCache } from './server/lib/cache' | 71 | import { VideosPreviewCache } from './server/lib/cache' |
72 | import { apiRouter, clientsRouter, staticRouter, servicesRouter, webfingerRouter, activityPubRouter } from './server/controllers' | 72 | import { |
73 | activityPubRouter, | ||
74 | apiRouter, | ||
75 | clientsRouter, | ||
76 | feedsRouter, | ||
77 | staticRouter, | ||
78 | servicesRouter, | ||
79 | webfingerRouter | ||
80 | } from './server/controllers' | ||
73 | import { Redis } from './server/lib/redis' | 81 | import { Redis } from './server/lib/redis' |
74 | import { BadActorFollowScheduler } from './server/lib/schedulers/bad-actor-follow-scheduler' | 82 | import { BadActorFollowScheduler } from './server/lib/schedulers/bad-actor-follow-scheduler' |
75 | import { RemoveOldJobsScheduler } from './server/lib/schedulers/remove-old-jobs-scheduler' | 83 | import { RemoveOldJobsScheduler } from './server/lib/schedulers/remove-old-jobs-scheduler' |
@@ -144,8 +152,9 @@ app.use(apiRoute, apiRouter) | |||
144 | // Services (oembed...) | 152 | // Services (oembed...) |
145 | app.use('/services', servicesRouter) | 153 | app.use('/services', servicesRouter) |
146 | 154 | ||
147 | app.use('/', webfingerRouter) | ||
148 | app.use('/', activityPubRouter) | 155 | app.use('/', activityPubRouter) |
156 | app.use('/', feedsRouter) | ||
157 | app.use('/', webfingerRouter) | ||
149 | 158 | ||
150 | // Client files | 159 | // Client files |
151 | app.use('/', clientsRouter) | 160 | app.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 @@ | |||
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 { |
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 @@ | |||
1 | export 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' | |||
2 | export * from './activitypub' | 2 | export * from './activitypub' |
3 | export * from './users' | 3 | export * from './users' |
4 | export * from './videos' | 4 | export * from './videos' |
5 | export * from './feeds' | ||
5 | export * from './server/job.model' | 6 | export * from './server/job.model' |
6 | export * from './oauth-client-local.model' | 7 | export * from './oauth-client-local.model' |
7 | export * from './result-list.model' | 8 | export * 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: |
@@ -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 | ||
4588 | pfeed@^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 | |||
4588 | pg-connection-string@0.1.3: | 4594 | pg-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 | ||
6801 | xml@^1.0.1: | ||
6802 | version "1.0.1" | ||
6803 | resolved "https://registry.yarnpkg.com/xml/-/xml-1.0.1.tgz#78ba72020029c5bc87b8a81a3cfcd74b4a2fc1e5" | ||
6804 | |||
6795 | xmldom@0.1.19: | 6805 | xmldom@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" |