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>
Alecks Gates 11 months ago
parent
commit
cb0eda5602
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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 26 - 1
server/models/video/video-caption.ts

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

+ 24 - 3
server/models/video/video-channel.ts

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

+ 41 - 12
server/models/video/video.ts

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

+ 2 - 2
server/tests/client.ts

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

+ 249 - 112
server/tests/feeds/feeds.ts

@@ -11,6 +11,7 @@ import {
   makeGetRequest,
   makeRawRequest,
   PeerTubeServer,
+  PluginsCommand,
   setAccessTokensToServers,
   setDefaultChannelAvatar,
   stopFfmpeg,
@@ -26,12 +27,15 @@ const expect = chai.expect
 describe('Test syndication feeds', () => {
   let servers: PeerTubeServer[] = []
   let serverHLSOnly: PeerTubeServer
+
   let userAccessToken: string
   let rootAccountId: number
   let rootChannelId: number
+
   let userAccountId: number
   let userChannelId: number
   let userFeedToken: string
+
   let liveId: string
 
   before(async function () {
@@ -93,7 +97,11 @@ describe('Test syndication feeds', () => {
       await servers[0].comments.createThread({ videoId: id, text: 'comment on unlisted video' })
     }
 
-    await waitJobs(servers)
+    await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } })
+
+    await waitJobs([ ...servers, serverHLSOnly ])
+
+    await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath('-podcast-custom-tags') })
   })
 
   describe('All feed', function () {
@@ -108,6 +116,11 @@ describe('Test syndication feeds', () => {
       }
     })
 
+    it('Should be well formed XML (covers Podcast endpoint)', async function () {
+      const podcast = await servers[0].feed.getPodcastXML({ ignoreCache: true, channelId: rootChannelId })
+      expect(podcast).xml.to.be.valid()
+    })
+
     it('Should be well formed JSON (covers JSON feed 1.0 endpoint)', async function () {
       for (const feed of [ 'video-comments' as 'video-comments', 'videos' as 'videos' ]) {
         const jsonText = await servers[0].feed.getJSON({ feed, ignoreCache: true })
@@ -153,168 +166,290 @@ describe('Test syndication feeds', () => {
 
   describe('Videos feed', function () {
 
-    it('Should contain a valid enclosure (covers RSS 2.0 endpoint)', async function () {
-      for (const server of servers) {
-        const rss = await server.feed.getXML({ feed: 'videos', ignoreCache: true })
+    describe('Podcast feed', function () {
+
+      it('Should contain a valid podcast:alternateEnclosure', async function () {
+        // Since podcast feeds should only work on the server they originate on,
+        // only test the first server where the videos reside
+        const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
         expect(XMLValidator.validate(rss)).to.be.true
 
         const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
         const xmlDoc = parser.parse(rss)
 
-        const enclosure = xmlDoc.rss.channel.item[0].enclosure
+        const enclosure = xmlDoc.rss.channel.item.enclosure
         expect(enclosure).to.exist
+        const alternateEnclosure = xmlDoc.rss.channel.item['podcast:alternateEnclosure']
+        expect(alternateEnclosure).to.exist
+
+        expect(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 })
+      })
     })
   })
 
@@ -470,6 +605,8 @@ describe('Test syndication feeds', () => {
   })
 
   after(async function () {
+    await servers[0].plugins.uninstall({ npmName: 'peertube-plugin-test-podcast-custom-tags' })
+
     await cleanupTests([ ...servers, serverHLSOnly ])
   })
 })

+ 82 - 0
server/tests/fixtures/peertube-plugin-test-podcast-custom-tags/main.js

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

+ 19 - 0
server/tests/fixtures/peertube-plugin-test-podcast-custom-tags/package.json

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

+ 1 - 0
server/tests/fixtures/peertube-plugin-test/main.js

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

+ 28 - 3
server/tests/plugins/action-hooks.ts

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

+ 2 - 0
server/types/express.d.ts

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

+ 3 - 4
server/types/models/account/account.ts

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

+ 2 - 2
server/types/models/actor/actor-follow.ts

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

+ 7 - 3
server/types/models/actor/actor.ts

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

+ 6 - 1
server/types/models/video/video-channels.ts

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

+ 2 - 2
server/types/models/video/video.ts

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

+ 14 - 1
shared/models/plugins/server/server-hook.model.ts

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

+ 1 - 0
shared/models/users/user-update-me.model.ts

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

+ 1 - 0
shared/models/users/user.model.ts

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

+ 2 - 1
shared/models/videos/video-include.enum.ts

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

+ 23 - 0
shared/server-commands/feeds/feeds-command.ts

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

+ 30 - 6
support/doc/api/openapi.yaml

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

+ 5 - 5
yarn.lock

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