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.
This commit is contained in:
parent
c36d5a6b98
commit
244e76a552
|
@ -27,6 +27,8 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit,
|
|||
totalItems: null
|
||||
}
|
||||
|
||||
syndicationItems = {}
|
||||
|
||||
protected baseVideoWidth = -1
|
||||
protected baseVideoHeight = 155
|
||||
|
||||
|
@ -61,6 +63,10 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit,
|
|||
return this.videoService.getMyVideos(newPagination, this.sort)
|
||||
}
|
||||
|
||||
generateSyndicationList () {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
|
||||
async deleteSelectedVideos () {
|
||||
const toDeleteVideosIds = Object.keys(this.checkedVideos)
|
||||
.filter(k => this.checkedVideos[k] === true)
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
|
||||
@Pipe({ name: 'myObjectLength' })
|
||||
export class ObjectLengthPipe implements PipeTransform {
|
||||
transform (value: Object) {
|
||||
return Object.keys(value).length
|
||||
}
|
||||
}
|
|
@ -10,6 +10,7 @@ import { MarkdownService } from '@app/videos/shared'
|
|||
|
||||
import { BsDropdownModule } from 'ngx-bootstrap/dropdown'
|
||||
import { ModalModule } from 'ngx-bootstrap/modal'
|
||||
import { PopoverModule } from 'ngx-bootstrap/popover'
|
||||
import { TabsModule } from 'ngx-bootstrap/tabs'
|
||||
import { TooltipModule } from 'ngx-bootstrap/tooltip'
|
||||
import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes'
|
||||
|
@ -21,11 +22,13 @@ import { EditButtonComponent } from './misc/edit-button.component'
|
|||
import { FromNowPipe } from './misc/from-now.pipe'
|
||||
import { LoaderComponent } from './misc/loader.component'
|
||||
import { NumberFormatterPipe } from './misc/number-formatter.pipe'
|
||||
import { ObjectLengthPipe } from './misc/object-length.pipe'
|
||||
import { RestExtractor, RestService } from './rest'
|
||||
import { UserService } from './users'
|
||||
import { VideoAbuseService } from './video-abuse'
|
||||
import { VideoBlacklistService } from './video-blacklist'
|
||||
import { VideoMiniatureComponent } from './video/video-miniature.component'
|
||||
import { VideoFeedComponent } from './video/video-feed.component'
|
||||
import { VideoThumbnailComponent } from './video/video-thumbnail.component'
|
||||
import { VideoService } from './video/video.service'
|
||||
|
||||
|
@ -39,6 +42,7 @@ import { VideoService } from './video/video.service'
|
|||
|
||||
BsDropdownModule.forRoot(),
|
||||
ModalModule.forRoot(),
|
||||
PopoverModule.forRoot(),
|
||||
TabsModule.forRoot(),
|
||||
TooltipModule.forRoot(),
|
||||
|
||||
|
@ -50,9 +54,11 @@ import { VideoService } from './video/video.service'
|
|||
LoaderComponent,
|
||||
VideoThumbnailComponent,
|
||||
VideoMiniatureComponent,
|
||||
VideoFeedComponent,
|
||||
DeleteButtonComponent,
|
||||
EditButtonComponent,
|
||||
NumberFormatterPipe,
|
||||
ObjectLengthPipe,
|
||||
FromNowPipe,
|
||||
MarkdownTextareaComponent,
|
||||
InfiniteScrollerDirective,
|
||||
|
@ -68,6 +74,7 @@ import { VideoService } from './video/video.service'
|
|||
|
||||
BsDropdownModule,
|
||||
ModalModule,
|
||||
PopoverModule,
|
||||
TabsModule,
|
||||
TooltipModule,
|
||||
PrimeSharedModule,
|
||||
|
@ -77,6 +84,7 @@ import { VideoService } from './video/video.service'
|
|||
LoaderComponent,
|
||||
VideoThumbnailComponent,
|
||||
VideoMiniatureComponent,
|
||||
VideoFeedComponent,
|
||||
DeleteButtonComponent,
|
||||
EditButtonComponent,
|
||||
MarkdownTextareaComponent,
|
||||
|
@ -84,6 +92,7 @@ import { VideoService } from './video/video.service'
|
|||
HelpComponent,
|
||||
|
||||
NumberFormatterPipe,
|
||||
ObjectLengthPipe,
|
||||
FromNowPipe
|
||||
],
|
||||
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
<div class="title-page title-page-single">
|
||||
{{ titlePage }}
|
||||
</div>
|
||||
<my-video-feed [syndicationItems]="syndicationItems"></my-video-feed>
|
||||
|
||||
<div *ngIf="pagination.totalItems === 0">No results.</div>
|
||||
|
||||
<div
|
||||
myInfiniteScroller
|
||||
[pageHeight]="pageHeight"
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
@import '_mixins';
|
||||
|
||||
.videos {
|
||||
text-align: center;
|
||||
|
||||
|
@ -6,6 +8,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
my-video-feed {
|
||||
display: inline-block;
|
||||
margin-left: -45px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.videos {
|
||||
text-align: center;
|
||||
|
|
|
@ -3,6 +3,7 @@ import { ActivatedRoute, Router } from '@angular/router'
|
|||
import { isInMobileView } from '@app/shared/misc/utils'
|
||||
import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive'
|
||||
import { NotificationsService } from 'angular2-notifications'
|
||||
import { PopoverModule } from 'ngx-bootstrap/popover'
|
||||
import 'rxjs/add/operator/debounceTime'
|
||||
import { Observable } from 'rxjs/Observable'
|
||||
import { fromEvent } from 'rxjs/observable/fromEvent'
|
||||
|
@ -11,6 +12,8 @@ import { AuthService } from '../../core/auth'
|
|||
import { ComponentPagination } from '../rest/component-pagination.model'
|
||||
import { SortField } from './sort-field.type'
|
||||
import { Video } from './video.model'
|
||||
import { FeedFormat } from '../../../../../shared'
|
||||
import { VideoFeedComponent } from '@app/shared/video/video-feed.component'
|
||||
|
||||
export abstract class AbstractVideoList implements OnInit, OnDestroy {
|
||||
private static LINES_PER_PAGE = 4
|
||||
|
@ -25,6 +28,8 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
|
|||
}
|
||||
sort: SortField = '-createdAt'
|
||||
defaultSort: SortField = '-createdAt'
|
||||
syndicationItems = {}
|
||||
|
||||
loadOnInit = true
|
||||
pageHeight: number
|
||||
videoWidth: number
|
||||
|
@ -47,6 +52,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy {
|
|||
private resizeSubscription: Subscription
|
||||
|
||||
abstract getVideosObservable (page: number): Observable<{ videos: Video[], totalVideos: number}>
|
||||
abstract generateSyndicationList ()
|
||||
|
||||
get user () {
|
||||
return this.authService.getUser()
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
<div class="video-feed">
|
||||
<span *ngIf="(syndicationItems | myObjectLength) >= 1" class="icon icon-syndication"
|
||||
[popover]="feedsList"
|
||||
placement="bottom"
|
||||
[outsideClick]="true">
|
||||
</span>
|
||||
|
||||
<ng-template #feedsList>
|
||||
<div *ngFor="let key of syndicationItems | keys">
|
||||
<a [href]="syndicationItems[key]">{{ key }}</a>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
@import '_mixins';
|
||||
|
||||
.video-feed {
|
||||
a {
|
||||
@include disable-default-a-behaviour;
|
||||
|
||||
color: black;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@include icon(12px);
|
||||
|
||||
&.icon-syndication {
|
||||
position: relative;
|
||||
top: -2px;
|
||||
background-image: url('../../../assets/images/global/syndication.svg');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'my-video-feed',
|
||||
styleUrls: [ './video-feed.component.scss' ],
|
||||
templateUrl: './video-feed.component.html'
|
||||
})
|
||||
export class VideoFeedComponent implements OnChanges {
|
||||
@Input() syndicationItems
|
||||
|
||||
ngOnChanges (changes: SimpleChanges) {
|
||||
this.syndicationItems = changes.syndicationItems.currentValue
|
||||
}
|
||||
}
|
|
@ -8,6 +8,7 @@ import { ResultList } from '../../../../../shared/models/result-list.model'
|
|||
import { UserVideoRateUpdate } from '../../../../../shared/models/videos/user-video-rate-update.model'
|
||||
import { UserVideoRate } from '../../../../../shared/models/videos/user-video-rate.model'
|
||||
import { VideoFilter } from '../../../../../shared/models/videos/video-query.type'
|
||||
import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum'
|
||||
import { VideoRateType } from '../../../../../shared/models/videos/video-rate.type'
|
||||
import { VideoUpdate } from '../../../../../shared/models/videos/video-update.model'
|
||||
import { environment } from '../../../environments/environment'
|
||||
|
@ -24,6 +25,7 @@ import { objectToFormData } from '@app/shared/misc/utils'
|
|||
@Injectable()
|
||||
export class VideoService {
|
||||
private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
|
||||
private static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
|
||||
|
||||
constructor (
|
||||
private authHttp: HttpClient,
|
||||
|
@ -115,6 +117,47 @@ export class VideoService {
|
|||
.catch((res) => this.restExtractor.handleError(res))
|
||||
}
|
||||
|
||||
baseFeed () {
|
||||
const feed = {}
|
||||
|
||||
for (let item in FeedFormat) {
|
||||
feed[FeedFormat[item]] = VideoService.BASE_FEEDS_URL + item.toLowerCase()
|
||||
}
|
||||
|
||||
return feed
|
||||
}
|
||||
|
||||
getFeed (
|
||||
filter?: VideoFilter
|
||||
) {
|
||||
let params = this.restService.addRestGetParams(new HttpParams())
|
||||
const feed = this.baseFeed()
|
||||
|
||||
if (filter) {
|
||||
params = params.set('filter', filter)
|
||||
}
|
||||
for (let item in feed) {
|
||||
feed[item] = feed[item] + ((params.toString().length === 0) ? '' : '?') + params.toString()
|
||||
}
|
||||
|
||||
return feed
|
||||
}
|
||||
|
||||
getAccountFeed (
|
||||
accountId: number,
|
||||
host?: string
|
||||
) {
|
||||
let params = this.restService.addRestGetParams(new HttpParams())
|
||||
const feed = this.baseFeed()
|
||||
|
||||
params = params.set('accountId', accountId.toString())
|
||||
for (let item in feed) {
|
||||
feed[item] = feed[item] + ((params.toString().length === 0) ? '' : '?') + params.toString()
|
||||
}
|
||||
|
||||
return feed
|
||||
}
|
||||
|
||||
searchVideos (
|
||||
search: string,
|
||||
videoPagination: ComponentPagination,
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
<div class="video-info-by">
|
||||
By {{ video.by }}
|
||||
<img [src]="getAvatarPath()" alt="Account avatar" />
|
||||
<my-video-feed [syndicationItems]="syndicationItems"></my-video-feed>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -80,6 +80,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
my-video-feed {
|
||||
margin-left: 5px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.video-actions-rates {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
|
||||
import { Component, ElementRef, NgZone, OnDestroy, OnInit, ViewChild, OnChanges } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { RedirectService } from '@app/core/routing/redirect.service'
|
||||
import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage'
|
||||
|
@ -9,18 +9,20 @@ import { Subscription } from 'rxjs/Subscription'
|
|||
import * as videojs from 'video.js'
|
||||
import 'videojs-hotkeys'
|
||||
import * as WebTorrent from 'webtorrent'
|
||||
import { UserVideoRateType, VideoRateType } from '../../../../../shared'
|
||||
import { UserVideoRateType, VideoRateType, FeedFormat } from '../../../../../shared'
|
||||
import '../../../assets/player/peertube-videojs-plugin'
|
||||
import { AuthService, ConfirmService } from '../../core'
|
||||
import { VideoBlacklistService } from '../../shared'
|
||||
import { Account } from '../../shared/account/account.model'
|
||||
import { VideoDetails } from '../../shared/video/video-details.model'
|
||||
import { VideoFeedComponent } from '../../shared/video/video-feed.component'
|
||||
import { Video } from '../../shared/video/video.model'
|
||||
import { VideoService } from '../../shared/video/video.service'
|
||||
import { MarkdownService } from '../shared'
|
||||
import { VideoDownloadComponent } from './modal/video-download.component'
|
||||
import { VideoReportComponent } from './modal/video-report.component'
|
||||
import { VideoShareComponent } from './modal/video-share.component'
|
||||
import { environment } from '../../../environments/environment'
|
||||
import { getVideojsOptions } from '../../../assets/player/peertube-player'
|
||||
|
||||
@Component({
|
||||
|
@ -38,6 +40,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
|
||||
otherVideosDisplayed: Video[] = []
|
||||
|
||||
syndicationItems = {}
|
||||
|
||||
player: videojs.Player
|
||||
playerElement: HTMLVideoElement
|
||||
userRating: UserVideoRateType = null
|
||||
|
@ -98,14 +102,15 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
const uuid = routeParams['uuid']
|
||||
// Video did not changed
|
||||
// Video did not change
|
||||
if (this.video && this.video.uuid === uuid) return
|
||||
|
||||
// Video did change
|
||||
this.videoService.getVideo(uuid).subscribe(
|
||||
video => {
|
||||
const startTime = this.route.snapshot.queryParams.start
|
||||
this.onVideoFetched(video, startTime)
|
||||
.catch(err => this.handleError(err))
|
||||
this.generateSyndicationList()
|
||||
},
|
||||
|
||||
error => {
|
||||
|
@ -242,6 +247,16 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
return this.video.tags.join(', ')
|
||||
}
|
||||
|
||||
generateSyndicationList () {
|
||||
const feeds = this.videoService.getAccountFeed(
|
||||
this.video.account.id,
|
||||
(this.video.isLocal) ? environment.apiUrl : this.video.account.host
|
||||
)
|
||||
this.syndicationItems['rss 2.0'] = feeds[FeedFormat.RSS]
|
||||
this.syndicationItems['atom 1.0'] = feeds[FeedFormat.ATOM]
|
||||
this.syndicationItems['json 1.0'] = feeds[FeedFormat.JSON]
|
||||
}
|
||||
|
||||
isVideoRemovable () {
|
||||
return this.video.isRemovableBy(this.authService.getUser())
|
||||
}
|
||||
|
|
|
@ -3,9 +3,12 @@ import { ActivatedRoute, Router } from '@angular/router'
|
|||
import { immutableAssign } from '@app/shared/misc/utils'
|
||||
import { NotificationsService } from 'angular2-notifications'
|
||||
import { AuthService } from '../../core/auth'
|
||||
import { PopoverModule } from 'ngx-bootstrap/popover'
|
||||
import { AbstractVideoList } from '../../shared/video/abstract-video-list'
|
||||
import { SortField } from '../../shared/video/sort-field.type'
|
||||
import { VideoService } from '../../shared/video/video.service'
|
||||
import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum'
|
||||
import * as url from 'url'
|
||||
|
||||
@Component({
|
||||
selector: 'my-videos-local',
|
||||
|
@ -27,6 +30,7 @@ export class VideoLocalComponent extends AbstractVideoList implements OnInit, On
|
|||
|
||||
ngOnInit () {
|
||||
super.ngOnInit()
|
||||
this.generateSyndicationList()
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
|
@ -38,4 +42,11 @@ export class VideoLocalComponent extends AbstractVideoList implements OnInit, On
|
|||
|
||||
return this.videoService.getVideos(newPagination, this.sort, 'local')
|
||||
}
|
||||
|
||||
generateSyndicationList () {
|
||||
const feeds = this.videoService.getFeed('local')
|
||||
this.syndicationItems['rss 2.0'] = feeds[FeedFormat.RSS]
|
||||
this.syndicationItems['atom 1.0'] = feeds[FeedFormat.ATOM]
|
||||
this.syndicationItems['json 1.0'] = feeds[FeedFormat.JSON]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,8 @@ import { AuthService } from '../../core/auth'
|
|||
import { AbstractVideoList } from '../../shared/video/abstract-video-list'
|
||||
import { SortField } from '../../shared/video/sort-field.type'
|
||||
import { VideoService } from '../../shared/video/video.service'
|
||||
import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum'
|
||||
import * as url from 'url'
|
||||
|
||||
@Component({
|
||||
selector: 'my-videos-recently-added',
|
||||
|
@ -27,6 +29,7 @@ export class VideoRecentlyAddedComponent extends AbstractVideoList implements On
|
|||
|
||||
ngOnInit () {
|
||||
super.ngOnInit()
|
||||
this.generateSyndicationList()
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
|
@ -38,4 +41,11 @@ export class VideoRecentlyAddedComponent extends AbstractVideoList implements On
|
|||
|
||||
return this.videoService.getVideos(newPagination, this.sort)
|
||||
}
|
||||
|
||||
generateSyndicationList () {
|
||||
const feeds = this.videoService.getFeed('local')
|
||||
this.syndicationItems['rss 2.0'] = feeds[FeedFormat.RSS]
|
||||
this.syndicationItems['atom 1.0'] = feeds[FeedFormat.ATOM]
|
||||
this.syndicationItems['json 1.0'] = feeds[FeedFormat.JSON]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { Subscription } from 'rxjs/Subscription'
|
|||
import { AuthService } from '../../core/auth'
|
||||
import { AbstractVideoList } from '../../shared/video/abstract-video-list'
|
||||
import { VideoService } from '../../shared/video/video.service'
|
||||
import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum'
|
||||
|
||||
@Component({
|
||||
selector: 'my-videos-search',
|
||||
|
@ -61,4 +62,8 @@ export class VideoSearchComponent extends AbstractVideoList implements OnInit, O
|
|||
const newPagination = immutableAssign(this.pagination, { currentPage: page })
|
||||
return this.videoService.searchVideos(this.otherRouteParams.search, newPagination, this.sort)
|
||||
}
|
||||
|
||||
generateSyndicationList () {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,8 @@ import { AuthService } from '../../core/auth'
|
|||
import { AbstractVideoList } from '../../shared/video/abstract-video-list'
|
||||
import { SortField } from '../../shared/video/sort-field.type'
|
||||
import { VideoService } from '../../shared/video/video.service'
|
||||
import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum'
|
||||
import * as url from 'url'
|
||||
|
||||
@Component({
|
||||
selector: 'my-videos-trending',
|
||||
|
@ -27,6 +29,7 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit,
|
|||
|
||||
ngOnInit () {
|
||||
super.ngOnInit()
|
||||
this.generateSyndicationList()
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
|
@ -37,4 +40,11 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit,
|
|||
const newPagination = immutableAssign(this.pagination, { currentPage: page })
|
||||
return this.videoService.getVideos(newPagination, this.sort)
|
||||
}
|
||||
|
||||
generateSyndicationList () {
|
||||
const feeds = this.videoService.getFeed('local')
|
||||
this.syndicationItems['rss 2.0'] = feeds[FeedFormat.RSS]
|
||||
this.syndicationItems['atom 1.0'] = feeds[FeedFormat.ATOM]
|
||||
this.syndicationItems['json 1.0'] = feeds[FeedFormat.JSON]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Generator: Adobe Illustrator 18.1.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<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"
|
||||
viewBox="0 0 559.372 559.372" style="enable-background:new 0 0 559.372 559.372;" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<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
|
||||
c37.537,22.032,71.707,48.45,102.514,79.254c30.803,30.804,57.221,64.974,79.254,102.51
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.7 KiB |
|
@ -42,7 +42,7 @@
|
|||
// Components w/ JavaScript
|
||||
@import "~bootstrap-sass/assets/stylesheets/bootstrap/modals";
|
||||
@import "~bootstrap-sass/assets/stylesheets/bootstrap/tooltip";
|
||||
//@import "~bootstrap-sass/assets/stylesheets/bootstrap/popovers";
|
||||
@import "~bootstrap-sass/assets/stylesheets/bootstrap/popovers";
|
||||
//@import "~bootstrap-sass/assets/stylesheets/bootstrap/carousel";
|
||||
|
||||
//// Utility classes
|
||||
|
|
|
@ -81,6 +81,7 @@
|
|||
"parse-torrent": "^5.8.0",
|
||||
"password-generator": "^2.0.2",
|
||||
"pem": "^1.12.3",
|
||||
"pfeed": "^1.1.5",
|
||||
"pg": "^7.4.1",
|
||||
"pg-hstore": "^2.3.2",
|
||||
"redis": "^2.8.0",
|
||||
|
|
13
server.ts
13
server.ts
|
@ -69,7 +69,15 @@ import { installApplication } from './server/initializers'
|
|||
import { Emailer } from './server/lib/emailer'
|
||||
import { JobQueue } from './server/lib/job-queue'
|
||||
import { VideosPreviewCache } from './server/lib/cache'
|
||||
import { apiRouter, clientsRouter, staticRouter, servicesRouter, webfingerRouter, activityPubRouter } from './server/controllers'
|
||||
import {
|
||||
activityPubRouter,
|
||||
apiRouter,
|
||||
clientsRouter,
|
||||
feedsRouter,
|
||||
staticRouter,
|
||||
servicesRouter,
|
||||
webfingerRouter
|
||||
} from './server/controllers'
|
||||
import { Redis } from './server/lib/redis'
|
||||
import { BadActorFollowScheduler } from './server/lib/schedulers/bad-actor-follow-scheduler'
|
||||
import { RemoveOldJobsScheduler } from './server/lib/schedulers/remove-old-jobs-scheduler'
|
||||
|
@ -144,8 +152,9 @@ app.use(apiRoute, apiRouter)
|
|||
// Services (oembed...)
|
||||
app.use('/services', servicesRouter)
|
||||
|
||||
app.use('/', webfingerRouter)
|
||||
app.use('/', activityPubRouter)
|
||||
app.use('/', feedsRouter)
|
||||
app.use('/', webfingerRouter)
|
||||
|
||||
// Client files
|
||||
app.use('/', clientsRouter)
|
||||
|
|
|
@ -0,0 +1,136 @@
|
|||
import * as express from 'express'
|
||||
import { CONFIG } from '../initializers'
|
||||
import { asyncMiddleware, feedsValidator } from '../middlewares'
|
||||
import { VideoModel } from '../models/video/video'
|
||||
import * as Feed from 'pfeed'
|
||||
import { ResultList } from '../../shared/models'
|
||||
import { AccountModel } from '../models/account/account'
|
||||
|
||||
const feedsRouter = express.Router()
|
||||
|
||||
feedsRouter.get('/feeds/videos.:format',
|
||||
asyncMiddleware(feedsValidator),
|
||||
asyncMiddleware(generateFeed)
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
feedsRouter
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function generateFeed (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
let feed = initFeed()
|
||||
let feedStart = 0
|
||||
let feedCount = 10
|
||||
let feedSort = '-createdAt'
|
||||
|
||||
let resultList: ResultList<VideoModel>
|
||||
const account: AccountModel = res.locals.account
|
||||
|
||||
if (account) {
|
||||
resultList = await VideoModel.listUserVideosForApi(
|
||||
account.id,
|
||||
feedStart,
|
||||
feedCount,
|
||||
feedSort,
|
||||
true
|
||||
)
|
||||
} else {
|
||||
resultList = await VideoModel.listForApi(
|
||||
feedStart,
|
||||
feedCount,
|
||||
feedSort,
|
||||
req.query.filter,
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
// Adding video items to the feed, one at a time
|
||||
resultList.data.forEach(video => {
|
||||
const formattedVideoFiles = video.getFormattedVideoFilesJSON()
|
||||
const torrents = formattedVideoFiles.map(videoFile => ({
|
||||
title: video.name,
|
||||
url: videoFile.torrentUrl,
|
||||
size_in_bytes: videoFile.size
|
||||
}))
|
||||
|
||||
feed.addItem({
|
||||
title: video.name,
|
||||
id: video.url,
|
||||
link: video.url,
|
||||
description: video.getTruncatedDescription(),
|
||||
content: video.description,
|
||||
author: [
|
||||
{
|
||||
name: video.VideoChannel.Account.getDisplayName(),
|
||||
link: video.VideoChannel.Account.Actor.url
|
||||
}
|
||||
],
|
||||
date: video.publishedAt,
|
||||
language: video.language,
|
||||
nsfw: video.nsfw,
|
||||
torrent: torrents
|
||||
})
|
||||
})
|
||||
|
||||
// Now the feed generation is done, let's send it!
|
||||
return sendFeed(feed, req, res)
|
||||
}
|
||||
|
||||
function initFeed () {
|
||||
const webserverUrl = CONFIG.WEBSERVER.URL
|
||||
|
||||
return new Feed({
|
||||
title: CONFIG.INSTANCE.NAME,
|
||||
description: CONFIG.INSTANCE.SHORT_DESCRIPTION,
|
||||
// updated: TODO: somehowGetLatestUpdate, // optional, default = today
|
||||
id: webserverUrl,
|
||||
link: webserverUrl,
|
||||
image: webserverUrl + '/client/assets/images/icons/icon-96x96.png',
|
||||
favicon: webserverUrl + '/client/assets/images/favicon.png',
|
||||
copyright: `All rights reserved, unless otherwise specified in the terms specified at ${webserverUrl}/about` +
|
||||
` and potential licenses granted by each content's rightholder.`,
|
||||
generator: `Toraifōsu`, // ^.~
|
||||
feedLinks: {
|
||||
json: `${webserverUrl}/feeds/videos.json`,
|
||||
atom: `${webserverUrl}/feeds/videos.atom`,
|
||||
rss: `${webserverUrl}/feeds/videos.xml`
|
||||
},
|
||||
author: {
|
||||
name: 'instance admin of ' + CONFIG.INSTANCE.NAME,
|
||||
email: CONFIG.ADMIN.EMAIL,
|
||||
link: `${webserverUrl}/about`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function sendFeed (feed, req: express.Request, res: express.Response) {
|
||||
const format = req.params.format
|
||||
|
||||
if (format === 'atom' || format === 'atom1') {
|
||||
res.set('Content-Type', 'application/atom+xml')
|
||||
return res.send(feed.atom1()).end()
|
||||
}
|
||||
|
||||
if (format === 'json' || format === 'json1') {
|
||||
res.set('Content-Type', 'application/json')
|
||||
return res.send(feed.json1()).end()
|
||||
}
|
||||
|
||||
if (format === 'rss' || format === 'rss2') {
|
||||
res.set('Content-Type', 'application/rss+xml')
|
||||
return res.send(feed.rss2()).end()
|
||||
}
|
||||
|
||||
// We're in the ambiguous '.xml' case and we look at the format query parameter
|
||||
if (req.query.format === 'atom' || req.query.format === 'atom1') {
|
||||
res.set('Content-Type', 'application/atom+xml')
|
||||
return res.send(feed.atom1()).end()
|
||||
}
|
||||
|
||||
res.set('Content-Type', 'application/rss+xml')
|
||||
return res.send(feed.rss2()).end()
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
export * from './activitypub'
|
||||
export * from './static'
|
||||
export * from './client'
|
||||
export * from './services'
|
||||
export * from './api'
|
||||
export * from './client'
|
||||
export * from './feeds'
|
||||
export * from './services'
|
||||
export * from './static'
|
||||
export * from './webfinger'
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import { exists } from './misc'
|
||||
|
||||
function isValidRSSFeed (value: string) {
|
||||
if (!exists(value)) return false
|
||||
|
||||
const feedExtensions = [
|
||||
'xml',
|
||||
'json',
|
||||
'json1',
|
||||
'rss',
|
||||
'rss2',
|
||||
'atom',
|
||||
'atom1'
|
||||
]
|
||||
|
||||
return feedExtensions.indexOf(value) !== -1
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
isValidRSSFeed
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import * as express from 'express'
|
||||
import { param, query } from 'express-validator/check'
|
||||
import { isAccountIdExist, isAccountNameValid, isLocalAccountNameExist } from '../../helpers/custom-validators/accounts'
|
||||
import { join } from 'path'
|
||||
import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
|
||||
import { logger } from '../../helpers/logger'
|
||||
import { areValidationErrors } from './utils'
|
||||
import { isValidRSSFeed } from '../../helpers/custom-validators/feeds'
|
||||
|
||||
const feedsValidator = [
|
||||
param('format').optional().custom(isValidRSSFeed).withMessage('Should have a valid format (rss, atom, json)'),
|
||||
query('format').optional().custom(isValidRSSFeed).withMessage('Should have a valid format (rss, atom, json)'),
|
||||
query('accountId').optional().custom(isIdOrUUIDValid),
|
||||
query('accountName').optional().custom(isAccountNameValid),
|
||||
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.debug('Checking feeds parameters', { parameters: req.query })
|
||||
|
||||
if (areValidationErrors(req, res)) return
|
||||
|
||||
if (req.query.accountId) {
|
||||
if (!await isAccountIdExist(req.query.accountId, res)) return
|
||||
} else if (req.query.accountName) {
|
||||
if (!await isLocalAccountNameExist(req.query.accountName, res)) return
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
feedsValidator
|
||||
}
|
|
@ -3,6 +3,7 @@ export * from './oembed'
|
|||
export * from './activitypub'
|
||||
export * from './pagination'
|
||||
export * from './follows'
|
||||
export * from './feeds'
|
||||
export * from './sort'
|
||||
export * from './users'
|
||||
export * from './videos'
|
||||
|
|
|
@ -246,7 +246,7 @@ export class AccountModel extends Model<AccountModel> {
|
|||
const actor = this.Actor.toFormattedJSON()
|
||||
const account = {
|
||||
id: this.id,
|
||||
displayName: this.name,
|
||||
displayName: this.getDisplayName(),
|
||||
description: this.description,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt
|
||||
|
@ -266,4 +266,8 @@ export class AccountModel extends Model<AccountModel> {
|
|||
isOwned () {
|
||||
return this.Actor.isOwned()
|
||||
}
|
||||
|
||||
getDisplayName () {
|
||||
return this.name
|
||||
}
|
||||
}
|
||||
|
|
|
@ -95,7 +95,8 @@ enum ScopeNames {
|
|||
}
|
||||
|
||||
@Scopes({
|
||||
[ScopeNames.AVAILABLE_FOR_LIST]: (actorId: number, filter?: VideoFilter) => ({
|
||||
[ScopeNames.AVAILABLE_FOR_LIST]: (actorId: number, filter?: VideoFilter, withFiles?: boolean) => {
|
||||
const query: IFindOptions<VideoModel> = {
|
||||
where: {
|
||||
id: {
|
||||
[Sequelize.Op.notIn]: Sequelize.literal(
|
||||
|
@ -130,7 +131,7 @@ enum ScopeNames {
|
|||
required: true,
|
||||
include: [
|
||||
{
|
||||
attributes: [ 'preferredUsername', 'url', 'serverId' ],
|
||||
attributes: [ 'preferredUsername', 'url', 'serverId', 'avatarId' ],
|
||||
model: ActorModel.unscoped(),
|
||||
required: true,
|
||||
where: VideoModel.buildActorWhereWithFilter(filter),
|
||||
|
@ -151,7 +152,17 @@ enum ScopeNames {
|
|||
]
|
||||
}
|
||||
]
|
||||
}),
|
||||
}
|
||||
|
||||
if (withFiles === true) {
|
||||
query.include.push({
|
||||
model: VideoFileModel.unscoped(),
|
||||
required: true
|
||||
})
|
||||
}
|
||||
|
||||
return query
|
||||
},
|
||||
[ScopeNames.WITH_ACCOUNT_DETAILS]: {
|
||||
include: [
|
||||
{
|
||||
|
@ -629,8 +640,8 @@ export class VideoModel extends Model<VideoModel> {
|
|||
})
|
||||
}
|
||||
|
||||
static listUserVideosForApi (userId: number, start: number, count: number, sort: string) {
|
||||
const query = {
|
||||
static listUserVideosForApi (userId: number, start: number, count: number, sort: string, withFiles = false) {
|
||||
const query: IFindOptions<VideoModel> = {
|
||||
offset: start,
|
||||
limit: count,
|
||||
order: getSort(sort),
|
||||
|
@ -651,6 +662,13 @@ export class VideoModel extends Model<VideoModel> {
|
|||
]
|
||||
}
|
||||
|
||||
if (withFiles === true) {
|
||||
query.include.push({
|
||||
model: VideoFileModel.unscoped(),
|
||||
required: true
|
||||
})
|
||||
}
|
||||
|
||||
return VideoModel.findAndCountAll(query).then(({ rows, count }) => {
|
||||
return {
|
||||
data: rows,
|
||||
|
@ -659,7 +677,7 @@ export class VideoModel extends Model<VideoModel> {
|
|||
})
|
||||
}
|
||||
|
||||
static async listForApi (start: number, count: number, sort: string, filter?: VideoFilter) {
|
||||
static async listForApi (start: number, count: number, sort: string, filter?: VideoFilter, withFiles = false) {
|
||||
const query = {
|
||||
offset: start,
|
||||
limit: count,
|
||||
|
@ -668,7 +686,7 @@ export class VideoModel extends Model<VideoModel> {
|
|||
|
||||
const serverActor = await getServerActor()
|
||||
|
||||
return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST, serverActor.id, filter ] })
|
||||
return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST, serverActor.id, filter, withFiles ] })
|
||||
.findAndCountAll(query)
|
||||
.then(({ rows, count }) => {
|
||||
return {
|
||||
|
@ -707,7 +725,8 @@ export class VideoModel extends Model<VideoModel> {
|
|||
const serverActor = await getServerActor()
|
||||
|
||||
return VideoModel.scope({ method: [ ScopeNames.AVAILABLE_FOR_LIST, serverActor.id ] })
|
||||
.findAndCountAll(query).then(({ rows, count }) => {
|
||||
.findAndCountAll(query)
|
||||
.then(({ rows, count }) => {
|
||||
return {
|
||||
data: rows,
|
||||
total: count
|
||||
|
@ -1006,8 +1025,15 @@ export class VideoModel extends Model<VideoModel> {
|
|||
}
|
||||
|
||||
// Format and sort video files
|
||||
detailsJson.files = this.getFormattedVideoFilesJSON()
|
||||
|
||||
return Object.assign(formattedJson, detailsJson)
|
||||
}
|
||||
|
||||
getFormattedVideoFilesJSON (): VideoFile[] {
|
||||
const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
|
||||
detailsJson.files = this.VideoFiles
|
||||
|
||||
return this.VideoFiles
|
||||
.map(videoFile => {
|
||||
let resolutionLabel = videoFile.resolution + 'p'
|
||||
|
||||
|
@ -1027,8 +1053,6 @@ export class VideoModel extends Model<VideoModel> {
|
|||
if (a.resolution.id === b.resolution.id) return 0
|
||||
return -1
|
||||
})
|
||||
|
||||
return Object.assign(formattedJson, detailsJson)
|
||||
}
|
||||
|
||||
toActivityPubObject (): VideoTorrentObject {
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
export enum FeedFormat {
|
||||
RSS = 'xml',
|
||||
ATOM = 'atom',
|
||||
JSON = 'json'
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export * from './feed-format.enum'
|
|
@ -2,6 +2,7 @@ export * from './actors'
|
|||
export * from './activitypub'
|
||||
export * from './users'
|
||||
export * from './videos'
|
||||
export * from './feeds'
|
||||
export * from './server/job.model'
|
||||
export * from './oauth-client-local.model'
|
||||
export * from './result-list.model'
|
||||
|
|
|
@ -78,6 +78,38 @@ paths:
|
|||
description: successful operation
|
||||
schema:
|
||||
$ref: '#/definitions/ServerConfig'
|
||||
/feeds/videos.{format}:
|
||||
get:
|
||||
tags:
|
||||
- Feeds
|
||||
consumes:
|
||||
- application/json
|
||||
produces:
|
||||
- application/json
|
||||
parameters:
|
||||
- name: format
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
enum: ['xml', 'atom' 'json']
|
||||
default: 'xml'
|
||||
description: 'The format expected (xml defaults to RSS 2.0, atom to ATOM 1.0 and json to JSON FEED 1.0'
|
||||
- name: accountId
|
||||
in: query
|
||||
required: false
|
||||
type: number
|
||||
description: 'The id of the local account to filter to (beware, users IDs and not actors IDs which will return empty feeds'
|
||||
- name: accountName
|
||||
in: query
|
||||
required: false
|
||||
type: string
|
||||
description: 'The name of the local account to filter to'
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
application/xml:
|
||||
/jobs:
|
||||
get:
|
||||
security:
|
||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -4585,6 +4585,12 @@ performance-now@^2.1.0:
|
|||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
|
||||
|
||||
pfeed@^1.1.2:
|
||||
version "1.1.5"
|
||||
resolved "https://registry.yarnpkg.com/pfeed/-/pfeed-1.1.5.tgz#6d0ab54209c60b45de03a15efaab7be867a3f71a"
|
||||
dependencies:
|
||||
xml "^1.0.1"
|
||||
|
||||
pg-connection-string@0.1.3:
|
||||
version "0.1.3"
|
||||
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-0.1.3.tgz#da1847b20940e42ee1492beaf65d49d91b245df7"
|
||||
|
@ -6792,6 +6798,10 @@ xhr2@^0.1.4:
|
|||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/xhr2/-/xhr2-0.1.4.tgz#7f87658847716db5026323812f818cadab387a5f"
|
||||
|
||||
xml@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/xml/-/xml-1.0.1.tgz#78ba72020029c5bc87b8a81a3cfcd74b4a2fc1e5"
|
||||
|
||||
xmldom@0.1.19:
|
||||
version "0.1.19"
|
||||
resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.19.tgz#631fc07776efd84118bf25171b37ed4d075a0abc"
|
||||
|
|
Loading…
Reference in New Issue