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>
This commit is contained in:
parent
3f0ceab06e
commit
cb0eda5602
|
@ -1,7 +1,7 @@
|
||||||
import { forkJoin } from 'rxjs'
|
import { forkJoin } from 'rxjs'
|
||||||
import { tap } from 'rxjs/operators'
|
import { tap } from 'rxjs/operators'
|
||||||
import { Component, OnInit } from '@angular/core'
|
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 { USER_EMAIL_VALIDATOR, USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators'
|
||||||
import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
|
import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
|
||||||
import { HttpStatusCode, User } from '@shared/models'
|
import { HttpStatusCode, User } from '@shared/models'
|
||||||
|
@ -20,7 +20,8 @@ export class MyAccountChangeEmailComponent extends FormReactive implements OnIni
|
||||||
protected formReactiveService: FormReactiveService,
|
protected formReactiveService: FormReactiveService,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
private serverService: ServerService
|
private serverService: ServerService,
|
||||||
|
private notifier: Notifier
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './my-account-email-preferences.component'
|
|
@ -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>
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -78,6 +78,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 col-lg-8 col-xl-9">
|
<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>
|
<my-account-change-email></my-account-change-email>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -22,6 +22,7 @@ import { MyAccountRoutingModule } from './my-account-routing.module'
|
||||||
import { MyAccountChangeEmailComponent } from './my-account-settings/my-account-change-email'
|
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 { 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 { 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 { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences'
|
||||||
import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component'
|
import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component'
|
||||||
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
|
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
|
||||||
|
@ -65,7 +66,9 @@ import { MyAccountComponent } from './my-account.component'
|
||||||
MyAccountAbusesListComponent,
|
MyAccountAbusesListComponent,
|
||||||
MyAccountServerBlocklistComponent,
|
MyAccountServerBlocklistComponent,
|
||||||
MyAccountNotificationsComponent,
|
MyAccountNotificationsComponent,
|
||||||
MyAccountNotificationPreferencesComponent
|
MyAccountNotificationPreferencesComponent,
|
||||||
|
|
||||||
|
MyAccountEmailPreferencesComponent
|
||||||
],
|
],
|
||||||
|
|
||||||
exports: [
|
exports: [
|
||||||
|
|
|
@ -19,6 +19,7 @@ export class User implements UserServerModel {
|
||||||
pendingEmail: string | null
|
pendingEmail: string | null
|
||||||
|
|
||||||
emailVerified: boolean
|
emailVerified: boolean
|
||||||
|
emailPublic: boolean
|
||||||
nsfwPolicy: NSFWPolicyType
|
nsfwPolicy: NSFWPolicyType
|
||||||
|
|
||||||
adminFlags?: UserAdminFlag
|
adminFlags?: UserAdminFlag
|
||||||
|
|
|
@ -54,6 +54,7 @@ export type CommonVideoParams = {
|
||||||
export class VideoService {
|
export class VideoService {
|
||||||
static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos'
|
static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos'
|
||||||
static BASE_FEEDS_URL = environment.apiUrl + '/feeds/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.'
|
static BASE_SUBSCRIPTION_FEEDS_URL = environment.apiUrl + '/feeds/subscriptions.'
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
|
@ -266,7 +267,15 @@ export class VideoService {
|
||||||
let params = this.restService.addRestGetParams(new HttpParams())
|
let params = this.restService.addRestGetParams(new HttpParams())
|
||||||
params = params.set('videoChannelId', videoChannelId.toString())
|
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) {
|
getVideoSubscriptionFeedUrls (accountId: number, feedToken: string) {
|
||||||
|
|
|
@ -97,7 +97,7 @@
|
||||||
"@opentelemetry/sdk-trace-base": "^1.3.1",
|
"@opentelemetry/sdk-trace-base": "^1.3.1",
|
||||||
"@opentelemetry/sdk-trace-node": "^1.3.1",
|
"@opentelemetry/sdk-trace-node": "^1.3.1",
|
||||||
"@opentelemetry/semantic-conventions": "^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",
|
"@peertube/http-signature": "^1.7.0",
|
||||||
"@uploadx/core": "^6.0.0",
|
"@uploadx/core": "^6.0.0",
|
||||||
"async-lru": "^1.1.1",
|
"async-lru": "^1.1.1",
|
||||||
|
@ -135,7 +135,7 @@
|
||||||
"jimp": "^0.22.4",
|
"jimp": "^0.22.4",
|
||||||
"js-yaml": "^4.0.0",
|
"js-yaml": "^4.0.0",
|
||||||
"jsonld": "~8.1.0",
|
"jsonld": "~8.1.0",
|
||||||
"lodash": "^4.17.10",
|
"lodash": "^4.17.21",
|
||||||
"lru-cache": "^7.13.0",
|
"lru-cache": "^7.13.0",
|
||||||
"magnet-uri": "^6.1.0",
|
"magnet-uri": "^6.1.0",
|
||||||
"markdown-it": "^13.0.1",
|
"markdown-it": "^13.0.1",
|
||||||
|
|
|
@ -212,7 +212,8 @@ async function updateMe (req: express.Request, res: express.Response) {
|
||||||
'theme',
|
'theme',
|
||||||
'noInstanceConfigWarningModal',
|
'noInstanceConfigWarningModal',
|
||||||
'noAccountSetupWarningModal',
|
'noAccountSetupWarningModal',
|
||||||
'noWelcomeModal'
|
'noWelcomeModal',
|
||||||
|
'emailPublic'
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const key of keysToUpdate) {
|
for (const key of keysToUpdate) {
|
||||||
|
|
|
@ -2,10 +2,12 @@ import express from 'express'
|
||||||
import { Transaction } from 'sequelize/types'
|
import { Transaction } from 'sequelize/types'
|
||||||
import { changeVideoChannelShare } from '@server/lib/activitypub/share'
|
import { changeVideoChannelShare } from '@server/lib/activitypub/share'
|
||||||
import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
|
import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
|
||||||
|
import { VideoPathManager } from '@server/lib/video-path-manager'
|
||||||
import { setVideoPrivacy } from '@server/lib/video-privacy'
|
import { setVideoPrivacy } from '@server/lib/video-privacy'
|
||||||
import { openapiOperationDoc } from '@server/middlewares/doc'
|
import { openapiOperationDoc } from '@server/middlewares/doc'
|
||||||
import { FilteredModelAttributes } from '@server/types'
|
import { FilteredModelAttributes } from '@server/types'
|
||||||
import { MVideoFullLight } from '@server/types/models'
|
import { MVideoFullLight } from '@server/types/models'
|
||||||
|
import { forceNumber } from '@shared/core-utils'
|
||||||
import { HttpStatusCode, VideoUpdate } from '@shared/models'
|
import { HttpStatusCode, VideoUpdate } from '@shared/models'
|
||||||
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
|
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
|
||||||
import { resetSequelizeInstance } from '../../../helpers/database-utils'
|
import { resetSequelizeInstance } from '../../../helpers/database-utils'
|
||||||
|
@ -18,8 +20,6 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
|
||||||
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares'
|
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares'
|
||||||
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
|
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
|
||||||
import { VideoModel } from '../../../models/video/video'
|
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 lTags = loggerTagsFactory('api', 'video')
|
||||||
const auditLogger = auditLoggerFactory('videos')
|
const auditLogger = auditLoggerFactory('videos')
|
||||||
|
|
|
@ -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 }
|
|
||||||
}
|
|
96
server/controllers/feeds/comment-feeds.ts
Normal file
96
server/controllers/feeds/comment-feeds.ts
Normal 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)
|
||||||
|
}
|
16
server/controllers/feeds/index.ts
Normal file
16
server/controllers/feeds/index.ts
Normal 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
|
||||||
|
}
|
145
server/controllers/feeds/shared/common-feed-utils.ts
Normal file
145
server/controllers/feeds/shared/common-feed-utils.ts
Normal 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 }
|
||||||
|
}
|
2
server/controllers/feeds/shared/index.ts
Normal file
2
server/controllers/feeds/shared/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './video-feed-utils'
|
||||||
|
export * from './common-feed-utils'
|
66
server/controllers/feeds/shared/video-feed-utils.ts
Normal file
66
server/controllers/feeds/shared/video-feed-utils.ts
Normal 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
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
189
server/controllers/feeds/video-feeds.ts
Normal file
189
server/controllers/feeds/video-feeds.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
301
server/controllers/feeds/video-podcast-feeds.ts
Normal file
301
server/controllers/feeds/video-podcast-feeds.ts
Normal 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)
|
||||||
|
}
|
|
@ -80,6 +80,10 @@ function isUserAutoPlayNextVideoPlaylistValid (value: any) {
|
||||||
return isBooleanValid(value)
|
return isBooleanValid(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUserEmailPublicValid (value: any) {
|
||||||
|
return isBooleanValid(value)
|
||||||
|
}
|
||||||
|
|
||||||
function isUserNoModal (value: any) {
|
function isUserNoModal (value: any) {
|
||||||
return isBooleanValid(value)
|
return isBooleanValid(value)
|
||||||
}
|
}
|
||||||
|
@ -114,5 +118,6 @@ export {
|
||||||
isUserAutoPlayNextVideoPlaylistValid,
|
isUserAutoPlayNextVideoPlaylistValid,
|
||||||
isUserDisplayNameValid,
|
isUserDisplayNameValid,
|
||||||
isUserDescriptionValid,
|
isUserDescriptionValid,
|
||||||
|
isUserEmailPublicValid,
|
||||||
isUserNoModal
|
isUserNoModal
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
'text/vtt': '.vtt',
|
||||||
'application/x-subrip': '.srt',
|
'application/x-subrip': '.srt',
|
||||||
'text/plain': '.srt'
|
'text/plain': '.srt'
|
||||||
}
|
},
|
||||||
|
EXT_MIMETYPE: null as { [ id: string ]: string }
|
||||||
},
|
},
|
||||||
TORRENT: {
|
TORRENT: {
|
||||||
MIMETYPE_EXT: {
|
MIMETYPE_EXT: {
|
||||||
|
@ -649,6 +650,7 @@ const MIMETYPES = {
|
||||||
}
|
}
|
||||||
MIMETYPES.AUDIO.EXT_MIMETYPE = invert(MIMETYPES.AUDIO.MIMETYPE_EXT)
|
MIMETYPES.AUDIO.EXT_MIMETYPE = invert(MIMETYPES.AUDIO.MIMETYPE_EXT)
|
||||||
MIMETYPES.IMAGE.EXT_MIMETYPE = invert(MIMETYPES.IMAGE.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([
|
const BINARY_CONTENT_TYPES = new Set([
|
||||||
'binary/octet-stream',
|
'binary/octet-stream',
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import { sequelizeTypescript } from '@server/initializers/database'
|
import { sequelizeTypescript } from '@server/initializers/database'
|
||||||
import { getServerActor } from '@server/models/application/application'
|
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 { AccountBlocklistModel } from '../models/account/account-blocklist'
|
||||||
import { ServerBlocklistModel } from '../models/server/server-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 serverAccountId = (await getServerActor()).Account.id
|
||||||
const sourceAccounts = [ serverAccountId ]
|
const sourceAccounts = [ serverAccountId ]
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,7 @@ import { AccountModel } from '../models/account/account'
|
||||||
import { VideoModel } from '../models/video/video'
|
import { VideoModel } from '../models/video/video'
|
||||||
import { VideoChannelModel } from '../models/video/video-channel'
|
import { VideoChannelModel } from '../models/video/video-channel'
|
||||||
import { VideoPlaylistModel } from '../models/video/video-playlist'
|
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 { getActivityStreamDuration } from './activitypub/activity'
|
||||||
import { getBiggestActorImage } from './actor-image'
|
import { getBiggestActorImage } from './actor-image'
|
||||||
import { Hooks } from './plugins/hooks'
|
import { Hooks } from './plugins/hooks'
|
||||||
|
@ -260,7 +260,7 @@ class ClientHtml {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async getAccountOrChannelHTMLPage (
|
private static async getAccountOrChannelHTMLPage (
|
||||||
loader: () => Promise<MAccountActor | MChannelActor>,
|
loader: () => Promise<MAccountHost | MChannelHost>,
|
||||||
req: express.Request,
|
req: express.Request,
|
||||||
res: express.Response
|
res: express.Response
|
||||||
) {
|
) {
|
||||||
|
@ -280,7 +280,7 @@ class ClientHtml {
|
||||||
let customHtml = ClientHtml.addTitleTag(html, entity.getDisplayName())
|
let customHtml = ClientHtml.addTitleTag(html, entity.getDisplayName())
|
||||||
customHtml = ClientHtml.addDescriptionTag(customHtml, description)
|
customHtml = ClientHtml.addDescriptionTag(customHtml, description)
|
||||||
|
|
||||||
const url = entity.getLocalUrl()
|
const url = entity.getClientUrl()
|
||||||
const originUrl = entity.Actor.url
|
const originUrl = entity.Actor.url
|
||||||
const siteName = CONFIG.INSTANCE.NAME
|
const siteName = CONFIG.INSTANCE.NAME
|
||||||
const title = entity.getDisplayName()
|
const title = entity.getDisplayName()
|
||||||
|
|
|
@ -37,7 +37,7 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
|
||||||
|
|
||||||
const preview = video.getPreview()
|
const preview = video.getPreview()
|
||||||
const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, preview.filename)
|
const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, preview.filename)
|
||||||
const remoteUrl = preview.getFileUrl(video)
|
const remoteUrl = preview.getOriginFileUrl(video)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await doRequestAndSaveToFile(remoteUrl, destPath)
|
await doRequestAndSaveToFile(remoteUrl, destPath)
|
||||||
|
|
35
server/lib/internal-event-emitter.ts
Normal file
35
server/lib/internal-event-emitter.ts
Normal 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
|
||||||
|
}
|
|
@ -399,6 +399,8 @@ class LiveManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
PeerTubeSocket.Instance.sendVideoLiveNewState(video)
|
PeerTubeSocket.Instance.sendVideoLiveNewState(video)
|
||||||
|
|
||||||
|
Hooks.runAction('action:live.video.state.updated', { video })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Cannot save/federate live video %d.', videoId, { err, ...localLTags })
|
logger.error('Cannot save/federate live video %d.', videoId, { err, ...localLTags })
|
||||||
}
|
}
|
||||||
|
@ -466,6 +468,8 @@ class LiveManager {
|
||||||
PeerTubeSocket.Instance.sendVideoLiveNewState(fullVideo)
|
PeerTubeSocket.Instance.sendVideoLiveNewState(fullVideo)
|
||||||
|
|
||||||
await federateVideoIfNeeded(fullVideo, false)
|
await federateVideoIfNeeded(fullVideo, false)
|
||||||
|
|
||||||
|
Hooks.runAction('action:live.video.state.updated', { video: fullVideo })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Cannot save/federate new video state of live streaming of video %s.', videoUUID, { err, ...lTags(videoUUID) })
|
logger.error('Cannot save/federate new video state of live streaming of video %s.', videoUUID, { err, ...lTags(videoUUID) })
|
||||||
}
|
}
|
||||||
|
|
|
@ -133,7 +133,7 @@ function buildVideosHelpers () {
|
||||||
|
|
||||||
const thumbnails = video.Thumbnails.map(t => ({
|
const thumbnails = video.Thumbnails.map(t => ({
|
||||||
type: t.type,
|
type: t.type,
|
||||||
url: t.getFileUrl(video),
|
url: t.getOriginFileUrl(video),
|
||||||
path: t.getPath()
|
path: t.getPath()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
14
server/middlewares/cache/cache.ts
vendored
14
server/middlewares/cache/cache.ts
vendored
|
@ -17,12 +17,22 @@ function cacheRoute (duration: string) {
|
||||||
function cacheRouteFactory (options: APICacheOptions) {
|
function cacheRouteFactory (options: APICacheOptions) {
|
||||||
const instance = new ApiCache({ ...defaultOptions, ...options })
|
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 {
|
export {
|
||||||
cacheRoute,
|
cacheRoute,
|
||||||
cacheRouteFactory
|
cacheRouteFactory,
|
||||||
|
|
||||||
|
buildPodcastGroupsCache
|
||||||
}
|
}
|
||||||
|
|
45
server/middlewares/cache/shared/api-cache.ts
vendored
45
server/middlewares/cache/shared/api-cache.ts
vendored
|
@ -27,7 +27,13 @@ export class ApiCache {
|
||||||
private readonly options: APICacheOptions
|
private readonly options: APICacheOptions
|
||||||
private readonly timers: { [ id: string ]: NodeJS.Timeout } = {}
|
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) {
|
constructor (options: APICacheOptions) {
|
||||||
this.options = {
|
this.options = {
|
||||||
|
@ -43,7 +49,7 @@ export class ApiCache {
|
||||||
|
|
||||||
return asyncMiddleware(
|
return asyncMiddleware(
|
||||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
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()
|
const redis = Redis.Instance.getClient()
|
||||||
|
|
||||||
if (!Redis.Instance.isConnected()) return this.makeResponseCacheable(res, next, key, duration)
|
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) {
|
private shouldCacheResponse (response: express.Response) {
|
||||||
if (!response) return false
|
if (!response) return false
|
||||||
if (this.options.excludeStatus.includes(response.statusCode)) return false
|
if (this.options.excludeStatus.includes(response.statusCode)) return false
|
||||||
|
@ -69,8 +98,16 @@ export class ApiCache {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private addIndexEntries (key: string) {
|
private addIndexEntries (key: string, res: express.Response) {
|
||||||
this.index.all.unshift(key)
|
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) {
|
private filterBlacklistedHeaders (headers: OutgoingHttpHeaders) {
|
||||||
|
@ -177,7 +214,7 @@ export class ApiCache {
|
||||||
self.accumulateContent(res, content)
|
self.accumulateContent(res, content)
|
||||||
|
|
||||||
if (res.locals.apicache.cacheable && res.locals.apicache.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 headers = res.locals.apicache.headers || res.getHeaders()
|
||||||
const cacheObject = self.createCacheObject(
|
const cacheObject = self.createCacheObject(
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { param, query } from 'express-validator'
|
||||||
import { HttpStatusCode } from '../../../shared/models/http/http-error-codes'
|
import { HttpStatusCode } from '../../../shared/models/http/http-error-codes'
|
||||||
import { isValidRSSFeed } from '../../helpers/custom-validators/feeds'
|
import { isValidRSSFeed } from '../../helpers/custom-validators/feeds'
|
||||||
import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc'
|
import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc'
|
||||||
|
import { buildPodcastGroupsCache } from '../cache'
|
||||||
import {
|
import {
|
||||||
areValidationErrors,
|
areValidationErrors,
|
||||||
checkCanSeeVideo,
|
checkCanSeeVideo,
|
||||||
|
@ -43,6 +44,21 @@ function setFeedFormatContentType (req: express.Request, res: express.Response,
|
||||||
acceptableContentTypes = [ 'application/xml', 'text/xml' ]
|
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)) {
|
if (req.accepts(acceptableContentTypes)) {
|
||||||
res.set('Content-Type', req.accepts(acceptableContentTypes) as string)
|
res.set('Content-Type', req.accepts(acceptableContentTypes) as string)
|
||||||
} else {
|
} else {
|
||||||
|
@ -55,6 +71,8 @@ function setFeedFormatContentType (req: express.Request, res: express.Response,
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const videoFeedsValidator = [
|
const videoFeedsValidator = [
|
||||||
query('accountId')
|
query('accountId')
|
||||||
.optional()
|
.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 = [
|
const videoSubscriptionFeedsValidator = [
|
||||||
query('accountId')
|
query('accountId')
|
||||||
.custom(isIdValid),
|
.custom(isIdValid),
|
||||||
|
@ -126,7 +169,10 @@ const videoCommentsFeedsValidator = [
|
||||||
export {
|
export {
|
||||||
feedsFormatValidator,
|
feedsFormatValidator,
|
||||||
setFeedFormatContentType,
|
setFeedFormatContentType,
|
||||||
|
setFeedPodcastContentType,
|
||||||
videoFeedsValidator,
|
videoFeedsValidator,
|
||||||
|
videoFeedsPodcastValidator,
|
||||||
videoSubscriptionFeedsValidator,
|
videoSubscriptionFeedsValidator,
|
||||||
|
videoFeedsPodcastSetCacheKey,
|
||||||
videoCommentsFeedsValidator
|
videoCommentsFeedsValidator
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
isUserBlockedReasonValid,
|
isUserBlockedReasonValid,
|
||||||
isUserDescriptionValid,
|
isUserDescriptionValid,
|
||||||
isUserDisplayNameValid,
|
isUserDisplayNameValid,
|
||||||
|
isUserEmailPublicValid,
|
||||||
isUserNoModal,
|
isUserNoModal,
|
||||||
isUserNSFWPolicyValid,
|
isUserNSFWPolicyValid,
|
||||||
isUserP2PEnabledValid,
|
isUserP2PEnabledValid,
|
||||||
|
@ -213,6 +214,9 @@ const usersUpdateMeValidator = [
|
||||||
body('password')
|
body('password')
|
||||||
.optional()
|
.optional()
|
||||||
.custom(isUserPasswordValid),
|
.custom(isUserPasswordValid),
|
||||||
|
body('emailPublic')
|
||||||
|
.optional()
|
||||||
|
.custom(isUserEmailPublicValid),
|
||||||
body('email')
|
body('email')
|
||||||
.optional()
|
.optional()
|
||||||
.isEmail(),
|
.isEmail(),
|
||||||
|
|
|
@ -28,8 +28,9 @@ import {
|
||||||
MAccountAP,
|
MAccountAP,
|
||||||
MAccountDefault,
|
MAccountDefault,
|
||||||
MAccountFormattable,
|
MAccountFormattable,
|
||||||
|
MAccountHost,
|
||||||
MAccountSummaryFormattable,
|
MAccountSummaryFormattable,
|
||||||
MChannelActor
|
MChannelHost
|
||||||
} from '../../types/models'
|
} from '../../types/models'
|
||||||
import { ActorModel } from '../actor/actor'
|
import { ActorModel } from '../actor/actor'
|
||||||
import { ActorFollowModel } from '../actor/actor-follow'
|
import { ActorFollowModel } from '../actor/actor-follow'
|
||||||
|
@ -410,10 +411,6 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
|
||||||
.findAll(query)
|
.findAll(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
getClientUrl () {
|
|
||||||
return WEBSERVER.URL + '/accounts/' + this.Actor.getIdentifier()
|
|
||||||
}
|
|
||||||
|
|
||||||
toFormattedJSON (this: MAccountFormattable): Account {
|
toFormattedJSON (this: MAccountFormattable): Account {
|
||||||
return {
|
return {
|
||||||
...this.Actor.toFormattedJSON(),
|
...this.Actor.toFormattedJSON(),
|
||||||
|
@ -463,8 +460,9 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
|
||||||
return this.name
|
return this.name
|
||||||
}
|
}
|
||||||
|
|
||||||
getLocalUrl (this: MAccountActor | MChannelActor) {
|
// Avoid error when running this method on MAccount... | MChannel...
|
||||||
return WEBSERVER.URL + `/accounts/` + this.Actor.preferredUsername
|
getClientUrl (this: MAccountHost | MChannelHost) {
|
||||||
|
return WEBSERVER.URL + '/a/' + this.Actor.getIdentifier()
|
||||||
}
|
}
|
||||||
|
|
||||||
isBlocked () {
|
isBlocked () {
|
||||||
|
|
|
@ -46,8 +46,8 @@ import {
|
||||||
MActorFormattable,
|
MActorFormattable,
|
||||||
MActorFull,
|
MActorFull,
|
||||||
MActorHost,
|
MActorHost,
|
||||||
|
MActorHostOnly,
|
||||||
MActorId,
|
MActorId,
|
||||||
MActorServer,
|
|
||||||
MActorSummaryFormattable,
|
MActorSummaryFormattable,
|
||||||
MActorUrl,
|
MActorUrl,
|
||||||
MActorWithInboxes
|
MActorWithInboxes
|
||||||
|
@ -663,15 +663,15 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
|
||||||
return this.serverId === null
|
return this.serverId === null
|
||||||
}
|
}
|
||||||
|
|
||||||
getWebfingerUrl (this: MActorServer) {
|
getWebfingerUrl (this: MActorHost) {
|
||||||
return 'acct:' + this.preferredUsername + '@' + this.getHost()
|
return 'acct:' + this.preferredUsername + '@' + this.getHost()
|
||||||
}
|
}
|
||||||
|
|
||||||
getIdentifier () {
|
getIdentifier (this: MActorHost) {
|
||||||
return this.Server ? `${this.preferredUsername}@${this.Server.host}` : this.preferredUsername
|
return this.Server ? `${this.preferredUsername}@${this.Server.host}` : this.preferredUsername
|
||||||
}
|
}
|
||||||
|
|
||||||
getHost (this: MActorHost) {
|
getHost (this: MActorHostOnly) {
|
||||||
return this.Server ? this.Server.host : WEBSERVER.HOST
|
return this.Server ? this.Server.host : WEBSERVER.HOST
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -404,6 +404,11 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
|
||||||
@Column
|
@Column
|
||||||
lastLoginDate: Date
|
lastLoginDate: Date
|
||||||
|
|
||||||
|
@AllowNull(false)
|
||||||
|
@Default(false)
|
||||||
|
@Column
|
||||||
|
emailPublic: boolean
|
||||||
|
|
||||||
@AllowNull(true)
|
@AllowNull(true)
|
||||||
@Default(null)
|
@Default(null)
|
||||||
@Column
|
@Column
|
||||||
|
@ -880,6 +885,7 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
|
||||||
theme: getThemeOrDefault(this.theme, DEFAULT_USER_THEME_NAME),
|
theme: getThemeOrDefault(this.theme, DEFAULT_USER_THEME_NAME),
|
||||||
|
|
||||||
pendingEmail: this.pendingEmail,
|
pendingEmail: this.pendingEmail,
|
||||||
|
emailPublic: this.emailPublic,
|
||||||
emailVerified: this.emailVerified,
|
emailVerified: this.emailVerified,
|
||||||
|
|
||||||
nsfwPolicy: this.nsfwPolicy,
|
nsfwPolicy: this.nsfwPolicy,
|
||||||
|
|
|
@ -459,7 +459,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
|
||||||
|
|
||||||
icon: icons.map(i => ({
|
icon: icons.map(i => ({
|
||||||
type: 'Image',
|
type: 'Image',
|
||||||
url: i.getFileUrl(video),
|
url: i.getOriginFileUrl(video),
|
||||||
mediaType: 'image/jpeg',
|
mediaType: 'image/jpeg',
|
||||||
width: i.width,
|
width: i.width,
|
||||||
height: i.height
|
height: i.height
|
||||||
|
|
|
@ -164,7 +164,7 @@ export class ThumbnailModel extends Model<Partial<AttributesOnly<ThumbnailModel>
|
||||||
return join(directory, filename)
|
return join(directory, filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
getFileUrl (video: MVideo) {
|
getOriginFileUrl (video: MVideo) {
|
||||||
const staticPath = ThumbnailModel.types[this.type].staticPath + this.filename
|
const staticPath = ThumbnailModel.types[this.type].staticPath + this.filename
|
||||||
|
|
||||||
if (video.isOwned()) return WEBSERVER.URL + staticPath
|
if (video.isOwned()) return WEBSERVER.URL + staticPath
|
||||||
|
@ -172,6 +172,10 @@ export class ThumbnailModel extends Model<Partial<AttributesOnly<ThumbnailModel>
|
||||||
return this.fileUrl
|
return this.fileUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLocalStaticPath () {
|
||||||
|
return ThumbnailModel.types[this.type].staticPath + this.filename
|
||||||
|
}
|
||||||
|
|
||||||
getPath () {
|
getPath () {
|
||||||
return ThumbnailModel.buildPath(this.type, this.filename)
|
return ThumbnailModel.buildPath(this.type, this.filename)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { remove } from 'fs-extra'
|
import { remove } from 'fs-extra'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { OrderItem, Transaction } from 'sequelize'
|
import { Op, OrderItem, Transaction } from 'sequelize'
|
||||||
import {
|
import {
|
||||||
AllowNull,
|
AllowNull,
|
||||||
BeforeDestroy,
|
BeforeDestroy,
|
||||||
|
@ -166,6 +166,31 @@ export class VideoCaptionModel extends Model<Partial<AttributesOnly<VideoCaption
|
||||||
return VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll(query)
|
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) {
|
static getLanguageLabel (language: string) {
|
||||||
return VIDEO_LANGUAGES[language] || 'Unknown'
|
return VIDEO_LANGUAGES[language] || 'Unknown'
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
|
import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
|
||||||
import {
|
import {
|
||||||
|
AfterCreate,
|
||||||
|
AfterDestroy,
|
||||||
|
AfterUpdate,
|
||||||
AllowNull,
|
AllowNull,
|
||||||
BeforeDestroy,
|
BeforeDestroy,
|
||||||
BelongsTo,
|
BelongsTo,
|
||||||
|
@ -18,7 +21,8 @@ import {
|
||||||
UpdatedAt
|
UpdatedAt
|
||||||
} from 'sequelize-typescript'
|
} from 'sequelize-typescript'
|
||||||
import { CONFIG } from '@server/initializers/config'
|
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 { forceNumber, pick } from '@shared/core-utils'
|
||||||
import { AttributesOnly } from '@shared/typescript-utils'
|
import { AttributesOnly } from '@shared/typescript-utils'
|
||||||
import { ActivityPubActor } from '../../../shared/models/activitypub'
|
import { ActivityPubActor } from '../../../shared/models/activitypub'
|
||||||
|
@ -36,6 +40,7 @@ import {
|
||||||
MChannelAP,
|
MChannelAP,
|
||||||
MChannelBannerAccountDefault,
|
MChannelBannerAccountDefault,
|
||||||
MChannelFormattable,
|
MChannelFormattable,
|
||||||
|
MChannelHost,
|
||||||
MChannelSummaryFormattable
|
MChannelSummaryFormattable
|
||||||
} from '../../types/models/video'
|
} from '../../types/models/video'
|
||||||
import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
|
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[]
|
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
|
@BeforeDestroy
|
||||||
static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
|
static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
|
||||||
if (!instance.Actor) {
|
if (!instance.Actor) {
|
||||||
|
@ -827,8 +847,9 @@ export class VideoChannelModel extends Model<Partial<AttributesOnly<VideoChannel
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getLocalUrl (this: MAccountActor | MChannelActor) {
|
// Avoid error when running this method on MAccount... | MChannel...
|
||||||
return WEBSERVER.URL + `/video-channels/` + this.Actor.preferredUsername
|
getClientUrl (this: MAccountHost | MChannelHost) {
|
||||||
|
return WEBSERVER.URL + '/c/' + this.Actor.getIdentifier()
|
||||||
}
|
}
|
||||||
|
|
||||||
getDisplayName () {
|
getDisplayName () {
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import Bluebird from 'bluebird'
|
import Bluebird from 'bluebird'
|
||||||
import { remove } from 'fs-extra'
|
import { remove } from 'fs-extra'
|
||||||
import { maxBy, minBy } from 'lodash'
|
import { maxBy, minBy } from 'lodash'
|
||||||
import { join } from 'path'
|
|
||||||
import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
|
import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
|
||||||
import {
|
import {
|
||||||
|
AfterCreate,
|
||||||
|
AfterDestroy,
|
||||||
|
AfterUpdate,
|
||||||
AllowNull,
|
AllowNull,
|
||||||
BeforeDestroy,
|
BeforeDestroy,
|
||||||
BelongsTo,
|
BelongsTo,
|
||||||
|
@ -25,6 +27,7 @@ import {
|
||||||
UpdatedAt
|
UpdatedAt
|
||||||
} from 'sequelize-typescript'
|
} from 'sequelize-typescript'
|
||||||
import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
|
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 { LiveManager } from '@server/lib/live/live-manager'
|
||||||
import { removeHLSFileObjectStorageByFilename, removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage'
|
import { removeHLSFileObjectStorageByFilename, removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage'
|
||||||
import { tracer } from '@server/lib/opentelemetry/tracing'
|
import { tracer } from '@server/lib/opentelemetry/tracing'
|
||||||
|
@ -66,7 +69,7 @@ import {
|
||||||
} from '../../helpers/custom-validators/videos'
|
} from '../../helpers/custom-validators/videos'
|
||||||
import { logger } from '../../helpers/logger'
|
import { logger } from '../../helpers/logger'
|
||||||
import { CONFIG } from '../../initializers/config'
|
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 { sendDeleteVideo } from '../../lib/activitypub/send'
|
||||||
import {
|
import {
|
||||||
MChannel,
|
MChannel,
|
||||||
|
@ -740,8 +743,23 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
|
||||||
})
|
})
|
||||||
VideoJobInfo: VideoJobInfoModel
|
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
|
@BeforeDestroy
|
||||||
static async sendDelete (instance: MVideoAccountLight, options) {
|
static async sendDelete (instance: MVideoAccountLight, options: { transaction: Transaction }) {
|
||||||
if (!instance.isOwned()) return undefined
|
if (!instance.isOwned()) return undefined
|
||||||
|
|
||||||
// Lazy load channels
|
// Lazy load channels
|
||||||
|
@ -1686,15 +1704,14 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
|
||||||
const thumbnail = this.getMiniature()
|
const thumbnail = this.getMiniature()
|
||||||
if (!thumbnail) return null
|
if (!thumbnail) return null
|
||||||
|
|
||||||
return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
|
return thumbnail.getLocalStaticPath()
|
||||||
}
|
}
|
||||||
|
|
||||||
getPreviewStaticPath () {
|
getPreviewStaticPath () {
|
||||||
const preview = this.getPreview()
|
const preview = this.getPreview()
|
||||||
if (!preview) return null
|
if (!preview) return null
|
||||||
|
|
||||||
// We use a local cache, so specify our cache endpoint instead of potential remote URL
|
return preview.getLocalStaticPath()
|
||||||
return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
|
toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
|
||||||
|
@ -1705,17 +1722,29 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
|
||||||
return videoModelToFormattedDetailsJSON(this)
|
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[] = []
|
let files: VideoFile[] = []
|
||||||
|
|
||||||
if (Array.isArray(this.VideoFiles)) {
|
if (Array.isArray(this.VideoFiles)) {
|
||||||
const result = videoFilesModelToFormattedJSON(this, this.VideoFiles, { includeMagnet })
|
files = files.concat(this.getFormattedWebVideoFilesJSON(includeMagnet))
|
||||||
files = files.concat(result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const p of (this.VideoStreamingPlaylists || [])) {
|
if (Array.isArray(this.VideoStreamingPlaylists)) {
|
||||||
const result = videoFilesModelToFormattedJSON(this, p.VideoFiles, { includeMagnet })
|
files = files.concat(this.getFormattedHLSVideoFilesJSON(includeMagnet))
|
||||||
files = files.concat(result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return files
|
return files
|
||||||
|
|
|
@ -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:title" content="${account.displayName}" />`)
|
||||||
expect(text).to.contain(`<meta property="og:description" content="${account.description}" />`)
|
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: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) {
|
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:title" content="${servers[0].store.channel.displayName}" />`)
|
||||||
expect(text).to.contain(`<meta property="og:description" content="${channelDescription}" />`)
|
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: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) {
|
async function watchVideoPageTest (path: string) {
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
makeGetRequest,
|
makeGetRequest,
|
||||||
makeRawRequest,
|
makeRawRequest,
|
||||||
PeerTubeServer,
|
PeerTubeServer,
|
||||||
|
PluginsCommand,
|
||||||
setAccessTokensToServers,
|
setAccessTokensToServers,
|
||||||
setDefaultChannelAvatar,
|
setDefaultChannelAvatar,
|
||||||
stopFfmpeg,
|
stopFfmpeg,
|
||||||
|
@ -26,12 +27,15 @@ const expect = chai.expect
|
||||||
describe('Test syndication feeds', () => {
|
describe('Test syndication feeds', () => {
|
||||||
let servers: PeerTubeServer[] = []
|
let servers: PeerTubeServer[] = []
|
||||||
let serverHLSOnly: PeerTubeServer
|
let serverHLSOnly: PeerTubeServer
|
||||||
|
|
||||||
let userAccessToken: string
|
let userAccessToken: string
|
||||||
let rootAccountId: number
|
let rootAccountId: number
|
||||||
let rootChannelId: number
|
let rootChannelId: number
|
||||||
|
|
||||||
let userAccountId: number
|
let userAccountId: number
|
||||||
let userChannelId: number
|
let userChannelId: number
|
||||||
let userFeedToken: string
|
let userFeedToken: string
|
||||||
|
|
||||||
let liveId: string
|
let liveId: string
|
||||||
|
|
||||||
before(async function () {
|
before(async function () {
|
||||||
|
@ -93,7 +97,11 @@ describe('Test syndication feeds', () => {
|
||||||
await servers[0].comments.createThread({ videoId: id, text: 'comment on unlisted video' })
|
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 () {
|
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 () {
|
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' ]) {
|
for (const feed of [ 'video-comments' as 'video-comments', 'videos' as 'videos' ]) {
|
||||||
const jsonText = await servers[0].feed.getJSON({ feed, ignoreCache: true })
|
const jsonText = await servers[0].feed.getJSON({ feed, ignoreCache: true })
|
||||||
|
@ -153,24 +166,149 @@ describe('Test syndication feeds', () => {
|
||||||
|
|
||||||
describe('Videos feed', function () {
|
describe('Videos feed', function () {
|
||||||
|
|
||||||
it('Should contain a valid enclosure (covers RSS 2.0 endpoint)', async function () {
|
describe('Podcast feed', function () {
|
||||||
for (const server of servers) {
|
|
||||||
const rss = await server.feed.getXML({ feed: 'videos', ignoreCache: true })
|
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
|
expect(XMLValidator.validate(rss)).to.be.true
|
||||||
|
|
||||||
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
|
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
|
||||||
const xmlDoc = parser.parse(rss)
|
const xmlDoc = parser.parse(rss)
|
||||||
|
|
||||||
const enclosure = xmlDoc.rss.channel.item[0].enclosure
|
const enclosure = xmlDoc.rss.channel.item.enclosure
|
||||||
expect(enclosure).to.exist
|
expect(enclosure).to.exist
|
||||||
|
const alternateEnclosure = xmlDoc.rss.channel.item['podcast:alternateEnclosure']
|
||||||
|
expect(alternateEnclosure).to.exist
|
||||||
|
|
||||||
expect(enclosure['@_type']).to.equal('video/webm')
|
expect(alternateEnclosure['@_type']).to.equal('video/webm')
|
||||||
expect(enclosure['@_length']).to.equal(218910)
|
expect(alternateEnclosure['@_length']).to.equal(218910)
|
||||||
expect(enclosure['@_url']).to.contain('-720.webm')
|
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 \'attachments\' object (covers JSON feed 1.0 endpoint)', async function () {
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('JSON feed', function () {
|
||||||
|
|
||||||
|
it('Should contain a valid \'attachments\' object', async function () {
|
||||||
for (const server of servers) {
|
for (const server of servers) {
|
||||||
const json = await server.feed.getJSON({ feed: 'videos', ignoreCache: true })
|
const json = await server.feed.getJSON({ feed: 'videos', ignoreCache: true })
|
||||||
const jsonObj = JSON.parse(json)
|
const jsonObj = JSON.parse(json)
|
||||||
|
@ -256,10 +394,6 @@ describe('Test syndication feeds', () => {
|
||||||
it('Should correctly have videos feed with HLS only', async function () {
|
it('Should correctly have videos feed with HLS only', async function () {
|
||||||
this.timeout(120000)
|
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 json = await serverHLSOnly.feed.getJSON({ feed: 'videos', ignoreCache: true })
|
||||||
const jsonObj = JSON.parse(json)
|
const jsonObj = JSON.parse(json)
|
||||||
expect(jsonObj.items.length).to.be.equal(1)
|
expect(jsonObj.items.length).to.be.equal(1)
|
||||||
|
@ -317,6 +451,7 @@ describe('Test syndication feeds', () => {
|
||||||
await makeRawRequest({ url: imageUrl, expectedStatus: HttpStatusCode.OK_200 })
|
await makeRawRequest({ url: imageUrl, expectedStatus: HttpStatusCode.OK_200 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('Video comments feed', function () {
|
describe('Video comments feed', function () {
|
||||||
|
|
||||||
|
@ -470,6 +605,8 @@ describe('Test syndication feeds', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
after(async function () {
|
after(async function () {
|
||||||
|
await servers[0].plugins.uninstall({ npmName: 'peertube-plugin-test-podcast-custom-tags' })
|
||||||
|
|
||||||
await cleanupTests([ ...servers, serverHLSOnly ])
|
await cleanupTests([ ...servers, serverHLSOnly ])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
82
server/tests/fixtures/peertube-plugin-test-podcast-custom-tags/main.js
vendored
Normal file
82
server/tests/fixtures/peertube-plugin-test-podcast-custom-tags/main.js
vendored
Normal 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 })
|
||||||
|
}
|
19
server/tests/fixtures/peertube-plugin-test-podcast-custom-tags/package.json
vendored
Normal file
19
server/tests/fixtures/peertube-plugin-test-podcast-custom-tags/package.json
vendored
Normal 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": []
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
|
||||||
'action:api.video-channel.deleted',
|
'action:api.video-channel.deleted',
|
||||||
|
|
||||||
'action:api.live-video.created',
|
'action:api.live-video.created',
|
||||||
|
'action:live.video.state.updated',
|
||||||
|
|
||||||
'action:api.video-thread.created',
|
'action:api.video-thread.created',
|
||||||
'action:api.video-comment-reply.created',
|
'action:api.video-comment-reply.created',
|
||||||
|
|
|
@ -9,7 +9,9 @@ import {
|
||||||
PeerTubeServer,
|
PeerTubeServer,
|
||||||
PluginsCommand,
|
PluginsCommand,
|
||||||
setAccessTokensToServers,
|
setAccessTokensToServers,
|
||||||
setDefaultVideoChannel
|
setDefaultVideoChannel,
|
||||||
|
stopFfmpeg,
|
||||||
|
waitJobs
|
||||||
} from '@shared/server-commands'
|
} from '@shared/server-commands'
|
||||||
|
|
||||||
describe('Test plugin action hooks', function () {
|
describe('Test plugin action hooks', function () {
|
||||||
|
@ -17,8 +19,8 @@ describe('Test plugin action hooks', function () {
|
||||||
let videoUUID: string
|
let videoUUID: string
|
||||||
let threadId: number
|
let threadId: number
|
||||||
|
|
||||||
function checkHook (hook: ServerHookName, strictCount = true) {
|
function checkHook (hook: ServerHookName, strictCount = true, count = 1) {
|
||||||
return servers[0].servers.waitUntilLog('Run hook ' + hook, 1, strictCount)
|
return servers[0].servers.waitUntilLog('Run hook ' + hook, count, strictCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
before(async function () {
|
before(async function () {
|
||||||
|
@ -115,6 +117,29 @@ describe('Test plugin action hooks', function () {
|
||||||
|
|
||||||
await checkHook('action:api.live-video.created')
|
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 () {
|
describe('Comments hooks', function () {
|
||||||
|
|
2
server/types/express.d.ts
vendored
2
server/types/express.d.ts
vendored
|
@ -110,6 +110,8 @@ declare module 'express' {
|
||||||
locals: {
|
locals: {
|
||||||
requestStart: number
|
requestStart: number
|
||||||
|
|
||||||
|
apicacheGroups: string[]
|
||||||
|
|
||||||
apicache: {
|
apicache: {
|
||||||
content: string | Buffer
|
content: string | Buffer
|
||||||
write: Writable['write']
|
write: Writable['write']
|
||||||
|
|
|
@ -8,8 +8,8 @@ import {
|
||||||
MActorDefault,
|
MActorDefault,
|
||||||
MActorDefaultLight,
|
MActorDefaultLight,
|
||||||
MActorFormattable,
|
MActorFormattable,
|
||||||
|
MActorHost,
|
||||||
MActorId,
|
MActorId,
|
||||||
MActorServer,
|
|
||||||
MActorSummary,
|
MActorSummary,
|
||||||
MActorSummaryFormattable,
|
MActorSummaryFormattable,
|
||||||
MActorUrl
|
MActorUrl
|
||||||
|
@ -68,10 +68,9 @@ export type MAccountActor =
|
||||||
MAccount &
|
MAccount &
|
||||||
Use<'Actor', MActor>
|
Use<'Actor', MActor>
|
||||||
|
|
||||||
// Full actor with server
|
export type MAccountHost =
|
||||||
export type MAccountServer =
|
|
||||||
MAccount &
|
MAccount &
|
||||||
Use<'Actor', MActorServer>
|
Use<'Actor', MActorHost>
|
||||||
|
|
||||||
// ############################################################################
|
// ############################################################################
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {
|
||||||
MActorDefaultAccountChannel,
|
MActorDefaultAccountChannel,
|
||||||
MActorDefaultChannelId,
|
MActorDefaultChannelId,
|
||||||
MActorFormattable,
|
MActorFormattable,
|
||||||
MActorHost,
|
MActorHostOnly,
|
||||||
MActorUsername
|
MActorUsername
|
||||||
} from './actor'
|
} from './actor'
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ export type MActorFollow = Omit<ActorFollowModel, 'ActorFollower' | 'ActorFollow
|
||||||
|
|
||||||
export type MActorFollowFollowingHost =
|
export type MActorFollowFollowingHost =
|
||||||
MActorFollow &
|
MActorFollow &
|
||||||
Use<'ActorFollowing', MActorUsername & MActorHost>
|
Use<'ActorFollowing', MActorUsername & MActorHostOnly>
|
||||||
|
|
||||||
// ############################################################################
|
// ############################################################################
|
||||||
|
|
||||||
|
|
|
@ -29,7 +29,11 @@ export type MActorLight = Omit<MActor, 'privateKey' | 'privateKey'>
|
||||||
|
|
||||||
// Some association attributes
|
// 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 MActorRedundancyAllowedOpt = PickWithOpt<ActorModel, 'Server', MServerRedundancyAllowed>
|
||||||
|
|
||||||
export type MActorDefaultLight =
|
export type MActorDefaultLight =
|
||||||
|
@ -68,8 +72,8 @@ export type MActorChannel =
|
||||||
|
|
||||||
export type MActorDefaultAccountChannel = MActorDefault & MActorAccount & MActorChannel
|
export type MActorDefaultAccountChannel = MActorDefault & MActorAccount & MActorChannel
|
||||||
|
|
||||||
export type MActorServer =
|
export type MActorServerLight =
|
||||||
MActor &
|
MActorLight &
|
||||||
Use<'Server', MServer>
|
Use<'Server', MServer>
|
||||||
|
|
||||||
// ############################################################################
|
// ############################################################################
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
MActorDefaultLight,
|
MActorDefaultLight,
|
||||||
MActorFormattable,
|
MActorFormattable,
|
||||||
MActorHost,
|
MActorHost,
|
||||||
|
MActorHostOnly,
|
||||||
MActorLight,
|
MActorLight,
|
||||||
MActorSummary,
|
MActorSummary,
|
||||||
MActorSummaryFormattable,
|
MActorSummaryFormattable,
|
||||||
|
@ -77,9 +78,13 @@ export type MChannelAccountLight =
|
||||||
Use<'Account', MAccountLight>
|
Use<'Account', MAccountLight>
|
||||||
|
|
||||||
export type MChannelHost =
|
export type MChannelHost =
|
||||||
MChannelId &
|
MChannel &
|
||||||
Use<'Actor', MActorHost>
|
Use<'Actor', MActorHost>
|
||||||
|
|
||||||
|
export type MChannelHostOnly =
|
||||||
|
MChannelId &
|
||||||
|
Use<'Actor', MActorHostOnly>
|
||||||
|
|
||||||
// ############################################################################
|
// ############################################################################
|
||||||
|
|
||||||
// Account associations
|
// Account associations
|
||||||
|
|
|
@ -13,7 +13,7 @@ import {
|
||||||
MChannelAccountSummaryFormattable,
|
MChannelAccountSummaryFormattable,
|
||||||
MChannelActor,
|
MChannelActor,
|
||||||
MChannelFormattable,
|
MChannelFormattable,
|
||||||
MChannelHost,
|
MChannelHostOnly,
|
||||||
MChannelUserId
|
MChannelUserId
|
||||||
} from './video-channels'
|
} from './video-channels'
|
||||||
import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file'
|
import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file'
|
||||||
|
@ -146,7 +146,7 @@ export type MVideoWithChannelActor =
|
||||||
|
|
||||||
export type MVideoWithHost =
|
export type MVideoWithHost =
|
||||||
MVideo &
|
MVideo &
|
||||||
Use<'VideoChannel', MChannelHost>
|
Use<'VideoChannel', MChannelHostOnly>
|
||||||
|
|
||||||
export type MVideoFullLight =
|
export type MVideoFullLight =
|
||||||
MVideo &
|
MVideo &
|
||||||
|
|
|
@ -122,7 +122,17 @@ export const serverFilterHookObject = {
|
||||||
|
|
||||||
// Filter the result of video JSON LD builder
|
// 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
|
// 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
|
export type ServerFilterHookName = keyof typeof serverFilterHookObject
|
||||||
|
@ -154,6 +164,9 @@ export const serverActionHookObject = {
|
||||||
|
|
||||||
// Fired when a live video is created
|
// Fired when a live video is created
|
||||||
'action:api.live-video.created': true,
|
'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
|
// Fired when a thread is created
|
||||||
'action:api.video-thread.created': true,
|
'action:api.video-thread.created': true,
|
||||||
|
|
|
@ -16,6 +16,7 @@ export interface UserUpdateMe {
|
||||||
videoLanguages?: string[]
|
videoLanguages?: string[]
|
||||||
|
|
||||||
email?: string
|
email?: string
|
||||||
|
emailPublic?: boolean
|
||||||
currentPassword?: string
|
currentPassword?: string
|
||||||
password?: string
|
password?: string
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ export interface User {
|
||||||
pendingEmail: string | null
|
pendingEmail: string | null
|
||||||
|
|
||||||
emailVerified: boolean
|
emailVerified: boolean
|
||||||
|
emailPublic: boolean
|
||||||
nsfwPolicy: NSFWPolicyType
|
nsfwPolicy: NSFWPolicyType
|
||||||
|
|
||||||
adminFlags?: UserAdminFlag
|
adminFlags?: UserAdminFlag
|
||||||
|
|
|
@ -3,5 +3,6 @@ export const enum VideoInclude {
|
||||||
NOT_PUBLISHED_STATE = 1 << 0,
|
NOT_PUBLISHED_STATE = 1 << 0,
|
||||||
BLACKLISTED = 1 << 1,
|
BLACKLISTED = 1 << 1,
|
||||||
BLOCKED_OWNER = 1 << 2,
|
BLOCKED_OWNER = 1 << 2,
|
||||||
FILES = 1 << 3
|
FILES = 1 << 3,
|
||||||
|
CAPTIONS = 1 << 4
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 & {
|
getJSON (options: OverrideCommandOptions & {
|
||||||
feed: FeedType
|
feed: FeedType
|
||||||
ignoreCache: boolean
|
ignoreCache: boolean
|
||||||
|
|
|
@ -433,7 +433,7 @@ paths:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
- Video Feeds
|
- Video Feeds
|
||||||
summary: List comments on videos
|
summary: Comments on videos feeds
|
||||||
operationId: getSyndicatedComments
|
operationId: getSyndicatedComments
|
||||||
parameters:
|
parameters:
|
||||||
- name: format
|
- name: format
|
||||||
|
@ -476,7 +476,7 @@ paths:
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
responses:
|
responses:
|
||||||
'204':
|
'200':
|
||||||
description: successful operation
|
description: successful operation
|
||||||
headers:
|
headers:
|
||||||
Cache-Control:
|
Cache-Control:
|
||||||
|
@ -528,7 +528,7 @@ paths:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
- Video Feeds
|
- Video Feeds
|
||||||
summary: List videos
|
summary: Common videos feeds
|
||||||
operationId: getSyndicatedVideos
|
operationId: getSyndicatedVideos
|
||||||
parameters:
|
parameters:
|
||||||
- name: format
|
- name: format
|
||||||
|
@ -573,7 +573,7 @@ paths:
|
||||||
- $ref: '#/components/parameters/hasHLSFiles'
|
- $ref: '#/components/parameters/hasHLSFiles'
|
||||||
- $ref: '#/components/parameters/hasWebtorrentFiles'
|
- $ref: '#/components/parameters/hasWebtorrentFiles'
|
||||||
responses:
|
responses:
|
||||||
'204':
|
'200':
|
||||||
description: successful operation
|
description: successful operation
|
||||||
headers:
|
headers:
|
||||||
Cache-Control:
|
Cache-Control:
|
||||||
|
@ -620,7 +620,7 @@ paths:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
- Video Feeds
|
- Video Feeds
|
||||||
summary: List videos of subscriptions tied to a token
|
summary: Videos of subscriptions feeds
|
||||||
operationId: getSyndicatedSubscriptionVideos
|
operationId: getSyndicatedSubscriptionVideos
|
||||||
parameters:
|
parameters:
|
||||||
- name: format
|
- name: format
|
||||||
|
@ -657,7 +657,7 @@ paths:
|
||||||
- $ref: '#/components/parameters/hasHLSFiles'
|
- $ref: '#/components/parameters/hasHLSFiles'
|
||||||
- $ref: '#/components/parameters/hasWebtorrentFiles'
|
- $ref: '#/components/parameters/hasWebtorrentFiles'
|
||||||
responses:
|
responses:
|
||||||
'204':
|
'200':
|
||||||
description: successful operation
|
description: successful operation
|
||||||
headers:
|
headers:
|
||||||
Cache-Control:
|
Cache-Control:
|
||||||
|
@ -683,6 +683,30 @@ paths:
|
||||||
'406':
|
'406':
|
||||||
description: accept header unsupported
|
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}':
|
'/api/v1/accounts/{name}':
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -1836,10 +1836,10 @@
|
||||||
resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.9.1.tgz#ad3367684a57879392513479e0a436cb2ac46dad"
|
resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.9.1.tgz#ad3367684a57879392513479e0a436cb2ac46dad"
|
||||||
integrity sha512-oPQdbFDmZvjXk5ZDoBGXG8B4tSB/qW5vQunJWQMFUBp7Xe8O1ByPANueJ+Jzg58esEBegyyxZ7LRmfJr7kFcFg==
|
integrity sha512-oPQdbFDmZvjXk5ZDoBGXG8B4tSB/qW5vQunJWQMFUBp7Xe8O1ByPANueJ+Jzg58esEBegyyxZ7LRmfJr7kFcFg==
|
||||||
|
|
||||||
"@peertube/feed@^5.0.1":
|
"@peertube/feed@^5.1.0":
|
||||||
version "5.0.2"
|
version "5.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@peertube/feed/-/feed-5.0.2.tgz#d9ae7f38f1ccc75d353a5e24ad335a982bc4df74"
|
resolved "https://registry.yarnpkg.com/@peertube/feed/-/feed-5.1.0.tgz#e2fec950459ebaa32ea35791c45177f8b6fa85e9"
|
||||||
integrity sha512-5c8NkeIDx6J8lOzYiaTGipich/7hTO+CzZjIHFb1SY3+c14BvNJxrFb8b/9aZ8tekIYxKspqb8hg7WcVYg4NXA==
|
integrity sha512-ggwIbjxh4oc1aAGYV7ZxtIpiEIGq3Rkg6FxvOSrk/EPZ76rExoIJCjKeSyd4zb/sGkyKldy+bGs1OUUVidWWTQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
xml-js "^1.6.11"
|
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"
|
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
|
||||||
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
|
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"
|
version "4.17.21"
|
||||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||||
|
|
Loading…
Reference in New Issue
Block a user