import { forkJoin } from 'rxjs'
import { tap } from 'rxjs/operators'
import { Component, OnInit } from '@angular/core'
-import { AuthService, ServerService, UserService } from '@app/core'
+import { AuthService, Notifier, ServerService, UserService } from '@app/core'
import { USER_EMAIL_VALIDATOR, USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators'
import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { HttpStatusCode, User } from '@shared/models'
protected formReactiveService: FormReactiveService,
private authService: AuthService,
private userService: UserService,
- private serverService: ServerService
+ private serverService: ServerService,
+ private notifier: Notifier
) {
super()
}
--- /dev/null
+export * from './my-account-email-preferences.component'
--- /dev/null
+<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>
--- /dev/null
+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)
+ })
+ }
+}
</div>
<div class="col-12 col-lg-8 col-xl-9">
- <my-account-two-factor-button [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-two-factor-button>
+ <my-account-two-factor-button [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-two-factor-button>
</div>
</div>
</div>
<div class="col-12 col-lg-8 col-xl-9">
+ <my-account-email-preferences class="d-block mb-5" [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-email-preferences>
+
<my-account-change-email></my-account-change-email>
</div>
</div>
import { MyAccountChangeEmailComponent } from './my-account-settings/my-account-change-email'
import { MyAccountChangePasswordComponent } from './my-account-settings/my-account-change-password/my-account-change-password.component'
import { MyAccountDangerZoneComponent } from './my-account-settings/my-account-danger-zone'
+import { MyAccountEmailPreferencesComponent } from './my-account-settings/my-account-email-preferences'
import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences'
import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component'
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
MyAccountAbusesListComponent,
MyAccountServerBlocklistComponent,
MyAccountNotificationsComponent,
- MyAccountNotificationPreferencesComponent
+ MyAccountNotificationPreferencesComponent,
+
+ MyAccountEmailPreferencesComponent
],
exports: [
pendingEmail: string | null
emailVerified: boolean
+ emailPublic: boolean
nsfwPolicy: NSFWPolicyType
adminFlags?: UserAdminFlag
export class VideoService {
static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos'
static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
+ static PODCAST_FEEDS_URL = environment.apiUrl + '/feeds/podcast/videos.xml'
static BASE_SUBSCRIPTION_FEEDS_URL = environment.apiUrl + '/feeds/subscriptions.'
constructor (
let params = this.restService.addRestGetParams(new HttpParams())
params = params.set('videoChannelId', videoChannelId.toString())
- return this.buildBaseFeedUrls(params)
+ const feedUrls = this.buildBaseFeedUrls(params)
+
+ feedUrls.push({
+ format: FeedFormat.RSS,
+ label: 'podcast rss 2.0',
+ url: VideoService.PODCAST_FEEDS_URL + `?videoChannelId=${videoChannelId}`
+ })
+
+ return feedUrls
}
getVideoSubscriptionFeedUrls (accountId: number, feedToken: string) {
"@opentelemetry/sdk-trace-base": "^1.3.1",
"@opentelemetry/sdk-trace-node": "^1.3.1",
"@opentelemetry/semantic-conventions": "^1.3.1",
- "@peertube/feed": "^5.0.1",
+ "@peertube/feed": "^5.1.0",
"@peertube/http-signature": "^1.7.0",
"@uploadx/core": "^6.0.0",
"async-lru": "^1.1.1",
"jimp": "^0.22.4",
"js-yaml": "^4.0.0",
"jsonld": "~8.1.0",
- "lodash": "^4.17.10",
+ "lodash": "^4.17.21",
"lru-cache": "^7.13.0",
"magnet-uri": "^6.1.0",
"markdown-it": "^13.0.1",
'theme',
'noInstanceConfigWarningModal',
'noAccountSetupWarningModal',
- 'noWelcomeModal'
+ 'noWelcomeModal',
+ 'emailPublic'
]
for (const key of keysToUpdate) {
import { Transaction } from 'sequelize/types'
import { changeVideoChannelShare } from '@server/lib/activitypub/share'
import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
+import { VideoPathManager } from '@server/lib/video-path-manager'
import { setVideoPrivacy } from '@server/lib/video-privacy'
import { openapiOperationDoc } from '@server/middlewares/doc'
import { FilteredModelAttributes } from '@server/types'
import { MVideoFullLight } from '@server/types/models'
+import { forceNumber } from '@shared/core-utils'
import { HttpStatusCode, VideoUpdate } from '@shared/models'
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
import { resetSequelizeInstance } from '../../../helpers/database-utils'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares'
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
import { VideoModel } from '../../../models/video/video'
-import { VideoPathManager } from '@server/lib/video-path-manager'
-import { forceNumber } from '@shared/core-utils'
const lTags = loggerTagsFactory('api', 'video')
const auditLogger = auditLoggerFactory('videos')
+++ /dev/null
-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 }
-}
--- /dev/null
+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)
+}
--- /dev/null
+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
+}
--- /dev/null
+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 }
+}
--- /dev/null
+export * from './video-feed-utils'
+export * from './common-feed-utils'
--- /dev/null
+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
+ }))
+ }
+}
--- /dev/null
+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
+ }
+ }
+ })
+ }
+}
--- /dev/null
+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)
+}
return isBooleanValid(value)
}
+function isUserEmailPublicValid (value: any) {
+ return isBooleanValid(value)
+}
+
function isUserNoModal (value: any) {
return isBooleanValid(value)
}
isUserAutoPlayNextVideoPlaylistValid,
isUserDisplayNameValid,
isUserDescriptionValid,
+ isUserEmailPublicValid,
isUserNoModal
}
// ---------------------------------------------------------------------------
-const LAST_MIGRATION_VERSION = 770
+const LAST_MIGRATION_VERSION = 775
// ---------------------------------------------------------------------------
'text/vtt': '.vtt',
'application/x-subrip': '.srt',
'text/plain': '.srt'
- }
+ },
+ EXT_MIMETYPE: null as { [ id: string ]: string }
},
TORRENT: {
MIMETYPE_EXT: {
}
MIMETYPES.AUDIO.EXT_MIMETYPE = invert(MIMETYPES.AUDIO.MIMETYPE_EXT)
MIMETYPES.IMAGE.EXT_MIMETYPE = invert(MIMETYPES.IMAGE.MIMETYPE_EXT)
+MIMETYPES.VIDEO_CAPTIONS.EXT_MIMETYPE = invert(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT)
const BINARY_CONTENT_TYPES = new Set([
'binary/octet-stream',
--- /dev/null
+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
+}
import { sequelizeTypescript } from '@server/initializers/database'
import { getServerActor } from '@server/models/application/application'
-import { MAccountBlocklist, MAccountId, MAccountServer, MServerBlocklist } from '@server/types/models'
+import { MAccountBlocklist, MAccountId, MAccountHost, MServerBlocklist } from '@server/types/models'
import { AccountBlocklistModel } from '../models/account/account-blocklist'
import { ServerBlocklistModel } from '../models/server/server-blocklist'
})
}
-async function isBlockedByServerOrAccount (targetAccount: MAccountServer, userAccount?: MAccountId) {
+async function isBlockedByServerOrAccount (targetAccount: MAccountHost, userAccount?: MAccountId) {
const serverAccountId = (await getServerActor()).Account.id
const sourceAccounts = [ serverAccountId ]
import { VideoModel } from '../models/video/video'
import { VideoChannelModel } from '../models/video/video-channel'
import { VideoPlaylistModel } from '../models/video/video-playlist'
-import { MAccountActor, MChannelActor, MVideo, MVideoPlaylist } from '../types/models'
+import { MAccountHost, MChannelHost, MVideo, MVideoPlaylist } from '../types/models'
import { getActivityStreamDuration } from './activitypub/activity'
import { getBiggestActorImage } from './actor-image'
import { Hooks } from './plugins/hooks'
}
private static async getAccountOrChannelHTMLPage (
- loader: () => Promise<MAccountActor | MChannelActor>,
+ loader: () => Promise<MAccountHost | MChannelHost>,
req: express.Request,
res: express.Response
) {
let customHtml = ClientHtml.addTitleTag(html, entity.getDisplayName())
customHtml = ClientHtml.addDescriptionTag(customHtml, description)
- const url = entity.getLocalUrl()
+ const url = entity.getClientUrl()
const originUrl = entity.Actor.url
const siteName = CONFIG.INSTANCE.NAME
const title = entity.getDisplayName()
const preview = video.getPreview()
const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, preview.filename)
- const remoteUrl = preview.getFileUrl(video)
+ const remoteUrl = preview.getOriginFileUrl(video)
try {
await doRequestAndSaveToFile(remoteUrl, destPath)
--- /dev/null
+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
+}
}
PeerTubeSocket.Instance.sendVideoLiveNewState(video)
+
+ Hooks.runAction('action:live.video.state.updated', { video })
} catch (err) {
logger.error('Cannot save/federate live video %d.', videoId, { err, ...localLTags })
}
PeerTubeSocket.Instance.sendVideoLiveNewState(fullVideo)
await federateVideoIfNeeded(fullVideo, false)
+
+ Hooks.runAction('action:live.video.state.updated', { video: fullVideo })
} catch (err) {
logger.error('Cannot save/federate new video state of live streaming of video %s.', videoUUID, { err, ...lTags(videoUUID) })
}
const thumbnails = video.Thumbnails.map(t => ({
type: t.type,
- url: t.getFileUrl(video),
+ url: t.getOriginFileUrl(video),
path: t.getPath()
}))
function cacheRouteFactory (options: APICacheOptions) {
const instance = new ApiCache({ ...defaultOptions, ...options })
- return instance.buildMiddleware.bind(instance)
+ return { instance, middleware: instance.buildMiddleware.bind(instance) }
+}
+
+// ---------------------------------------------------------------------------
+
+function buildPodcastGroupsCache (options: {
+ channelId: number
+}) {
+ return 'podcast-feed-' + options.channelId
}
// ---------------------------------------------------------------------------
export {
cacheRoute,
- cacheRouteFactory
+ cacheRouteFactory,
+
+ buildPodcastGroupsCache
}
private readonly options: APICacheOptions
private readonly timers: { [ id: string ]: NodeJS.Timeout } = {}
- private readonly index: { all: string[] } = { all: [] }
+ private readonly index = {
+ groups: [] as string[],
+ all: [] as string[]
+ }
+
+ // Cache keys per group
+ private groups: { [groupIndex: string]: string[] } = {}
constructor (options: APICacheOptions) {
this.options = {
return asyncMiddleware(
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
- const key = Redis.Instance.getPrefix() + 'api-cache-' + req.originalUrl
+ const key = this.getCacheKey(req)
const redis = Redis.Instance.getClient()
if (!Redis.Instance.isConnected()) return this.makeResponseCacheable(res, next, key, duration)
)
}
+ clearGroupSafe (group: string) {
+ const run = async () => {
+ const cacheKeys = this.groups[group]
+ if (!cacheKeys) return
+
+ for (const key of cacheKeys) {
+ try {
+ await this.clear(key)
+ } catch (err) {
+ logger.error('Cannot clear ' + key, { err })
+ }
+ }
+
+ delete this.groups[group]
+ }
+
+ void run()
+ }
+
+ private getCacheKey (req: express.Request) {
+ return Redis.Instance.getPrefix() + 'api-cache-' + req.originalUrl
+ }
+
private shouldCacheResponse (response: express.Response) {
if (!response) return false
if (this.options.excludeStatus.includes(response.statusCode)) return false
return true
}
- private addIndexEntries (key: string) {
+ private addIndexEntries (key: string, res: express.Response) {
this.index.all.unshift(key)
+
+ const groups = res.locals.apicacheGroups || []
+
+ for (const group of groups) {
+ if (!this.groups[group]) this.groups[group] = []
+
+ this.groups[group].push(key)
+ }
}
private filterBlacklistedHeaders (headers: OutgoingHttpHeaders) {
self.accumulateContent(res, content)
if (res.locals.apicache.cacheable && res.locals.apicache.content) {
- self.addIndexEntries(key)
+ self.addIndexEntries(key, res)
const headers = res.locals.apicache.headers || res.getHeaders()
const cacheObject = self.createCacheObject(
import { HttpStatusCode } from '../../../shared/models/http/http-error-codes'
import { isValidRSSFeed } from '../../helpers/custom-validators/feeds'
import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc'
+import { buildPodcastGroupsCache } from '../cache'
import {
areValidationErrors,
checkCanSeeVideo,
acceptableContentTypes = [ 'application/xml', 'text/xml' ]
}
+ return feedContentTypeResponse(req, res, next, acceptableContentTypes)
+}
+
+function setFeedPodcastContentType (req: express.Request, res: express.Response, next: express.NextFunction) {
+ const acceptableContentTypes = [ 'application/rss+xml', 'application/xml', 'text/xml' ]
+
+ return feedContentTypeResponse(req, res, next, acceptableContentTypes)
+}
+
+function feedContentTypeResponse (
+ req: express.Request,
+ res: express.Response,
+ next: express.NextFunction,
+ acceptableContentTypes: string[]
+) {
if (req.accepts(acceptableContentTypes)) {
res.set('Content-Type', req.accepts(acceptableContentTypes) as string)
} else {
return next()
}
+// ---------------------------------------------------------------------------
+
const videoFeedsValidator = [
query('accountId')
.optional()
}
]
+// ---------------------------------------------------------------------------
+
+const videoFeedsPodcastValidator = [
+ query('videoChannelId')
+ .custom(isIdValid),
+
+ async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ if (areValidationErrors(req, res)) return
+ if (!await doesVideoChannelIdExist(req.query.videoChannelId, res)) return
+
+ return next()
+ }
+]
+
+const videoFeedsPodcastSetCacheKey = [
+ (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ if (req.query.videoChannelId) {
+ res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ]
+ }
+
+ return next()
+ }
+]
+// ---------------------------------------------------------------------------
+
const videoSubscriptionFeedsValidator = [
query('accountId')
.custom(isIdValid),
export {
feedsFormatValidator,
setFeedFormatContentType,
+ setFeedPodcastContentType,
videoFeedsValidator,
+ videoFeedsPodcastValidator,
videoSubscriptionFeedsValidator,
+ videoFeedsPodcastSetCacheKey,
videoCommentsFeedsValidator
}
isUserBlockedReasonValid,
isUserDescriptionValid,
isUserDisplayNameValid,
+ isUserEmailPublicValid,
isUserNoModal,
isUserNSFWPolicyValid,
isUserP2PEnabledValid,
body('password')
.optional()
.custom(isUserPasswordValid),
+ body('emailPublic')
+ .optional()
+ .custom(isUserEmailPublicValid),
body('email')
.optional()
.isEmail(),
MAccountAP,
MAccountDefault,
MAccountFormattable,
+ MAccountHost,
MAccountSummaryFormattable,
- MChannelActor
+ MChannelHost
} from '../../types/models'
import { ActorModel } from '../actor/actor'
import { ActorFollowModel } from '../actor/actor-follow'
.findAll(query)
}
- getClientUrl () {
- return WEBSERVER.URL + '/accounts/' + this.Actor.getIdentifier()
- }
-
toFormattedJSON (this: MAccountFormattable): Account {
return {
...this.Actor.toFormattedJSON(),
return this.name
}
- getLocalUrl (this: MAccountActor | MChannelActor) {
- return WEBSERVER.URL + `/accounts/` + this.Actor.preferredUsername
+ // Avoid error when running this method on MAccount... | MChannel...
+ getClientUrl (this: MAccountHost | MChannelHost) {
+ return WEBSERVER.URL + '/a/' + this.Actor.getIdentifier()
}
isBlocked () {
MActorFormattable,
MActorFull,
MActorHost,
+ MActorHostOnly,
MActorId,
- MActorServer,
MActorSummaryFormattable,
MActorUrl,
MActorWithInboxes
return this.serverId === null
}
- getWebfingerUrl (this: MActorServer) {
+ getWebfingerUrl (this: MActorHost) {
return 'acct:' + this.preferredUsername + '@' + this.getHost()
}
- getIdentifier () {
+ getIdentifier (this: MActorHost) {
return this.Server ? `${this.preferredUsername}@${this.Server.host}` : this.preferredUsername
}
- getHost (this: MActorHost) {
+ getHost (this: MActorHostOnly) {
return this.Server ? this.Server.host : WEBSERVER.HOST
}
@Column
lastLoginDate: Date
+ @AllowNull(false)
+ @Default(false)
+ @Column
+ emailPublic: boolean
+
@AllowNull(true)
@Default(null)
@Column
theme: getThemeOrDefault(this.theme, DEFAULT_USER_THEME_NAME),
pendingEmail: this.pendingEmail,
+ emailPublic: this.emailPublic,
emailVerified: this.emailVerified,
nsfwPolicy: this.nsfwPolicy,
icon: icons.map(i => ({
type: 'Image',
- url: i.getFileUrl(video),
+ url: i.getOriginFileUrl(video),
mediaType: 'image/jpeg',
width: i.width,
height: i.height
return join(directory, filename)
}
- getFileUrl (video: MVideo) {
+ getOriginFileUrl (video: MVideo) {
const staticPath = ThumbnailModel.types[this.type].staticPath + this.filename
if (video.isOwned()) return WEBSERVER.URL + staticPath
return this.fileUrl
}
+ getLocalStaticPath () {
+ return ThumbnailModel.types[this.type].staticPath + this.filename
+ }
+
getPath () {
return ThumbnailModel.buildPath(this.type, this.filename)
}
import { remove } from 'fs-extra'
import { join } from 'path'
-import { OrderItem, Transaction } from 'sequelize'
+import { Op, OrderItem, Transaction } from 'sequelize'
import {
AllowNull,
BeforeDestroy,
return VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll(query)
}
+ static async listCaptionsOfMultipleVideos (videoIds: number[], transaction?: Transaction) {
+ const query = {
+ order: [ [ 'language', 'ASC' ] ] as OrderItem[],
+ where: {
+ videoId: {
+ [Op.in]: videoIds
+ }
+ },
+ transaction
+ }
+
+ const captions = await VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll<MVideoCaptionVideo>(query)
+ const result: { [ id: number ]: MVideoCaptionVideo[] } = {}
+
+ for (const id of videoIds) {
+ result[id] = []
+ }
+
+ for (const caption of captions) {
+ result[caption.videoId].push(caption)
+ }
+
+ return result
+ }
+
static getLanguageLabel (language: string) {
return VIDEO_LANGUAGES[language] || 'Unknown'
}
import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
import {
+ AfterCreate,
+ AfterDestroy,
+ AfterUpdate,
AllowNull,
BeforeDestroy,
BelongsTo,
UpdatedAt
} from 'sequelize-typescript'
import { CONFIG } from '@server/initializers/config'
-import { MAccountActor } from '@server/types/models'
+import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
+import { MAccountHost } from '@server/types/models'
import { forceNumber, pick } from '@shared/core-utils'
import { AttributesOnly } from '@shared/typescript-utils'
import { ActivityPubActor } from '../../../shared/models/activitypub'
MChannelAP,
MChannelBannerAccountDefault,
MChannelFormattable,
+ MChannelHost,
MChannelSummaryFormattable
} from '../../types/models/video'
import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
})
VideoPlaylists: VideoPlaylistModel[]
+ @AfterCreate
+ static notifyCreate (channel: MChannel) {
+ InternalEventEmitter.Instance.emit('channel-created', { channel })
+ }
+
+ @AfterUpdate
+ static notifyUpdate (channel: MChannel) {
+ InternalEventEmitter.Instance.emit('channel-updated', { channel })
+ }
+
+ @AfterDestroy
+ static notifyDestroy (channel: MChannel) {
+ InternalEventEmitter.Instance.emit('channel-deleted', { channel })
+ }
+
@BeforeDestroy
static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
if (!instance.Actor) {
})
}
- getLocalUrl (this: MAccountActor | MChannelActor) {
- return WEBSERVER.URL + `/video-channels/` + this.Actor.preferredUsername
+ // Avoid error when running this method on MAccount... | MChannel...
+ getClientUrl (this: MAccountHost | MChannelHost) {
+ return WEBSERVER.URL + '/c/' + this.Actor.getIdentifier()
}
getDisplayName () {
import Bluebird from 'bluebird'
import { remove } from 'fs-extra'
import { maxBy, minBy } from 'lodash'
-import { join } from 'path'
import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
import {
+ AfterCreate,
+ AfterDestroy,
+ AfterUpdate,
AllowNull,
BeforeDestroy,
BelongsTo,
UpdatedAt
} from 'sequelize-typescript'
import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
+import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
import { LiveManager } from '@server/lib/live/live-manager'
import { removeHLSFileObjectStorageByFilename, removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage'
import { tracer } from '@server/lib/opentelemetry/tracing'
} from '../../helpers/custom-validators/videos'
import { logger } from '../../helpers/logger'
import { CONFIG } from '../../initializers/config'
-import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
+import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
import { sendDeleteVideo } from '../../lib/activitypub/send'
import {
MChannel,
})
VideoJobInfo: VideoJobInfoModel
+ @AfterCreate
+ static notifyCreate (video: MVideo) {
+ InternalEventEmitter.Instance.emit('video-created', { video })
+ }
+
+ @AfterUpdate
+ static notifyUpdate (video: MVideo) {
+ InternalEventEmitter.Instance.emit('video-updated', { video })
+ }
+
+ @AfterDestroy
+ static notifyDestroy (video: MVideo) {
+ InternalEventEmitter.Instance.emit('video-deleted', { video })
+ }
+
@BeforeDestroy
- static async sendDelete (instance: MVideoAccountLight, options) {
+ static async sendDelete (instance: MVideoAccountLight, options: { transaction: Transaction }) {
if (!instance.isOwned()) return undefined
// Lazy load channels
const thumbnail = this.getMiniature()
if (!thumbnail) return null
- return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename)
+ return thumbnail.getLocalStaticPath()
}
getPreviewStaticPath () {
const preview = this.getPreview()
if (!preview) return null
- // We use a local cache, so specify our cache endpoint instead of potential remote URL
- return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename)
+ return preview.getLocalStaticPath()
}
toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
return videoModelToFormattedDetailsJSON(this)
}
- getFormattedVideoFilesJSON (includeMagnet = true): VideoFile[] {
+ getFormattedWebVideoFilesJSON (includeMagnet = true): VideoFile[] {
+ return videoFilesModelToFormattedJSON(this, this.VideoFiles, { includeMagnet })
+ }
+
+ getFormattedHLSVideoFilesJSON (includeMagnet = true): VideoFile[] {
+ let acc: VideoFile[] = []
+
+ for (const p of this.VideoStreamingPlaylists) {
+ acc = acc.concat(videoFilesModelToFormattedJSON(this, p.VideoFiles, { includeMagnet }))
+ }
+
+ return acc
+ }
+
+ getFormattedAllVideoFilesJSON (includeMagnet = true): VideoFile[] {
let files: VideoFile[] = []
if (Array.isArray(this.VideoFiles)) {
- const result = videoFilesModelToFormattedJSON(this, this.VideoFiles, { includeMagnet })
- files = files.concat(result)
+ files = files.concat(this.getFormattedWebVideoFilesJSON(includeMagnet))
}
- for (const p of (this.VideoStreamingPlaylists || [])) {
- const result = videoFilesModelToFormattedJSON(this, p.VideoFiles, { includeMagnet })
- files = files.concat(result)
+ if (Array.isArray(this.VideoStreamingPlaylists)) {
+ files = files.concat(this.getFormattedHLSVideoFilesJSON(includeMagnet))
}
return files
expect(text).to.contain(`<meta property="og:title" content="${account.displayName}" />`)
expect(text).to.contain(`<meta property="og:description" content="${account.description}" />`)
expect(text).to.contain('<meta property="og:type" content="website" />')
- expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/accounts/${servers[0].store.user.username}" />`)
+ expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/a/${servers[0].store.user.username}" />`)
}
async function channelPageTest (path: string) {
expect(text).to.contain(`<meta property="og:title" content="${servers[0].store.channel.displayName}" />`)
expect(text).to.contain(`<meta property="og:description" content="${channelDescription}" />`)
expect(text).to.contain('<meta property="og:type" content="website" />')
- expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/video-channels/${servers[0].store.channel.name}" />`)
+ expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/c/${servers[0].store.channel.name}" />`)
}
async function watchVideoPageTest (path: string) {
makeGetRequest,
makeRawRequest,
PeerTubeServer,
+ PluginsCommand,
setAccessTokensToServers,
setDefaultChannelAvatar,
stopFfmpeg,
describe('Test syndication feeds', () => {
let servers: PeerTubeServer[] = []
let serverHLSOnly: PeerTubeServer
+
let userAccessToken: string
let rootAccountId: number
let rootChannelId: number
+
let userAccountId: number
let userChannelId: number
let userFeedToken: string
+
let liveId: string
before(async function () {
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 () {
}
})
+ it('Should be well formed XML (covers Podcast endpoint)', async function () {
+ const podcast = await servers[0].feed.getPodcastXML({ ignoreCache: true, channelId: rootChannelId })
+ expect(podcast).xml.to.be.valid()
+ })
+
it('Should be well formed JSON (covers JSON feed 1.0 endpoint)', async function () {
for (const feed of [ 'video-comments' as 'video-comments', 'videos' as 'videos' ]) {
const jsonText = await servers[0].feed.getJSON({ feed, ignoreCache: true })
describe('Videos feed', function () {
- it('Should contain a valid enclosure (covers RSS 2.0 endpoint)', async function () {
- for (const server of servers) {
- const rss = await server.feed.getXML({ feed: 'videos', ignoreCache: true })
+ describe('Podcast feed', function () {
+
+ it('Should contain a valid podcast:alternateEnclosure', async function () {
+ // Since podcast feeds should only work on the server they originate on,
+ // only test the first server where the videos reside
+ const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
- const enclosure = xmlDoc.rss.channel.item[0].enclosure
+ const enclosure = xmlDoc.rss.channel.item.enclosure
expect(enclosure).to.exist
+ const alternateEnclosure = xmlDoc.rss.channel.item['podcast:alternateEnclosure']
+ expect(alternateEnclosure).to.exist
+
+ expect(alternateEnclosure['@_type']).to.equal('video/webm')
+ expect(alternateEnclosure['@_length']).to.equal(218910)
+ expect(alternateEnclosure['@_lang']).to.equal('zh')
+ expect(alternateEnclosure['@_title']).to.equal('720p')
+ expect(alternateEnclosure['@_default']).to.equal(true)
+
+ expect(alternateEnclosure['podcast:source'][0]['@_uri']).to.contain('-720.webm')
+ expect(alternateEnclosure['podcast:source'][0]['@_uri']).to.equal(enclosure['@_url'])
+ expect(alternateEnclosure['podcast:source'][1]['@_uri']).to.contain('-720.torrent')
+ expect(alternateEnclosure['podcast:source'][1]['@_contentType']).to.equal('application/x-bittorrent')
+ expect(alternateEnclosure['podcast:source'][2]['@_uri']).to.contain('magnet:?')
+ })
- expect(enclosure['@_type']).to.equal('video/webm')
- expect(enclosure['@_length']).to.equal(218910)
- expect(enclosure['@_url']).to.contain('-720.webm')
- }
- })
+ 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
- it('Should contain a valid \'attachments\' object (covers JSON feed 1.0 endpoint)', async function () {
- for (const server of servers) {
- const json = await server.feed.getJSON({ feed: 'videos', ignoreCache: true })
- const jsonObj = JSON.parse(json)
- expect(jsonObj.items.length).to.be.equal(2)
- expect(jsonObj.items[0].attachments).to.exist
- expect(jsonObj.items[0].attachments.length).to.be.eq(1)
- expect(jsonObj.items[0].attachments[0].mime_type).to.be.eq('application/x-bittorrent')
- expect(jsonObj.items[0].attachments[0].size_in_bytes).to.be.eq(218910)
- expect(jsonObj.items[0].attachments[0].url).to.contain('720.torrent')
- }
+ const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
+ const xmlDoc = parser.parse(rss)
+
+ const enclosure = xmlDoc.rss.channel.item.enclosure
+ const alternateEnclosure = xmlDoc.rss.channel.item['podcast:alternateEnclosure']
+ expect(alternateEnclosure).to.exist
+
+ expect(alternateEnclosure['@_type']).to.equal('application/x-mpegURL')
+ expect(alternateEnclosure['@_lang']).to.equal('zh')
+ expect(alternateEnclosure['@_title']).to.equal('HLS')
+ expect(alternateEnclosure['@_default']).to.equal(true)
+
+ expect(alternateEnclosure['podcast:source']['@_uri']).to.contain('-master.m3u8')
+ expect(alternateEnclosure['podcast:source']['@_uri']).to.equal(enclosure['@_url'])
+ })
+
+ it('Should contain a valid podcast:socialInteract', async function () {
+ const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
+ expect(XMLValidator.validate(rss)).to.be.true
+
+ const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
+ const xmlDoc = parser.parse(rss)
+
+ const item = xmlDoc.rss.channel.item
+ const socialInteract = item['podcast:socialInteract']
+ expect(socialInteract).to.exist
+ expect(socialInteract['@_protocol']).to.equal('activitypub')
+ expect(socialInteract['@_uri']).to.exist
+ expect(socialInteract['@_accountUrl']).to.exist
+ })
+
+ it('Should contain a valid support custom tags for plugins', async function () {
+ const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: userChannelId })
+ expect(XMLValidator.validate(rss)).to.be.true
+
+ const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
+ const xmlDoc = parser.parse(rss)
+
+ const fooTag = xmlDoc.rss.channel.fooTag
+ expect(fooTag).to.exist
+ expect(fooTag['@_bar']).to.equal('baz')
+ expect(fooTag['#text']).to.equal(42)
+
+ const bizzBuzzItem = xmlDoc.rss.channel['biz:buzzItem']
+ expect(bizzBuzzItem).to.exist
+
+ let nestedTag = bizzBuzzItem.nestedTag
+ expect(nestedTag).to.exist
+ expect(nestedTag).to.equal('example nested tag')
+
+ const item = xmlDoc.rss.channel.item
+ const fizzTag = item.fizzTag
+ expect(fizzTag).to.exist
+ expect(fizzTag['@_bar']).to.equal('baz')
+ expect(fizzTag['#text']).to.equal(21)
+
+ const bizzBuzz = item['biz:buzz']
+ expect(bizzBuzz).to.exist
+
+ nestedTag = bizzBuzz.nestedTag
+ expect(nestedTag).to.exist
+ expect(nestedTag).to.equal('example nested tag')
+ })
+
+ it('Should contain a valid podcast:liveItem for live streams', async function () {
+ this.timeout(120000)
+
+ const { uuid } = await servers[0].live.create({
+ fields: {
+ name: 'live-0',
+ privacy: VideoPrivacy.PUBLIC,
+ channelId: rootChannelId,
+ permanentLive: false
+ }
+ })
+ liveId = uuid
+
+ const ffmpeg = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveId, copyCodecs: true, fixtureName: 'video_short.mp4' })
+ await servers[0].live.waitUntilPublished({ videoId: liveId })
+
+ const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
+ expect(XMLValidator.validate(rss)).to.be.true
+
+ const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
+ const xmlDoc = parser.parse(rss)
+ const liveItem = xmlDoc.rss.channel['podcast:liveItem']
+ expect(liveItem.title).to.equal('live-0')
+ expect(liveItem['@_status']).to.equal('live')
+
+ const enclosure = liveItem.enclosure
+ const alternateEnclosure = liveItem['podcast:alternateEnclosure']
+ expect(alternateEnclosure).to.exist
+ expect(alternateEnclosure['@_type']).to.equal('application/x-mpegURL')
+ expect(alternateEnclosure['@_title']).to.equal('HLS live stream')
+ expect(alternateEnclosure['@_default']).to.equal(true)
+
+ expect(alternateEnclosure['podcast:source']['@_uri']).to.contain('/master.m3u8')
+ expect(alternateEnclosure['podcast:source']['@_uri']).to.equal(enclosure['@_url'])
+
+ await stopFfmpeg(ffmpeg)
+
+ await servers[0].live.waitUntilEnded({ videoId: liveId })
+
+ await waitJobs(servers)
+ })
})
- it('Should filter by account', async function () {
- {
- const json = await servers[0].feed.getJSON({ feed: 'videos', query: { accountId: rootAccountId }, ignoreCache: true })
- const jsonObj = JSON.parse(json)
- expect(jsonObj.items.length).to.be.equal(1)
- expect(jsonObj.items[0].title).to.equal('my super name for server 1')
- expect(jsonObj.items[0].author.name).to.equal('Main root channel')
- }
+ describe('JSON feed', function () {
- {
- const json = await servers[0].feed.getJSON({ feed: 'videos', query: { accountId: userAccountId }, ignoreCache: true })
- const jsonObj = JSON.parse(json)
- expect(jsonObj.items.length).to.be.equal(1)
- expect(jsonObj.items[0].title).to.equal('user video')
- expect(jsonObj.items[0].author.name).to.equal('Main john channel')
- }
+ it('Should contain a valid \'attachments\' object', async function () {
+ for (const server of servers) {
+ const json = await server.feed.getJSON({ feed: 'videos', ignoreCache: true })
+ const jsonObj = JSON.parse(json)
+ expect(jsonObj.items.length).to.be.equal(2)
+ expect(jsonObj.items[0].attachments).to.exist
+ expect(jsonObj.items[0].attachments.length).to.be.eq(1)
+ expect(jsonObj.items[0].attachments[0].mime_type).to.be.eq('application/x-bittorrent')
+ expect(jsonObj.items[0].attachments[0].size_in_bytes).to.be.eq(218910)
+ expect(jsonObj.items[0].attachments[0].url).to.contain('720.torrent')
+ }
+ })
- for (const server of servers) {
+ it('Should filter by account', async function () {
{
- const json = await server.feed.getJSON({ feed: 'videos', query: { accountName: 'root@' + servers[0].host }, ignoreCache: true })
+ const json = await servers[0].feed.getJSON({ feed: 'videos', query: { accountId: rootAccountId }, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('my super name for server 1')
+ expect(jsonObj.items[0].author.name).to.equal('Main root channel')
}
{
- const json = await server.feed.getJSON({ feed: 'videos', query: { accountName: 'john@' + servers[0].host }, ignoreCache: true })
+ const json = await servers[0].feed.getJSON({ feed: 'videos', query: { accountId: userAccountId }, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('user video')
+ expect(jsonObj.items[0].author.name).to.equal('Main john channel')
}
- }
- })
- it('Should filter by video channel', async function () {
- {
- const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: rootChannelId }, ignoreCache: true })
- const jsonObj = JSON.parse(json)
- expect(jsonObj.items.length).to.be.equal(1)
- expect(jsonObj.items[0].title).to.equal('my super name for server 1')
- expect(jsonObj.items[0].author.name).to.equal('Main root channel')
- }
-
- {
- const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: userChannelId }, ignoreCache: true })
- const jsonObj = JSON.parse(json)
- expect(jsonObj.items.length).to.be.equal(1)
- expect(jsonObj.items[0].title).to.equal('user video')
- expect(jsonObj.items[0].author.name).to.equal('Main john channel')
- }
+ for (const server of servers) {
+ {
+ const json = await server.feed.getJSON({ feed: 'videos', query: { accountName: 'root@' + servers[0].host }, ignoreCache: true })
+ const jsonObj = JSON.parse(json)
+ expect(jsonObj.items.length).to.be.equal(1)
+ expect(jsonObj.items[0].title).to.equal('my super name for server 1')
+ }
+
+ {
+ const json = await server.feed.getJSON({ feed: 'videos', query: { accountName: 'john@' + servers[0].host }, ignoreCache: true })
+ const jsonObj = JSON.parse(json)
+ expect(jsonObj.items.length).to.be.equal(1)
+ expect(jsonObj.items[0].title).to.equal('user video')
+ }
+ }
+ })
- for (const server of servers) {
+ it('Should filter by video channel', async function () {
{
- const query = { videoChannelName: 'root_channel@' + servers[0].host }
- const json = await server.feed.getJSON({ feed: 'videos', query, ignoreCache: true })
+ const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: rootChannelId }, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('my super name for server 1')
+ expect(jsonObj.items[0].author.name).to.equal('Main root channel')
}
{
- const query = { videoChannelName: 'john_channel@' + servers[0].host }
- const json = await server.feed.getJSON({ feed: 'videos', query, ignoreCache: true })
+ const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: userChannelId }, ignoreCache: true })
const jsonObj = JSON.parse(json)
expect(jsonObj.items.length).to.be.equal(1)
expect(jsonObj.items[0].title).to.equal('user video')
+ expect(jsonObj.items[0].author.name).to.equal('Main john channel')
}
- }
- })
- it('Should correctly have videos feed with HLS only', async function () {
- this.timeout(120000)
-
- await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } })
+ for (const server of servers) {
+ {
+ const query = { videoChannelName: 'root_channel@' + servers[0].host }
+ const json = await server.feed.getJSON({ feed: 'videos', query, ignoreCache: true })
+ const jsonObj = JSON.parse(json)
+ expect(jsonObj.items.length).to.be.equal(1)
+ expect(jsonObj.items[0].title).to.equal('my super name for server 1')
+ }
+
+ {
+ const query = { videoChannelName: 'john_channel@' + servers[0].host }
+ const json = await server.feed.getJSON({ feed: 'videos', query, ignoreCache: true })
+ const jsonObj = JSON.parse(json)
+ expect(jsonObj.items.length).to.be.equal(1)
+ expect(jsonObj.items[0].title).to.equal('user video')
+ }
+ }
+ })
- await waitJobs([ serverHLSOnly ])
+ it('Should correctly have videos feed with HLS only', async function () {
+ this.timeout(120000)
- const json = await serverHLSOnly.feed.getJSON({ feed: 'videos', ignoreCache: true })
- const jsonObj = JSON.parse(json)
- expect(jsonObj.items.length).to.be.equal(1)
- expect(jsonObj.items[0].attachments).to.exist
- expect(jsonObj.items[0].attachments.length).to.be.eq(4)
-
- for (let i = 0; i < 4; i++) {
- expect(jsonObj.items[0].attachments[i].mime_type).to.be.eq('application/x-bittorrent')
- expect(jsonObj.items[0].attachments[i].size_in_bytes).to.be.greaterThan(0)
- expect(jsonObj.items[0].attachments[i].url).to.exist
- }
- })
+ const json = await serverHLSOnly.feed.getJSON({ feed: 'videos', ignoreCache: true })
+ const jsonObj = JSON.parse(json)
+ expect(jsonObj.items.length).to.be.equal(1)
+ expect(jsonObj.items[0].attachments).to.exist
+ expect(jsonObj.items[0].attachments.length).to.be.eq(4)
- it('Should not display waiting live videos', async function () {
- const { uuid } = await servers[0].live.create({
- fields: {
- name: 'live',
- privacy: VideoPrivacy.PUBLIC,
- channelId: rootChannelId
+ for (let i = 0; i < 4; i++) {
+ expect(jsonObj.items[0].attachments[i].mime_type).to.be.eq('application/x-bittorrent')
+ expect(jsonObj.items[0].attachments[i].size_in_bytes).to.be.greaterThan(0)
+ expect(jsonObj.items[0].attachments[i].url).to.exist
}
})
- liveId = uuid
- const json = await servers[0].feed.getJSON({ feed: 'videos', ignoreCache: true })
+ it('Should not display waiting live videos', async function () {
+ const { uuid } = await servers[0].live.create({
+ fields: {
+ name: 'live',
+ privacy: VideoPrivacy.PUBLIC,
+ channelId: rootChannelId
+ }
+ })
+ liveId = uuid
- const jsonObj = JSON.parse(json)
- expect(jsonObj.items.length).to.be.equal(2)
- expect(jsonObj.items[0].title).to.equal('my super name for server 1')
- expect(jsonObj.items[1].title).to.equal('user video')
- })
+ const json = await servers[0].feed.getJSON({ feed: 'videos', ignoreCache: true })
+
+ const jsonObj = JSON.parse(json)
+ expect(jsonObj.items.length).to.be.equal(2)
+ expect(jsonObj.items[0].title).to.equal('my super name for server 1')
+ expect(jsonObj.items[1].title).to.equal('user video')
+ })
- it('Should display published live videos', async function () {
- this.timeout(120000)
+ it('Should display published live videos', async function () {
+ this.timeout(120000)
- const ffmpeg = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveId, copyCodecs: true, fixtureName: 'video_short.mp4' })
- await servers[0].live.waitUntilPublished({ videoId: liveId })
+ const ffmpeg = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveId, copyCodecs: true, fixtureName: 'video_short.mp4' })
+ await servers[0].live.waitUntilPublished({ videoId: liveId })
- const json = await servers[0].feed.getJSON({ feed: 'videos', ignoreCache: true })
+ const json = await servers[0].feed.getJSON({ feed: 'videos', ignoreCache: true })
- const jsonObj = JSON.parse(json)
- expect(jsonObj.items.length).to.be.equal(3)
- expect(jsonObj.items[0].title).to.equal('live')
- expect(jsonObj.items[1].title).to.equal('my super name for server 1')
- expect(jsonObj.items[2].title).to.equal('user video')
+ const jsonObj = JSON.parse(json)
+ expect(jsonObj.items.length).to.be.equal(3)
+ expect(jsonObj.items[0].title).to.equal('live')
+ expect(jsonObj.items[1].title).to.equal('my super name for server 1')
+ expect(jsonObj.items[2].title).to.equal('user video')
- await stopFfmpeg(ffmpeg)
- })
+ await stopFfmpeg(ffmpeg)
+ })
- it('Should have the channel avatar as feed icon', async function () {
- const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: rootChannelId }, ignoreCache: true })
+ it('Should have the channel avatar as feed icon', async function () {
+ const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: rootChannelId }, ignoreCache: true })
- const jsonObj = JSON.parse(json)
- const imageUrl = jsonObj.icon
- expect(imageUrl).to.include('/lazy-static/avatars/')
- await makeRawRequest({ url: imageUrl, expectedStatus: HttpStatusCode.OK_200 })
+ const jsonObj = JSON.parse(json)
+ const imageUrl = jsonObj.icon
+ expect(imageUrl).to.include('/lazy-static/avatars/')
+ await makeRawRequest({ url: imageUrl, expectedStatus: HttpStatusCode.OK_200 })
+ })
})
})
})
after(async function () {
+ await servers[0].plugins.uninstall({ npmName: 'peertube-plugin-test-podcast-custom-tags' })
+
await cleanupTests([ ...servers, serverHLSOnly ])
})
})
--- /dev/null
+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 })
+}
--- /dev/null
+{
+ "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": []
+}
'action:api.video-channel.deleted',
'action:api.live-video.created',
+ 'action:live.video.state.updated',
'action:api.video-thread.created',
'action:api.video-comment-reply.created',
PeerTubeServer,
PluginsCommand,
setAccessTokensToServers,
- setDefaultVideoChannel
+ setDefaultVideoChannel,
+ stopFfmpeg,
+ waitJobs
} from '@shared/server-commands'
describe('Test plugin action hooks', function () {
let videoUUID: string
let threadId: number
- function checkHook (hook: ServerHookName, strictCount = true) {
- return servers[0].servers.waitUntilLog('Run hook ' + hook, 1, strictCount)
+ function checkHook (hook: ServerHookName, strictCount = true, count = 1) {
+ return servers[0].servers.waitUntilLog('Run hook ' + hook, count, strictCount)
}
before(async function () {
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 () {
locals: {
requestStart: number
+ apicacheGroups: string[]
+
apicache: {
content: string | Buffer
write: Writable['write']
MActorDefault,
MActorDefaultLight,
MActorFormattable,
+ MActorHost,
MActorId,
- MActorServer,
MActorSummary,
MActorSummaryFormattable,
MActorUrl
MAccount &
Use<'Actor', MActor>
-// Full actor with server
-export type MAccountServer =
+export type MAccountHost =
MAccount &
- Use<'Actor', MActorServer>
+ Use<'Actor', MActorHost>
// ############################################################################
MActorDefaultAccountChannel,
MActorDefaultChannelId,
MActorFormattable,
- MActorHost,
+ MActorHostOnly,
MActorUsername
} from './actor'
export type MActorFollowFollowingHost =
MActorFollow &
- Use<'ActorFollowing', MActorUsername & MActorHost>
+ Use<'ActorFollowing', MActorUsername & MActorHostOnly>
// ############################################################################
// Some association attributes
-export type MActorHost = Use<'Server', MServerHost>
+export type MActorHostOnly = Use<'Server', MServerHost>
+export type MActorHost =
+ MActorLight &
+ Use<'Server', MServerHost>
+
export type MActorRedundancyAllowedOpt = PickWithOpt<ActorModel, 'Server', MServerRedundancyAllowed>
export type MActorDefaultLight =
export type MActorDefaultAccountChannel = MActorDefault & MActorAccount & MActorChannel
-export type MActorServer =
- MActor &
+export type MActorServerLight =
+ MActorLight &
Use<'Server', MServer>
// ############################################################################
MActorDefaultLight,
MActorFormattable,
MActorHost,
+ MActorHostOnly,
MActorLight,
MActorSummary,
MActorSummaryFormattable,
Use<'Account', MAccountLight>
export type MChannelHost =
- MChannelId &
+ MChannel &
Use<'Actor', MActorHost>
+export type MChannelHostOnly =
+ MChannelId &
+ Use<'Actor', MActorHostOnly>
+
// ############################################################################
// Account associations
MChannelAccountSummaryFormattable,
MChannelActor,
MChannelFormattable,
- MChannelHost,
+ MChannelHostOnly,
MChannelUserId
} from './video-channels'
import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file'
export type MVideoWithHost =
MVideo &
- Use<'VideoChannel', MChannelHost>
+ Use<'VideoChannel', MChannelHostOnly>
export type MVideoFullLight =
MVideo &
// Filter the result of video JSON LD builder
// You may also need to use filter:activity-pub.activity.context.build.result to also update JSON LD context
- 'filter:activity-pub.video.json-ld.build.result': true
+ 'filter:activity-pub.video.json-ld.build.result': true,
+
+ // Filter result to allow custom XMLNS definitions in podcast RSS feeds
+ // Peertube >= 5.2
+ 'filter:feed.podcast.rss.create-custom-xmlns.result': true,
+
+ // Filter result to allow custom tags in podcast RSS feeds
+ // Peertube >= 5.2
+ 'filter:feed.podcast.channel.create-custom-tags.result': true,
+ // Peertube >= 5.2
+ 'filter:feed.podcast.video.create-custom-tags.result': true
}
export type ServerFilterHookName = keyof typeof serverFilterHookObject
// Fired when a live video is created
'action:api.live-video.created': true,
+ // Fired when a live video starts or ends
+ // Peertube >= 5.2
+ 'action:live.video.state.updated': true,
// Fired when a thread is created
'action:api.video-thread.created': true,
videoLanguages?: string[]
email?: string
+ emailPublic?: boolean
currentPassword?: string
password?: string
pendingEmail: string | null
emailVerified: boolean
+ emailPublic: boolean
nsfwPolicy: NSFWPolicyType
adminFlags?: UserAdminFlag
NOT_PUBLISHED_STATE = 1 << 0,
BLACKLISTED = 1 << 1,
BLOCKED_OWNER = 1 << 2,
- FILES = 1 << 3
+ FILES = 1 << 3,
+ CAPTIONS = 1 << 4
}
})
}
+ getPodcastXML (options: OverrideCommandOptions & {
+ ignoreCache: boolean
+ channelId: number
+ }) {
+ const { ignoreCache, channelId } = options
+ const path = `/feeds/podcast/videos.xml`
+
+ const query: { [id: string]: string } = {}
+
+ if (ignoreCache) query.v = buildUUID()
+ if (channelId) query.videoChannelId = channelId + ''
+
+ return this.getRequestText({
+ ...options,
+
+ path,
+ query,
+ accept: 'application/xml',
+ implicitToken: false,
+ defaultExpectedStatus: HttpStatusCode.OK_200
+ })
+ }
+
getJSON (options: OverrideCommandOptions & {
feed: FeedType
ignoreCache: boolean
get:
tags:
- Video Feeds
- summary: List comments on videos
+ summary: Comments on videos feeds
operationId: getSyndicatedComments
parameters:
- name: format
schema:
type: string
responses:
- '204':
+ '200':
description: successful operation
headers:
Cache-Control:
get:
tags:
- Video Feeds
- summary: List videos
+ summary: Common videos feeds
operationId: getSyndicatedVideos
parameters:
- name: format
- $ref: '#/components/parameters/hasHLSFiles'
- $ref: '#/components/parameters/hasWebtorrentFiles'
responses:
- '204':
+ '200':
description: successful operation
headers:
Cache-Control:
get:
tags:
- Video Feeds
- summary: List videos of subscriptions tied to a token
+ summary: Videos of subscriptions feeds
operationId: getSyndicatedSubscriptionVideos
parameters:
- name: format
- $ref: '#/components/parameters/hasHLSFiles'
- $ref: '#/components/parameters/hasWebtorrentFiles'
responses:
- '204':
+ '200':
description: successful operation
headers:
Cache-Control:
'406':
description: accept header unsupported
+ '/feeds/podcast/videos.xml':
+ get:
+ tags:
+ - Video Feeds
+ summary: Videos podcast feed
+ operationId: getVideosPodcastFeed
+ parameters:
+ - name: videoChannelId
+ in: query
+ description: 'Limit listing to a specific video channel'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: successful operation
+ headers:
+ Cache-Control:
+ schema:
+ type: string
+ default: 'max-age=900' # 15 min cache
+ '404':
+ description: video channel not found
+
'/api/v1/accounts/{name}':
get:
tags:
resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.9.1.tgz#ad3367684a57879392513479e0a436cb2ac46dad"
integrity sha512-oPQdbFDmZvjXk5ZDoBGXG8B4tSB/qW5vQunJWQMFUBp7Xe8O1ByPANueJ+Jzg58esEBegyyxZ7LRmfJr7kFcFg==
-"@peertube/feed@^5.0.1":
- version "5.0.2"
- resolved "https://registry.yarnpkg.com/@peertube/feed/-/feed-5.0.2.tgz#d9ae7f38f1ccc75d353a5e24ad335a982bc4df74"
- integrity sha512-5c8NkeIDx6J8lOzYiaTGipich/7hTO+CzZjIHFb1SY3+c14BvNJxrFb8b/9aZ8tekIYxKspqb8hg7WcVYg4NXA==
+"@peertube/feed@^5.1.0":
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/@peertube/feed/-/feed-5.1.0.tgz#e2fec950459ebaa32ea35791c45177f8b6fa85e9"
+ integrity sha512-ggwIbjxh4oc1aAGYV7ZxtIpiEIGq3Rkg6FxvOSrk/EPZ76rExoIJCjKeSyd4zb/sGkyKldy+bGs1OUUVidWWTQ==
dependencies:
xml-js "^1.6.11"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
-lodash@4.17.21, lodash@>=4.17.13, lodash@^4.17.10, lodash@^4.17.14, lodash@^4.17.20, lodash@^4.17.21:
+lodash@4.17.21, lodash@>=4.17.13, lodash@^4.17.14, lodash@^4.17.20, lodash@^4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==