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.
pull/500/head
Rigel Kent 2018-04-17 00:49:04 +02:00 committed by Rigel
parent c36d5a6b98
commit 244e76a552
No known key found for this signature in database
GPG Key ID: EA12971B0E438F36
33 changed files with 608 additions and 84 deletions

View File

@ -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)

View File

@ -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
}
}

View File

@ -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
],

View File

@ -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"

View File

@ -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;

View File

@ -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()

View File

@ -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>

View File

@ -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');
}
}
}

View File

@ -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
}
}

View File

@ -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,

View File

@ -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>

View File

@ -80,6 +80,11 @@
}
}
my-video-feed {
margin-left: 5px;
margin-top: 1px;
}
.video-actions-rates {
display: flex;
flex-direction: column;

View File

@ -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())
}

View File

@ -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]
}
}

View File

@ -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]
}
}

View File

@ -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.')
}
}

View File

@ -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]
}
}

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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)

136
server/controllers/feeds.ts Normal file
View File

@ -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()
}

View File

@ -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'

View File

@ -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
}

View File

@ -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
}

View File

@ -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'

View File

@ -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
}
}

View File

@ -95,14 +95,15 @@ enum ScopeNames {
}
@Scopes({
[ScopeNames.AVAILABLE_FOR_LIST]: (actorId: number, filter?: VideoFilter) => ({
where: {
id: {
[Sequelize.Op.notIn]: Sequelize.literal(
'(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
),
[ Sequelize.Op.in ]: Sequelize.literal(
'(' +
[ScopeNames.AVAILABLE_FOR_LIST]: (actorId: number, filter?: VideoFilter, withFiles?: boolean) => {
const query: IFindOptions<VideoModel> = {
where: {
id: {
[Sequelize.Op.notIn]: Sequelize.literal(
'(SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")'
),
[ Sequelize.Op.in ]: Sequelize.literal(
'(' +
'SELECT "videoShare"."videoId" AS "id" FROM "videoShare" ' +
'INNER JOIN "actorFollow" ON "actorFollow"."targetActorId" = "videoShare"."actorId" ' +
'WHERE "actorFollow"."actorId" = ' + parseInt(actorId.toString(), 10) +
@ -113,45 +114,55 @@ enum ScopeNames {
'INNER JOIN "actor" ON "account"."actorId" = "actor"."id" ' +
'LEFT JOIN "actorFollow" ON "actorFollow"."targetActorId" = "actor"."id" ' +
'WHERE "actor"."serverId" IS NULL OR "actorFollow"."actorId" = ' + parseInt(actorId.toString(), 10) +
')'
)
')'
)
},
privacy: VideoPrivacy.PUBLIC
},
privacy: VideoPrivacy.PUBLIC
},
include: [
{
attributes: [ 'name', 'description' ],
model: VideoChannelModel.unscoped(),
required: true,
include: [
{
attributes: [ 'name' ],
model: AccountModel.unscoped(),
required: true,
include: [
{
attributes: [ 'preferredUsername', 'url', 'serverId' ],
model: ActorModel.unscoped(),
required: true,
where: VideoModel.buildActorWhereWithFilter(filter),
include: [
{
attributes: [ 'host' ],
model: ServerModel.unscoped(),
required: false
},
{
model: AvatarModel.unscoped(),
required: false
}
]
}
]
}
]
}
]
}),
include: [
{
attributes: [ 'name', 'description' ],
model: VideoChannelModel.unscoped(),
required: true,
include: [
{
attributes: [ 'name' ],
model: AccountModel.unscoped(),
required: true,
include: [
{
attributes: [ 'preferredUsername', 'url', 'serverId', 'avatarId' ],
model: ActorModel.unscoped(),
required: true,
where: VideoModel.buildActorWhereWithFilter(filter),
include: [
{
attributes: [ 'host' ],
model: ServerModel.unscoped(),
required: false
},
{
model: AvatarModel.unscoped(),
required: false
}
]
}
]
}
]
}
]
}
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,31 +1025,36 @@ export class VideoModel extends Model<VideoModel> {
}
// Format and sort video files
const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
detailsJson.files = this.VideoFiles
.map(videoFile => {
let resolutionLabel = videoFile.resolution + 'p'
return {
resolution: {
id: videoFile.resolution,
label: resolutionLabel
},
magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
size: videoFile.size,
torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp)
} as VideoFile
})
.sort((a, b) => {
if (a.resolution.id < b.resolution.id) return 1
if (a.resolution.id === b.resolution.id) return 0
return -1
})
detailsJson.files = this.getFormattedVideoFilesJSON()
return Object.assign(formattedJson, detailsJson)
}
getFormattedVideoFilesJSON (): VideoFile[] {
const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
return this.VideoFiles
.map(videoFile => {
let resolutionLabel = videoFile.resolution + 'p'
return {
resolution: {
id: videoFile.resolution,
label: resolutionLabel
},
magnetUri: this.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
size: videoFile.size,
torrentUrl: this.getTorrentUrl(videoFile, baseUrlHttp),
fileUrl: this.getVideoFileUrl(videoFile, baseUrlHttp)
} as VideoFile
})
.sort((a, b) => {
if (a.resolution.id < b.resolution.id) return 1
if (a.resolution.id === b.resolution.id) return 0
return -1
})
}
toActivityPubObject (): VideoTorrentObject {
const { baseUrlHttp, baseUrlWs } = this.getBaseUrls()
if (!this.Tags) this.Tags = []

View File

@ -0,0 +1,5 @@
export enum FeedFormat {
RSS = 'xml',
ATOM = 'atom',
JSON = 'json'
}

View File

@ -0,0 +1 @@
export * from './feed-format.enum'

View File

@ -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'

View File

@ -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:

View File

@ -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"