Browse Source

Add Podcast RSS feeds (#5487)

* Initial test implementation of Podcast RSS

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

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

* Update to pfeed-podcast 1.2.2

* Initial test implementation of Podcast RSS

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

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

* Update to pfeed-podcast 1.2.2

* Initial test implementation of Podcast RSS

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

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

* Update to pfeed-podcast 1.2.2

* Add correct feed image to RSS channel

* Prefer HLS videos for podcast RSS

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

* Prefix podcast RSS images with root server URL

* Add optional video query support to include captions

* Add transcripts & person images to podcast RSS feed

* Prefer webseed/webtorrent files over HLS fragmented mp4s

* Experimentally adding podcast fields to basic config page

* Add validation for new basic config fields

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

* Initial test implementation of Podcast RSS

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

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

* Update to pfeed-podcast 1.2.2

* Add correct feed image to RSS channel

* Prefer HLS videos for podcast RSS

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

* Prefix podcast RSS images with root server URL

* Add optional video query support to include captions

* Add transcripts & person images to podcast RSS feed

* Prefer webseed/webtorrent files over HLS fragmented mp4s

* Experimentally adding podcast fields to basic config page

* Add validation for new basic config fields

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

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

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

* Add liveItem and trackers to Podcast RSS feeds

Remove height from alternateEnclosure, replaced with title.

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

* Upgrade to Node 16

* Refactor clearCacheRoute to use ApiCache

* Remove unnecessary type hint

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

* Use new file paths for captions/playlists

* Fix legacy videos in RSS after migration to object storage

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

* Don't include fragmented MP4s in podcast RSS feeds

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

* Fix undefined category when no videos exist

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

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

* Use comma for podcast:categories delimiter

* Make cache clearing async

* Fix merge, temporarily test with pfeed-podcast

* Syntax changes

* Add EXT_MIMETYPE constants for captions

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

* Add test for podacst:socialInteract

* Add filters hooks for podcast customTags

* Remove showdown, updated to pfeed-podcast 6.1.2

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

* Avoid assigning undefined category to podcast feeds

* Remove nvmrc

* Remove comment

* Remove unused podcast config

* Remove more unused podcast config

* Fix MChannelAccountDefault type hint missed in merge

* Remove extra line

* Re-add newline in config

* Fix lint errors for isEmailPublic

* Fix thumbnails in podcast feeds

* Requested changes based on review

* Provide podcast rss 2.0 only on video channels

* Misc cleanup for a less messy PR

* Lint fixes

* Remove pfeed-podcast

* Add peertube version to new hooks

* Don't use query include, remove TODO

* Remove film medium hack

* Clear podcast rss cache before video/channel update hooks

* Clear podcast rss cache before video uploaded/deleted hooks

* Refactor podcast feed cache clearing

* Set correct person name from video channel

* Styling

* Fix tests

---------

Co-authored-by: Chocobozzz <me@florianbigard.com>
pull/5340/merge
Alecks Gates GitHub 1 week ago
parent
commit
cb0eda5602
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 1683 additions and 585 deletions
  1. +3
    -2
      client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts
  2. +1
    -0
      client/src/app/+my-account/my-account-settings/my-account-email-preferences/index.ts
  3. +15
    -0
      client/src/app/+my-account/my-account-settings/my-account-email-preferences/my-account-email-preferences.component.html
  4. +0
    -0
      client/src/app/+my-account/my-account-settings/my-account-email-preferences/my-account-email-preferences.component.scss
  5. +51
    -0
      client/src/app/+my-account/my-account-settings/my-account-email-preferences/my-account-email-preferences.component.ts
  6. +3
    -1
      client/src/app/+my-account/my-account-settings/my-account-settings.component.html
  7. +4
    -1
      client/src/app/+my-account/my-account.module.ts
  8. +1
    -0
      client/src/app/core/users/user.model.ts
  9. +10
    -1
      client/src/app/shared/shared-main/video/video.service.ts
  10. +2
    -2
      package.json
  11. +2
    -1
      server/controllers/api/users/me.ts
  12. +2
    -2
      server/controllers/api/videos/update.ts
  13. +0
    -389
      server/controllers/feeds.ts
  14. +96
    -0
      server/controllers/feeds/comment-feeds.ts
  15. +16
    -0
      server/controllers/feeds/index.ts
  16. +145
    -0
      server/controllers/feeds/shared/common-feed-utils.ts
  17. +2
    -0
      server/controllers/feeds/shared/index.ts
  18. +66
    -0
      server/controllers/feeds/shared/video-feed-utils.ts
  19. +189
    -0
      server/controllers/feeds/video-feeds.ts
  20. +301
    -0
      server/controllers/feeds/video-podcast-feeds.ts
  21. +5
    -0
      server/helpers/custom-validators/users.ts
  22. +4
    -2
      server/initializers/constants.ts
  23. +25
    -0
      server/initializers/migrations/0775-add-user-is-email-public.ts
  24. +2
    -2
      server/lib/blocklist.ts
  25. +3
    -3
      server/lib/client-html.ts
  26. +1
    -1
      server/lib/files-cache/videos-preview-cache.ts
  27. +35
    -0
      server/lib/internal-event-emitter.ts
  28. +4
    -0
      server/lib/live/live-manager.ts
  29. +1
    -1
      server/lib/plugins/plugin-helpers-builder.ts
  30. +12
    -2
      server/middlewares/cache/cache.ts
  31. +41
    -4
      server/middlewares/cache/shared/api-cache.ts
  32. +46
    -0
      server/middlewares/validators/feeds.ts
  33. +4
    -0
      server/middlewares/validators/users.ts
  34. +5
    -7
      server/models/account/account.ts
  35. +4
    -4
      server/models/actor/actor.ts
  36. +6
    -0
      server/models/user/user.ts
  37. +1
    -1
      server/models/video/formatter/video-format-utils.ts
  38. +5
    -1
      server/models/video/thumbnail.ts
  39. +26
    -1
      server/models/video/video-caption.ts
  40. +24
    -3
      server/models/video/video-channel.ts
  41. +41
    -12
      server/models/video/video.ts
  42. +2
    -2
      server/tests/client.ts
  43. +249
    -112
      server/tests/feeds/feeds.ts
  44. +82
    -0
      server/tests/fixtures/peertube-plugin-test-podcast-custom-tags/main.js
  45. +19
    -0
      server/tests/fixtures/peertube-plugin-test-podcast-custom-tags/package.json
  46. +1
    -0
      server/tests/fixtures/peertube-plugin-test/main.js
  47. +28
    -3
      server/tests/plugins/action-hooks.ts
  48. +2
    -0
      server/types/express.d.ts
  49. +3
    -4
      server/types/models/account/account.ts
  50. +2
    -2
      server/types/models/actor/actor-follow.ts
  51. +7
    -3
      server/types/models/actor/actor.ts
  52. +6
    -1
      server/types/models/video/video-channels.ts
  53. +2
    -2
      server/types/models/video/video.ts
  54. +14
    -1
      shared/models/plugins/server/server-hook.model.ts
  55. +1
    -0
      shared/models/users/user-update-me.model.ts
  56. +1
    -0
      shared/models/users/user.model.ts
  57. +2
    -1
      shared/models/videos/video-include.enum.ts
  58. +23
    -0
      shared/server-commands/feeds/feeds-command.ts
  59. +30
    -6
      support/doc/api/openapi.yaml
  60. +5
    -5
      yarn.lock

+ 3
- 2
client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts View File

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


+ 1
- 0
client/src/app/+my-account/my-account-settings/my-account-email-preferences/index.ts View File

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

+ 15
- 0
client/src/app/+my-account/my-account-settings/my-account-email-preferences/my-account-email-preferences.component.html View File

@@ -0,0 +1,15 @@
<form role="form" (ngSubmit)="updateEmailPublic()" [formGroup]="form">

<div class="form-group">
<my-peertube-checkbox
inputName="email-public" formControlName="email-public"
i18n-labelText labelText="Allow email to be publicly displayed"
>
<ng-container ngProjectAs="description">
<span i18n>Necessary to claim podcast RSS feeds.</span>
</ng-container>
</my-peertube-checkbox>
</div>

<input class="peertube-button orange-button" type="submit" i18n-value value="Save" [disabled]="!form.valid">
</form>

+ 0
- 0
client/src/app/+my-account/my-account-settings/my-account-email-preferences/my-account-email-preferences.component.scss View File


+ 51
- 0
client/src/app/+my-account/my-account-settings/my-account-email-preferences/my-account-email-preferences.component.ts View File

@@ -0,0 +1,51 @@
import { Subject } from 'rxjs'
import { Component, Input, OnInit } from '@angular/core'
import { Notifier, UserService } from '@app/core'
import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
import { User, UserUpdateMe } from '@shared/models'

@Component({
selector: 'my-account-email-preferences',
templateUrl: './my-account-email-preferences.component.html',
styleUrls: [ './my-account-email-preferences.component.scss' ]
})
export class MyAccountEmailPreferencesComponent extends FormReactive implements OnInit {
@Input() user: User = null
@Input() userInformationLoaded: Subject<any>

constructor (
protected formReactiveService: FormReactiveService,
private userService: UserService,
private notifier: Notifier
) {
super()
}

ngOnInit () {
this.buildForm({
'email-public': null
})

this.userInformationLoaded.subscribe(() => {
this.form.patchValue({ 'email-public': this.user.emailPublic })
})
}

updateEmailPublic () {
const details: UserUpdateMe = {
emailPublic: this.form.value['email-public']
}

this.userService.updateMyProfile(details)
.subscribe({
next: () => {
if (details.emailPublic) this.notifier.success($localize`Email is now public`)
else this.notifier.success($localize`Email is now private`)

this.user.emailPublic = details.emailPublic
},

error: err => console.log(err.message)
})
}
}

+ 3
- 1
client/src/app/+my-account/my-account-settings/my-account-settings.component.html View File

@@ -68,7 +68,7 @@
</div>

<div class="col-12 col-lg-8 col-xl-9">
<my-account-two-factor-button [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-two-factor-button>
<my-account-two-factor-button [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-two-factor-button>
</div>
</div>

@@ -78,6 +78,8 @@
</div>

<div class="col-12 col-lg-8 col-xl-9">
<my-account-email-preferences class="d-block mb-5" [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-email-preferences>

<my-account-change-email></my-account-change-email>
</div>
</div>


+ 4
- 1
client/src/app/+my-account/my-account.module.ts View File

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

MyAccountEmailPreferencesComponent
],

exports: [


+ 1
- 0
client/src/app/core/users/user.model.ts View File

@@ -19,6 +19,7 @@ export class User implements UserServerModel {
pendingEmail: string | null

emailVerified: boolean
emailPublic: boolean
nsfwPolicy: NSFWPolicyType

adminFlags?: UserAdminFlag


+ 10
- 1
client/src/app/shared/shared-main/video/video.service.ts View File

@@ -54,6 +54,7 @@ export type CommonVideoParams = {
export class VideoService {
static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos'
static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
static PODCAST_FEEDS_URL = environment.apiUrl + '/feeds/podcast/videos.xml'
static BASE_SUBSCRIPTION_FEEDS_URL = environment.apiUrl + '/feeds/subscriptions.'

constructor (
@@ -266,7 +267,15 @@ export class VideoService {
let params = this.restService.addRestGetParams(new HttpParams())
params = params.set('videoChannelId', videoChannelId.toString())

return this.buildBaseFeedUrls(params)
const feedUrls = this.buildBaseFeedUrls(params)

feedUrls.push({
format: FeedFormat.RSS,
label: 'podcast rss 2.0',
url: VideoService.PODCAST_FEEDS_URL + `?videoChannelId=${videoChannelId}`
})

return feedUrls
}

getVideoSubscriptionFeedUrls (accountId: number, feedToken: string) {


+ 2
- 2
package.json View File

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


+ 2
- 1
server/controllers/api/users/me.ts View File

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

for (const key of keysToUpdate) {


+ 2
- 2
server/controllers/api/videos/update.ts View File

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

const lTags = loggerTagsFactory('api', 'video')
const auditLogger = auditLoggerFactory('videos')


+ 0
- 389
server/controllers/feeds.ts View File

@@ -1,389 +0,0 @@
import express from 'express'
import { extname } from 'path'
import { Feed } from '@peertube/feed'
import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown'
import { getServerActor } from '@server/models/application/application'
import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils'
import { MAccountDefault, MChannelBannerAccountDefault, MVideoFullLight } from '@server/types/models'
import { ActorImageType, VideoInclude } from '@shared/models'
import { buildNSFWFilter } from '../helpers/express-utils'
import { CONFIG } from '../initializers/config'
import { MIMETYPES, PREVIEWS_SIZE, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../initializers/constants'
import {
asyncMiddleware,
commonVideosFiltersValidator,
feedsFormatValidator,
setDefaultVideosSort,
setFeedFormatContentType,
videoCommentsFeedsValidator,
videoFeedsValidator,
videosSortValidator,
videoSubscriptionFeedsValidator
} from '../middlewares'
import { cacheRouteFactory } from '../middlewares/cache/cache'
import { VideoModel } from '../models/video/video'
import { VideoCommentModel } from '../models/video/video-comment'

const feedsRouter = express.Router()

const cacheRoute = cacheRouteFactory({
headerBlacklist: [ 'Content-Type' ]
})

feedsRouter.get('/feeds/video-comments.:format',
feedsFormatValidator,
setFeedFormatContentType,
cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS),
asyncMiddleware(videoFeedsValidator),
asyncMiddleware(videoCommentsFeedsValidator),
asyncMiddleware(generateVideoCommentsFeed)
)

feedsRouter.get('/feeds/videos.:format',
videosSortValidator,
setDefaultVideosSort,
feedsFormatValidator,
setFeedFormatContentType,
cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS),
commonVideosFiltersValidator,
asyncMiddleware(videoFeedsValidator),
asyncMiddleware(generateVideoFeed)
)

feedsRouter.get('/feeds/subscriptions.:format',
videosSortValidator,
setDefaultVideosSort,
feedsFormatValidator,
setFeedFormatContentType,
cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS),
commonVideosFiltersValidator,
asyncMiddleware(videoSubscriptionFeedsValidator),
asyncMiddleware(generateVideoFeedForSubscriptions)
)

// ---------------------------------------------------------------------------

export {
feedsRouter
}

// ---------------------------------------------------------------------------

async function generateVideoCommentsFeed (req: express.Request, res: express.Response) {
const start = 0
const video = res.locals.videoAll
const account = res.locals.account
const videoChannel = res.locals.videoChannel

const comments = await VideoCommentModel.listForFeed({
start,
count: CONFIG.FEEDS.COMMENTS.COUNT,
videoId: video ? video.id : undefined,
accountId: account ? account.id : undefined,
videoChannelId: videoChannel ? videoChannel.id : undefined
})

const { name, description, imageUrl } = buildFeedMetadata({ video, account, videoChannel })

const feed = initFeed({
name,
description,
imageUrl,
resourceType: 'video-comments',
queryString: new URL(WEBSERVER.URL + req.originalUrl).search
})

// Adding video items to the feed, one at a time
for (const comment of comments) {
const localLink = WEBSERVER.URL + comment.getCommentStaticPath()

let title = comment.Video.name
const author: { name: string, link: string }[] = []

if (comment.Account) {
title += ` - ${comment.Account.getDisplayName()}`
author.push({
name: comment.Account.getDisplayName(),
link: comment.Account.Actor.url
})
}

feed.addItem({
title,
id: localLink,
link: localLink,
content: toSafeHtml(comment.text),
author,
date: comment.createdAt
})
}

// Now the feed generation is done, let's send it!
return sendFeed(feed, req, res)
}

async function generateVideoFeed (req: express.Request, res: express.Response) {
const start = 0
const account = res.locals.account
const videoChannel = res.locals.videoChannel
const nsfw = buildNSFWFilter(res, req.query.nsfw)

const { name, description, imageUrl } = buildFeedMetadata({ videoChannel, account })

const feed = initFeed({
name,
description,
imageUrl,
resourceType: 'videos',
queryString: new URL(WEBSERVER.URL + req.url).search
})

const options = {
accountId: account ? account.id : null,
videoChannelId: videoChannel ? videoChannel.id : null
}

const server = await getServerActor()
const { data } = await VideoModel.listForApi({
start,
count: CONFIG.FEEDS.VIDEOS.COUNT,
sort: req.query.sort,
displayOnlyForFollower: {
actorId: server.id,
orLocalVideos: true
},
nsfw,
isLocal: req.query.isLocal,
include: req.query.include | VideoInclude.FILES,
hasFiles: true,
countVideos: false,
...options
})

addVideosToFeed(feed, data)

// Now the feed generation is done, let's send it!
return sendFeed(feed, req, res)
}

async function generateVideoFeedForSubscriptions (req: express.Request, res: express.Response) {
const start = 0
const account = res.locals.account
const nsfw = buildNSFWFilter(res, req.query.nsfw)

const { name, description, imageUrl } = buildFeedMetadata({ account })

const feed = initFeed({
name,
description,
imageUrl,
resourceType: 'videos',
queryString: new URL(WEBSERVER.URL + req.url).search
})

const { data } = await VideoModel.listForApi({
start,
count: CONFIG.FEEDS.VIDEOS.COUNT,
sort: req.query.sort,
nsfw,

isLocal: req.query.isLocal,

hasFiles: true,
include: req.query.include | VideoInclude.FILES,

countVideos: false,

displayOnlyForFollower: {
actorId: res.locals.user.Account.Actor.id,
orLocalVideos: false
},
user: res.locals.user
})

addVideosToFeed(feed, data)

// Now the feed generation is done, let's send it!
return sendFeed(feed, req, res)
}

function initFeed (parameters: {
name: string
description: string
imageUrl: string
resourceType?: 'videos' | 'video-comments'
queryString?: string
}) {
const webserverUrl = WEBSERVER.URL
const { name, description, resourceType, queryString, imageUrl } = parameters

return new Feed({
title: name,
description: mdToOneLinePlainText(description),
// updated: TODO: somehowGetLatestUpdate, // optional, default = today
id: webserverUrl,
link: webserverUrl,
image: imageUrl,
favicon: webserverUrl + '/client/assets/images/favicon.png',
copyright: `All rights reserved, unless otherwise specified in the terms specified at ${webserverUrl}/about` +
` and potential licenses granted by each content's rightholder.`,
generator: `Toraifōsu`, // ^.~
feedLinks: {
json: `${webserverUrl}/feeds/${resourceType}.json${queryString}`,
atom: `${webserverUrl}/feeds/${resourceType}.atom${queryString}`,
rss: `${webserverUrl}/feeds/${resourceType}.xml${queryString}`
},
author: {
name: 'Instance admin of ' + CONFIG.INSTANCE.NAME,
email: CONFIG.ADMIN.EMAIL,
link: `${webserverUrl}/about`
}
})
}

function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
for (const video of videos) {
const formattedVideoFiles = video.getFormattedVideoFilesJSON(false)

const torrents = formattedVideoFiles.map(videoFile => ({
title: video.name,
url: videoFile.torrentUrl,
size_in_bytes: videoFile.size
}))

const videoFiles = formattedVideoFiles.map(videoFile => {
const result = {
type: MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)],
medium: 'video',
height: videoFile.resolution.id,
fileSize: videoFile.size,
url: videoFile.fileUrl,
framerate: videoFile.fps,
duration: video.duration
}

if (video.language) Object.assign(result, { lang: video.language })

return result
})

const categories: { value: number, label: string }[] = []
if (video.category) {
categories.push({
value: video.category,
label: getCategoryLabel(video.category)
})
}

const localLink = WEBSERVER.URL + video.getWatchStaticPath()

feed.addItem({
title: video.name,
id: localLink,
link: localLink,
description: mdToOneLinePlainText(video.getTruncatedDescription()),
content: toSafeHtml(video.description),
author: [
{
name: video.VideoChannel.getDisplayName(),
link: video.VideoChannel.Actor.url
}
],
date: video.publishedAt,
nsfw: video.nsfw,
torrents,

// Enclosure
video: videoFiles.length !== 0
? {
url: videoFiles[0].url,
length: videoFiles[0].fileSize,
type: videoFiles[0].type
}
: undefined,

// Media RSS
videos: videoFiles,

embed: {
url: WEBSERVER.URL + video.getEmbedStaticPath(),
allowFullscreen: true
},
player: {
url: WEBSERVER.URL + video.getWatchStaticPath()
},
categories,
community: {
statistics: {
views: video.views
}
},
thumbnails: [
{
url: WEBSERVER.URL + video.getPreviewStaticPath(),
height: PREVIEWS_SIZE.height,
width: PREVIEWS_SIZE.width
}
]
})
}
}

function sendFeed (feed: Feed, req: express.Request, res: express.Response) {
const format = req.params.format

if (format === 'atom' || format === 'atom1') {
return res.send(feed.atom1()).end()
}

if (format === 'json' || format === 'json1') {
return res.send(feed.json1()).end()
}

if (format === 'rss' || format === 'rss2') {
return res.send(feed.rss2()).end()
}

// We're in the ambiguous '.xml' case and we look at the format query parameter
if (req.query.format === 'atom' || req.query.format === 'atom1') {
return res.send(feed.atom1()).end()
}

return res.send(feed.rss2()).end()
}

function buildFeedMetadata (options: {
videoChannel?: MChannelBannerAccountDefault
account?: MAccountDefault
video?: MVideoFullLight
}) {
const { video, videoChannel, account } = options

let imageUrl = WEBSERVER.URL + '/client/assets/images/icons/icon-96x96.png'
let name: string
let description: string

if (videoChannel) {
name = videoChannel.getDisplayName()
description = videoChannel.description

if (videoChannel.Actor.hasImage(ActorImageType.AVATAR)) {
imageUrl = WEBSERVER.URL + videoChannel.Actor.Avatars[0].getStaticPath()
}
} else if (account) {
name = account.getDisplayName()
description = account.description

if (account.Actor.hasImage(ActorImageType.AVATAR)) {
imageUrl = WEBSERVER.URL + account.Actor.Avatars[0].getStaticPath()
}
} else if (video) {
name = video.name
description = video.description
} else {
name = CONFIG.INSTANCE.NAME
description = CONFIG.INSTANCE.DESCRIPTION
}

return { name, description, imageUrl }
}

+ 96
- 0
server/controllers/feeds/comment-feeds.ts View File

@@ -0,0 +1,96 @@
import express from 'express'
import { toSafeHtml } from '@server/helpers/markdown'
import { cacheRouteFactory } from '@server/middlewares'
import { CONFIG } from '../../initializers/config'
import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
import {
asyncMiddleware,
feedsFormatValidator,
setFeedFormatContentType,
videoCommentsFeedsValidator,
videoFeedsValidator
} from '../../middlewares'
import { VideoCommentModel } from '../../models/video/video-comment'
import { buildFeedMetadata, initFeed, sendFeed } from './shared'

const commentFeedsRouter = express.Router()

// ---------------------------------------------------------------------------

const { middleware: cacheRouteMiddleware } = cacheRouteFactory({
headerBlacklist: [ 'Content-Type' ]
})

// ---------------------------------------------------------------------------

commentFeedsRouter.get('/feeds/video-comments.:format',
feedsFormatValidator,
setFeedFormatContentType,
cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
asyncMiddleware(videoFeedsValidator),
asyncMiddleware(videoCommentsFeedsValidator),
asyncMiddleware(generateVideoCommentsFeed)
)

// ---------------------------------------------------------------------------

export {
commentFeedsRouter
}

// ---------------------------------------------------------------------------

async function generateVideoCommentsFeed (req: express.Request, res: express.Response) {
const start = 0
const video = res.locals.videoAll
const account = res.locals.account
const videoChannel = res.locals.videoChannel

const comments = await VideoCommentModel.listForFeed({
start,
count: CONFIG.FEEDS.COMMENTS.COUNT,
videoId: video ? video.id : undefined,
accountId: account ? account.id : undefined,
videoChannelId: videoChannel ? videoChannel.id : undefined
})

const { name, description, imageUrl, link } = await buildFeedMetadata({ video, account, videoChannel })

const feed = initFeed({
name,
description,
imageUrl,
isPodcast: false,
link,
resourceType: 'video-comments',
queryString: new URL(WEBSERVER.URL + req.originalUrl).search
})

// Adding video items to the feed, one at a time
for (const comment of comments) {
const localLink = WEBSERVER.URL + comment.getCommentStaticPath()

let title = comment.Video.name
const author: { name: string, link: string }[] = []

if (comment.Account) {
title += ` - ${comment.Account.getDisplayName()}`
author.push({
name: comment.Account.getDisplayName(),
link: comment.Account.Actor.url
})
}

feed.addItem({
title,
id: localLink,
link: localLink,
content: toSafeHtml(comment.text),
author,
date: comment.createdAt
})
}

// Now the feed generation is done, let's send it!
return sendFeed(feed, req, res)
}

+ 16
- 0
server/controllers/feeds/index.ts View File

@@ -0,0 +1,16 @@
import express from 'express'
import { commentFeedsRouter } from './comment-feeds'
import { videoFeedsRouter } from './video-feeds'
import { videoPodcastFeedsRouter } from './video-podcast-feeds'

const feedsRouter = express.Router()

feedsRouter.use('/', commentFeedsRouter)
feedsRouter.use('/', videoFeedsRouter)
feedsRouter.use('/', videoPodcastFeedsRouter)

// ---------------------------------------------------------------------------

export {
feedsRouter
}

+ 145
- 0
server/controllers/feeds/shared/common-feed-utils.ts View File

@@ -0,0 +1,145 @@
import express from 'express'
import { Feed } from '@peertube/feed'
import { CustomTag, CustomXMLNS, Person } from '@peertube/feed/lib/typings'
import { mdToOneLinePlainText } from '@server/helpers/markdown'
import { CONFIG } from '@server/initializers/config'
import { WEBSERVER } from '@server/initializers/constants'
import { UserModel } from '@server/models/user/user'
import { MAccountDefault, MChannelBannerAccountDefault, MUser, MVideoFullLight } from '@server/types/models'
import { pick } from '@shared/core-utils'
import { ActorImageType } from '@shared/models'

export function initFeed (parameters: {
name: string
description: string
imageUrl: string
isPodcast: boolean
link?: string
locked?: { isLocked: boolean, email: string }
author?: {
name: string
link: string
imageUrl: string
}
person?: Person[]
resourceType?: 'videos' | 'video-comments'
queryString?: string
medium?: string
stunServers?: string[]
trackers?: string[]
customXMLNS?: CustomXMLNS[]
customTags?: CustomTag[]
}) {
const webserverUrl = WEBSERVER.URL
const { name, description, link, imageUrl, isPodcast, resourceType, queryString, medium } = parameters

return new Feed({
title: name,
description: mdToOneLinePlainText(description),
// updated: TODO: somehowGetLatestUpdate, // optional, default = today
id: link || webserverUrl,
link: link || webserverUrl,
image: imageUrl,
favicon: webserverUrl + '/client/assets/images/favicon.png',
copyright: `All rights reserved, unless otherwise specified in the terms specified at ${webserverUrl}/about` +
` and potential licenses granted by each content's rightholder.`,
generator: `Toraifōsu`, // ^.~
medium: medium || 'video',
feedLinks: {
json: `${webserverUrl}/feeds/${resourceType}.json${queryString}`,
atom: `${webserverUrl}/feeds/${resourceType}.atom${queryString}`,
rss: isPodcast
? `${webserverUrl}/feeds/podcast/videos.xml${queryString}`
: `${webserverUrl}/feeds/${resourceType}.xml${queryString}`
},

...pick(parameters, [ 'stunServers', 'trackers', 'customXMLNS', 'customTags', 'author', 'person', 'locked' ])
})
}

export function sendFeed (feed: Feed, req: express.Request, res: express.Response) {
const format = req.params.format

if (format === 'atom' || format === 'atom1') {
return res.send(feed.atom1()).end()
}

if (format === 'json' || format === 'json1') {
return res.send(feed.json1()).end()
}

if (format === 'rss' || format === 'rss2') {
return res.send(feed.rss2()).end()
}

// We're in the ambiguous '.xml' case and we look at the format query parameter
if (req.query.format === 'atom' || req.query.format === 'atom1') {
return res.send(feed.atom1()).end()
}

return res.send(feed.rss2()).end()
}

export async function buildFeedMetadata (options: {
videoChannel?: MChannelBannerAccountDefault
account?: MAccountDefault
video?: MVideoFullLight
}) {
const { video, videoChannel, account } = options

let imageUrl = WEBSERVER.URL + '/client/assets/images/icons/icon-96x96.png'
let accountImageUrl: string
let name: string
let userName: string
let description: string
let email: string
let link: string
let accountLink: string
let user: MUser

if (videoChannel) {
name = videoChannel.getDisplayName()
description = videoChannel.description
link = videoChannel.getClientUrl()
accountLink = videoChannel.Account.getClientUrl()

if (videoChannel.Actor.hasImage(ActorImageType.AVATAR)) {
imageUrl = WEBSERVER.URL + videoChannel.Actor.Avatars[0].getStaticPath()
}

if (videoChannel.Account.Actor.hasImage(ActorImageType.AVATAR)) {
accountImageUrl = WEBSERVER.URL + videoChannel.Account.Actor.Avatars[0].getStaticPath()
}

user = await UserModel.loadById(videoChannel.Account.userId)
userName = videoChannel.Account.getDisplayName()
} else if (account) {
name = account.getDisplayName()
description = account.description
link = account.getClientUrl()
accountLink = link

if (account.Actor.hasImage(ActorImageType.AVATAR)) {
imageUrl = WEBSERVER.URL + account.Actor.Avatars[0].getStaticPath()
accountImageUrl = imageUrl
}

user = await UserModel.loadById(account.userId)
} else if (video) {
name = video.name
description = video.description
link = video.url
} else {
name = CONFIG.INSTANCE.NAME
description = CONFIG.INSTANCE.DESCRIPTION
link = WEBSERVER.URL
}

// If the user is local, has a verified email address, and allows it to be publicly displayed
// Return it so the owner can prove ownership of their feed
if (user && !user.pluginAuth && user.emailVerified && user.emailPublic) {
email = user.email
}

return { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink }
}

+ 2
- 0
server/controllers/feeds/shared/index.ts View File

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

+ 66
- 0
server/controllers/feeds/shared/video-feed-utils.ts View File

@@ -0,0 +1,66 @@
import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown'
import { CONFIG } from '@server/initializers/config'
import { WEBSERVER } from '@server/initializers/constants'
import { getServerActor } from '@server/models/application/application'
import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils'
import { DisplayOnlyForFollowerOptions } from '@server/models/video/sql/video'
import { VideoModel } from '@server/models/video/video'
import { MThumbnail, MUserDefault } from '@server/types/models'
import { VideoInclude } from '@shared/models'

export async function getVideosForFeeds (options: {
sort: string
nsfw: boolean
isLocal: boolean
include: VideoInclude

accountId?: number
videoChannelId?: number
displayOnlyForFollower?: DisplayOnlyForFollowerOptions
user?: MUserDefault
}) {
const server = await getServerActor()

const { data } = await VideoModel.listForApi({
start: 0,
count: CONFIG.FEEDS.VIDEOS.COUNT,
displayOnlyForFollower: {
actorId: server.id,
orLocalVideos: true
},
hasFiles: true,
countVideos: false,

...options
})

return data
}

export function getCommonVideoFeedAttributes (video: VideoModel) {
const localLink = WEBSERVER.URL + video.getWatchStaticPath()

const thumbnailModels: MThumbnail[] = []
if (video.hasPreview()) thumbnailModels.push(video.getPreview())
thumbnailModels.push(video.getMiniature())

return {
title: video.name,
link: localLink,
description: mdToOneLinePlainText(video.getTruncatedDescription()),
content: toSafeHtml(video.description),

date: video.publishedAt,
nsfw: video.nsfw,

category: video.category
? [ { name: getCategoryLabel(video.category) } ]
: undefined,

thumbnails: thumbnailModels.map(t => ({
url: WEBSERVER.URL + t.getLocalStaticPath(),
width: t.width,
height: t.height
}))
}
}

+ 189
- 0
server/controllers/feeds/video-feeds.ts View File

@@ -0,0 +1,189 @@
import express from 'express'
import { extname } from 'path'
import { Feed } from '@peertube/feed'
import { cacheRouteFactory } from '@server/middlewares'
import { VideoModel } from '@server/models/video/video'
import { VideoInclude } from '@shared/models'
import { buildNSFWFilter } from '../../helpers/express-utils'
import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
import {
asyncMiddleware,
commonVideosFiltersValidator,
feedsFormatValidator,
setDefaultVideosSort,
setFeedFormatContentType,
videoFeedsValidator,
videosSortValidator,
videoSubscriptionFeedsValidator
} from '../../middlewares'
import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed, sendFeed } from './shared'

const videoFeedsRouter = express.Router()

const { middleware: cacheRouteMiddleware } = cacheRouteFactory({
headerBlacklist: [ 'Content-Type' ]
})

// ---------------------------------------------------------------------------

videoFeedsRouter.get('/feeds/videos.:format',
videosSortValidator,
setDefaultVideosSort,
feedsFormatValidator,
setFeedFormatContentType,
cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
commonVideosFiltersValidator,
asyncMiddleware(videoFeedsValidator),
asyncMiddleware(generateVideoFeed)
)

videoFeedsRouter.get('/feeds/subscriptions.:format',
videosSortValidator,
setDefaultVideosSort,
feedsFormatValidator,
setFeedFormatContentType,
cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
commonVideosFiltersValidator,
asyncMiddleware(videoSubscriptionFeedsValidator),
asyncMiddleware(generateVideoFeedForSubscriptions)
)

// ---------------------------------------------------------------------------

export {
videoFeedsRouter
}

// ---------------------------------------------------------------------------

async function generateVideoFeed (req: express.Request, res: express.Response) {
const account = res.locals.account
const videoChannel = res.locals.videoChannel

const { name, description, imageUrl, accountImageUrl, link, accountLink } = await buildFeedMetadata({ videoChannel, account })

const feed = initFeed({
name,
description,
link,
isPodcast: false,
imageUrl,
author: { name, link: accountLink, imageUrl: accountImageUrl },
resourceType: 'videos',
queryString: new URL(WEBSERVER.URL + req.url).search
})

const data = await getVideosForFeeds({
sort: req.query.sort,
nsfw: buildNSFWFilter(res, req.query.nsfw),
isLocal: req.query.isLocal,
include: req.query.include | VideoInclude.FILES,
accountId: account?.id,
videoChannelId: videoChannel?.id
})

addVideosToFeed(feed, data)

// Now the feed generation is done, let's send it!
return sendFeed(feed, req, res)
}

async function generateVideoFeedForSubscriptions (req: express.Request, res: express.Response) {
const account = res.locals.account
const { name, description, imageUrl, link } = await buildFeedMetadata({ account })

const feed = initFeed({
name,
description,
link,
isPodcast: false,
imageUrl,
resourceType: 'videos',
queryString: new URL(WEBSERVER.URL + req.url).search
})

const data = await getVideosForFeeds({
sort: req.query.sort,
nsfw: buildNSFWFilter(res, req.query.nsfw),
isLocal: req.query.isLocal,
include: req.query.include | VideoInclude.FILES,
displayOnlyForFollower: {
actorId: res.locals.user.Account.Actor.id,
orLocalVideos: false
},
user: res.locals.user
})

addVideosToFeed(feed, data)

// Now the feed generation is done, let's send it!
return sendFeed(feed, req, res)
}

// ---------------------------------------------------------------------------

function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
/**
* Adding video items to the feed object, one at a time
*/
for (const video of videos) {
const formattedVideoFiles = video.getFormattedAllVideoFilesJSON(false)

const torrents = formattedVideoFiles.map(videoFile => ({
title: video.name,
url: videoFile.torrentUrl,
size_in_bytes: videoFile.size
}))

const videoFiles = formattedVideoFiles.map(videoFile => {
return {
type: MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)],
medium: 'video',
height: videoFile.resolution.id,
fileSize: videoFile.size,
url: videoFile.fileUrl,
framerate: videoFile.fps,
duration: video.duration,
lang: video.language
}
})

feed.addItem({
...getCommonVideoFeedAttributes(video),

id: WEBSERVER.URL + video.getWatchStaticPath(),
author: [
{
name: video.VideoChannel.getDisplayName(),
link: video.VideoChannel.getClientUrl()
}
],
torrents,

// Enclosure
video: videoFiles.length !== 0
? {
url: videoFiles[0].url,
length: videoFiles[0].fileSize,
type: videoFiles[0].type
}
: undefined,

// Media RSS
videos: videoFiles,

embed: {
url: WEBSERVER.URL + video.getEmbedStaticPath(),
allowFullscreen: true
},
player: {
url: WEBSERVER.URL + video.getWatchStaticPath()
},
community: {
statistics: {
views: video.views
}
}
})
}
}

+ 301
- 0
server/controllers/feeds/video-podcast-feeds.ts View File

@@ -0,0 +1,301 @@
import express from 'express'
import { extname } from 'path'
import { Feed } from '@peertube/feed'
import { CustomTag, CustomXMLNS, LiveItemStatus } from '@peertube/feed/lib/typings'
import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
import { Hooks } from '@server/lib/plugins/hooks'
import { buildPodcastGroupsCache, cacheRouteFactory, videoFeedsPodcastSetCacheKey } from '@server/middlewares'
import { MVideo, MVideoCaptionVideo, MVideoFullLight } from '@server/types/models'
import { sortObjectComparator } from '@shared/core-utils'
import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@shared/models'
import { buildNSFWFilter } from '../../helpers/express-utils'
import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares'
import { VideoModel } from '../../models/video/video'
import { VideoCaptionModel } from '../../models/video/video-caption'
import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed } from './shared'

const videoPodcastFeedsRouter = express.Router()

// ---------------------------------------------------------------------------

const { middleware: podcastCacheRouteMiddleware, instance: podcastApiCache } = cacheRouteFactory({
headerBlacklist: [ 'Content-Type' ]
})

for (const event of ([ 'video-created', 'video-updated', 'video-deleted' ] as const)) {
InternalEventEmitter.Instance.on(event, ({ video }) => {
if (video.remote) return

podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: video.channelId }))
})
}

for (const event of ([ 'channel-updated', 'channel-deleted' ] as const)) {
InternalEventEmitter.Instance.on(event, ({ channel }) => {
podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: channel.id }))
})
}

// ---------------------------------------------------------------------------

videoPodcastFeedsRouter.get('/feeds/podcast/videos.xml',
setFeedPodcastContentType,
videoFeedsPodcastSetCacheKey,
podcastCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
asyncMiddleware(videoFeedsPodcastValidator),
asyncMiddleware(generateVideoPodcastFeed)
)

// ---------------------------------------------------------------------------

export {
videoPodcastFeedsRouter
}

// ---------------------------------------------------------------------------

async function generateVideoPodcastFeed (req: express.Request, res: express.Response) {
const videoChannel = res.locals.videoChannel

const { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink } = await buildFeedMetadata({ videoChannel })

const data = await getVideosForFeeds({
sort: '-publishedAt',
nsfw: buildNSFWFilter(),
// Prevent podcast feeds from listing videos in other instances
// helps prevent duplicates when they are indexed -- only the author should control them
isLocal: true,
include: VideoInclude.FILES,
videoChannelId: videoChannel?.id
})

const customTags: CustomTag[] = await Hooks.wrapObject(
[],
'filter:feed.podcast.channel.create-custom-tags.result',
{ videoChannel }
)

const customXMLNS: CustomXMLNS[] = await Hooks.wrapObject(
[],
'filter:feed.podcast.rss.create-custom-xmlns.result'
)

const feed = initFeed({
name,
description,
link,
isPodcast: true,
imageUrl,

locked: email
? { isLocked: true, email } // Default to true because we have no way of offering a redirect yet
: undefined,

person: [ { name: userName, href: accountLink, img: accountImageUrl } ],
resourceType: 'videos',
queryString: new URL(WEBSERVER.URL + req.url).search,
medium: 'video',
customXMLNS,
customTags
})

await addVideosToPodcastFeed(feed, data)

// Now the feed generation is done, let's send it!
return res.send(feed.podcast()).end()
}

type PodcastMedia =
{
type: string
length: number
bitrate: number
sources: { uri: string, contentType?: string }[]
title: string
language?: string
} |
{
sources: { uri: string }[]
type: string
title: string
}

async function generatePodcastItem (options: {
video: VideoModel
liveItem: boolean
media: PodcastMedia[]
}) {
const { video, liveItem, media } = options

const customTags: CustomTag[] = await Hooks.wrapObject(
[],
'filter:feed.podcast.video.create-custom-tags.result',
{ video, liveItem }
)

const account = video.VideoChannel.Account

const author = {
name: account.getDisplayName(),
href: account.getClientUrl()
}

return {
...getCommonVideoFeedAttributes(video),

trackers: video.getTrackerUrls(),

author: [ author ],
person: [
{
...author,

img: account.Actor.hasImage(ActorImageType.AVATAR)
? WEBSERVER.URL + account.Actor.Avatars[0].getStaticPath()
: undefined
}
],

media,

socialInteract: [
{
uri: video.url,
protocol: 'activitypub',
accountUrl: account.getClientUrl()
}
],

customTags
}
}

async function addVideosToPodcastFeed (feed: Feed, videos: VideoModel[]) {
const captionsGroup = await VideoCaptionModel.listCaptionsOfMultipleVideos(videos.map(v => v.id))

for (const video of videos) {
if (!video.isLive) {
await addVODPodcastItem({ feed, video, captionsGroup })
} else if (video.isLive && video.state !== VideoState.LIVE_ENDED) {
await addLivePodcastItem({ feed, video })
}
}
}

async function addVODPodcastItem (options: {
feed: Feed
video: VideoModel
captionsGroup: { [ id: number ]: MVideoCaptionVideo[] }
}) {
const { feed, video, captionsGroup } = options

const webVideos = video.getFormattedWebVideoFilesJSON(true)
.map(f => buildVODWebVideoFile(video, f))
.sort(sortObjectComparator('bitrate', 'desc'))

const streamingPlaylistFiles = buildVODStreamingPlaylists(video)

// Order matters here, the first media URI will be the "default"
// So web videos are default if enabled
const media = [ ...webVideos, ...streamingPlaylistFiles ]

const videoCaptions = buildVODCaptions(video, captionsGroup[video.id])
const item = await generatePodcastItem({ video, liveItem: false, media })

feed.addPodcastItem({ ...item, subTitle: videoCaptions })
}

async function addLivePodcastItem (options: {
feed: Feed
video: VideoModel
}) {
const { feed, video } = options

let status: LiveItemStatus

switch (video.state) {
case VideoState.WAITING_FOR_LIVE:
status = LiveItemStatus.pending
break
case VideoState.PUBLISHED:
status = LiveItemStatus.live
break
}

const item = await generatePodcastItem({ video, liveItem: true, media: buildLiveStreamingPlaylists(video) })

feed.addPodcastLiveItem({ ...item, status, start: video.updatedAt.toISOString() })
}

// ---------------------------------------------------------------------------

function buildVODWebVideoFile (video: MVideo, videoFile: VideoFile) {
const isAudio = videoFile.resolution.id === VideoResolution.H_NOVIDEO
const type = isAudio
? MIMETYPES.AUDIO.EXT_MIMETYPE[extname(videoFile.fileUrl)]
: MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)]

const sources = [
{ uri: videoFile.fileUrl },
{ uri: videoFile.torrentUrl, contentType: 'application/x-bittorrent' }
]

if (videoFile.magnetUri) {
sources.push({ uri: videoFile.magnetUri })
}

return {
type,
title: videoFile.resolution.label,
length: videoFile.size,
bitrate: videoFile.size / video.duration * 8,
language: video.language,
sources
}
}

function buildVODStreamingPlaylists (video: MVideoFullLight) {
const hls = video.getHLSPlaylist()
if (!hls) return []

return [
{
type: 'application/x-mpegURL',
title: 'HLS',
sources: [
{ uri: hls.getMasterPlaylistUrl(video) }
],
language: video.language
}
]
}

function buildLiveStreamingPlaylists (video: MVideoFullLight) {
const hls = video.getHLSPlaylist()

return [
{
type: 'application/x-mpegURL',
title: `HLS live stream`,
sources: [
{ uri: hls.getMasterPlaylistUrl(video) }
],
language: video.language
}
]
}

function buildVODCaptions (video: MVideo, videoCaptions: MVideoCaptionVideo[]) {
return videoCaptions.map(caption => {
const type = MIMETYPES.VIDEO_CAPTIONS.EXT_MIMETYPE[extname(caption.filename)]
if (!type) return null

return {
url: caption.getFileUrl(video),
language: caption.language,
type,
rel: 'captions'
}
}).filter(c => c)
}

+ 5
- 0
server/helpers/custom-validators/users.ts View File

@@ -80,6 +80,10 @@ function isUserAutoPlayNextVideoPlaylistValid (value: any) {
return isBooleanValid(value)
}

function isUserEmailPublicValid (value: any) {
return isBooleanValid(value)
}

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

+ 4
- 2
server/initializers/constants.ts View File

@@ -27,7 +27,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'

// ---------------------------------------------------------------------------

const LAST_MIGRATION_VERSION = 770
const LAST_MIGRATION_VERSION = 775

// ---------------------------------------------------------------------------

@@ -634,7 +634,8 @@ const MIMETYPES = {
'text/vtt': '.vtt',
'application/x-subrip': '.srt',
'text/plain': '.srt'
}
},
EXT_MIMETYPE: null as { [ id: string ]: string }
},
TORRENT: {
MIMETYPE_EXT: {
@@ -649,6 +650,7 @@ const MIMETYPES = {
}
MIMETYPES.AUDIO.EXT_MIMETYPE = invert(MIMETYPES.AUDIO.MIMETYPE_EXT)
MIMETYPES.IMAGE.EXT_MIMETYPE = invert(MIMETYPES.IMAGE.MIMETYPE_EXT)
MIMETYPES.VIDEO_CAPTIONS.EXT_MIMETYPE = invert(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT)

const BINARY_CONTENT_TYPES = new Set([
'binary/octet-stream',


+ 25
- 0
server/initializers/migrations/0775-add-user-is-email-public.ts View File

@@ -0,0 +1,25 @@
import * as Sequelize from 'sequelize'

async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
}): Promise<void> {

const data = {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
}

await utils.queryInterface.addColumn('user', 'emailPublic', data)
}

function down (options) {
throw new Error('Not implemented.')
}

export {
up,
down
}

+ 2
- 2
server/lib/blocklist.ts View File

@@ -1,6 +1,6 @@
import { sequelizeTypescript } from '@server/initializers/database'
import { getServerActor } from '@server/models/application/application'
import { MAccountBlocklist, MAccountId, MAccountServer, MServerBlocklist } from '@server/types/models'
import { MAccountBlocklist, MAccountId, MAccountHost, MServerBlocklist } from '@server/types/models'
import { AccountBlocklistModel } from '../models/account/account-blocklist'
import { ServerBlocklistModel } from '../models/server/server-blocklist'

@@ -34,7 +34,7 @@ function removeServerFromBlocklist (serverBlock: MServerBlocklist) {
})
}

async function isBlockedByServerOrAccount (targetAccount: MAccountServer, userAccount?: MAccountId) {
async function isBlockedByServerOrAccount (targetAccount: MAccountHost, userAccount?: MAccountId) {
const serverAccountId = (await getServerActor()).Account.id
const sourceAccounts = [ serverAccountId ]



+ 3
- 3
server/lib/client-html.ts View File

@@ -27,7 +27,7 @@ import { AccountModel } from '../models/account/account'
import { VideoModel } from '../models/video/video'
import { VideoChannelModel } from '../models/video/video-channel'
import { VideoPlaylistModel } from '../models/video/video-playlist'
import { MAccountActor, MChannelActor, MVideo, MVideoPlaylist } from '../types/models'
import { MAccountHost, MChannelHost, MVideo, MVideoPlaylist } from '../types/models'
import { getActivityStreamDuration } from './activitypub/activity'
import { getBiggestActorImage } from './actor-image'
import { Hooks } from './plugins/hooks'
@@ -260,7 +260,7 @@ class ClientHtml {
}

private static async getAccountOrChannelHTMLPage (
loader: () => Promise<MAccountActor | MChannelActor>,
loader: () => Promise<MAccountHost | MChannelHost>,
req: express.Request,
res: express.Response
) {
@@ -280,7 +280,7 @@ class ClientHtml {
let customHtml = ClientHtml.addTitleTag(html, entity.getDisplayName())
customHtml = ClientHtml.addDescriptionTag(customHtml, description)

const url = entity.getLocalUrl()
const url = entity.getClientUrl()
const originUrl = entity.Actor.url
const siteName = CONFIG.INSTANCE.NAME
const title = entity.getDisplayName()


+ 1
- 1
server/lib/files-cache/videos-preview-cache.ts View File

@@ -37,7 +37,7 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {

const preview = video.getPreview()
const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, preview.filename)
const remoteUrl = preview.getFileUrl(video)
const remoteUrl = preview.getOriginFileUrl(video)

try {
await doRequestAndSaveToFile(remoteUrl, destPath)


+ 35
- 0
server/lib/internal-event-emitter.ts View File

@@ -0,0 +1,35 @@
import { MChannel, MVideo } from '@server/types/models'
import { EventEmitter } from 'events'

export interface PeerTubeInternalEvents {
'video-created': (options: { video: MVideo }) => void
'video-updated': (options: { video: MVideo }) => void
'video-deleted': (options: { video: MVideo }) => void

'channel-created': (options: { channel: MChannel }) => void
'channel-updated': (options: { channel: MChannel }) => void
'channel-deleted': (options: { channel: MChannel }) => void
}

declare interface InternalEventEmitter {
on<U extends keyof PeerTubeInternalEvents>(
event: U, listener: PeerTubeInternalEvents[U]
): this

emit<U extends keyof PeerTubeInternalEvents>(
event: U, ...args: Parameters<PeerTubeInternalEvents[U]>
): boolean
}

class InternalEventEmitter extends EventEmitter {

private static instance: InternalEventEmitter

static get Instance () {
return this.instance || (this.instance = new this())
}
}

export {
InternalEventEmitter
}

+ 4
- 0
server/lib/live/live-manager.ts View File

@@ -399,6 +399,8 @@ class LiveManager {
}

PeerTubeSocket.Instance.sendVideoLiveNewState(video)

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

await federateVideoIfNeeded(fullVideo, false)

Hooks.runAction('action:live.video.state.updated', { video: fullVideo })
} catch (err) {
logger.error('Cannot save/federate new video state of live streaming of video %s.', videoUUID, { err, ...lTags(videoUUID) })
}


+ 1
- 1
server/lib/plugins/plugin-helpers-builder.ts View File

@@ -133,7 +133,7 @@ function buildVideosHelpers () {

const thumbnails = video.Thumbnails.map(t => ({
type: t.type,
url: t.getFileUrl(video),
url: t.getOriginFileUrl(video),
path: t.getPath()
}))



+ 12
- 2
server/middlewares/cache/cache.ts View File

@@ -17,12 +17,22 @@ function cacheRoute (duration: string) {
function cacheRouteFactory (options: APICacheOptions) {
const instance = new ApiCache({ ...defaultOptions, ...options })

return instance.buildMiddleware.bind(instance)
return { instance, middleware: instance.buildMiddleware.bind(instance) }
}

// ---------------------------------------------------------------------------

function buildPodcastGroupsCache (options: {
channelId: number
}) {
return 'podcast-feed-' + options.channelId
}

// ---------------------------------------------------------------------------

export {
cacheRoute,
cacheRouteFactory
cacheRouteFactory,

buildPodcastGroupsCache
}

+ 41
- 4
server/middlewares/cache/shared/api-cache.ts View File

@@ -27,7 +27,13 @@ export class ApiCache {
private readonly options: APICacheOptions
private readonly timers: { [ id: string ]: NodeJS.Timeout } = {}

private readonly index: { all: string[] } = { all: [] }
private readonly index = {
groups: [] as string[],
all: [] as string[]
}

// Cache keys per group
private groups: { [groupIndex: string]: string[] } = {}

constructor (options: APICacheOptions) {
this.options = {
@@ -43,7 +49,7 @@ export class ApiCache {

return asyncMiddleware(
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const key = Redis.Instance.getPrefix() + 'api-cache-' + req.originalUrl
const key = this.getCacheKey(req)
const redis = Redis.Instance.getClient()

if (!Redis.Instance.isConnected()) return this.makeResponseCacheable(res, next, key, duration)
@@ -62,6 +68,29 @@ export class ApiCache {
)
}

clearGroupSafe (group: string) {
const run = async () => {
const cacheKeys = this.groups[group]
if (!cacheKeys) return

for (const key of cacheKeys) {
try {
await this.clear(key)
} catch (err) {
logger.error('Cannot clear ' + key, { err })
}
}

delete this.groups[group]
}

void run()
}

private getCacheKey (req: express.Request) {
return Redis.Instance.getPrefix() + 'api-cache-' + req.originalUrl
}

private shouldCacheResponse (response: express.Response) {
if (!response) return false
if (this.options.excludeStatus.includes(response.statusCode)) return false
@@ -69,8 +98,16 @@ export class ApiCache {
return true
}

private addIndexEntries (key: string) {
private addIndexEntries (key: string, res: express.Response) {
this.index.all.unshift(key)

const groups = res.locals.apicacheGroups || []

for (const group of groups) {
if (!this.groups[group]) this.groups[group] = []

this.groups[group].push(key)
}
}

private filterBlacklistedHeaders (headers: OutgoingHttpHeaders) {
@@ -177,7 +214,7 @@ export class ApiCache {
self.accumulateContent(res, content)

if (res.locals.apicache.cacheable && res.locals.apicache.content) {
self.addIndexEntries(key)
self.addIndexEntries(key, res)

const headers = res.locals.apicache.headers || res.getHeaders()
const cacheObject = self.createCacheObject(


+ 46
- 0
server/middlewares/validators/feeds.ts View File

@@ -3,6 +3,7 @@ import { param, query } from 'express-validator'
import { HttpStatusCode } from '../../../shared/models/http/http-error-codes'
import { isValidRSSFeed } from '../../helpers/custom-validators/feeds'
import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc'
import { buildPodcastGroupsCache } from '../cache'
import {
areValidationErrors,
checkCanSeeVideo,
@@ -43,6 +44,21 @@ function setFeedFormatContentType (req: express.Request, res: express.Response,
acceptableContentTypes = [ 'application/xml', 'text/xml' ]
}

return feedContentTypeResponse(req, res, next, acceptableContentTypes)
}

function setFeedPodcastContentType (req: express.Request, res: express.Response, next: express.NextFunction) {
const acceptableContentTypes = [ 'application/rss+xml', 'application/xml', 'text/xml' ]

return feedContentTypeResponse(req, res, next, acceptableContentTypes)
}

function feedContentTypeResponse (
req: express.Request,
res: express.Response,
next: express.NextFunction,
acceptableContentTypes: string[]
) {
if (req.accepts(acceptableContentTypes)) {
res.set('Content-Type', req.accepts(acceptableContentTypes) as string)
} else {
@@ -55,6 +71,8 @@ function setFeedFormatContentType (req: express.Request, res: express.Response,
return next()
}

// ---------------------------------------------------------------------------

const videoFeedsValidator = [
query('accountId')
.optional()
@@ -82,6 +100,31 @@ const videoFeedsValidator = [
}
]

// ---------------------------------------------------------------------------

const videoFeedsPodcastValidator = [
query('videoChannelId')
.custom(isIdValid),

async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoChannelIdExist(req.query.videoChannelId, res)) return

return next()
}
]

const videoFeedsPodcastSetCacheKey = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.query.videoChannelId) {
res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ]
}

return next()
}
]
// ---------------------------------------------------------------------------

const videoSubscriptionFeedsValidator = [
query('accountId')
.custom(isIdValid),
@@ -126,7 +169,10 @@ const videoCommentsFeedsValidator = [
export {
feedsFormatValidator,
setFeedFormatContentType,
setFeedPodcastContentType,
videoFeedsValidator,
videoFeedsPodcastValidator,
videoSubscriptionFeedsValidator,
videoFeedsPodcastSetCacheKey,
videoCommentsFeedsValidator
}

+ 4
- 0
server/middlewares/validators/users.ts View File

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


+ 5
- 7
server/models/account/account.ts View File

@@ -28,8 +28,9 @@ import {
MAccountAP,
MAccountDefault,
MAccountFormattable,
MAccountHost,
MAccountSummaryFormattable,
MChannelActor
MChannelHost
} from '../../types/models'
import { ActorModel } from '../actor/actor'
import { ActorFollowModel } from '../actor/actor-follow'
@@ -410,10 +411,6 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
.findAll(query)
}

getClientUrl () {
return WEBSERVER.URL + '/accounts/' + this.Actor.getIdentifier()
}

toFormattedJSON (this: MAccountFormattable): Account {
return {
...this.Actor.toFormattedJSON(),
@@ -463,8 +460,9 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
return this.name
}

getLocalUrl (this: MAccountActor | MChannelActor) {
return WEBSERVER.URL + `/accounts/` + this.Actor.preferredUsername
// Avoid error when running this method on MAccount... | MChannel...
getClientUrl (this: MAccountHost | MChannelHost) {
return WEBSERVER.URL + '/a/' + this.Actor.getIdentifier()
}

isBlocked () {


+ 4
- 4
server/models/actor/actor.ts View File

@@ -46,8 +46,8 @@ import {
MActorFormattable,
MActorFull,
MActorHost,
MActorHostOnly,
MActorId,
MActorServer,
MActorSummaryFormattable,
MActorUrl,
MActorWithInboxes
@@ -663,15 +663,15 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
return this.serverId === null
}

getWebfingerUrl (this: MActorServer) {
getWebfingerUrl (this: MActorHost) {
return 'acct:' + this.preferredUsername + '@' + this.getHost()
}

getIdentifier () {
getIdentifier (this: MActorHost) {
return this.Server ? `${this.preferredUsername}@${this.Server.host}` : this.preferredUsername
}

getHost (this: MActorHost) {
getHost (this: MActorHostOnly) {
return this.Server ? this.Server.host : WEBSERVER.HOST
}



+ 6
- 0
server/models/user/user.ts View File

@@ -404,6 +404,11 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
@Column
lastLoginDate: Date

@AllowNull(false)
@Default(false)
@Column
emailPublic: boolean

@AllowNull(true)
@Default(null)
@Column
@@ -880,6 +885,7 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
theme: getThemeOrDefault(this.theme, DEFAULT_USER_THEME_NAME),

pendingEmail: this.pendingEmail,
emailPublic: this.emailPublic,
emailVerified: this.emailVerified,

nsfwPolicy: this.nsfwPolicy,


+ 1
- 1
server/models/video/formatter/video-format-utils.ts View File

@@ -459,7 +459,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {

icon: icons.map(i => ({
type: 'Image',
url: i.getFileUrl(video),
url: i.getOriginFileUrl(video),
mediaType: 'image/jpeg',
width: i.width,
height: i.height


+ 5
- 1
server/models/video/thumbnail.ts View File

@@ -164,7 +164,7 @@ export class ThumbnailModel extends Model<Partial<AttributesOnly<ThumbnailModel>
return join(directory, filename)
}

getFileUrl (video: MVideo) {
getOriginFileUrl (video: MVideo) {
const staticPath = ThumbnailModel.types[this.type].staticPath + this.filename