Add Podcast RSS feeds (#5487)

* Initial test implementation of Podcast RSS

This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.

I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.

* Update to pfeed-podcast 1.2.2

* Initial test implementation of Podcast RSS

This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.

I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.

* Update to pfeed-podcast 1.2.2

* Initial test implementation of Podcast RSS

This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.

I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.

* Update to pfeed-podcast 1.2.2

* Add correct feed image to RSS channel

* Prefer HLS videos for podcast RSS

Remove video/stream titles, add optional height attribute to podcast RSS

* Prefix podcast RSS images with root server URL

* Add optional video query support to include captions

* Add transcripts & person images to podcast RSS feed

* Prefer webseed/webtorrent files over HLS fragmented mp4s

* Experimentally adding podcast fields to basic config page

* Add validation for new basic config fields

* Don't include "content" in podcast feed, use full description for "description"

* Initial test implementation of Podcast RSS

This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option.

I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort.

* Update to pfeed-podcast 1.2.2

* Add correct feed image to RSS channel

* Prefer HLS videos for podcast RSS

Remove video/stream titles, add optional height attribute to podcast RSS

* Prefix podcast RSS images with root server URL

* Add optional video query support to include captions

* Add transcripts & person images to podcast RSS feed

* Prefer webseed/webtorrent files over HLS fragmented mp4s

* Experimentally adding podcast fields to basic config page

* Add validation for new basic config fields

* Don't include "content" in podcast feed, use full description for "description"

* Add medium/socialInteract to podcast RSS feeds. Use HTML for description

* Change base production image to bullseye, install prosody in image

* Add liveItem and trackers to Podcast RSS feeds

Remove height from alternateEnclosure, replaced with title.

* Clear Podcast RSS feed cache when live streams start/end

* Upgrade to Node 16

* Refactor clearCacheRoute to use ApiCache

* Remove unnecessary type hint

* Update dockerfile to node 16, install python-is-python2

* Use new file paths for captions/playlists

* Fix legacy videos in RSS after migration to object storage

* Improve method of identifying non-fragmented mp4s in podcast RSS feeds

* Don't include fragmented MP4s in podcast RSS feeds

* Add experimental support for podcast:categories on the podcast RSS item

* Fix undefined category when no videos exist

Allows for empty feeds to exist (important for feeds that might only go live)

* Add support for podcast:locked -- user has to opt in to show their email

* Use comma for podcast:categories delimiter

* Make cache clearing async

* Fix merge, temporarily test with pfeed-podcast

* Syntax changes

* Add EXT_MIMETYPE constants for captions

* Update & fix tests, fix enclosure mimetypes, remove admin email

* Add test for podacst:socialInteract

* Add filters hooks for podcast customTags

* Remove showdown, updated to pfeed-podcast 6.1.2

* Add 'action:api.live-video.state.updated' hook

* Avoid assigning undefined category to podcast feeds

* Remove nvmrc

* Remove comment

* Remove unused podcast config

* Remove more unused podcast config

* Fix MChannelAccountDefault type hint missed in merge

* Remove extra line

* Re-add newline in config

* Fix lint errors for isEmailPublic

* Fix thumbnails in podcast feeds

* Requested changes based on review

* Provide podcast rss 2.0 only on video channels

* Misc cleanup for a less messy PR

* Lint fixes

* Remove pfeed-podcast

* Add peertube version to new hooks

* Don't use query include, remove TODO

* Remove film medium hack

* Clear podcast rss cache before video/channel update hooks

* Clear podcast rss cache before video uploaded/deleted hooks

* Refactor podcast feed cache clearing

* Set correct person name from video channel

* Styling

* Fix tests

---------

Co-authored-by: Chocobozzz <me@florianbigard.com>
pull/5817/head
Alecks Gates 2023-05-22 09:00:05 -05:00 committed by GitHub
parent 3f0ceab06e
commit cb0eda5602
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 1712 additions and 614 deletions

View File

@ -1,7 +1,7 @@
import { forkJoin } from 'rxjs'
import { tap } from 'rxjs/operators'
import { Component, OnInit } from '@angular/core'
import { AuthService, ServerService, UserService } from '@app/core'
import { AuthService, Notifier, ServerService, UserService } from '@app/core'
import { USER_EMAIL_VALIDATOR, USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators'
import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { HttpStatusCode, User } from '@shared/models'
@ -20,7 +20,8 @@ export class MyAccountChangeEmailComponent extends FormReactive implements OnIni
protected formReactiveService: FormReactiveService,
private authService: AuthService,
private userService: UserService,
private serverService: ServerService
private serverService: ServerService,
private notifier: Notifier
) {
super()
}

View File

@ -0,0 +1 @@
export * from './my-account-email-preferences.component'

View File

@ -0,0 +1,15 @@
<form role="form" (ngSubmit)="updateEmailPublic()" [formGroup]="form">
<div class="form-group">
<my-peertube-checkbox
inputName="email-public" formControlName="email-public"
i18n-labelText labelText="Allow email to be publicly displayed"
>
<ng-container ngProjectAs="description">
<span i18n>Necessary to claim podcast RSS feeds.</span>
</ng-container>
</my-peertube-checkbox>
</div>
<input class="peertube-button orange-button" type="submit" i18n-value value="Save" [disabled]="!form.valid">
</form>

View File

@ -0,0 +1,51 @@
import { Subject } from 'rxjs'
import { Component, Input, OnInit } from '@angular/core'
import { Notifier, UserService } from '@app/core'
import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { User, UserUpdateMe } from '@shared/models'
@Component({
selector: 'my-account-email-preferences',
templateUrl: './my-account-email-preferences.component.html',
styleUrls: [ './my-account-email-preferences.component.scss' ]
})
export class MyAccountEmailPreferencesComponent extends FormReactive implements OnInit {
@Input() user: User = null
@Input() userInformationLoaded: Subject<any>
constructor (
protected formReactiveService: FormReactiveService,
private userService: UserService,
private notifier: Notifier
) {
super()
}
ngOnInit () {
this.buildForm({
'email-public': null
})
this.userInformationLoaded.subscribe(() => {
this.form.patchValue({ 'email-public': this.user.emailPublic })
})
}
updateEmailPublic () {
const details: UserUpdateMe = {
emailPublic: this.form.value['email-public']
}
this.userService.updateMyProfile(details)
.subscribe({
next: () => {
if (details.emailPublic) this.notifier.success($localize`Email is now public`)
else this.notifier.success($localize`Email is now private`)
this.user.emailPublic = details.emailPublic
},
error: err => console.log(err.message)
})
}
}

View File

@ -68,7 +68,7 @@
</div>
<div class="col-12 col-lg-8 col-xl-9">
<my-account-two-factor-button [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-two-factor-button>
<my-account-two-factor-button [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-two-factor-button>
</div>
</div>
@ -78,6 +78,8 @@
</div>
<div class="col-12 col-lg-8 col-xl-9">
<my-account-email-preferences class="d-block mb-5" [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-email-preferences>
<my-account-change-email></my-account-change-email>
</div>
</div>

View File

@ -22,6 +22,7 @@ import { MyAccountRoutingModule } from './my-account-routing.module'
import { MyAccountChangeEmailComponent } from './my-account-settings/my-account-change-email'
import { MyAccountChangePasswordComponent } from './my-account-settings/my-account-change-password/my-account-change-password.component'
import { MyAccountDangerZoneComponent } from './my-account-settings/my-account-danger-zone'
import { MyAccountEmailPreferencesComponent } from './my-account-settings/my-account-email-preferences'
import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences'
import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component'
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
@ -65,7 +66,9 @@ import { MyAccountComponent } from './my-account.component'
MyAccountAbusesListComponent,
MyAccountServerBlocklistComponent,
MyAccountNotificationsComponent,
MyAccountNotificationPreferencesComponent
MyAccountNotificationPreferencesComponent,
MyAccountEmailPreferencesComponent
],
exports: [

View File

@ -19,6 +19,7 @@ export class User implements UserServerModel {
pendingEmail: string | null
emailVerified: boolean
emailPublic: boolean
nsfwPolicy: NSFWPolicyType
adminFlags?: UserAdminFlag

View File

@ -54,6 +54,7 @@ export type CommonVideoParams = {
export class VideoService {
static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos'
static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
static PODCAST_FEEDS_URL = environment.apiUrl + '/feeds/podcast/videos.xml'
static BASE_SUBSCRIPTION_FEEDS_URL = environment.apiUrl + '/feeds/subscriptions.'
constructor (
@ -266,7 +267,15 @@ export class VideoService {
let params = this.restService.addRestGetParams(new HttpParams())
params = params.set('videoChannelId', videoChannelId.toString())
return this.buildBaseFeedUrls(params)
const feedUrls = this.buildBaseFeedUrls(params)
feedUrls.push({
format: FeedFormat.RSS,
label: 'podcast rss 2.0',
url: VideoService.PODCAST_FEEDS_URL + `?videoChannelId=${videoChannelId}`
})
return feedUrls
}
getVideoSubscriptionFeedUrls (accountId: number, feedToken: string) {

View File

@ -97,7 +97,7 @@
"@opentelemetry/sdk-trace-base": "^1.3.1",
"@opentelemetry/sdk-trace-node": "^1.3.1",
"@opentelemetry/semantic-conventions": "^1.3.1",
"@peertube/feed": "^5.0.1",
"@peertube/feed": "^5.1.0",
"@peertube/http-signature": "^1.7.0",
"@uploadx/core": "^6.0.0",
"async-lru": "^1.1.1",
@ -135,7 +135,7 @@
"jimp": "^0.22.4",
"js-yaml": "^4.0.0",
"jsonld": "~8.1.0",
"lodash": "^4.17.10",
"lodash": "^4.17.21",
"lru-cache": "^7.13.0",
"magnet-uri": "^6.1.0",
"markdown-it": "^13.0.1",

View File

@ -212,7 +212,8 @@ async function updateMe (req: express.Request, res: express.Response) {
'theme',
'noInstanceConfigWarningModal',
'noAccountSetupWarningModal',
'noWelcomeModal'
'noWelcomeModal',
'emailPublic'
]
for (const key of keysToUpdate) {

View File

@ -2,10 +2,12 @@ import express from 'express'
import { Transaction } from 'sequelize/types'
import { changeVideoChannelShare } from '@server/lib/activitypub/share'
import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
import { VideoPathManager } from '@server/lib/video-path-manager'
import { setVideoPrivacy } from '@server/lib/video-privacy'
import { openapiOperationDoc } from '@server/middlewares/doc'
import { FilteredModelAttributes } from '@server/types'
import { MVideoFullLight } from '@server/types/models'
import { forceNumber } from '@shared/core-utils'
import { HttpStatusCode, VideoUpdate } from '@shared/models'
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
import { resetSequelizeInstance } from '../../../helpers/database-utils'
@ -18,8 +20,6 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares'
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
import { VideoModel } from '../../../models/video/video'
import { VideoPathManager } from '@server/lib/video-path-manager'
import { forceNumber } from '@shared/core-utils'
const lTags = loggerTagsFactory('api', 'video')
const auditLogger = auditLoggerFactory('videos')

View File

@ -1,389 +0,0 @@
import express from 'express'
import { extname } from 'path'
import { Feed } from '@peertube/feed'
import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown'
import { getServerActor } from '@server/models/application/application'
import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils'
import { MAccountDefault, MChannelBannerAccountDefault, MVideoFullLight } from '@server/types/models'
import { ActorImageType, VideoInclude } from '@shared/models'
import { buildNSFWFilter } from '../helpers/express-utils'
import { CONFIG } from '../initializers/config'
import { MIMETYPES, PREVIEWS_SIZE, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../initializers/constants'
import {
asyncMiddleware,
commonVideosFiltersValidator,
feedsFormatValidator,
setDefaultVideosSort,
setFeedFormatContentType,
videoCommentsFeedsValidator,
videoFeedsValidator,
videosSortValidator,
videoSubscriptionFeedsValidator
} from '../middlewares'
import { cacheRouteFactory } from '../middlewares/cache/cache'
import { VideoModel } from '../models/video/video'
import { VideoCommentModel } from '../models/video/video-comment'
const feedsRouter = express.Router()
const cacheRoute = cacheRouteFactory({
headerBlacklist: [ 'Content-Type' ]
})
feedsRouter.get('/feeds/video-comments.:format',
feedsFormatValidator,
setFeedFormatContentType,
cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS),
asyncMiddleware(videoFeedsValidator),
asyncMiddleware(videoCommentsFeedsValidator),
asyncMiddleware(generateVideoCommentsFeed)
)
feedsRouter.get('/feeds/videos.:format',
videosSortValidator,
setDefaultVideosSort,
feedsFormatValidator,
setFeedFormatContentType,
cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS),
commonVideosFiltersValidator,
asyncMiddleware(videoFeedsValidator),
asyncMiddleware(generateVideoFeed)
)
feedsRouter.get('/feeds/subscriptions.:format',
videosSortValidator,
setDefaultVideosSort,
feedsFormatValidator,
setFeedFormatContentType,
cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS),
commonVideosFiltersValidator,
asyncMiddleware(videoSubscriptionFeedsValidator),
asyncMiddleware(generateVideoFeedForSubscriptions)
)
// ---------------------------------------------------------------------------
export {
feedsRouter
}
// ---------------------------------------------------------------------------
async function generateVideoCommentsFeed (req: express.Request, res: express.Response) {
const start = 0
const video = res.locals.videoAll
const account = res.locals.account
const videoChannel = res.locals.videoChannel
const comments = await VideoCommentModel.listForFeed({
start,
count: CONFIG.FEEDS.COMMENTS.COUNT,
videoId: video ? video.id : undefined,
accountId: account ? account.id : undefined,
videoChannelId: videoChannel ? videoChannel.id : undefined
})
const { name, description, imageUrl } = buildFeedMetadata({ video, account, videoChannel })
const feed = initFeed({
name,
description,
imageUrl,
resourceType: 'video-comments',
queryString: new URL(WEBSERVER.URL + req.originalUrl).search
})
// Adding video items to the feed, one at a time
for (const comment of comments) {
const localLink = WEBSERVER.URL + comment.getCommentStaticPath()
let title = comment.Video.name
const author: { name: string, link: string }[] = []
if (comment.Account) {
title += ` - ${comment.Account.getDisplayName()}`
author.push({
name: comment.Account.getDisplayName(),
link: comment.Account.Actor.url
})
}
feed.addItem({
title,
id: localLink,
link: localLink,
content: toSafeHtml(comment.text),
author,
date: comment.createdAt
})
}
// Now the feed generation is done, let's send it!
return sendFeed(feed, req, res)
}
async function generateVideoFeed (req: express.Request, res: express.Response) {
const start = 0
const account = res.locals.account
const videoChannel = res.locals.videoChannel
const nsfw = buildNSFWFilter(res, req.query.nsfw)
const { name, description, imageUrl } = buildFeedMetadata({ videoChannel, account })
const feed = initFeed({
name,
description,
imageUrl,
resourceType: 'videos',
queryString: new URL(WEBSERVER.URL + req.url).search
})
const options = {
accountId: account ? account.id : null,
videoChannelId: videoChannel ? videoChannel.id : null
}
const server = await getServerActor()
const { data } = await VideoModel.listForApi({
start,
count: CONFIG.FEEDS.VIDEOS.COUNT,
sort: req.query.sort,
displayOnlyForFollower: {
actorId: server.id,
orLocalVideos: true
},
nsfw,
isLocal: req.query.isLocal,
include: req.query.include | VideoInclude.FILES,
hasFiles: true,
countVideos: false,
...options
})
addVideosToFeed(feed, data)
// Now the feed generation is done, let's send it!
return sendFeed(feed, req, res)
}
async function generateVideoFeedForSubscriptions (req: express.Request, res: express.Response) {
const start = 0
const account = res.locals.account
const nsfw = buildNSFWFilter(res, req.query.nsfw)
const { name, description, imageUrl } = buildFeedMetadata({ account })
const feed = initFeed({
name,
description,
imageUrl,
resourceType: 'videos',
queryString: new URL(WEBSERVER.URL + req.url).search
})
const { data } = await VideoModel.listForApi({
start,
count: CONFIG.FEEDS.VIDEOS.COUNT,
sort: req.query.sort,
nsfw,
isLocal: req.query.isLocal,
hasFiles: true,
include: req.query.include | VideoInclude.FILES,
countVideos: false,
displayOnlyForFollower: {
actorId: res.locals.user.Account.Actor.id,
orLocalVideos: false
},
user: res.locals.user
})
addVideosToFeed(feed, data)
// Now the feed generation is done, let's send it!
return sendFeed(feed, req, res)
}
function initFeed (parameters: {
name: string
description: string
imageUrl: string
resourceType?: 'videos' | 'video-comments'
queryString?: string
}) {
const webserverUrl = WEBSERVER.URL
const { name, description, resourceType, queryString, imageUrl } = parameters
return new Feed({
title: name,
description: mdToOneLinePlainText(description),
// updated: TODO: somehowGetLatestUpdate, // optional, default = today
id: webserverUrl,
link: webserverUrl,
image: imageUrl,
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/${resourceType}.json${queryString}`,
atom: `${webserverUrl}/feeds/${resourceType}.atom${queryString}`,
rss: `${webserverUrl}/feeds/${resourceType}.xml${queryString}`
},
author: {
name: 'Instance admin of ' + CONFIG.INSTANCE.NAME,
email: CONFIG.ADMIN.EMAIL,
link: `${webserverUrl}/about`
}
})
}
function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
for (const video of videos) {
const formattedVideoFiles = video.getFormattedVideoFilesJSON(false)
const torrents = formattedVideoFiles.map(videoFile => ({
title: video.name,
url: videoFile.torrentUrl,
size_in_bytes: videoFile.size
}))
const videoFiles = formattedVideoFiles.map(videoFile => {
const result = {
type: MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)],
medium: 'video',
height: videoFile.resolution.id,
fileSize: videoFile.size,
url: videoFile.fileUrl,
framerate: videoFile.fps,
duration: video.duration
}
if (video.language) Object.assign(result, { lang: video.language })
return result
})
const categories: { value: number, label: string }[] = []
if (video.category) {
categories.push({
value: video.category,
label: getCategoryLabel(video.category)
})
}
const localLink = WEBSERVER.URL + video.getWatchStaticPath()
feed.addItem({
title: video.name,
id: localLink,
link: localLink,
description: mdToOneLinePlainText(video.getTruncatedDescription()),
content: toSafeHtml(video.description),
author: [
{
name: video.VideoChannel.getDisplayName(),
link: video.VideoChannel.Actor.url
}
],
date: video.publishedAt,
nsfw: video.nsfw,
torrents,
// Enclosure
video: videoFiles.length !== 0
? {
url: videoFiles[0].url,
length: videoFiles[0].fileSize,
type: videoFiles[0].type
}
: undefined,
// Media RSS
videos: videoFiles,
embed: {
url: WEBSERVER.URL + video.getEmbedStaticPath(),
allowFullscreen: true
},
player: {
url: WEBSERVER.URL + video.getWatchStaticPath()
},
categories,
community: {
statistics: {
views: video.views
}
},
thumbnails: [
{
url: WEBSERVER.URL + video.getPreviewStaticPath(),
height: PREVIEWS_SIZE.height,
width: PREVIEWS_SIZE.width
}
]
})
}
}
function sendFeed (feed: Feed, req: express.Request, res: express.Response) {
const format = req.params.format
if (format === 'atom' || format === 'atom1') {
return res.send(feed.atom1()).end()
}
if (format === 'json' || format === 'json1') {
return res.send(feed.json1()).end()
}
if (format === 'rss' || format === 'rss2') {
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') {
return res.send(feed.atom1()).end()
}
return res.send(feed.rss2()).end()
}
function buildFeedMetadata (options: {
videoChannel?: MChannelBannerAccountDefault
account?: MAccountDefault
video?: MVideoFullLight
}) {
const { video, videoChannel, account } = options
let imageUrl = WEBSERVER.URL + '/client/assets/images/icons/icon-96x96.png'
let name: string
let description: string
if (videoChannel) {
name = videoChannel.getDisplayName()
description = videoChannel.description
if (videoChannel.Actor.hasImage(ActorImageType.AVATAR)) {
imageUrl = WEBSERVER.URL + videoChannel.Actor.Avatars[0].getStaticPath()
}
} else if (account) {
name = account.getDisplayName()
description = account.description
if (account.Actor.hasImage(ActorImageType.AVATAR)) {
imageUrl = WEBSERVER.URL + account.Actor.Avatars[0].getStaticPath()
}
} else if (video) {
name = video.name
description = video.description
} else {
name = CONFIG.INSTANCE.NAME
description = CONFIG.INSTANCE.DESCRIPTION
}
return { name, description, imageUrl }
}

View File

@ -0,0 +1,96 @@
import express from 'express'
import { toSafeHtml } from '@server/helpers/markdown'
import { cacheRouteFactory } from '@server/middlewares'
import { CONFIG } from '../../initializers/config'
import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
import {
asyncMiddleware,
feedsFormatValidator,
setFeedFormatContentType,
videoCommentsFeedsValidator,
videoFeedsValidator
} from '../../middlewares'
import { VideoCommentModel } from '../../models/video/video-comment'
import { buildFeedMetadata, initFeed, sendFeed } from './shared'
const commentFeedsRouter = express.Router()
// ---------------------------------------------------------------------------
const { middleware: cacheRouteMiddleware } = cacheRouteFactory({
headerBlacklist: [ 'Content-Type' ]
})
// ---------------------------------------------------------------------------
commentFeedsRouter.get('/feeds/video-comments.:format',
feedsFormatValidator,
setFeedFormatContentType,
cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
asyncMiddleware(videoFeedsValidator),
asyncMiddleware(videoCommentsFeedsValidator),
asyncMiddleware(generateVideoCommentsFeed)
)
// ---------------------------------------------------------------------------
export {
commentFeedsRouter
}
// ---------------------------------------------------------------------------
async function generateVideoCommentsFeed (req: express.Request, res: express.Response) {
const start = 0
const video = res.locals.videoAll
const account = res.locals.account
const videoChannel = res.locals.videoChannel
const comments = await VideoCommentModel.listForFeed({
start,
count: CONFIG.FEEDS.COMMENTS.COUNT,
videoId: video ? video.id : undefined,
accountId: account ? account.id : undefined,
videoChannelId: videoChannel ? videoChannel.id : undefined
})
const { name, description, imageUrl, link } = await buildFeedMetadata({ video, account, videoChannel })
const feed = initFeed({
name,
description,
imageUrl,
isPodcast: false,
link,
resourceType: 'video-comments',
queryString: new URL(WEBSERVER.URL + req.originalUrl).search
})
// Adding video items to the feed, one at a time
for (const comment of comments) {
const localLink = WEBSERVER.URL + comment.getCommentStaticPath()
let title = comment.Video.name
const author: { name: string, link: string }[] = []
if (comment.Account) {
title += ` - ${comment.Account.getDisplayName()}`
author.push({
name: comment.Account.getDisplayName(),
link: comment.Account.Actor.url
})
}
feed.addItem({
title,
id: localLink,
link: localLink,
content: toSafeHtml(comment.text),
author,
date: comment.createdAt
})
}
// Now the feed generation is done, let's send it!
return sendFeed(feed, req, res)
}

View File

@ -0,0 +1,16 @@
import express from 'express'
import { commentFeedsRouter } from './comment-feeds'
import { videoFeedsRouter } from './video-feeds'
import { videoPodcastFeedsRouter } from './video-podcast-feeds'
const feedsRouter = express.Router()
feedsRouter.use('/', commentFeedsRouter)
feedsRouter.use('/', videoFeedsRouter)
feedsRouter.use('/', videoPodcastFeedsRouter)
// ---------------------------------------------------------------------------
export {
feedsRouter
}

View File

@ -0,0 +1,145 @@
import express from 'express'
import { Feed } from '@peertube/feed'
import { CustomTag, CustomXMLNS, Person } from '@peertube/feed/lib/typings'
import { mdToOneLinePlainText } from '@server/helpers/markdown'
import { CONFIG } from '@server/initializers/config'
import { WEBSERVER } from '@server/initializers/constants'
import { UserModel } from '@server/models/user/user'
import { MAccountDefault, MChannelBannerAccountDefault, MUser, MVideoFullLight } from '@server/types/models'
import { pick } from '@shared/core-utils'
import { ActorImageType } from '@shared/models'
export function initFeed (parameters: {
name: string
description: string
imageUrl: string
isPodcast: boolean
link?: string
locked?: { isLocked: boolean, email: string }
author?: {
name: string
link: string
imageUrl: string
}
person?: Person[]
resourceType?: 'videos' | 'video-comments'
queryString?: string
medium?: string
stunServers?: string[]
trackers?: string[]
customXMLNS?: CustomXMLNS[]
customTags?: CustomTag[]
}) {
const webserverUrl = WEBSERVER.URL
const { name, description, link, imageUrl, isPodcast, resourceType, queryString, medium } = parameters
return new Feed({
title: name,
description: mdToOneLinePlainText(description),
// updated: TODO: somehowGetLatestUpdate, // optional, default = today
id: link || webserverUrl,
link: link || webserverUrl,
image: imageUrl,
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`, // ^.~
medium: medium || 'video',
feedLinks: {
json: `${webserverUrl}/feeds/${resourceType}.json${queryString}`,
atom: `${webserverUrl}/feeds/${resourceType}.atom${queryString}`,
rss: isPodcast
? `${webserverUrl}/feeds/podcast/videos.xml${queryString}`
: `${webserverUrl}/feeds/${resourceType}.xml${queryString}`
},
...pick(parameters, [ 'stunServers', 'trackers', 'customXMLNS', 'customTags', 'author', 'person', 'locked' ])
})
}
export function sendFeed (feed: Feed, req: express.Request, res: express.Response) {
const format = req.params.format
if (format === 'atom' || format === 'atom1') {
return res.send(feed.atom1()).end()
}
if (format === 'json' || format === 'json1') {
return res.send(feed.json1()).end()
}
if (format === 'rss' || format === 'rss2') {
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') {
return res.send(feed.atom1()).end()
}
return res.send(feed.rss2()).end()
}
export async function buildFeedMetadata (options: {
videoChannel?: MChannelBannerAccountDefault
account?: MAccountDefault
video?: MVideoFullLight
}) {
const { video, videoChannel, account } = options
let imageUrl = WEBSERVER.URL + '/client/assets/images/icons/icon-96x96.png'
let accountImageUrl: string
let name: string
let userName: string
let description: string
let email: string
let link: string
let accountLink: string
let user: MUser
if (videoChannel) {
name = videoChannel.getDisplayName()
description = videoChannel.description
link = videoChannel.getClientUrl()
accountLink = videoChannel.Account.getClientUrl()
if (videoChannel.Actor.hasImage(ActorImageType.AVATAR)) {
imageUrl = WEBSERVER.URL + videoChannel.Actor.Avatars[0].getStaticPath()
}
if (videoChannel.Account.Actor.hasImage(ActorImageType.AVATAR)) {
accountImageUrl = WEBSERVER.URL + videoChannel.Account.Actor.Avatars[0].getStaticPath()
}
user = await UserModel.loadById(videoChannel.Account.userId)
userName = videoChannel.Account.getDisplayName()
} else if (account) {
name = account.getDisplayName()
description = account.description
link = account.getClientUrl()
accountLink = link
if (account.Actor.hasImage(ActorImageType.AVATAR)) {
imageUrl = WEBSERVER.URL + account.Actor.Avatars[0].getStaticPath()
accountImageUrl = imageUrl
}
user = await UserModel.loadById(account.userId)
} else if (video) {
name = video.name
description = video.description
link = video.url
} else {
name = CONFIG.INSTANCE.NAME
description = CONFIG.INSTANCE.DESCRIPTION
link = WEBSERVER.URL
}
// If the user is local, has a verified email address, and allows it to be publicly displayed
// Return it so the owner can prove ownership of their feed
if (user && !user.pluginAuth && user.emailVerified && user.emailPublic) {
email = user.email
}
return { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink }
}

View File

@ -0,0 +1,2 @@
export * from './video-feed-utils'
export * from './common-feed-utils'

View File

@ -0,0 +1,66 @@
import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown'
import { CONFIG } from '@server/initializers/config'
import { WEBSERVER } from '@server/initializers/constants'
import { getServerActor } from '@server/models/application/application'
import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils'
import { DisplayOnlyForFollowerOptions } from '@server/models/video/sql/video'
import { VideoModel } from '@server/models/video/video'
import { MThumbnail, MUserDefault } from '@server/types/models'
import { VideoInclude } from '@shared/models'
export async function getVideosForFeeds (options: {
sort: string
nsfw: boolean
isLocal: boolean
include: VideoInclude
accountId?: number
videoChannelId?: number
displayOnlyForFollower?: DisplayOnlyForFollowerOptions
user?: MUserDefault
}) {
const server = await getServerActor()
const { data } = await VideoModel.listForApi({
start: 0,
count: CONFIG.FEEDS.VIDEOS.COUNT,
displayOnlyForFollower: {
actorId: server.id,
orLocalVideos: true
},
hasFiles: true,
countVideos: false,
...options
})
return data
}
export function getCommonVideoFeedAttributes (video: VideoModel) {
const localLink = WEBSERVER.URL + video.getWatchStaticPath()
const thumbnailModels: MThumbnail[] = []
if (video.hasPreview()) thumbnailModels.push(video.getPreview())
thumbnailModels.push(video.getMiniature())
return {
title: video.name,
link: localLink,
description: mdToOneLinePlainText(video.getTruncatedDescription()),
content: toSafeHtml(video.description),
date: video.publishedAt,
nsfw: video.nsfw,
category: video.category
? [ { name: getCategoryLabel(video.category) } ]
: undefined,
thumbnails: thumbnailModels.map(t => ({
url: WEBSERVER.URL + t.getLocalStaticPath(),
width: t.width,
height: t.height
}))
}
}

View File

@ -0,0 +1,189 @@
import express from 'express'
import { extname } from 'path'
import { Feed } from '@peertube/feed'
import { cacheRouteFactory } from '@server/middlewares'
import { VideoModel } from '@server/models/video/video'
import { VideoInclude } from '@shared/models'
import { buildNSFWFilter } from '../../helpers/express-utils'
import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
import {
asyncMiddleware,
commonVideosFiltersValidator,
feedsFormatValidator,
setDefaultVideosSort,
setFeedFormatContentType,
videoFeedsValidator,
videosSortValidator,
videoSubscriptionFeedsValidator
} from '../../middlewares'
import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed, sendFeed } from './shared'
const videoFeedsRouter = express.Router()
const { middleware: cacheRouteMiddleware } = cacheRouteFactory({
headerBlacklist: [ 'Content-Type' ]
})
// ---------------------------------------------------------------------------
videoFeedsRouter.get('/feeds/videos.:format',
videosSortValidator,
setDefaultVideosSort,
feedsFormatValidator,
setFeedFormatContentType,
cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
commonVideosFiltersValidator,
asyncMiddleware(videoFeedsValidator),
asyncMiddleware(generateVideoFeed)
)
videoFeedsRouter.get('/feeds/subscriptions.:format',
videosSortValidator,
setDefaultVideosSort,
feedsFormatValidator,
setFeedFormatContentType,
cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
commonVideosFiltersValidator,
asyncMiddleware(videoSubscriptionFeedsValidator),
asyncMiddleware(generateVideoFeedForSubscriptions)
)
// ---------------------------------------------------------------------------
export {
videoFeedsRouter
}
// ---------------------------------------------------------------------------
async function generateVideoFeed (req: express.Request, res: express.Response) {
const account = res.locals.account
const videoChannel = res.locals.videoChannel
const { name, description, imageUrl, accountImageUrl, link, accountLink } = await buildFeedMetadata({ videoChannel, account })
const feed = initFeed({
name,
description,
link,
isPodcast: false,
imageUrl,
author: { name, link: accountLink, imageUrl: accountImageUrl },
resourceType: 'videos',
queryString: new URL(WEBSERVER.URL + req.url).search
})
const data = await getVideosForFeeds({
sort: req.query.sort,
nsfw: buildNSFWFilter(res, req.query.nsfw),
isLocal: req.query.isLocal,
include: req.query.include | VideoInclude.FILES,
accountId: account?.id,
videoChannelId: videoChannel?.id
})
addVideosToFeed(feed, data)
// Now the feed generation is done, let's send it!
return sendFeed(feed, req, res)
}
async function generateVideoFeedForSubscriptions (req: express.Request, res: express.Response) {
const account = res.locals.account
const { name, description, imageUrl, link } = await buildFeedMetadata({ account })
const feed = initFeed({
name,
description,
link,
isPodcast: false,
imageUrl,
resourceType: 'videos',
queryString: new URL(WEBSERVER.URL + req.url).search
})
const data = await getVideosForFeeds({
sort: req.query.sort,
nsfw: buildNSFWFilter(res, req.query.nsfw),
isLocal: req.query.isLocal,
include: req.query.include | VideoInclude.FILES,
displayOnlyForFollower: {
actorId: res.locals.user.Account.Actor.id,
orLocalVideos: false
},
user: res.locals.user
})
addVideosToFeed(feed, data)
// Now the feed generation is done, let's send it!
return sendFeed(feed, req, res)
}
// ---------------------------------------------------------------------------
function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
/**
* Adding video items to the feed object, one at a time
*/
for (const video of videos) {
const formattedVideoFiles = video.getFormattedAllVideoFilesJSON(false)
const torrents = formattedVideoFiles.map(videoFile => ({
title: video.name,
url: videoFile.torrentUrl,
size_in_bytes: videoFile.size
}))
const videoFiles = formattedVideoFiles.map(videoFile => {
return {
type: MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)],
medium: 'video',
height: videoFile.resolution.id,
fileSize: videoFile.size,
url: videoFile.fileUrl,
framerate: videoFile.fps,
duration: video.duration,
lang: video.language
}
})
feed.addItem({
...getCommonVideoFeedAttributes(video),
id: WEBSERVER.URL + video.getWatchStaticPath(),
author: [
{
name: video.VideoChannel.getDisplayName(),
link: video.VideoChannel.getClientUrl()
}
],
torrents,
// Enclosure
video: videoFiles.length !== 0
? {
url: videoFiles[0].url,
length: videoFiles[0].fileSize,
type: videoFiles[0].type
}
: undefined,
// Media RSS
videos: videoFiles,
embed: {
url: WEBSERVER.URL + video.getEmbedStaticPath(),
allowFullscreen: true
},
player: {
url: WEBSERVER.URL + video.getWatchStaticPath()
},
community: {
statistics: {
views: video.views
}
}
})
}
}

View File

@ -0,0 +1,301 @@
import express from 'express'
import { extname } from 'path'
import { Feed } from '@peertube/feed'
import { CustomTag, CustomXMLNS, LiveItemStatus } from '@peertube/feed/lib/typings'
import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
import { Hooks } from '@server/lib/plugins/hooks'
import { buildPodcastGroupsCache, cacheRouteFactory, videoFeedsPodcastSetCacheKey } from '@server/middlewares'
import { MVideo, MVideoCaptionVideo, MVideoFullLight } from '@server/types/models'
import { sortObjectComparator } from '@shared/core-utils'
import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@shared/models'
import { buildNSFWFilter } from '../../helpers/express-utils'
import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares'
import { VideoModel } from '../../models/video/video'
import { VideoCaptionModel } from '../../models/video/video-caption'
import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed } from './shared'
const videoPodcastFeedsRouter = express.Router()
// ---------------------------------------------------------------------------
const { middleware: podcastCacheRouteMiddleware, instance: podcastApiCache } = cacheRouteFactory({
headerBlacklist: [ 'Content-Type' ]
})
for (const event of ([ 'video-created', 'video-updated', 'video-deleted' ] as const)) {
InternalEventEmitter.Instance.on(event, ({ video }) => {
if (video.remote) return
podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: video.channelId }))
})
}
for (const event of ([ 'channel-updated', 'channel-deleted' ] as const)) {
InternalEventEmitter.Instance.on(event, ({ channel }) => {
podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: channel.id }))
})
}
// ---------------------------------------------------------------------------
videoPodcastFeedsRouter.get('/feeds/podcast/videos.xml',
setFeedPodcastContentType,
videoFeedsPodcastSetCacheKey,
podcastCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
asyncMiddleware(videoFeedsPodcastValidator),
asyncMiddleware(generateVideoPodcastFeed)
)
// ---------------------------------------------------------------------------
export {
videoPodcastFeedsRouter
}
// ---------------------------------------------------------------------------
async function generateVideoPodcastFeed (req: express.Request, res: express.Response) {
const videoChannel = res.locals.videoChannel
const { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink } = await buildFeedMetadata({ videoChannel })
const data = await getVideosForFeeds({
sort: '-publishedAt',
nsfw: buildNSFWFilter(),
// Prevent podcast feeds from listing videos in other instances
// helps prevent duplicates when they are indexed -- only the author should control them
isLocal: true,
include: VideoInclude.FILES,
videoChannelId: videoChannel?.id
})
const customTags: CustomTag[] = await Hooks.wrapObject(
[],
'filter:feed.podcast.channel.create-custom-tags.result',
{ videoChannel }
)
const customXMLNS: CustomXMLNS[] = await Hooks.wrapObject(
[],
'filter:feed.podcast.rss.create-custom-xmlns.result'
)
const feed = initFeed({
name,
description,
link,
isPodcast: true,
imageUrl,
locked: email
? { isLocked: true, email } // Default to true because we have no way of offering a redirect yet
: undefined,
person: [ { name: userName, href: accountLink, img: accountImageUrl } ],
resourceType: 'videos',
queryString: new URL(WEBSERVER.URL + req.url).search,
medium: 'video',
customXMLNS,
customTags
})
await addVideosToPodcastFeed(feed, data)
// Now the feed generation is done, let's send it!
return res.send(feed.podcast()).end()
}
type PodcastMedia =
{
type: string
length: number
bitrate: number
sources: { uri: string, contentType?: string }[]
title: string
language?: string
} |
{
sources: { uri: string }[]
type: string
title: string
}
async function generatePodcastItem (options: {
video: VideoModel
liveItem: boolean
media: PodcastMedia[]
}) {
const { video, liveItem, media } = options
const customTags: CustomTag[] = await Hooks.wrapObject(
[],
'filter:feed.podcast.video.create-custom-tags.result',
{ video, liveItem }
)
const account = video.VideoChannel.Account
const author = {
name: account.getDisplayName(),
href: account.getClientUrl()
}
return {
...getCommonVideoFeedAttributes(video),
trackers: video.getTrackerUrls(),
author: [ author ],
person: [
{
...author,
img: account.Actor.hasImage(ActorImageType.AVATAR)
? WEBSERVER.URL + account.Actor.Avatars[0].getStaticPath()
: undefined
}
],
media,
socialInteract: [
{
uri: video.url,
protocol: 'activitypub',
accountUrl: account.getClientUrl()
}
],
customTags
}
}
async function addVideosToPodcastFeed (feed: Feed, videos: VideoModel[]) {
const captionsGroup = await VideoCaptionModel.listCaptionsOfMultipleVideos(videos.map(v => v.id))
for (const video of videos) {
if (!video.isLive) {
await addVODPodcastItem({ feed, video, captionsGroup })
} else if (video.isLive && video.state !== VideoState.LIVE_ENDED) {
await addLivePodcastItem({ feed, video })
}
}
}
async function addVODPodcastItem (options: {
feed: Feed
video: VideoModel
captionsGroup: { [ id: number ]: MVideoCaptionVideo[] }
}) {
const { feed, video, captionsGroup } = options
const webVideos = video.getFormattedWebVideoFilesJSON(true)
.map(f => buildVODWebVideoFile(video, f))
.sort(sortObjectComparator('bitrate', 'desc'))
const streamingPlaylistFiles = buildVODStreamingPlaylists(video)
// Order matters here, the first media URI will be the "default"
// So web videos are default if enabled
const media = [ ...webVideos, ...streamingPlaylistFiles ]
const videoCaptions = buildVODCaptions(video, captionsGroup[video.id])
const item = await generatePodcastItem({ video, liveItem: false, media })
feed.addPodcastItem({ ...item, subTitle: videoCaptions })
}
async function addLivePodcastItem (options: {
feed: Feed
video: VideoModel
}) {
const { feed, video } = options
let status: LiveItemStatus
switch (video.state) {
case VideoState.WAITING_FOR_LIVE:
status = LiveItemStatus.pending
break
case VideoState.PUBLISHED:
status = LiveItemStatus.live
break
}
const item = await generatePodcastItem({ video, liveItem: true, media: buildLiveStreamingPlaylists(video) })
feed.addPodcastLiveItem({ ...item, status, start: video.updatedAt.toISOString() })
}
// ---------------------------------------------------------------------------
function buildVODWebVideoFile (video: MVideo, videoFile: VideoFile) {
const isAudio = videoFile.resolution.id === VideoResolution.H_NOVIDEO
const type = isAudio
? MIMETYPES.AUDIO.EXT_MIMETYPE[extname(videoFile.fileUrl)]
: MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)]
const sources = [
{ uri: videoFile.fileUrl },
{ uri: videoFile.torrentUrl, contentType: 'application/x-bittorrent' }
]
if (videoFile.magnetUri) {
sources.push({ uri: videoFile.magnetUri })
}
return {
type,
title: videoFile.resolution.label,
length: videoFile.size,
bitrate: videoFile.size / video.duration * 8,
language: video.language,
sources
}
}
function buildVODStreamingPlaylists (video: MVideoFullLight) {
const hls = video.getHLSPlaylist()
if (!hls) return []
return [
{
type: 'application/x-mpegURL',
title: 'HLS',
sources: [
{ uri: hls.getMasterPlaylistUrl(video) }
],
language: video.language
}
]
}
function buildLiveStreamingPlaylists (video: MVideoFullLight) {
const hls = video.getHLSPlaylist()
return [
{
type: 'application/x-mpegURL',
title: `HLS live stream`,
sources: [
{ uri: hls.getMasterPlaylistUrl(video) }
],
language: video.language
}
]
}
function buildVODCaptions (video: MVideo, videoCaptions: MVideoCaptionVideo[]) {
return videoCaptions.map(caption => {
const type = MIMETYPES.VIDEO_CAPTIONS.EXT_MIMETYPE[extname(caption.filename)]
if (!type) return null
return {
url: caption.getFileUrl(video),
language: caption.language,
type,
rel: 'captions'
}
}).filter(c => c)
}

View File

@ -80,6 +80,10 @@ function isUserAutoPlayNextVideoPlaylistValid (value: any) {
return isBooleanValid(value)
}
function isUserEmailPublicValid (value: any) {
return isBooleanValid(value)
}
function isUserNoModal (value: any) {
return isBooleanValid(value)
}
@ -114,5 +118,6 @@ export {
isUserAutoPlayNextVideoPlaylistValid,
isUserDisplayNameValid,
isUserDescriptionValid,
isUserEmailPublicValid,
isUserNoModal
}

View File

@ -27,7 +27,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
// ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 770
const LAST_MIGRATION_VERSION = 775
// ---------------------------------------------------------------------------
@ -634,7 +634,8 @@ const MIMETYPES = {
'text/vtt': '.vtt',
'application/x-subrip': '.srt',
'text/plain': '.srt'
}
},
EXT_MIMETYPE: null as { [ id: string ]: string }
},
TORRENT: {
MIMETYPE_EXT: {
@ -649,6 +650,7 @@ const MIMETYPES = {
}
MIMETYPES.AUDIO.EXT_MIMETYPE = invert(MIMETYPES.AUDIO.MIMETYPE_EXT)
MIMETYPES.IMAGE.EXT_MIMETYPE = invert(MIMETYPES.IMAGE.MIMETYPE_EXT)
MIMETYPES.VIDEO_CAPTIONS.EXT_MIMETYPE = invert(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT)
const BINARY_CONTENT_TYPES = new Set([
'binary/octet-stream',

View File

@ -0,0 +1,25 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
}): Promise<void> {
const data = {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
}
await utils.queryInterface.addColumn('user', 'emailPublic', data)
}
function down (options) {
throw new Error('Not implemented.')
}
export {
up,
down
}

View File

@ -1,6 +1,6 @@
import { sequelizeTypescript } from '@server/initializers/database'
import { getServerActor } from '@server/models/application/application'
import { MAccountBlocklist, MAccountId, MAccountServer, MServerBlocklist } from '@server/types/models'
import { MAccountBlocklist, MAccountId, MAccountHost, MServerBlocklist } from '@server/types/models'
import { AccountBlocklistModel } from '../models/account/account-blocklist'
import { ServerBlocklistModel } from '../models/server/server-blocklist'
@ -34,7 +34,7 @@ function removeServerFromBlocklist (serverBlock: MServerBlocklist) {
})
}
async function isBlockedByServerOrAccount (targetAccount: MAccountServer, userAccount?: MAccountId) {
async function isBlockedByServerOrAccount (targetAccount: MAccountHost, userAccount?: MAccountId) {
const serverAccountId = (await getServerActor()).Account.id
const sourceAccounts = [ serverAccountId ]

View File

@ -27,7 +27,7 @@ import { AccountModel } from '../models/account/account'
import { VideoModel } from '../models/video/video'
import { VideoChannelModel } from '../models/video/video-channel'
import { VideoPlaylistModel } from '../models/video/video-playlist'
import { MAccountActor, MChannelActor, MVideo, MVideoPlaylist } from '../types/models'
import { MAccountHost, MChannelHost, MVideo, MVideoPlaylist } from '../types/models'
import { getActivityStreamDuration } from './activitypub/activity'
import { getBiggestActorImage } from './actor-image'
import { Hooks } from './plugins/hooks'
@ -260,7 +260,7 @@ class ClientHtml {
}
private static async getAccountOrChannelHTMLPage (
loader: () => Promise<MAccountActor | MChannelActor>,
loader: () => Promise<MAccountHost | MChannelHost>,
req: express.Request,
res: express.Response
) {
@ -280,7 +280,7 @@ class ClientHtml {
let customHtml = ClientHtml.addTitleTag(html, entity.getDisplayName())
customHtml = ClientHtml.addDescriptionTag(customHtml, description)
const url = entity.getLocalUrl()
const url = entity.getClientUrl()
const originUrl = entity.Actor.url
const siteName = CONFIG.INSTANCE.NAME
const title = entity.getDisplayName()

View File

@ -37,7 +37,7 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
const preview = video.getPreview()
const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, preview.filename)
const remoteUrl = preview.getFileUrl(video)
const remoteUrl = preview.getOriginFileUrl(video)
try {
await doRequestAndSaveToFile(remoteUrl, destPath)

View File

@ -0,0 +1,35 @@
import { MChannel, MVideo } from '@server/types/models'
import { EventEmitter } from 'events'
export interface PeerTubeInternalEvents {
'video-created': (options: { video: MVideo }) => void
'video-updated': (options: { video: MVideo }) => void
'video-deleted': (options: { video: MVideo }) => void
'channel-created': (options: { channel: MChannel }) => void
'channel-updated': (options: { channel: MChannel }) => void
'channel-deleted': (options: { channel: MChannel }) => void
}
declare interface InternalEventEmitter {
on<U extends keyof PeerTubeInternalEvents>(
event: U, listener: PeerTubeInternalEvents[U]
): this
emit<U extends keyof PeerTubeInternalEvents>(
event: U, ...args: Parameters<PeerTubeInternalEvents[U]>
): boolean
}
class InternalEventEmitter extends EventEmitter {
private static instance: InternalEventEmitter
static get Instance () {
return this.instance || (this.instance = new this())
}
}
export {
InternalEventEmitter
}

View File

@ -399,6 +399,8 @@ class LiveManager {
}
PeerTubeSocket.Instance.sendVideoLiveNewState(video)
Hooks.runAction('action:live.video.state.updated', { video })
} catch (err) {
logger.error('Cannot save/federate live video %d.', videoId, { err, ...localLTags })
}
@ -466,6 +468,8 @@ class LiveManager {
PeerTubeSocket.Instance.sendVideoLiveNewState(fullVideo)
await federateVideoIfNeeded(fullVideo, false)
Hooks.runAction('action:live.video.state.updated', { video: fullVideo })
} catch (err) {
logger.error('Cannot save/federate new video state of live streaming of video %s.', videoUUID, { err, ...lTags(videoUUID) })
}

View File

@ -133,7 +133,7 @@ function buildVideosHelpers () {
const thumbnails = video.Thumbnails.map(t => ({
type: t.type,
url: t.getFileUrl(video),
url: t.getOriginFileUrl(video),
path: t.getPath()
}))

View File

@ -17,12 +17,22 @@ function cacheRoute (duration: string) {
function cacheRouteFactory (options: APICacheOptions) {
const instance = new ApiCache({ ...defaultOptions, ...options })
return instance.buildMiddleware.bind(instance)
return { instance, middleware: instance.buildMiddleware.bind(instance) }
}
// ---------------------------------------------------------------------------
function buildPodcastGroupsCache (options: {
channelId: number
}) {
return 'podcast-feed-' + options.channelId
}
// ---------------------------------------------------------------------------
export {
cacheRoute,
cacheRouteFactory
cacheRouteFactory,
buildPodcastGroupsCache
}

View File

@ -27,7 +27,13 @@ export class ApiCache {
private readonly options: APICacheOptions
private readonly timers: { [ id: string ]: NodeJS.Timeout } = {}
private readonly index: { all: string[] } = { all: [] }
private readonly index = {
groups: [] as string[],
all: [] as string[]
}
// Cache keys per group
private groups: { [groupIndex: string]: string[] } = {}
constructor (options: APICacheOptions) {
this.options = {
@ -43,7 +49,7 @@ export class ApiCache {
return asyncMiddleware(
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const key = Redis.Instance.getPrefix() + 'api-cache-' + req.originalUrl
const key = this.getCacheKey(req)
const redis = Redis.Instance.getClient()
if (!Redis.Instance.isConnected()) return this.makeResponseCacheable(res, next, key, duration)
@ -62,6 +68,29 @@ export class ApiCache {
)
}
clearGroupSafe (group: string) {
const run = async () => {
const cacheKeys = this.groups[group]
if (!cacheKeys) return
for (const key of cacheKeys) {
try {
await this.clear(key)
} catch (err) {
logger.error('Cannot clear ' + key, { err })
}
}
delete this.groups[group]
}
void run()
}
private getCacheKey (req: express.Request) {
return Redis.Instance.getPrefix() + 'api-cache-' + req.originalUrl
}
private shouldCacheResponse (response: express.Response) {
if (!response) return false
if (this.options.excludeStatus.includes(response.statusCode)) return false
@ -69,8 +98,16 @@ export class ApiCache {
return true
}
private addIndexEntries (key: string) {
private addIndexEntries (key: string, res: express.Response) {
this.index.all.unshift(key)
const groups = res.locals.apicacheGroups || []
for (const group of groups) {
if (!this.groups[group]) this.groups[group] = []
this.groups[group].push(key)
}
}
private filterBlacklistedHeaders (headers: OutgoingHttpHeaders) {
@ -177,7 +214,7 @@ export class ApiCache {
self.accumulateContent(res, content)
if (res.locals.apicache.cacheable && res.locals.apicache.content) {
self.addIndexEntries(key)
self.addIndexEntries(key, res)
const headers = res.locals.apicache.headers || res.getHeaders()
const cacheObject = self.createCacheObject(

View File

@ -3,6 +3,7 @@ import { param, query } from 'express-validator'
import { HttpStatusCode } from '../../../shared/models/http/http-error-codes'
import { isValidRSSFeed } from '../../helpers/custom-validators/feeds'
import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc'
import { buildPodcastGroupsCache } from '../cache'
import {
areValidationErrors,
checkCanSeeVideo,
@ -43,6 +44,21 @@ function setFeedFormatContentType (req: express.Request, res: express.Response,
acceptableContentTypes = [ 'application/xml', 'text/xml' ]
}
return feedContentTypeResponse(req, res, next, acceptableContentTypes)
}
function setFeedPodcastContentType (req: express.Request, res: express.Response, next: express.NextFunction) {
const acceptableContentTypes = [ 'application/rss+xml', 'application/xml', 'text/xml' ]
return feedContentTypeResponse(req, res, next, acceptableContentTypes)
}
function feedContentTypeResponse (
req: express.Request,
res: express.Response,
next: express.NextFunction,
acceptableContentTypes: string[]
) {
if (req.accepts(acceptableContentTypes)) {
res.set('Content-Type', req.accepts(acceptableContentTypes) as string)
} else {
@ -55,6 +71,8 @@ function setFeedFormatContentType (req: express.Request, res: express.Response,
return next()
}
// ---------------------------------------------------------------------------
const videoFeedsValidator = [
query('accountId')
.optional()
@ -82,6 +100,31 @@ const videoFeedsValidator = [
}
]
// ---------------------------------------------------------------------------
const videoFeedsPodcastValidator = [
query('videoChannelId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoChannelIdExist(req.query.videoChannelId, res)) return
return next()
}
]
const videoFeedsPodcastSetCacheKey = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.query.videoChannelId) {
res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ]
}
return next()
}
]
// ---------------------------------------------------------------------------
const videoSubscriptionFeedsValidator = [
query('accountId')
.custom(isIdValid),
@ -126,7 +169,10 @@ const videoCommentsFeedsValidator = [
export {
feedsFormatValidator,
setFeedFormatContentType,
setFeedPodcastContentType,
videoFeedsValidator,
videoFeedsPodcastValidator,
videoSubscriptionFeedsValidator,
videoFeedsPodcastSetCacheKey,
videoCommentsFeedsValidator
}

View File

@ -11,6 +11,7 @@ import {
isUserBlockedReasonValid,
isUserDescriptionValid,
isUserDisplayNameValid,
isUserEmailPublicValid,
isUserNoModal,
isUserNSFWPolicyValid,
isUserP2PEnabledValid,
@ -213,6 +214,9 @@ const usersUpdateMeValidator = [
body('password')
.optional()
.custom(isUserPasswordValid),
body('emailPublic')
.optional()
.custom(isUserEmailPublicValid),
body('email')
.optional()
.isEmail(),

View File

@ -28,8 +28,9 @@ import {
MAccountAP,
MAccountDefault,
MAccountFormattable,
MAccountHost,
MAccountSummaryFormattable,
MChannelActor
MChannelHost
} from '../../types/models'
import { ActorModel } from '../actor/actor'
import { ActorFollowModel } from '../actor/actor-follow'
@ -410,10 +411,6 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
.findAll(query)
}
getClientUrl () {
return WEBSERVER.URL + '/accounts/' + this.Actor.getIdentifier()
}
toFormattedJSON (this: MAccountFormattable): Account {
return {
...this.Actor.toFormattedJSON(),
@ -463,8 +460,9 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
return this.name
}
getLocalUrl (this: MAccountActor | MChannelActor) {
return WEBSERVER.URL + `/accounts/` + this.Actor.preferredUsername
// Avoid error when running this method on MAccount... | MChannel...
getClientUrl (this: MAccountHost | MChannelHost) {
return WEBSERVER.URL + '/a/' + this.Actor.getIdentifier()
}
isBlocked () {

View File

@ -46,8 +46,8 @@ import {
MActorFormattable,
MActorFull,
MActorHost,
MActorHostOnly,
MActorId,
MActorServer,
MActorSummaryFormattable,
MActorUrl,
MActorWithInboxes
@ -663,15 +663,15 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
return this.serverId === null
}
getWebfingerUrl (this: MActorServer) {
getWebfingerUrl (this: MActorHost) {
return 'acct:' + this.preferredUsername + '@' + this.getHost()
}
getIdentifier () {
getIdentifier (this: MActorHost) {
return this.Server ? `${this.preferredUsername}@${this.Server.host}` : this.preferredUsername
}
getHost (this: MActorHost) {
getHost (this: MActorHostOnly) {
return this.Server ? this.Server.host : WEBSERVER.HOST
}

View File

@ -404,6 +404,11 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
@Column
lastLoginDate: Date
@AllowNull(false)
@Default(false)
@Column
emailPublic: boolean
@AllowNull(true)
@Default(null)
@Column
@ -880,6 +885,7 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
theme: getThemeOrDefault(this.theme, DEFAULT_USER_THEME_NAME),
pendingEmail: this.pendingEmail,
emailPublic: this.emailPublic,
emailVerified: this.emailVerified,
nsfwPolicy: this.nsfwPolicy,

View File

@ -459,7 +459,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
icon: icons.map(i => ({
type: 'Image',
url: i.getFileUrl(video),
url: i.getOriginFileUrl(video),
mediaType: 'image/jpeg',
width: i.width,
height: i.height

View File

@ -164,7 +164,7 @@ export class ThumbnailModel extends Model<Partial<AttributesOnly<ThumbnailModel>
return join(directory, filename)
}
getFileUrl (video: MVideo) {
getOriginFileUrl (video: MVideo) {
const staticPath = ThumbnailModel.types[this.type].staticPath + this.filename
if (video.isOwned()) return WEBSERVER.URL + staticPath
@ -172,6 +172,10 @@ export class ThumbnailModel extends Model<Partial<AttributesOnly<ThumbnailModel>
return this.fileUrl
}
getLocalStaticPath () {
return ThumbnailModel.types[this.type].staticPath + this.filename
}
getPath () {
return ThumbnailModel.buildPath(this.type, this.filename)
}

View File

@ -1,6 +1,6 @@
import { remove } from 'fs-extra'
import { join } from 'path'
import { OrderItem, Transaction } from 'sequelize'
import { Op, OrderItem, Transaction } from 'sequelize'
import {
AllowNull,
BeforeDestroy,
@ -166,6 +166,31 @@ export class VideoCaptionModel extends Model<Partial<AttributesOnly<VideoCaption
return VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll(query)
}
static async listCaptionsOfMultipleVideos (videoIds: number[], transaction?: Transaction) {
const query = {
order: [ [ 'language', 'ASC' ] ] as OrderItem[],
where: {
videoId: {
[Op.in]: videoIds
}
},
transaction
}
const captions = await VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll<MVideoCaptionVideo>(query)
const result: { [ id: number ]: MVideoCaptionVideo[] } = {}
for (const id of videoIds) {
result[id] = []
}
for (const caption of captions) {
result[caption.videoId].push(caption)
}
return result
}
static getLanguageLabel (language: string) {
return VIDEO_LANGUAGES[language] || 'Unknown'
}

View File

@ -1,5 +1,8 @@
import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
import {
AfterCreate,
AfterDestroy,
AfterUpdate,
AllowNull,
BeforeDestroy,
BelongsTo,
@ -18,7 +21,8 @@ import {
UpdatedAt
} from 'sequelize-typescript'
import { CONFIG } from '@server/initializers/config'
import { MAccountActor } from '@server/types/models'
import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
import { MAccountHost } from '@server/types/models'
import { forceNumber, pick } from '@shared/core-utils'
import { AttributesOnly } from '@shared/typescript-utils'
import { ActivityPubActor } from '../../../shared/models/activitypub'
@ -36,6 +40,7 @@ import {
MChannelAP,
MChannelBannerAccountDefault,
MChannelFormattable,
MChannelHost,
MChannelSummaryFormattable
} from '../../types/models/video'
import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
@ -416,6 +421,21 @@ export class VideoChannelModel extends Model<Partial<AttributesOnly<VideoChannel
})
VideoPlaylists: VideoPlaylistModel[]
@AfterCreate
static notifyCreate (channel: MChannel) {
InternalEventEmitter.Instance.emit('channel-created', { channel })
}
@AfterUpdate
static notifyUpdate (channel: MChannel) {
InternalEventEmitter.Instance.emit('channel-updated', { channel })
}
@AfterDestroy
static notifyDestroy (channel: MChannel) {
InternalEventEmitter.Instance.emit('channel-deleted', { channel })
}
@BeforeDestroy
static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
if (!instance.Actor) {
@ -827,8 +847,9 @@ export class VideoChannelModel extends Model<Partial<AttributesOnly<VideoChannel
})
}
getLocalUrl (this: MAccountActor | MChannelActor) {
return WEBSERVER.URL + `/video-channels/` + this.Actor.preferredUsername
// Avoid error when running this method on MAccount... | MChannel...
getClientUrl (this: MAccountHost | MChannelHost) {
return WEBSERVER.URL + '/c/' + this.Actor.getIdentifier()
}
getDisplayName () {

View File

@ -1,9 +1,11 @@
import Bluebird from 'bluebird'
import { remove } from 'fs-extra'
import { maxBy, minBy } from 'lodash'
import { join } from 'path'
import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
import {
AfterCreate,
AfterDestroy,
AfterUpdate,
AllowNull,
BeforeDestroy,
BelongsTo,
@ -25,6 +27,7 @@ import {
UpdatedAt
} from 'sequelize-typescript'
import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
import { LiveManager } from '@server/lib/live/live-manager'
import { removeHLSFileObjectStorageByFilename, removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage'
import { tracer } from '@server/lib/opentelemetry/tracing'
@ -66,7 +69,7 @@ import {
} from '../../helpers/custom-validators/videos'
import { logger } from '../../helpers/logger'
import { CONFIG } from '../../initializers/config'
import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
import { sendDeleteVideo } from '../../lib/activitypub/send'
import {
MChannel,
@ -740,8 +743,23 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
})
VideoJobInfo: VideoJobInfoModel
@AfterCreate
static notifyCreate (video: MVideo) {
InternalEventEmitter.Instance.emit('video-created', { video })
}
@AfterUpdate
static notifyUpdate (video: MVideo) {
InternalEventEmitter.Instance.emit('video-updated', { video })
}
@AfterDestroy
static notifyDestroy (video: MVideo) {
InternalEventEmitter.Instance.emit('video-deleted', { video })
}
@BeforeDestroy
static async sendDelete (instance: MVideoAccountLight, options) {
static async sendDelete (instance: MVideoAccountLight, options: { transaction: Transaction }) {
if (!instance.isOwned()) return undefined
// Lazy load channels
@ -1686,15 +1704,14 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
const thumbnail = this.getMiniature()
if (!thumbnail) return null
return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
return thumbnail.getLocalStaticPath()
}
getPreviewStaticPath () {
const preview = this.getPreview()
if (!preview) return null
// We use a local cache, so specify our cache endpoint instead of potential remote URL
return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename)
return preview.getLocalStaticPath()
}
toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
@ -1705,17 +1722,29 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
return videoModelToFormattedDetailsJSON(this)
}
getFormattedVideoFilesJSON (includeMagnet = true): VideoFile[] {
getFormattedWebVideoFilesJSON (includeMagnet = true): VideoFile[] {
return videoFilesModelToFormattedJSON(this, this.VideoFiles, { includeMagnet })
}
getFormattedHLSVideoFilesJSON (includeMagnet = true): VideoFile[] {
let acc: VideoFile[] = []
for (const p of this.VideoStreamingPlaylists) {
acc = acc.concat(videoFilesModelToFormattedJSON(this, p.VideoFiles, { includeMagnet }))
}
return acc
}
getFormattedAllVideoFilesJSON (includeMagnet = true): VideoFile[] {
let files: VideoFile[] = []
if (Array.isArray(this.VideoFiles)) {
const result = videoFilesModelToFormattedJSON(this, this.VideoFiles, { includeMagnet })
files = files.concat(result)
files = files.concat(this.getFormattedWebVideoFilesJSON(includeMagnet))
}
for (const p of (this.VideoStreamingPlaylists || [])) {
const result = videoFilesModelToFormattedJSON(this, p.VideoFiles, { includeMagnet })
files = files.concat(result)
if (Array.isArray(this.VideoStreamingPlaylists)) {
files = files.concat(this.getFormattedHLSVideoFilesJSON(includeMagnet))
}
return files

View File

@ -172,7 +172,7 @@ describe('Test a client controllers', function () {
expect(text).to.contain(`<meta property="og:title" content="${account.displayName}" />`)
expect(text).to.contain(`<meta property="og:description" content="${account.description}" />`)
expect(text).to.contain('<meta property="og:type" content="website" />')
expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/accounts/${servers[0].store.user.username}" />`)
expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/a/${servers[0].store.user.username}" />`)
}
async function channelPageTest (path: string) {
@ -182,7 +182,7 @@ describe('Test a client controllers', function () {
expect(text).to.contain(`<meta property="og:title" content="${servers[0].store.channel.displayName}" />`)
expect(text).to.contain(`<meta property="og:description" content="${channelDescription}" />`)
expect(text).to.contain('<meta property="og:type" content="website" />')
expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/video-channels/${servers[0].store.channel.name}" />`)
expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/c/${servers[0].store.channel.name}" />`)
}
async function watchVideoPageTest (path: string) {

View File

@ -11,6 +11,7 @@ import {
makeGetRequest,
makeRawRequest,
PeerTubeServer,
PluginsCommand,
setAccessTokensToServers,
setDefaultChannelAvatar,
stopFfmpeg,
@ -26,12 +27,15 @@ const expect = chai.expect
describe('Test syndication feeds', () => {
let servers: PeerTubeServer[] = []
let serverHLSOnly: PeerTubeServer
let userAccessToken: string
let rootAccountId: number
let rootChannelId: number
let userAccountId: number
let userChannelId: number
let userFeedToken: string
let liveId: string
before(async function () {
@ -93,7 +97,11 @@ describe('Test syndication feeds', () => {
await servers[0].comments.createThread({ videoId: id, text: 'comment on unlisted video' })
}
await waitJobs(servers)
await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } })
await waitJobs([ ...servers, serverHLSOnly ])
await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath('-podcast-custom-tags') })
})
describe('All feed', function () {
@ -108,6 +116,11 @@ describe('Test syndication feeds', () => {
}
})
it('Should be well formed XML (covers Podcast endpoint)', async function () {
const podcast = await servers[0].feed.getPodcastXML({ ignoreCache: true, channelId: rootChannelId })
expect(podcast).xml.to.be.valid()
})
it('Should be well formed JSON (covers JSON feed 1.0 endpoint)', async function () {
for (const feed of [ 'video-comments' as 'video-comments', 'videos' as 'videos' ]) {
const jsonText = await servers[0].feed.getJSON({ feed, ignoreCache: true })
@ -153,168 +166,290 @@ describe('Test syndication feeds', () => {
describe('Videos feed', function () {
it('Should contain a valid enclosure (covers RSS 2.0 endpoint)', async function () {
for (const server of servers) {
const rss = await server.feed.getXML({ feed: 'videos', ignoreCache: true })
describe('Podcast feed', function () {
it('Should contain a valid podcast:alternateEnclosure', async function () {
// Since podcast feeds should only work on the server they originate on,
// only test the first server where the videos reside
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const enclosure = xmlDoc.rss.channel.item[0].enclosure
const enclosure = xmlDoc.rss.channel.item.enclosure
expect(enclosure).to.exist
const alternateEnclosure = xmlDoc.rss.channel.item['podcast:alternateEnclosure']
expect(alternateEnclosure).to.exist
expect(enclosure['@_type']).to.equal('video/webm')
expect(enclosure['@_length']).to.equal(218910)
expect(enclosure['@_url']).to.contain('-720.webm')
}
expect(alternateEnclosure['@_type']).to.equal('video/webm')
expect(alternateEnclosure['@_length']).to.equal(218910)
expect(alternateEnclosure['@_lang']).to.equal('zh')
expect(alternateEnclosure['@_title']).to.equal('720p')
expect(alternateEnclosure['@_default']).to.equal(true)
expect(alternateEnclosure['podcast:source'][0]['@_uri']).to.contain('-720.webm')
expect(alternateEnclosure['podcast:source'][0]['@_uri']).to.equal(enclosure['@_url'])
expect(alternateEnclosure['podcast:source'][1]['@_uri']).to.contain('-720.torrent')
expect(alternateEnclosure['podcast:source'][1]['@_contentType']).to.equal('application/x-bittorrent')
expect(alternateEnclosure['podcast:source'][2]['@_uri']).to.contain('magnet:?')
})
it('Should contain a valid podcast:alternateEnclosure with HLS only', async function () {
const rss = await serverHLSOnly.feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const enclosure = xmlDoc.rss.channel.item.enclosure
const alternateEnclosure = xmlDoc.rss.channel.item['podcast:alternateEnclosure']
expect(alternateEnclosure).to.exist
expect(alternateEnclosure['@_type']).to.equal('application/x-mpegURL')
expect(alternateEnclosure['@_lang']).to.equal('zh')
expect(alternateEnclosure['@_title']).to.equal('HLS')
expect(alternateEnclosure['@_default']).to.equal(true)
expect(alternateEnclosure['podcast:source']['@_uri']).to.contain('-master.m3u8')
expect(alternateEnclosure['podcast:source']['@_uri']).to.equal(enclosure['@_url'])
})
it('Should contain a valid podcast:socialInteract', async function () {
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const item = xmlDoc.rss.channel.item
const socialInteract = item['podcast:socialInteract']
expect(socialInteract).to.exist
expect(socialInteract['@_protocol']).to.equal('activitypub')
expect(socialInteract['@_uri']).to.exist
expect(socialInteract['@_accountUrl']).to.exist
})
it('Should contain a valid support custom tags for plugins', async function () {
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: userChannelId })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const fooTag = xmlDoc.rss.channel.fooTag
expect(fooTag).to.exist
expect(fooTag['@_bar']).to.equal('baz')
expect(fooTag['#text']).to.equal(42)
const bizzBuzzItem = xmlDoc.rss.channel['biz:buzzItem']
expect(bizzBuzzItem).to.exist
let nestedTag = bizzBuzzItem.nestedTag
expect(nestedTag).to.exist
expect(nestedTag).to.equal('example nested tag')
const item = xmlDoc.rss.channel.item
const fizzTag = item.fizzTag
expect(fizzTag).to.exist
expect(fizzTag['@_bar']).to.equal('baz')
expect(fizzTag['#text']).to.equal(21)
const bizzBuzz = item['biz:buzz']
expect(bizzBuzz).to.exist
nestedTag = bizzBuzz.nestedTag
expect(nestedTag).to.exist
expect(nestedTag).to.equal('example nested tag')
})
it('Should contain a valid podcast:liveItem for live streams', async function () {
this.timeout(120000)
const { uuid } = await servers[0].live.create({
fields: {
name: 'live-0',
privacy: VideoPrivacy.PUBLIC,
channelId: rootChannelId,
permanentLive: false
}
})
liveId = uuid
const ffmpeg = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveId, copyCodecs: true, fixtureName: 'video_short.mp4' })
await servers[0].live.waitUntilPublished({ videoId: liveId })
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const liveItem = xmlDoc.rss.channel['podcast:liveItem']
expect(liveItem.title).to.equal('live-0')
expect(liveItem['@_status']).to.equal('live')
const enclosure = liveItem.enclosure
const alternateEnclosure = liveItem['podcast:alternateEnclosure']
expect(alternateEnclosure).to.exist
expect(alternateEnclosure['@_type']).to.equal('application/x-mpegURL')
expect(alternateEnclosure['@_title']).to.equal('HLS live stream')
expect(alternateEnclosure['@_default']).to.equal(true)
expect(alternateEnclosure['podcast:source']['@_uri']).to.contain('/master.m3u8')
expect(alternateEnclosure['podcast:source']['@_uri']).to.equal(enclosure['@_url'])
await stopFfmpeg(ffmpeg)
await servers[0].live.waitUntilEnded({ videoId: liveId })
await waitJobs(servers)
})
})
it('Should contain a valid \'attachments\' object (covers JSON feed 1.0 endpoint)', async function () {
for (const server of servers) {
const json = await server.feed.getJSON({ feed: 'videos', ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(2)
expect(jsonObj.items[0].attachments).to.exist
expect(jsonObj.items[0].attachments.length).to.be.eq(1)
expect(jsonObj.items[0].attachments[0].mime_type).to.be.eq('application/x-bittorrent')
expect(jsonObj.items[0].attachments[0].size_in_bytes).to.be.eq(218910)
expect(jsonObj.items[0].attachments[0].url).to.contain('720.torrent')
}
})
describe('JSON feed', function () {
it('Should filter by account', async function () {
{
const json = await servers[0].feed.getJSON({ feed: 'videos', query: { accountId: rootAccountId }, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('my super name for server 1')
expect(jsonObj.items[0].author.name).to.equal('Main root channel')
}
{
const json = await servers[0].feed.getJSON({ feed: 'videos', query: { accountId: userAccountId }, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('user video')
expect(jsonObj.items[0].author.name).to.equal('Main john channel')
}
for (const server of servers) {
{
const json = await server.feed.getJSON({ feed: 'videos', query: { accountName: 'root@' + servers[0].host }, ignoreCache: true })
it('Should contain a valid \'attachments\' object', async function () {
for (const server of servers) {
const json = await server.feed.getJSON({ feed: 'videos', ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('my super name for server 1')
}
{
const json = await server.feed.getJSON({ feed: 'videos', query: { accountName: 'john@' + servers[0].host }, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('user video')
}
}
})
it('Should filter by video channel', async function () {
{
const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: rootChannelId }, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('my super name for server 1')
expect(jsonObj.items[0].author.name).to.equal('Main root channel')
}
{
const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: userChannelId }, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('user video')
expect(jsonObj.items[0].author.name).to.equal('Main john channel')
}
for (const server of servers) {
{
const query = { videoChannelName: 'root_channel@' + servers[0].host }
const json = await server.feed.getJSON({ feed: 'videos', query, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('my super name for server 1')
}
{
const query = { videoChannelName: 'john_channel@' + servers[0].host }
const json = await server.feed.getJSON({ feed: 'videos', query, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('user video')
}
}
})
it('Should correctly have videos feed with HLS only', async function () {
this.timeout(120000)
await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } })
await waitJobs([ serverHLSOnly ])
const json = await serverHLSOnly.feed.getJSON({ feed: 'videos', ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].attachments).to.exist
expect(jsonObj.items[0].attachments.length).to.be.eq(4)
for (let i = 0; i < 4; i++) {
expect(jsonObj.items[0].attachments[i].mime_type).to.be.eq('application/x-bittorrent')
expect(jsonObj.items[0].attachments[i].size_in_bytes).to.be.greaterThan(0)
expect(jsonObj.items[0].attachments[i].url).to.exist
}
})
it('Should not display waiting live videos', async function () {
const { uuid } = await servers[0].live.create({
fields: {
name: 'live',
privacy: VideoPrivacy.PUBLIC,
channelId: rootChannelId
expect(jsonObj.items.length).to.be.equal(2)
expect(jsonObj.items[0].attachments).to.exist
expect(jsonObj.items[0].attachments.length).to.be.eq(1)
expect(jsonObj.items[0].attachments[0].mime_type).to.be.eq('application/x-bittorrent')
expect(jsonObj.items[0].attachments[0].size_in_bytes).to.be.eq(218910)
expect(jsonObj.items[0].attachments[0].url).to.contain('720.torrent')
}
})
liveId = uuid
const json = await servers[0].feed.getJSON({ feed: 'videos', ignoreCache: true })
it('Should filter by account', async function () {
{
const json = await servers[0].feed.getJSON({ feed: 'videos', query: { accountId: rootAccountId }, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('my super name for server 1')
expect(jsonObj.items[0].author.name).to.equal('Main root channel')
}
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(2)
expect(jsonObj.items[0].title).to.equal('my super name for server 1')
expect(jsonObj.items[1].title).to.equal('user video')
})
{
const json = await servers[0].feed.getJSON({ feed: 'videos', query: { accountId: userAccountId }, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('user video')
expect(jsonObj.items[0].author.name).to.equal('Main john channel')
}
it('Should display published live videos', async function () {
this.timeout(120000)
for (const server of servers) {
{
const json = await server.feed.getJSON({ feed: 'videos', query: { accountName: 'root@' + servers[0].host }, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('my super name for server 1')
}
const ffmpeg = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveId, copyCodecs: true, fixtureName: 'video_short.mp4' })
await servers[0].live.waitUntilPublished({ videoId: liveId })
{
const json = await server.feed.getJSON({ feed: 'videos', query: { accountName: 'john@' + servers[0].host }, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('user video')
}
}
})
const json = await servers[0].feed.getJSON({ feed: 'videos', ignoreCache: true })
it('Should filter by video channel', async function () {
{
const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: rootChannelId }, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('my super name for server 1')
expect(jsonObj.items[0].author.name).to.equal('Main root channel')
}
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(3)
expect(jsonObj.items[0].title).to.equal('live')
expect(jsonObj.items[1].title).to.equal('my super name for server 1')
expect(jsonObj.items[2].title).to.equal('user video')
{
const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: userChannelId }, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('user video')
expect(jsonObj.items[0].author.name).to.equal('Main john channel')
}
await stopFfmpeg(ffmpeg)
})
for (const server of servers) {
{
const query = { videoChannelName: 'root_channel@' + servers[0].host }
const json = await server.feed.getJSON({ feed: 'videos', query, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('my super name for server 1')
}
it('Should have the channel avatar as feed icon', async function () {
const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: rootChannelId }, ignoreCache: true })
{
const query = { videoChannelName: 'john_channel@' + servers[0].host }
const json = await server.feed.getJSON({ feed: 'videos', query, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('user video')
}
}
})
const jsonObj = JSON.parse(json)
const imageUrl = jsonObj.icon
expect(imageUrl).to.include('/lazy-static/avatars/')
await makeRawRequest({ url: imageUrl, expectedStatus: HttpStatusCode.OK_200 })
it('Should correctly have videos feed with HLS only', async function () {
this.timeout(120000)
const json = await serverHLSOnly.feed.getJSON({ feed: 'videos', ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].attachments).to.exist
expect(jsonObj.items[0].attachments.length).to.be.eq(4)
for (let i = 0; i < 4; i++) {
expect(jsonObj.items[0].attachments[i].mime_type).to.be.eq('application/x-bittorrent')
expect(jsonObj.items[0].attachments[i].size_in_bytes).to.be.greaterThan(0)
expect(jsonObj.items[0].attachments[i].url).to.exist
}
})
it('Should not display waiting live videos', async function () {
const { uuid } = await servers[0].live.create({
fields: {
name: 'live',
privacy: VideoPrivacy.PUBLIC,
channelId: rootChannelId
}
})
liveId = uuid
const json = await servers[0].feed.getJSON({ feed: 'videos', ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(2)
expect(jsonObj.items[0].title).to.equal('my super name for server 1')
expect(jsonObj.items[1].title).to.equal('user video')
})
it('Should display published live videos', async function () {
this.timeout(120000)
const ffmpeg = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveId, copyCodecs: true, fixtureName: 'video_short.mp4' })
await servers[0].live.waitUntilPublished({ videoId: liveId })
const json = await servers[0].feed.getJSON({ feed: 'videos', ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(3)
expect(jsonObj.items[0].title).to.equal('live')
expect(jsonObj.items[1].title).to.equal('my super name for server 1')
expect(jsonObj.items[2].title).to.equal('user video')
await stopFfmpeg(ffmpeg)
})
it('Should have the channel avatar as feed icon', async function () {
const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: rootChannelId }, ignoreCache: true })
const jsonObj = JSON.parse(json)
const imageUrl = jsonObj.icon
expect(imageUrl).to.include('/lazy-static/avatars/')
await makeRawRequest({ url: imageUrl, expectedStatus: HttpStatusCode.OK_200 })
})
})
})
@ -470,6 +605,8 @@ describe('Test syndication feeds', () => {
})
after(async function () {
await servers[0].plugins.uninstall({ npmName: 'peertube-plugin-test-podcast-custom-tags' })
await cleanupTests([ ...servers, serverHLSOnly ])
})
})

View File

@ -0,0 +1,82 @@
async function register ({ registerHook, registerSetting, settingsManager, storageManager, peertubeHelpers }) {
registerHook({
target: 'filter:feed.podcast.rss.create-custom-xmlns.result',
handler: (result, params) => {
return result.concat([
{
name: "biz",
value: "https://example.com/biz-xmlns",
},
])
}
})
registerHook({
target: 'filter:feed.podcast.channel.create-custom-tags.result',
handler: (result, params) => {
const { videoChannel } = params
return result.concat([
{
name: "fooTag",
attributes: { "bar": "baz" },
value: "42",
},
{
name: "biz:videoChannel",
attributes: { "name": videoChannel.name, "id": videoChannel.id },
},
{
name: "biz:buzzItem",
value: [
{
name: "nestedTag",
value: "example nested tag",
},
],
},
])
}
})
registerHook({
target: 'filter:feed.podcast.video.create-custom-tags.result',
handler: (result, params) => {
const { video, liveItem } = params
return result.concat([
{
name: "fizzTag",
attributes: { "bar": "baz" },
value: "21",
},
{
name: "biz:video",
attributes: { "name": video.name, "id": video.id, "isLive": liveItem },
},
{
name: "biz:buzz",
value: [
{
name: "nestedTag",
value: "example nested tag",
},
],
}
])
}
})
}
async function unregister () {
return
}
module.exports = {
register,
unregister
}
// ############################################################################
function addToCount (obj) {
return Object.assign({}, obj, { count: obj.count + 1 })
}

View File

@ -0,0 +1,19 @@
{
"name": "peertube-plugin-test-podcast-custom-tags",
"version": "0.0.1",
"description": "Plugin test custom tags in Podcast RSS feeds",
"engine": {
"peertube": ">=1.3.0"
},
"keywords": [
"peertube",
"plugin"
],
"homepage": "https://github.com/Chocobozzz/PeerTube",
"author": "Chocobozzz",
"bugs": "https://github.com/Chocobozzz/PeerTube/issues",
"library": "./main.js",
"staticDirs": {},
"css": [],
"clientScripts": []
}

View File

@ -14,6 +14,7 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
'action:api.video-channel.deleted',
'action:api.live-video.created',
'action:live.video.state.updated',
'action:api.video-thread.created',
'action:api.video-comment-reply.created',

View File

@ -9,7 +9,9 @@ import {
PeerTubeServer,
PluginsCommand,
setAccessTokensToServers,
setDefaultVideoChannel
setDefaultVideoChannel,
stopFfmpeg,
waitJobs
} from '@shared/server-commands'
describe('Test plugin action hooks', function () {
@ -17,8 +19,8 @@ describe('Test plugin action hooks', function () {
let videoUUID: string
let threadId: number
function checkHook (hook: ServerHookName, strictCount = true) {
return servers[0].servers.waitUntilLog('Run hook ' + hook, 1, strictCount)
function checkHook (hook: ServerHookName, strictCount = true, count = 1) {
return servers[0].servers.waitUntilLog('Run hook ' + hook, count, strictCount)
}
before(async function () {
@ -115,6 +117,29 @@ describe('Test plugin action hooks', function () {
await checkHook('action:api.live-video.created')
})
it('Should run action:live.video.state.updated', async function () {
this.timeout(60000)
const attributes = {
name: 'live',
privacy: VideoPrivacy.PUBLIC,
channelId: servers[0].store.channel.id
}
const { uuid: liveVideoId } = await servers[0].live.create({ fields: attributes })
const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoId })
await servers[0].live.waitUntilPublished({ videoId: liveVideoId })
await waitJobs(servers)
await checkHook('action:live.video.state.updated', true, 1)
await stopFfmpeg(ffmpegCommand)
await servers[0].live.waitUntilEnded({ videoId: liveVideoId })
await waitJobs(servers)
await checkHook('action:live.video.state.updated', true, 2)
})
})
describe('Comments hooks', function () {

View File

@ -110,6 +110,8 @@ declare module 'express' {
locals: {
requestStart: number
apicacheGroups: string[]
apicache: {
content: string | Buffer
write: Writable['write']

View File

@ -8,8 +8,8 @@ import {
MActorDefault,
MActorDefaultLight,
MActorFormattable,
MActorHost,
MActorId,
MActorServer,
MActorSummary,
MActorSummaryFormattable,
MActorUrl
@ -68,10 +68,9 @@ export type MAccountActor =
MAccount &
Use<'Actor', MActor>
// Full actor with server
export type MAccountServer =
export type MAccountHost =
MAccount &
Use<'Actor', MActorServer>
Use<'Actor', MActorHost>
// ############################################################################

View File

@ -7,7 +7,7 @@ import {
MActorDefaultAccountChannel,
MActorDefaultChannelId,
MActorFormattable,
MActorHost,
MActorHostOnly,
MActorUsername
} from './actor'
@ -21,7 +21,7 @@ export type MActorFollow = Omit<ActorFollowModel, 'ActorFollower' | 'ActorFollow
export type MActorFollowFollowingHost =
MActorFollow &
Use<'ActorFollowing', MActorUsername & MActorHost>
Use<'ActorFollowing', MActorUsername & MActorHostOnly>
// ############################################################################

View File

@ -29,7 +29,11 @@ export type MActorLight = Omit<MActor, 'privateKey' | 'privateKey'>
// Some association attributes
export type MActorHost = Use<'Server', MServerHost>
export type MActorHostOnly = Use<'Server', MServerHost>
export type MActorHost =
MActorLight &
Use<'Server', MServerHost>
export type MActorRedundancyAllowedOpt = PickWithOpt<ActorModel, 'Server', MServerRedundancyAllowed>
export type MActorDefaultLight =
@ -68,8 +72,8 @@ export type MActorChannel =
export type MActorDefaultAccountChannel = MActorDefault & MActorAccount & MActorChannel
export type MActorServer =
MActor &
export type MActorServerLight =
MActorLight &
Use<'Server', MServer>
// ############################################################################

View File

@ -21,6 +21,7 @@ import {
MActorDefaultLight,
MActorFormattable,
MActorHost,
MActorHostOnly,
MActorLight,
MActorSummary,
MActorSummaryFormattable,
@ -77,9 +78,13 @@ export type MChannelAccountLight =
Use<'Account', MAccountLight>
export type MChannelHost =
MChannelId &
MChannel &
Use<'Actor', MActorHost>
export type MChannelHostOnly =
MChannelId &
Use<'Actor', MActorHostOnly>
// ############################################################################
// Account associations

View File

@ -13,7 +13,7 @@ import {
MChannelAccountSummaryFormattable,
MChannelActor,
MChannelFormattable,
MChannelHost,
MChannelHostOnly,
MChannelUserId
} from './video-channels'
import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file'
@ -146,7 +146,7 @@ export type MVideoWithChannelActor =
export type MVideoWithHost =
MVideo &
Use<'VideoChannel', MChannelHost>
Use<'VideoChannel', MChannelHostOnly>
export type MVideoFullLight =
MVideo &

View File

@ -122,7 +122,17 @@ export const serverFilterHookObject = {
// Filter the result of video JSON LD builder
// You may also need to use filter:activity-pub.activity.context.build.result to also update JSON LD context
'filter:activity-pub.video.json-ld.build.result': true
'filter:activity-pub.video.json-ld.build.result': true,
// Filter result to allow custom XMLNS definitions in podcast RSS feeds
// Peertube >= 5.2
'filter:feed.podcast.rss.create-custom-xmlns.result': true,
// Filter result to allow custom tags in podcast RSS feeds
// Peertube >= 5.2
'filter:feed.podcast.channel.create-custom-tags.result': true,
// Peertube >= 5.2
'filter:feed.podcast.video.create-custom-tags.result': true
}
export type ServerFilterHookName = keyof typeof serverFilterHookObject
@ -154,6 +164,9 @@ export const serverActionHookObject = {
// Fired when a live video is created
'action:api.live-video.created': true,
// Fired when a live video starts or ends
// Peertube >= 5.2
'action:live.video.state.updated': true,
// Fired when a thread is created
'action:api.video-thread.created': true,

View File

@ -16,6 +16,7 @@ export interface UserUpdateMe {
videoLanguages?: string[]
email?: string
emailPublic?: boolean
currentPassword?: string
password?: string

View File

@ -13,6 +13,7 @@ export interface User {
pendingEmail: string | null
emailVerified: boolean
emailPublic: boolean
nsfwPolicy: NSFWPolicyType
adminFlags?: UserAdminFlag

View File

@ -3,5 +3,6 @@ export const enum VideoInclude {
NOT_PUBLISHED_STATE = 1 << 0,
BLACKLISTED = 1 << 1,
BLOCKED_OWNER = 1 << 2,
FILES = 1 << 3
FILES = 1 << 3,
CAPTIONS = 1 << 4
}

View File

@ -30,6 +30,29 @@ export class FeedCommand extends AbstractCommand {
})
}
getPodcastXML (options: OverrideCommandOptions & {
ignoreCache: boolean
channelId: number
}) {
const { ignoreCache, channelId } = options
const path = `/feeds/podcast/videos.xml`
const query: { [id: string]: string } = {}
if (ignoreCache) query.v = buildUUID()
if (channelId) query.videoChannelId = channelId + ''
return this.getRequestText({
...options,
path,
query,
accept: 'application/xml',
implicitToken: false,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
getJSON (options: OverrideCommandOptions & {
feed: FeedType
ignoreCache: boolean

View File

@ -433,7 +433,7 @@ paths:
get:
tags:
- Video Feeds
summary: List comments on videos
summary: Comments on videos feeds
operationId: getSyndicatedComments
parameters:
- name: format
@ -476,7 +476,7 @@ paths:
schema:
type: string
responses:
'204':
'200':
description: successful operation
headers:
Cache-Control:
@ -528,7 +528,7 @@ paths:
get:
tags:
- Video Feeds
summary: List videos
summary: Common videos feeds
operationId: getSyndicatedVideos
parameters:
- name: format
@ -573,7 +573,7 @@ paths:
- $ref: '#/components/parameters/hasHLSFiles'
- $ref: '#/components/parameters/hasWebtorrentFiles'
responses:
'204':
'200':
description: successful operation
headers:
Cache-Control:
@ -620,7 +620,7 @@ paths:
get:
tags:
- Video Feeds
summary: List videos of subscriptions tied to a token
summary: Videos of subscriptions feeds
operationId: getSyndicatedSubscriptionVideos
parameters:
- name: format
@ -657,7 +657,7 @@ paths:
- $ref: '#/components/parameters/hasHLSFiles'
- $ref: '#/components/parameters/hasWebtorrentFiles'
responses:
'204':
'200':
description: successful operation
headers:
Cache-Control:
@ -683,6 +683,30 @@ paths:
'406':
description: accept header unsupported
'/feeds/podcast/videos.xml':
get:
tags:
- Video Feeds
summary: Videos podcast feed
operationId: getVideosPodcastFeed
parameters:
- name: videoChannelId
in: query
description: 'Limit listing to a specific video channel'
required: true
schema:
type: string
responses:
'200':
description: successful operation
headers:
Cache-Control:
schema:
type: string
default: 'max-age=900' # 15 min cache
'404':
description: video channel not found
'/api/v1/accounts/{name}':
get:
tags:

View File

@ -1836,10 +1836,10 @@
resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.9.1.tgz#ad3367684a57879392513479e0a436cb2ac46dad"
integrity sha512-oPQdbFDmZvjXk5ZDoBGXG8B4tSB/qW5vQunJWQMFUBp7Xe8O1ByPANueJ+Jzg58esEBegyyxZ7LRmfJr7kFcFg==
"@peertube/feed@^5.0.1":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@peertube/feed/-/feed-5.0.2.tgz#d9ae7f38f1ccc75d353a5e24ad335a982bc4df74"
integrity sha512-5c8NkeIDx6J8lOzYiaTGipich/7hTO+CzZjIHFb1SY3+c14BvNJxrFb8b/9aZ8tekIYxKspqb8hg7WcVYg4NXA==
"@peertube/feed@^5.1.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@peertube/feed/-/feed-5.1.0.tgz#e2fec950459ebaa32ea35791c45177f8b6fa85e9"
integrity sha512-ggwIbjxh4oc1aAGYV7ZxtIpiEIGq3Rkg6FxvOSrk/EPZ76rExoIJCjKeSyd4zb/sGkyKldy+bGs1OUUVidWWTQ==
dependencies:
xml-js "^1.6.11"
@ -6362,7 +6362,7 @@ lodash.merge@4.6.2, lodash.merge@^4.6.2:
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
lodash@4.17.21, lodash@>=4.17.13, lodash@^4.17.10, lodash@^4.17.14, lodash@^4.17.20, lodash@^4.17.21:
lodash@4.17.21, lodash@>=4.17.13, lodash@^4.17.14, lodash@^4.17.20, lodash@^4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==