aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAlecks Gates <agates@mail.agates.io>2023-05-22 09:00:05 -0500
committerGitHub <noreply@github.com>2023-05-22 16:00:05 +0200
commitcb0eda5602a21d1626a7face32de6153ed07b5f9 (patch)
treed6a7a4e31c7267c130871ac8e3beb42994271c20
parent3f0ceab06e5320f62f593c49daa30d963dbc36f9 (diff)
downloadPeerTube-cb0eda5602a21d1626a7face32de6153ed07b5f9.tar.gz
PeerTube-cb0eda5602a21d1626a7face32de6153ed07b5f9.tar.zst
PeerTube-cb0eda5602a21d1626a7face32de6153ed07b5f9.zip
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>
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts5
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-email-preferences/index.ts1
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-email-preferences/my-account-email-preferences.component.html15
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-email-preferences/my-account-email-preferences.component.scss0
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-email-preferences/my-account-email-preferences.component.ts51
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-settings.component.html4
-rw-r--r--client/src/app/+my-account/my-account.module.ts5
-rw-r--r--client/src/app/core/users/user.model.ts1
-rw-r--r--client/src/app/shared/shared-main/video/video.service.ts11
-rw-r--r--package.json4
-rw-r--r--server/controllers/api/users/me.ts3
-rw-r--r--server/controllers/api/videos/update.ts4
-rw-r--r--server/controllers/feeds.ts389
-rw-r--r--server/controllers/feeds/comment-feeds.ts96
-rw-r--r--server/controllers/feeds/index.ts16
-rw-r--r--server/controllers/feeds/shared/common-feed-utils.ts145
-rw-r--r--server/controllers/feeds/shared/index.ts2
-rw-r--r--server/controllers/feeds/shared/video-feed-utils.ts66
-rw-r--r--server/controllers/feeds/video-feeds.ts189
-rw-r--r--server/controllers/feeds/video-podcast-feeds.ts301
-rw-r--r--server/helpers/custom-validators/users.ts5
-rw-r--r--server/initializers/constants.ts6
-rw-r--r--server/initializers/migrations/0775-add-user-is-email-public.ts25
-rw-r--r--server/lib/blocklist.ts4
-rw-r--r--server/lib/client-html.ts6
-rw-r--r--server/lib/files-cache/videos-preview-cache.ts2
-rw-r--r--server/lib/internal-event-emitter.ts35
-rw-r--r--server/lib/live/live-manager.ts4
-rw-r--r--server/lib/plugins/plugin-helpers-builder.ts2
-rw-r--r--server/middlewares/cache/cache.ts14
-rw-r--r--server/middlewares/cache/shared/api-cache.ts45
-rw-r--r--server/middlewares/validators/feeds.ts46
-rw-r--r--server/middlewares/validators/users.ts4
-rw-r--r--server/models/account/account.ts12
-rw-r--r--server/models/actor/actor.ts8
-rw-r--r--server/models/user/user.ts6
-rw-r--r--server/models/video/formatter/video-format-utils.ts2
-rw-r--r--server/models/video/thumbnail.ts6
-rw-r--r--server/models/video/video-caption.ts27
-rw-r--r--server/models/video/video-channel.ts27
-rw-r--r--server/models/video/video.ts53
-rw-r--r--server/tests/client.ts4
-rw-r--r--server/tests/feeds/feeds.ts361
-rw-r--r--server/tests/fixtures/peertube-plugin-test-podcast-custom-tags/main.js82
-rw-r--r--server/tests/fixtures/peertube-plugin-test-podcast-custom-tags/package.json19
-rw-r--r--server/tests/fixtures/peertube-plugin-test/main.js1
-rw-r--r--server/tests/plugins/action-hooks.ts31
-rw-r--r--server/types/express.d.ts2
-rw-r--r--server/types/models/account/account.ts7
-rw-r--r--server/types/models/actor/actor-follow.ts4
-rw-r--r--server/types/models/actor/actor.ts10
-rw-r--r--server/types/models/video/video-channels.ts7
-rw-r--r--server/types/models/video/video.ts4
-rw-r--r--shared/models/plugins/server/server-hook.model.ts15
-rw-r--r--shared/models/users/user-update-me.model.ts1
-rw-r--r--shared/models/users/user.model.ts1
-rw-r--r--shared/models/videos/video-include.enum.ts3
-rw-r--r--shared/server-commands/feeds/feeds-command.ts23
-rw-r--r--support/doc/api/openapi.yaml36
-rw-r--r--yarn.lock10
60 files changed, 1683 insertions, 585 deletions
diff --git a/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts b/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts
index 235fbec4a..ebb7ed2da 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts
@@ -1,7 +1,7 @@
1import { forkJoin } from 'rxjs' 1import { forkJoin } from 'rxjs'
2import { tap } from 'rxjs/operators' 2import { tap } from 'rxjs/operators'
3import { Component, OnInit } from '@angular/core' 3import { Component, OnInit } from '@angular/core'
4import { AuthService, ServerService, UserService } from '@app/core' 4import { AuthService, Notifier, ServerService, UserService } from '@app/core'
5import { USER_EMAIL_VALIDATOR, USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators' 5import { USER_EMAIL_VALIDATOR, USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators'
6import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' 6import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
7import { HttpStatusCode, User } from '@shared/models' 7import { HttpStatusCode, User } from '@shared/models'
@@ -20,7 +20,8 @@ export class MyAccountChangeEmailComponent extends FormReactive implements OnIni
20 protected formReactiveService: FormReactiveService, 20 protected formReactiveService: FormReactiveService,
21 private authService: AuthService, 21 private authService: AuthService,
22 private userService: UserService, 22 private userService: UserService,
23 private serverService: ServerService 23 private serverService: ServerService,
24 private notifier: Notifier
24 ) { 25 ) {
25 super() 26 super()
26 } 27 }
diff --git a/client/src/app/+my-account/my-account-settings/my-account-email-preferences/index.ts b/client/src/app/+my-account/my-account-settings/my-account-email-preferences/index.ts
new file mode 100644
index 000000000..20b98e7d8
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-email-preferences/index.ts
@@ -0,0 +1 @@
export * from './my-account-email-preferences.component'
diff --git a/client/src/app/+my-account/my-account-settings/my-account-email-preferences/my-account-email-preferences.component.html b/client/src/app/+my-account/my-account-settings/my-account-email-preferences/my-account-email-preferences.component.html
new file mode 100644
index 000000000..c4fe52743
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-email-preferences/my-account-email-preferences.component.html
@@ -0,0 +1,15 @@
1<form role="form" (ngSubmit)="updateEmailPublic()" [formGroup]="form">
2
3 <div class="form-group">
4 <my-peertube-checkbox
5 inputName="email-public" formControlName="email-public"
6 i18n-labelText labelText="Allow email to be publicly displayed"
7 >
8 <ng-container ngProjectAs="description">
9 <span i18n>Necessary to claim podcast RSS feeds.</span>
10 </ng-container>
11 </my-peertube-checkbox>
12 </div>
13
14 <input class="peertube-button orange-button" type="submit" i18n-value value="Save" [disabled]="!form.valid">
15</form>
diff --git a/client/src/app/+my-account/my-account-settings/my-account-email-preferences/my-account-email-preferences.component.scss b/client/src/app/+my-account/my-account-settings/my-account-email-preferences/my-account-email-preferences.component.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-email-preferences/my-account-email-preferences.component.scss
diff --git a/client/src/app/+my-account/my-account-settings/my-account-email-preferences/my-account-email-preferences.component.ts b/client/src/app/+my-account/my-account-settings/my-account-email-preferences/my-account-email-preferences.component.ts
new file mode 100644
index 000000000..7fd59d7c8
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-email-preferences/my-account-email-preferences.component.ts
@@ -0,0 +1,51 @@
1import { Subject } from 'rxjs'
2import { Component, Input, OnInit } from '@angular/core'
3import { Notifier, UserService } from '@app/core'
4import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
5import { User, UserUpdateMe } from '@shared/models'
6
7@Component({
8 selector: 'my-account-email-preferences',
9 templateUrl: './my-account-email-preferences.component.html',
10 styleUrls: [ './my-account-email-preferences.component.scss' ]
11})
12export class MyAccountEmailPreferencesComponent extends FormReactive implements OnInit {
13 @Input() user: User = null
14 @Input() userInformationLoaded: Subject<any>
15
16 constructor (
17 protected formReactiveService: FormReactiveService,
18 private userService: UserService,
19 private notifier: Notifier
20 ) {
21 super()
22 }
23
24 ngOnInit () {
25 this.buildForm({
26 'email-public': null
27 })
28
29 this.userInformationLoaded.subscribe(() => {
30 this.form.patchValue({ 'email-public': this.user.emailPublic })
31 })
32 }
33
34 updateEmailPublic () {
35 const details: UserUpdateMe = {
36 emailPublic: this.form.value['email-public']
37 }
38
39 this.userService.updateMyProfile(details)
40 .subscribe({
41 next: () => {
42 if (details.emailPublic) this.notifier.success($localize`Email is now public`)
43 else this.notifier.success($localize`Email is now private`)
44
45 this.user.emailPublic = details.emailPublic
46 },
47
48 error: err => console.log(err.message)
49 })
50 }
51}
diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
index 666205de6..3986354c1 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
+++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
@@ -68,7 +68,7 @@
68 </div> 68 </div>
69 69
70 <div class="col-12 col-lg-8 col-xl-9"> 70 <div class="col-12 col-lg-8 col-xl-9">
71 <my-account-two-factor-button [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-two-factor-button> 71 <my-account-two-factor-button [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-two-factor-button>
72 </div> 72 </div>
73</div> 73</div>
74 74
@@ -78,6 +78,8 @@
78 </div> 78 </div>
79 79
80 <div class="col-12 col-lg-8 col-xl-9"> 80 <div class="col-12 col-lg-8 col-xl-9">
81 <my-account-email-preferences class="d-block mb-5" [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-email-preferences>
82
81 <my-account-change-email></my-account-change-email> 83 <my-account-change-email></my-account-change-email>
82 </div> 84 </div>
83</div> 85</div>
diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts
index 84b057647..673bd2837 100644
--- a/client/src/app/+my-account/my-account.module.ts
+++ b/client/src/app/+my-account/my-account.module.ts
@@ -22,6 +22,7 @@ import { MyAccountRoutingModule } from './my-account-routing.module'
22import { MyAccountChangeEmailComponent } from './my-account-settings/my-account-change-email' 22import { MyAccountChangeEmailComponent } from './my-account-settings/my-account-change-email'
23import { MyAccountChangePasswordComponent } from './my-account-settings/my-account-change-password/my-account-change-password.component' 23import { MyAccountChangePasswordComponent } from './my-account-settings/my-account-change-password/my-account-change-password.component'
24import { MyAccountDangerZoneComponent } from './my-account-settings/my-account-danger-zone' 24import { MyAccountDangerZoneComponent } from './my-account-settings/my-account-danger-zone'
25import { MyAccountEmailPreferencesComponent } from './my-account-settings/my-account-email-preferences'
25import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences' 26import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences'
26import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component' 27import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component'
27import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' 28import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
@@ -65,7 +66,9 @@ import { MyAccountComponent } from './my-account.component'
65 MyAccountAbusesListComponent, 66 MyAccountAbusesListComponent,
66 MyAccountServerBlocklistComponent, 67 MyAccountServerBlocklistComponent,
67 MyAccountNotificationsComponent, 68 MyAccountNotificationsComponent,
68 MyAccountNotificationPreferencesComponent 69 MyAccountNotificationPreferencesComponent,
70
71 MyAccountEmailPreferencesComponent
69 ], 72 ],
70 73
71 exports: [ 74 exports: [
diff --git a/client/src/app/core/users/user.model.ts b/client/src/app/core/users/user.model.ts
index 5534bca33..2d783145f 100644
--- a/client/src/app/core/users/user.model.ts
+++ b/client/src/app/core/users/user.model.ts
@@ -19,6 +19,7 @@ export class User implements UserServerModel {
19 pendingEmail: string | null 19 pendingEmail: string | null
20 20
21 emailVerified: boolean 21 emailVerified: boolean
22 emailPublic: boolean
22 nsfwPolicy: NSFWPolicyType 23 nsfwPolicy: NSFWPolicyType
23 24
24 adminFlags?: UserAdminFlag 25 adminFlags?: UserAdminFlag
diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts
index 152149827..78a49567f 100644
--- a/client/src/app/shared/shared-main/video/video.service.ts
+++ b/client/src/app/shared/shared-main/video/video.service.ts
@@ -54,6 +54,7 @@ export type CommonVideoParams = {
54export class VideoService { 54export class VideoService {
55 static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos' 55 static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos'
56 static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.' 56 static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
57 static PODCAST_FEEDS_URL = environment.apiUrl + '/feeds/podcast/videos.xml'
57 static BASE_SUBSCRIPTION_FEEDS_URL = environment.apiUrl + '/feeds/subscriptions.' 58 static BASE_SUBSCRIPTION_FEEDS_URL = environment.apiUrl + '/feeds/subscriptions.'
58 59
59 constructor ( 60 constructor (
@@ -266,7 +267,15 @@ export class VideoService {
266 let params = this.restService.addRestGetParams(new HttpParams()) 267 let params = this.restService.addRestGetParams(new HttpParams())
267 params = params.set('videoChannelId', videoChannelId.toString()) 268 params = params.set('videoChannelId', videoChannelId.toString())
268 269
269 return this.buildBaseFeedUrls(params) 270 const feedUrls = this.buildBaseFeedUrls(params)
271
272 feedUrls.push({
273 format: FeedFormat.RSS,
274 label: 'podcast rss 2.0',
275 url: VideoService.PODCAST_FEEDS_URL + `?videoChannelId=${videoChannelId}`
276 })
277
278 return feedUrls
270 } 279 }
271 280
272 getVideoSubscriptionFeedUrls (accountId: number, feedToken: string) { 281 getVideoSubscriptionFeedUrls (accountId: number, feedToken: string) {
diff --git a/package.json b/package.json
index 38ed90533..00a12c08b 100644
--- a/package.json
+++ b/package.json
@@ -97,7 +97,7 @@
97 "@opentelemetry/sdk-trace-base": "^1.3.1", 97 "@opentelemetry/sdk-trace-base": "^1.3.1",
98 "@opentelemetry/sdk-trace-node": "^1.3.1", 98 "@opentelemetry/sdk-trace-node": "^1.3.1",
99 "@opentelemetry/semantic-conventions": "^1.3.1", 99 "@opentelemetry/semantic-conventions": "^1.3.1",
100 "@peertube/feed": "^5.0.1", 100 "@peertube/feed": "^5.1.0",
101 "@peertube/http-signature": "^1.7.0", 101 "@peertube/http-signature": "^1.7.0",
102 "@uploadx/core": "^6.0.0", 102 "@uploadx/core": "^6.0.0",
103 "async-lru": "^1.1.1", 103 "async-lru": "^1.1.1",
@@ -135,7 +135,7 @@
135 "jimp": "^0.22.4", 135 "jimp": "^0.22.4",
136 "js-yaml": "^4.0.0", 136 "js-yaml": "^4.0.0",
137 "jsonld": "~8.1.0", 137 "jsonld": "~8.1.0",
138 "lodash": "^4.17.10", 138 "lodash": "^4.17.21",
139 "lru-cache": "^7.13.0", 139 "lru-cache": "^7.13.0",
140 "magnet-uri": "^6.1.0", 140 "magnet-uri": "^6.1.0",
141 "markdown-it": "^13.0.1", 141 "markdown-it": "^13.0.1",
diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts
index 00f580ee9..218091d91 100644
--- a/server/controllers/api/users/me.ts
+++ b/server/controllers/api/users/me.ts
@@ -212,7 +212,8 @@ async function updateMe (req: express.Request, res: express.Response) {
212 'theme', 212 'theme',
213 'noInstanceConfigWarningModal', 213 'noInstanceConfigWarningModal',
214 'noAccountSetupWarningModal', 214 'noAccountSetupWarningModal',
215 'noWelcomeModal' 215 'noWelcomeModal',
216 'emailPublic'
216 ] 217 ]
217 218
218 for (const key of keysToUpdate) { 219 for (const key of keysToUpdate) {
diff --git a/server/controllers/api/videos/update.ts b/server/controllers/api/videos/update.ts
index 5ab54a006..ddab428d4 100644
--- a/server/controllers/api/videos/update.ts
+++ b/server/controllers/api/videos/update.ts
@@ -2,10 +2,12 @@ import express from 'express'
2import { Transaction } from 'sequelize/types' 2import { Transaction } from 'sequelize/types'
3import { changeVideoChannelShare } from '@server/lib/activitypub/share' 3import { changeVideoChannelShare } from '@server/lib/activitypub/share'
4import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' 4import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
5import { VideoPathManager } from '@server/lib/video-path-manager'
5import { setVideoPrivacy } from '@server/lib/video-privacy' 6import { setVideoPrivacy } from '@server/lib/video-privacy'
6import { openapiOperationDoc } from '@server/middlewares/doc' 7import { openapiOperationDoc } from '@server/middlewares/doc'
7import { FilteredModelAttributes } from '@server/types' 8import { FilteredModelAttributes } from '@server/types'
8import { MVideoFullLight } from '@server/types/models' 9import { MVideoFullLight } from '@server/types/models'
10import { forceNumber } from '@shared/core-utils'
9import { HttpStatusCode, VideoUpdate } from '@shared/models' 11import { HttpStatusCode, VideoUpdate } from '@shared/models'
10import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 12import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
11import { resetSequelizeInstance } from '../../../helpers/database-utils' 13import { resetSequelizeInstance } from '../../../helpers/database-utils'
@@ -18,8 +20,6 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
18import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares' 20import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares'
19import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' 21import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
20import { VideoModel } from '../../../models/video/video' 22import { VideoModel } from '../../../models/video/video'
21import { VideoPathManager } from '@server/lib/video-path-manager'
22import { forceNumber } from '@shared/core-utils'
23 23
24const lTags = loggerTagsFactory('api', 'video') 24const lTags = loggerTagsFactory('api', 'video')
25const auditLogger = auditLoggerFactory('videos') 25const auditLogger = auditLoggerFactory('videos')
diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts
deleted file mode 100644
index ef810a842..000000000
--- a/server/controllers/feeds.ts
+++ /dev/null
@@ -1,389 +0,0 @@
1import express from 'express'
2import { extname } from 'path'
3import { Feed } from '@peertube/feed'
4import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown'
5import { getServerActor } from '@server/models/application/application'
6import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils'
7import { MAccountDefault, MChannelBannerAccountDefault, MVideoFullLight } from '@server/types/models'
8import { ActorImageType, VideoInclude } from '@shared/models'
9import { buildNSFWFilter } from '../helpers/express-utils'
10import { CONFIG } from '../initializers/config'
11import { MIMETYPES, PREVIEWS_SIZE, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../initializers/constants'
12import {
13 asyncMiddleware,
14 commonVideosFiltersValidator,
15 feedsFormatValidator,
16 setDefaultVideosSort,
17 setFeedFormatContentType,
18 videoCommentsFeedsValidator,
19 videoFeedsValidator,
20 videosSortValidator,
21 videoSubscriptionFeedsValidator
22} from '../middlewares'
23import { cacheRouteFactory } from '../middlewares/cache/cache'
24import { VideoModel } from '../models/video/video'
25import { VideoCommentModel } from '../models/video/video-comment'
26
27const feedsRouter = express.Router()
28
29const cacheRoute = cacheRouteFactory({
30 headerBlacklist: [ 'Content-Type' ]
31})
32
33feedsRouter.get('/feeds/video-comments.:format',
34 feedsFormatValidator,
35 setFeedFormatContentType,
36 cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS),
37 asyncMiddleware(videoFeedsValidator),
38 asyncMiddleware(videoCommentsFeedsValidator),
39 asyncMiddleware(generateVideoCommentsFeed)
40)
41
42feedsRouter.get('/feeds/videos.:format',
43 videosSortValidator,
44 setDefaultVideosSort,
45 feedsFormatValidator,
46 setFeedFormatContentType,
47 cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS),
48 commonVideosFiltersValidator,
49 asyncMiddleware(videoFeedsValidator),
50 asyncMiddleware(generateVideoFeed)
51)
52
53feedsRouter.get('/feeds/subscriptions.:format',
54 videosSortValidator,
55 setDefaultVideosSort,
56 feedsFormatValidator,
57 setFeedFormatContentType,
58 cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS),
59 commonVideosFiltersValidator,
60 asyncMiddleware(videoSubscriptionFeedsValidator),
61 asyncMiddleware(generateVideoFeedForSubscriptions)
62)
63
64// ---------------------------------------------------------------------------
65
66export {
67 feedsRouter
68}
69
70// ---------------------------------------------------------------------------
71
72async function generateVideoCommentsFeed (req: express.Request, res: express.Response) {
73 const start = 0
74 const video = res.locals.videoAll
75 const account = res.locals.account
76 const videoChannel = res.locals.videoChannel
77
78 const comments = await VideoCommentModel.listForFeed({
79 start,
80 count: CONFIG.FEEDS.COMMENTS.COUNT,
81 videoId: video ? video.id : undefined,
82 accountId: account ? account.id : undefined,
83 videoChannelId: videoChannel ? videoChannel.id : undefined
84 })
85
86 const { name, description, imageUrl } = buildFeedMetadata({ video, account, videoChannel })
87
88 const feed = initFeed({
89 name,
90 description,
91 imageUrl,
92 resourceType: 'video-comments',
93 queryString: new URL(WEBSERVER.URL + req.originalUrl).search
94 })
95
96 // Adding video items to the feed, one at a time
97 for (const comment of comments) {
98 const localLink = WEBSERVER.URL + comment.getCommentStaticPath()
99
100 let title = comment.Video.name
101 const author: { name: string, link: string }[] = []
102
103 if (comment.Account) {
104 title += ` - ${comment.Account.getDisplayName()}`
105 author.push({
106 name: comment.Account.getDisplayName(),
107 link: comment.Account.Actor.url
108 })
109 }
110
111 feed.addItem({
112 title,
113 id: localLink,
114 link: localLink,
115 content: toSafeHtml(comment.text),
116 author,
117 date: comment.createdAt
118 })
119 }
120
121 // Now the feed generation is done, let's send it!
122 return sendFeed(feed, req, res)
123}
124
125async function generateVideoFeed (req: express.Request, res: express.Response) {
126 const start = 0
127 const account = res.locals.account
128 const videoChannel = res.locals.videoChannel
129 const nsfw = buildNSFWFilter(res, req.query.nsfw)
130
131 const { name, description, imageUrl } = buildFeedMetadata({ videoChannel, account })
132
133 const feed = initFeed({
134 name,
135 description,
136 imageUrl,
137 resourceType: 'videos',
138 queryString: new URL(WEBSERVER.URL + req.url).search
139 })
140
141 const options = {
142 accountId: account ? account.id : null,
143 videoChannelId: videoChannel ? videoChannel.id : null
144 }
145
146 const server = await getServerActor()
147 const { data } = await VideoModel.listForApi({
148 start,
149 count: CONFIG.FEEDS.VIDEOS.COUNT,
150 sort: req.query.sort,
151 displayOnlyForFollower: {
152 actorId: server.id,
153 orLocalVideos: true
154 },
155 nsfw,
156 isLocal: req.query.isLocal,
157 include: req.query.include | VideoInclude.FILES,
158 hasFiles: true,
159 countVideos: false,
160 ...options
161 })
162
163 addVideosToFeed(feed, data)
164
165 // Now the feed generation is done, let's send it!
166 return sendFeed(feed, req, res)
167}
168
169async function generateVideoFeedForSubscriptions (req: express.Request, res: express.Response) {
170 const start = 0
171 const account = res.locals.account
172 const nsfw = buildNSFWFilter(res, req.query.nsfw)
173
174 const { name, description, imageUrl } = buildFeedMetadata({ account })
175
176 const feed = initFeed({
177 name,
178 description,
179 imageUrl,
180 resourceType: 'videos',
181 queryString: new URL(WEBSERVER.URL + req.url).search
182 })
183
184 const { data } = await VideoModel.listForApi({
185 start,
186 count: CONFIG.FEEDS.VIDEOS.COUNT,
187 sort: req.query.sort,
188 nsfw,
189
190 isLocal: req.query.isLocal,
191
192 hasFiles: true,
193 include: req.query.include | VideoInclude.FILES,
194
195 countVideos: false,
196
197 displayOnlyForFollower: {
198 actorId: res.locals.user.Account.Actor.id,
199 orLocalVideos: false
200 },
201 user: res.locals.user
202 })
203
204 addVideosToFeed(feed, data)
205
206 // Now the feed generation is done, let's send it!
207 return sendFeed(feed, req, res)
208}
209
210function initFeed (parameters: {
211 name: string
212 description: string
213 imageUrl: string
214 resourceType?: 'videos' | 'video-comments'
215 queryString?: string
216}) {
217 const webserverUrl = WEBSERVER.URL
218 const { name, description, resourceType, queryString, imageUrl } = parameters
219
220 return new Feed({
221 title: name,
222 description: mdToOneLinePlainText(description),
223 // updated: TODO: somehowGetLatestUpdate, // optional, default = today
224 id: webserverUrl,
225 link: webserverUrl,
226 image: imageUrl,
227 favicon: webserverUrl + '/client/assets/images/favicon.png',
228 copyright: `All rights reserved, unless otherwise specified in the terms specified at ${webserverUrl}/about` +
229 ` and potential licenses granted by each content's rightholder.`,
230 generator: `Toraifōsu`, // ^.~
231 feedLinks: {
232 json: `${webserverUrl}/feeds/${resourceType}.json${queryString}`,
233 atom: `${webserverUrl}/feeds/${resourceType}.atom${queryString}`,
234 rss: `${webserverUrl}/feeds/${resourceType}.xml${queryString}`
235 },
236 author: {
237 name: 'Instance admin of ' + CONFIG.INSTANCE.NAME,
238 email: CONFIG.ADMIN.EMAIL,
239 link: `${webserverUrl}/about`
240 }
241 })
242}
243
244function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
245 for (const video of videos) {
246 const formattedVideoFiles = video.getFormattedVideoFilesJSON(false)
247
248 const torrents = formattedVideoFiles.map(videoFile => ({
249 title: video.name,
250 url: videoFile.torrentUrl,
251 size_in_bytes: videoFile.size
252 }))
253
254 const videoFiles = formattedVideoFiles.map(videoFile => {
255 const result = {
256 type: MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)],
257 medium: 'video',
258 height: videoFile.resolution.id,
259 fileSize: videoFile.size,
260 url: videoFile.fileUrl,
261 framerate: videoFile.fps,
262 duration: video.duration
263 }
264
265 if (video.language) Object.assign(result, { lang: video.language })
266
267 return result
268 })
269
270 const categories: { value: number, label: string }[] = []
271 if (video.category) {
272 categories.push({
273 value: video.category,
274 label: getCategoryLabel(video.category)
275 })
276 }
277
278 const localLink = WEBSERVER.URL + video.getWatchStaticPath()
279
280 feed.addItem({
281 title: video.name,
282 id: localLink,
283 link: localLink,
284 description: mdToOneLinePlainText(video.getTruncatedDescription()),
285 content: toSafeHtml(video.description),
286 author: [
287 {
288 name: video.VideoChannel.getDisplayName(),
289 link: video.VideoChannel.Actor.url
290 }
291 ],
292 date: video.publishedAt,
293 nsfw: video.nsfw,
294 torrents,
295
296 // Enclosure
297 video: videoFiles.length !== 0
298 ? {
299 url: videoFiles[0].url,
300 length: videoFiles[0].fileSize,
301 type: videoFiles[0].type
302 }
303 : undefined,
304
305 // Media RSS
306 videos: videoFiles,
307
308 embed: {
309 url: WEBSERVER.URL + video.getEmbedStaticPath(),
310 allowFullscreen: true
311 },
312 player: {
313 url: WEBSERVER.URL + video.getWatchStaticPath()
314 },
315 categories,
316 community: {
317 statistics: {
318 views: video.views
319 }
320 },
321 thumbnails: [
322 {
323 url: WEBSERVER.URL + video.getPreviewStaticPath(),
324 height: PREVIEWS_SIZE.height,
325 width: PREVIEWS_SIZE.width
326 }
327 ]
328 })
329 }
330}
331
332function sendFeed (feed: Feed, req: express.Request, res: express.Response) {
333 const format = req.params.format
334
335 if (format === 'atom' || format === 'atom1') {
336 return res.send(feed.atom1()).end()
337 }
338
339 if (format === 'json' || format === 'json1') {
340 return res.send(feed.json1()).end()
341 }
342
343 if (format === 'rss' || format === 'rss2') {
344 return res.send(feed.rss2()).end()
345 }
346
347 // We're in the ambiguous '.xml' case and we look at the format query parameter
348 if (req.query.format === 'atom' || req.query.format === 'atom1') {
349 return res.send(feed.atom1()).end()
350 }
351
352 return res.send(feed.rss2()).end()
353}
354
355function buildFeedMetadata (options: {
356 videoChannel?: MChannelBannerAccountDefault
357 account?: MAccountDefault
358 video?: MVideoFullLight
359}) {
360 const { video, videoChannel, account } = options
361
362 let imageUrl = WEBSERVER.URL + '/client/assets/images/icons/icon-96x96.png'
363 let name: string
364 let description: string
365
366 if (videoChannel) {
367 name = videoChannel.getDisplayName()
368 description = videoChannel.description
369
370 if (videoChannel.Actor.hasImage(ActorImageType.AVATAR)) {
371 imageUrl = WEBSERVER.URL + videoChannel.Actor.Avatars[0].getStaticPath()
372 }
373 } else if (account) {
374 name = account.getDisplayName()
375 description = account.description
376
377 if (account.Actor.hasImage(ActorImageType.AVATAR)) {
378 imageUrl = WEBSERVER.URL + account.Actor.Avatars[0].getStaticPath()
379 }
380 } else if (video) {
381 name = video.name
382 description = video.description
383 } else {
384 name = CONFIG.INSTANCE.NAME
385 description = CONFIG.INSTANCE.DESCRIPTION
386 }
387
388 return { name, description, imageUrl }
389}
diff --git a/server/controllers/feeds/comment-feeds.ts b/server/controllers/feeds/comment-feeds.ts
new file mode 100644
index 000000000..bdc53b51f
--- /dev/null
+++ b/server/controllers/feeds/comment-feeds.ts
@@ -0,0 +1,96 @@
1import express from 'express'
2import { toSafeHtml } from '@server/helpers/markdown'
3import { cacheRouteFactory } from '@server/middlewares'
4import { CONFIG } from '../../initializers/config'
5import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
6import {
7 asyncMiddleware,
8 feedsFormatValidator,
9 setFeedFormatContentType,
10 videoCommentsFeedsValidator,
11 videoFeedsValidator
12} from '../../middlewares'
13import { VideoCommentModel } from '../../models/video/video-comment'
14import { buildFeedMetadata, initFeed, sendFeed } from './shared'
15
16const commentFeedsRouter = express.Router()
17
18// ---------------------------------------------------------------------------
19
20const { middleware: cacheRouteMiddleware } = cacheRouteFactory({
21 headerBlacklist: [ 'Content-Type' ]
22})
23
24// ---------------------------------------------------------------------------
25
26commentFeedsRouter.get('/feeds/video-comments.:format',
27 feedsFormatValidator,
28 setFeedFormatContentType,
29 cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
30 asyncMiddleware(videoFeedsValidator),
31 asyncMiddleware(videoCommentsFeedsValidator),
32 asyncMiddleware(generateVideoCommentsFeed)
33)
34
35// ---------------------------------------------------------------------------
36
37export {
38 commentFeedsRouter
39}
40
41// ---------------------------------------------------------------------------
42
43async function generateVideoCommentsFeed (req: express.Request, res: express.Response) {
44 const start = 0
45 const video = res.locals.videoAll
46 const account = res.locals.account
47 const videoChannel = res.locals.videoChannel
48
49 const comments = await VideoCommentModel.listForFeed({
50 start,
51 count: CONFIG.FEEDS.COMMENTS.COUNT,
52 videoId: video ? video.id : undefined,
53 accountId: account ? account.id : undefined,
54 videoChannelId: videoChannel ? videoChannel.id : undefined
55 })
56
57 const { name, description, imageUrl, link } = await buildFeedMetadata({ video, account, videoChannel })
58
59 const feed = initFeed({
60 name,
61 description,
62 imageUrl,
63 isPodcast: false,
64 link,
65 resourceType: 'video-comments',
66 queryString: new URL(WEBSERVER.URL + req.originalUrl).search
67 })
68
69 // Adding video items to the feed, one at a time
70 for (const comment of comments) {
71 const localLink = WEBSERVER.URL + comment.getCommentStaticPath()
72
73 let title = comment.Video.name
74 const author: { name: string, link: string }[] = []
75
76 if (comment.Account) {
77 title += ` - ${comment.Account.getDisplayName()}`
78 author.push({
79 name: comment.Account.getDisplayName(),
80 link: comment.Account.Actor.url
81 })
82 }
83
84 feed.addItem({
85 title,
86 id: localLink,
87 link: localLink,
88 content: toSafeHtml(comment.text),
89 author,
90 date: comment.createdAt
91 })
92 }
93
94 // Now the feed generation is done, let's send it!
95 return sendFeed(feed, req, res)
96}
diff --git a/server/controllers/feeds/index.ts b/server/controllers/feeds/index.ts
new file mode 100644
index 000000000..e344a1448
--- /dev/null
+++ b/server/controllers/feeds/index.ts
@@ -0,0 +1,16 @@
1import express from 'express'
2import { commentFeedsRouter } from './comment-feeds'
3import { videoFeedsRouter } from './video-feeds'
4import { videoPodcastFeedsRouter } from './video-podcast-feeds'
5
6const feedsRouter = express.Router()
7
8feedsRouter.use('/', commentFeedsRouter)
9feedsRouter.use('/', videoFeedsRouter)
10feedsRouter.use('/', videoPodcastFeedsRouter)
11
12// ---------------------------------------------------------------------------
13
14export {
15 feedsRouter
16}
diff --git a/server/controllers/feeds/shared/common-feed-utils.ts b/server/controllers/feeds/shared/common-feed-utils.ts
new file mode 100644
index 000000000..375c2814b
--- /dev/null
+++ b/server/controllers/feeds/shared/common-feed-utils.ts
@@ -0,0 +1,145 @@
1import express from 'express'
2import { Feed } from '@peertube/feed'
3import { CustomTag, CustomXMLNS, Person } from '@peertube/feed/lib/typings'
4import { mdToOneLinePlainText } from '@server/helpers/markdown'
5import { CONFIG } from '@server/initializers/config'
6import { WEBSERVER } from '@server/initializers/constants'
7import { UserModel } from '@server/models/user/user'
8import { MAccountDefault, MChannelBannerAccountDefault, MUser, MVideoFullLight } from '@server/types/models'
9import { pick } from '@shared/core-utils'
10import { ActorImageType } from '@shared/models'
11
12export function initFeed (parameters: {
13 name: string
14 description: string
15 imageUrl: string
16 isPodcast: boolean
17 link?: string
18 locked?: { isLocked: boolean, email: string }
19 author?: {
20 name: string
21 link: string
22 imageUrl: string
23 }
24 person?: Person[]
25 resourceType?: 'videos' | 'video-comments'
26 queryString?: string
27 medium?: string
28 stunServers?: string[]
29 trackers?: string[]
30 customXMLNS?: CustomXMLNS[]
31 customTags?: CustomTag[]
32}) {
33 const webserverUrl = WEBSERVER.URL
34 const { name, description, link, imageUrl, isPodcast, resourceType, queryString, medium } = parameters
35
36 return new Feed({
37 title: name,
38 description: mdToOneLinePlainText(description),
39 // updated: TODO: somehowGetLatestUpdate, // optional, default = today
40 id: link || webserverUrl,
41 link: link || webserverUrl,
42 image: imageUrl,
43 favicon: webserverUrl + '/client/assets/images/favicon.png',
44 copyright: `All rights reserved, unless otherwise specified in the terms specified at ${webserverUrl}/about` +
45 ` and potential licenses granted by each content's rightholder.`,
46 generator: `Toraifōsu`, // ^.~
47 medium: medium || 'video',
48 feedLinks: {
49 json: `${webserverUrl}/feeds/${resourceType}.json${queryString}`,
50 atom: `${webserverUrl}/feeds/${resourceType}.atom${queryString}`,
51 rss: isPodcast
52 ? `${webserverUrl}/feeds/podcast/videos.xml${queryString}`
53 : `${webserverUrl}/feeds/${resourceType}.xml${queryString}`
54 },
55
56 ...pick(parameters, [ 'stunServers', 'trackers', 'customXMLNS', 'customTags', 'author', 'person', 'locked' ])
57 })
58}
59
60export function sendFeed (feed: Feed, req: express.Request, res: express.Response) {
61 const format = req.params.format
62
63 if (format === 'atom' || format === 'atom1') {
64 return res.send(feed.atom1()).end()
65 }
66
67 if (format === 'json' || format === 'json1') {
68 return res.send(feed.json1()).end()
69 }
70
71 if (format === 'rss' || format === 'rss2') {
72 return res.send(feed.rss2()).end()
73 }
74
75 // We're in the ambiguous '.xml' case and we look at the format query parameter
76 if (req.query.format === 'atom' || req.query.format === 'atom1') {
77 return res.send(feed.atom1()).end()
78 }
79
80 return res.send(feed.rss2()).end()
81}
82
83export async function buildFeedMetadata (options: {
84 videoChannel?: MChannelBannerAccountDefault
85 account?: MAccountDefault
86 video?: MVideoFullLight
87}) {
88 const { video, videoChannel, account } = options
89
90 let imageUrl = WEBSERVER.URL + '/client/assets/images/icons/icon-96x96.png'
91 let accountImageUrl: string
92 let name: string
93 let userName: string
94 let description: string
95 let email: string
96 let link: string
97 let accountLink: string
98 let user: MUser
99
100 if (videoChannel) {
101 name = videoChannel.getDisplayName()
102 description = videoChannel.description
103 link = videoChannel.getClientUrl()
104 accountLink = videoChannel.Account.getClientUrl()
105
106 if (videoChannel.Actor.hasImage(ActorImageType.AVATAR)) {
107 imageUrl = WEBSERVER.URL + videoChannel.Actor.Avatars[0].getStaticPath()
108 }
109
110 if (videoChannel.Account.Actor.hasImage(ActorImageType.AVATAR)) {
111 accountImageUrl = WEBSERVER.URL + videoChannel.Account.Actor.Avatars[0].getStaticPath()
112 }
113
114 user = await UserModel.loadById(videoChannel.Account.userId)
115 userName = videoChannel.Account.getDisplayName()
116 } else if (account) {
117 name = account.getDisplayName()
118 description = account.description
119 link = account.getClientUrl()
120 accountLink = link
121
122 if (account.Actor.hasImage(ActorImageType.AVATAR)) {
123 imageUrl = WEBSERVER.URL + account.Actor.Avatars[0].getStaticPath()
124 accountImageUrl = imageUrl
125 }
126
127 user = await UserModel.loadById(account.userId)
128 } else if (video) {
129 name = video.name
130 description = video.description
131 link = video.url
132 } else {
133 name = CONFIG.INSTANCE.NAME
134 description = CONFIG.INSTANCE.DESCRIPTION
135 link = WEBSERVER.URL
136 }
137
138 // If the user is local, has a verified email address, and allows it to be publicly displayed
139 // Return it so the owner can prove ownership of their feed
140 if (user && !user.pluginAuth && user.emailVerified && user.emailPublic) {
141 email = user.email
142 }
143
144 return { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink }
145}
diff --git a/server/controllers/feeds/shared/index.ts b/server/controllers/feeds/shared/index.ts
new file mode 100644
index 000000000..0136c8477
--- /dev/null
+++ b/server/controllers/feeds/shared/index.ts
@@ -0,0 +1,2 @@
1export * from './video-feed-utils'
2export * from './common-feed-utils'
diff --git a/server/controllers/feeds/shared/video-feed-utils.ts b/server/controllers/feeds/shared/video-feed-utils.ts
new file mode 100644
index 000000000..3175cea59
--- /dev/null
+++ b/server/controllers/feeds/shared/video-feed-utils.ts
@@ -0,0 +1,66 @@
1import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown'
2import { CONFIG } from '@server/initializers/config'
3import { WEBSERVER } from '@server/initializers/constants'
4import { getServerActor } from '@server/models/application/application'
5import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils'
6import { DisplayOnlyForFollowerOptions } from '@server/models/video/sql/video'
7import { VideoModel } from '@server/models/video/video'
8import { MThumbnail, MUserDefault } from '@server/types/models'
9import { VideoInclude } from '@shared/models'
10
11export async function getVideosForFeeds (options: {
12 sort: string
13 nsfw: boolean
14 isLocal: boolean
15 include: VideoInclude
16
17 accountId?: number
18 videoChannelId?: number
19 displayOnlyForFollower?: DisplayOnlyForFollowerOptions
20 user?: MUserDefault
21}) {
22 const server = await getServerActor()
23
24 const { data } = await VideoModel.listForApi({
25 start: 0,
26 count: CONFIG.FEEDS.VIDEOS.COUNT,
27 displayOnlyForFollower: {
28 actorId: server.id,
29 orLocalVideos: true
30 },
31 hasFiles: true,
32 countVideos: false,
33
34 ...options
35 })
36
37 return data
38}
39
40export function getCommonVideoFeedAttributes (video: VideoModel) {
41 const localLink = WEBSERVER.URL + video.getWatchStaticPath()
42
43 const thumbnailModels: MThumbnail[] = []
44 if (video.hasPreview()) thumbnailModels.push(video.getPreview())
45 thumbnailModels.push(video.getMiniature())
46
47 return {
48 title: video.name,
49 link: localLink,
50 description: mdToOneLinePlainText(video.getTruncatedDescription()),
51 content: toSafeHtml(video.description),
52
53 date: video.publishedAt,
54 nsfw: video.nsfw,
55
56 category: video.category
57 ? [ { name: getCategoryLabel(video.category) } ]
58 : undefined,
59
60 thumbnails: thumbnailModels.map(t => ({
61 url: WEBSERVER.URL + t.getLocalStaticPath(),
62 width: t.width,
63 height: t.height
64 }))
65 }
66}
diff --git a/server/controllers/feeds/video-feeds.ts b/server/controllers/feeds/video-feeds.ts
new file mode 100644
index 000000000..b6e0663eb
--- /dev/null
+++ b/server/controllers/feeds/video-feeds.ts
@@ -0,0 +1,189 @@
1import express from 'express'
2import { extname } from 'path'
3import { Feed } from '@peertube/feed'
4import { cacheRouteFactory } from '@server/middlewares'
5import { VideoModel } from '@server/models/video/video'
6import { VideoInclude } from '@shared/models'
7import { buildNSFWFilter } from '../../helpers/express-utils'
8import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
9import {
10 asyncMiddleware,
11 commonVideosFiltersValidator,
12 feedsFormatValidator,
13 setDefaultVideosSort,
14 setFeedFormatContentType,
15 videoFeedsValidator,
16 videosSortValidator,
17 videoSubscriptionFeedsValidator
18} from '../../middlewares'
19import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed, sendFeed } from './shared'
20
21const videoFeedsRouter = express.Router()
22
23const { middleware: cacheRouteMiddleware } = cacheRouteFactory({
24 headerBlacklist: [ 'Content-Type' ]
25})
26
27// ---------------------------------------------------------------------------
28
29videoFeedsRouter.get('/feeds/videos.:format',
30 videosSortValidator,
31 setDefaultVideosSort,
32 feedsFormatValidator,
33 setFeedFormatContentType,
34 cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
35 commonVideosFiltersValidator,
36 asyncMiddleware(videoFeedsValidator),
37 asyncMiddleware(generateVideoFeed)
38)
39
40videoFeedsRouter.get('/feeds/subscriptions.:format',
41 videosSortValidator,
42 setDefaultVideosSort,
43 feedsFormatValidator,
44 setFeedFormatContentType,
45 cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
46 commonVideosFiltersValidator,
47 asyncMiddleware(videoSubscriptionFeedsValidator),
48 asyncMiddleware(generateVideoFeedForSubscriptions)
49)
50
51// ---------------------------------------------------------------------------
52
53export {
54 videoFeedsRouter
55}
56
57// ---------------------------------------------------------------------------
58
59async function generateVideoFeed (req: express.Request, res: express.Response) {
60 const account = res.locals.account
61 const videoChannel = res.locals.videoChannel
62
63 const { name, description, imageUrl, accountImageUrl, link, accountLink } = await buildFeedMetadata({ videoChannel, account })
64
65 const feed = initFeed({
66 name,
67 description,
68 link,
69 isPodcast: false,
70 imageUrl,
71 author: { name, link: accountLink, imageUrl: accountImageUrl },
72 resourceType: 'videos',
73 queryString: new URL(WEBSERVER.URL + req.url).search
74 })
75
76 const data = await getVideosForFeeds({
77 sort: req.query.sort,
78 nsfw: buildNSFWFilter(res, req.query.nsfw),
79 isLocal: req.query.isLocal,
80 include: req.query.include | VideoInclude.FILES,
81 accountId: account?.id,
82 videoChannelId: videoChannel?.id
83 })
84
85 addVideosToFeed(feed, data)
86
87 // Now the feed generation is done, let's send it!
88 return sendFeed(feed, req, res)
89}
90
91async function generateVideoFeedForSubscriptions (req: express.Request, res: express.Response) {
92 const account = res.locals.account
93 const { name, description, imageUrl, link } = await buildFeedMetadata({ account })
94
95 const feed = initFeed({
96 name,
97 description,
98 link,
99 isPodcast: false,
100 imageUrl,
101 resourceType: 'videos',
102 queryString: new URL(WEBSERVER.URL + req.url).search
103 })
104
105 const data = await getVideosForFeeds({
106 sort: req.query.sort,
107 nsfw: buildNSFWFilter(res, req.query.nsfw),
108 isLocal: req.query.isLocal,
109 include: req.query.include | VideoInclude.FILES,
110 displayOnlyForFollower: {
111 actorId: res.locals.user.Account.Actor.id,
112 orLocalVideos: false
113 },
114 user: res.locals.user
115 })
116
117 addVideosToFeed(feed, data)
118
119 // Now the feed generation is done, let's send it!
120 return sendFeed(feed, req, res)
121}
122
123// ---------------------------------------------------------------------------
124
125function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
126 /**
127 * Adding video items to the feed object, one at a time
128 */
129 for (const video of videos) {
130 const formattedVideoFiles = video.getFormattedAllVideoFilesJSON(false)
131
132 const torrents = formattedVideoFiles.map(videoFile => ({
133 title: video.name,
134 url: videoFile.torrentUrl,
135 size_in_bytes: videoFile.size
136 }))
137
138 const videoFiles = formattedVideoFiles.map(videoFile => {
139 return {
140 type: MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)],
141 medium: 'video',
142 height: videoFile.resolution.id,
143 fileSize: videoFile.size,
144 url: videoFile.fileUrl,
145 framerate: videoFile.fps,
146 duration: video.duration,
147 lang: video.language
148 }
149 })
150
151 feed.addItem({
152 ...getCommonVideoFeedAttributes(video),
153
154 id: WEBSERVER.URL + video.getWatchStaticPath(),
155 author: [
156 {
157 name: video.VideoChannel.getDisplayName(),
158 link: video.VideoChannel.getClientUrl()
159 }
160 ],
161 torrents,
162
163 // Enclosure
164 video: videoFiles.length !== 0
165 ? {
166 url: videoFiles[0].url,
167 length: videoFiles[0].fileSize,
168 type: videoFiles[0].type
169 }
170 : undefined,
171
172 // Media RSS
173 videos: videoFiles,
174
175 embed: {
176 url: WEBSERVER.URL + video.getEmbedStaticPath(),
177 allowFullscreen: true
178 },
179 player: {
180 url: WEBSERVER.URL + video.getWatchStaticPath()
181 },
182 community: {
183 statistics: {
184 views: video.views
185 }
186 }
187 })
188 }
189}
diff --git a/server/controllers/feeds/video-podcast-feeds.ts b/server/controllers/feeds/video-podcast-feeds.ts
new file mode 100644
index 000000000..45d31c781
--- /dev/null
+++ b/server/controllers/feeds/video-podcast-feeds.ts
@@ -0,0 +1,301 @@
1import express from 'express'
2import { extname } from 'path'
3import { Feed } from '@peertube/feed'
4import { CustomTag, CustomXMLNS, LiveItemStatus } from '@peertube/feed/lib/typings'
5import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
6import { Hooks } from '@server/lib/plugins/hooks'
7import { buildPodcastGroupsCache, cacheRouteFactory, videoFeedsPodcastSetCacheKey } from '@server/middlewares'
8import { MVideo, MVideoCaptionVideo, MVideoFullLight } from '@server/types/models'
9import { sortObjectComparator } from '@shared/core-utils'
10import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@shared/models'
11import { buildNSFWFilter } from '../../helpers/express-utils'
12import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants'
13import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares'
14import { VideoModel } from '../../models/video/video'
15import { VideoCaptionModel } from '../../models/video/video-caption'
16import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed } from './shared'
17
18const videoPodcastFeedsRouter = express.Router()
19
20// ---------------------------------------------------------------------------
21
22const { middleware: podcastCacheRouteMiddleware, instance: podcastApiCache } = cacheRouteFactory({
23 headerBlacklist: [ 'Content-Type' ]
24})
25
26for (const event of ([ 'video-created', 'video-updated', 'video-deleted' ] as const)) {
27 InternalEventEmitter.Instance.on(event, ({ video }) => {
28 if (video.remote) return
29
30 podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: video.channelId }))
31 })
32}
33
34for (const event of ([ 'channel-updated', 'channel-deleted' ] as const)) {
35 InternalEventEmitter.Instance.on(event, ({ channel }) => {
36 podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: channel.id }))
37 })
38}
39
40// ---------------------------------------------------------------------------
41
42videoPodcastFeedsRouter.get('/feeds/podcast/videos.xml',
43 setFeedPodcastContentType,
44 videoFeedsPodcastSetCacheKey,
45 podcastCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
46 asyncMiddleware(videoFeedsPodcastValidator),
47 asyncMiddleware(generateVideoPodcastFeed)
48)
49
50// ---------------------------------------------------------------------------
51
52export {
53 videoPodcastFeedsRouter
54}
55
56// ---------------------------------------------------------------------------
57
58async function generateVideoPodcastFeed (req: express.Request, res: express.Response) {
59 const videoChannel = res.locals.videoChannel
60
61 const { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink } = await buildFeedMetadata({ videoChannel })
62
63 const data = await getVideosForFeeds({
64 sort: '-publishedAt',
65 nsfw: buildNSFWFilter(),
66 // Prevent podcast feeds from listing videos in other instances
67 // helps prevent duplicates when they are indexed -- only the author should control them
68 isLocal: true,
69 include: VideoInclude.FILES,
70 videoChannelId: videoChannel?.id
71 })
72
73 const customTags: CustomTag[] = await Hooks.wrapObject(
74 [],
75 'filter:feed.podcast.channel.create-custom-tags.result',
76 { videoChannel }
77 )
78
79 const customXMLNS: CustomXMLNS[] = await Hooks.wrapObject(
80 [],
81 'filter:feed.podcast.rss.create-custom-xmlns.result'
82 )
83
84 const feed = initFeed({
85 name,
86 description,
87 link,
88 isPodcast: true,
89 imageUrl,
90
91 locked: email
92 ? { isLocked: true, email } // Default to true because we have no way of offering a redirect yet
93 : undefined,
94
95 person: [ { name: userName, href: accountLink, img: accountImageUrl } ],
96 resourceType: 'videos',
97 queryString: new URL(WEBSERVER.URL + req.url).search,
98 medium: 'video',
99 customXMLNS,
100 customTags
101 })
102
103 await addVideosToPodcastFeed(feed, data)
104
105 // Now the feed generation is done, let's send it!
106 return res.send(feed.podcast()).end()
107}
108
109type PodcastMedia =
110 {
111 type: string
112 length: number
113 bitrate: number
114 sources: { uri: string, contentType?: string }[]
115 title: string
116 language?: string
117 } |
118 {
119 sources: { uri: string }[]
120 type: string
121 title: string
122 }
123
124async function generatePodcastItem (options: {
125 video: VideoModel
126 liveItem: boolean
127 media: PodcastMedia[]
128}) {
129 const { video, liveItem, media } = options
130
131 const customTags: CustomTag[] = await Hooks.wrapObject(
132 [],
133 'filter:feed.podcast.video.create-custom-tags.result',
134 { video, liveItem }
135 )
136
137 const account = video.VideoChannel.Account
138
139 const author = {
140 name: account.getDisplayName(),
141 href: account.getClientUrl()
142 }
143
144 return {
145 ...getCommonVideoFeedAttributes(video),
146
147 trackers: video.getTrackerUrls(),
148
149 author: [ author ],
150 person: [
151 {
152 ...author,
153
154 img: account.Actor.hasImage(ActorImageType.AVATAR)
155 ? WEBSERVER.URL + account.Actor.Avatars[0].getStaticPath()
156 : undefined
157 }
158 ],
159
160 media,
161
162 socialInteract: [
163 {
164 uri: video.url,
165 protocol: 'activitypub',
166 accountUrl: account.getClientUrl()
167 }
168 ],
169
170 customTags
171 }
172}
173
174async function addVideosToPodcastFeed (feed: Feed, videos: VideoModel[]) {
175 const captionsGroup = await VideoCaptionModel.listCaptionsOfMultipleVideos(videos.map(v => v.id))
176
177 for (const video of videos) {
178 if (!video.isLive) {
179 await addVODPodcastItem({ feed, video, captionsGroup })
180 } else if (video.isLive && video.state !== VideoState.LIVE_ENDED) {
181 await addLivePodcastItem({ feed, video })
182 }
183 }
184}
185
186async function addVODPodcastItem (options: {
187 feed: Feed
188 video: VideoModel
189 captionsGroup: { [ id: number ]: MVideoCaptionVideo[] }
190}) {
191 const { feed, video, captionsGroup } = options
192
193 const webVideos = video.getFormattedWebVideoFilesJSON(true)
194 .map(f => buildVODWebVideoFile(video, f))
195 .sort(sortObjectComparator('bitrate', 'desc'))
196
197 const streamingPlaylistFiles = buildVODStreamingPlaylists(video)
198
199 // Order matters here, the first media URI will be the "default"
200 // So web videos are default if enabled
201 const media = [ ...webVideos, ...streamingPlaylistFiles ]
202
203 const videoCaptions = buildVODCaptions(video, captionsGroup[video.id])
204 const item = await generatePodcastItem({ video, liveItem: false, media })
205
206 feed.addPodcastItem({ ...item, subTitle: videoCaptions })
207}
208
209async function addLivePodcastItem (options: {
210 feed: Feed
211 video: VideoModel
212}) {
213 const { feed, video } = options
214
215 let status: LiveItemStatus
216
217 switch (video.state) {
218 case VideoState.WAITING_FOR_LIVE:
219 status = LiveItemStatus.pending
220 break
221 case VideoState.PUBLISHED:
222 status = LiveItemStatus.live
223 break
224 }
225
226 const item = await generatePodcastItem({ video, liveItem: true, media: buildLiveStreamingPlaylists(video) })
227
228 feed.addPodcastLiveItem({ ...item, status, start: video.updatedAt.toISOString() })
229}
230
231// ---------------------------------------------------------------------------
232
233function buildVODWebVideoFile (video: MVideo, videoFile: VideoFile) {
234 const isAudio = videoFile.resolution.id === VideoResolution.H_NOVIDEO
235 const type = isAudio
236 ? MIMETYPES.AUDIO.EXT_MIMETYPE[extname(videoFile.fileUrl)]
237 : MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)]
238
239 const sources = [
240 { uri: videoFile.fileUrl },
241 { uri: videoFile.torrentUrl, contentType: 'application/x-bittorrent' }
242 ]
243
244 if (videoFile.magnetUri) {
245 sources.push({ uri: videoFile.magnetUri })
246 }
247
248 return {
249 type,
250 title: videoFile.resolution.label,
251 length: videoFile.size,
252 bitrate: videoFile.size / video.duration * 8,
253 language: video.language,
254 sources
255 }
256}
257
258function buildVODStreamingPlaylists (video: MVideoFullLight) {
259 const hls = video.getHLSPlaylist()
260 if (!hls) return []
261
262 return [
263 {
264 type: 'application/x-mpegURL',
265 title: 'HLS',
266 sources: [
267 { uri: hls.getMasterPlaylistUrl(video) }
268 ],
269 language: video.language
270 }
271 ]
272}
273
274function buildLiveStreamingPlaylists (video: MVideoFullLight) {
275 const hls = video.getHLSPlaylist()
276
277 return [
278 {
279 type: 'application/x-mpegURL',
280 title: `HLS live stream`,
281 sources: [
282 { uri: hls.getMasterPlaylistUrl(video) }
283 ],
284 language: video.language
285 }
286 ]
287}
288
289function buildVODCaptions (video: MVideo, videoCaptions: MVideoCaptionVideo[]) {
290 return videoCaptions.map(caption => {
291 const type = MIMETYPES.VIDEO_CAPTIONS.EXT_MIMETYPE[extname(caption.filename)]
292 if (!type) return null
293
294 return {
295 url: caption.getFileUrl(video),
296 language: caption.language,
297 type,
298 rel: 'captions'
299 }
300 }).filter(c => c)
301}
diff --git a/server/helpers/custom-validators/users.ts b/server/helpers/custom-validators/users.ts
index 9df550fc2..f02b3ba65 100644
--- a/server/helpers/custom-validators/users.ts
+++ b/server/helpers/custom-validators/users.ts
@@ -80,6 +80,10 @@ function isUserAutoPlayNextVideoPlaylistValid (value: any) {
80 return isBooleanValid(value) 80 return isBooleanValid(value)
81} 81}
82 82
83function isUserEmailPublicValid (value: any) {
84 return isBooleanValid(value)
85}
86
83function isUserNoModal (value: any) { 87function isUserNoModal (value: any) {
84 return isBooleanValid(value) 88 return isBooleanValid(value)
85} 89}
@@ -114,5 +118,6 @@ export {
114 isUserAutoPlayNextVideoPlaylistValid, 118 isUserAutoPlayNextVideoPlaylistValid,
115 isUserDisplayNameValid, 119 isUserDisplayNameValid,
116 isUserDescriptionValid, 120 isUserDescriptionValid,
121 isUserEmailPublicValid,
117 isUserNoModal 122 isUserNoModal
118} 123}
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index ba522c9de..020ed68da 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -27,7 +27,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
27 27
28// --------------------------------------------------------------------------- 28// ---------------------------------------------------------------------------
29 29
30const LAST_MIGRATION_VERSION = 770 30const LAST_MIGRATION_VERSION = 775
31 31
32// --------------------------------------------------------------------------- 32// ---------------------------------------------------------------------------
33 33
@@ -634,7 +634,8 @@ const MIMETYPES = {
634 'text/vtt': '.vtt', 634 'text/vtt': '.vtt',
635 'application/x-subrip': '.srt', 635 'application/x-subrip': '.srt',
636 'text/plain': '.srt' 636 'text/plain': '.srt'
637 } 637 },
638 EXT_MIMETYPE: null as { [ id: string ]: string }
638 }, 639 },
639 TORRENT: { 640 TORRENT: {
640 MIMETYPE_EXT: { 641 MIMETYPE_EXT: {
@@ -649,6 +650,7 @@ const MIMETYPES = {
649} 650}
650MIMETYPES.AUDIO.EXT_MIMETYPE = invert(MIMETYPES.AUDIO.MIMETYPE_EXT) 651MIMETYPES.AUDIO.EXT_MIMETYPE = invert(MIMETYPES.AUDIO.MIMETYPE_EXT)
651MIMETYPES.IMAGE.EXT_MIMETYPE = invert(MIMETYPES.IMAGE.MIMETYPE_EXT) 652MIMETYPES.IMAGE.EXT_MIMETYPE = invert(MIMETYPES.IMAGE.MIMETYPE_EXT)
653MIMETYPES.VIDEO_CAPTIONS.EXT_MIMETYPE = invert(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT)
652 654
653const BINARY_CONTENT_TYPES = new Set([ 655const BINARY_CONTENT_TYPES = new Set([
654 'binary/octet-stream', 656 'binary/octet-stream',
diff --git a/server/initializers/migrations/0775-add-user-is-email-public.ts b/server/initializers/migrations/0775-add-user-is-email-public.ts
new file mode 100644
index 000000000..74dee192c
--- /dev/null
+++ b/server/initializers/migrations/0775-add-user-is-email-public.ts
@@ -0,0 +1,25 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7}): Promise<void> {
8
9 const data = {
10 type: Sequelize.BOOLEAN,
11 allowNull: false,
12 defaultValue: false
13 }
14
15 await utils.queryInterface.addColumn('user', 'emailPublic', data)
16}
17
18function down (options) {
19 throw new Error('Not implemented.')
20}
21
22export {
23 up,
24 down
25}
diff --git a/server/lib/blocklist.ts b/server/lib/blocklist.ts
index a11b717b5..009e229ce 100644
--- a/server/lib/blocklist.ts
+++ b/server/lib/blocklist.ts
@@ -1,6 +1,6 @@
1import { sequelizeTypescript } from '@server/initializers/database' 1import { sequelizeTypescript } from '@server/initializers/database'
2import { getServerActor } from '@server/models/application/application' 2import { getServerActor } from '@server/models/application/application'
3import { MAccountBlocklist, MAccountId, MAccountServer, MServerBlocklist } from '@server/types/models' 3import { MAccountBlocklist, MAccountId, MAccountHost, MServerBlocklist } from '@server/types/models'
4import { AccountBlocklistModel } from '../models/account/account-blocklist' 4import { AccountBlocklistModel } from '../models/account/account-blocklist'
5import { ServerBlocklistModel } from '../models/server/server-blocklist' 5import { ServerBlocklistModel } from '../models/server/server-blocklist'
6 6
@@ -34,7 +34,7 @@ function removeServerFromBlocklist (serverBlock: MServerBlocklist) {
34 }) 34 })
35} 35}
36 36
37async function isBlockedByServerOrAccount (targetAccount: MAccountServer, userAccount?: MAccountId) { 37async function isBlockedByServerOrAccount (targetAccount: MAccountHost, userAccount?: MAccountId) {
38 const serverAccountId = (await getServerActor()).Account.id 38 const serverAccountId = (await getServerActor()).Account.id
39 const sourceAccounts = [ serverAccountId ] 39 const sourceAccounts = [ serverAccountId ]
40 40
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts
index 058f29f03..18b16bee1 100644
--- a/server/lib/client-html.ts
+++ b/server/lib/client-html.ts
@@ -27,7 +27,7 @@ import { AccountModel } from '../models/account/account'
27import { VideoModel } from '../models/video/video' 27import { VideoModel } from '../models/video/video'
28import { VideoChannelModel } from '../models/video/video-channel' 28import { VideoChannelModel } from '../models/video/video-channel'
29import { VideoPlaylistModel } from '../models/video/video-playlist' 29import { VideoPlaylistModel } from '../models/video/video-playlist'
30import { MAccountActor, MChannelActor, MVideo, MVideoPlaylist } from '../types/models' 30import { MAccountHost, MChannelHost, MVideo, MVideoPlaylist } from '../types/models'
31import { getActivityStreamDuration } from './activitypub/activity' 31import { getActivityStreamDuration } from './activitypub/activity'
32import { getBiggestActorImage } from './actor-image' 32import { getBiggestActorImage } from './actor-image'
33import { Hooks } from './plugins/hooks' 33import { Hooks } from './plugins/hooks'
@@ -260,7 +260,7 @@ class ClientHtml {
260 } 260 }
261 261
262 private static async getAccountOrChannelHTMLPage ( 262 private static async getAccountOrChannelHTMLPage (
263 loader: () => Promise<MAccountActor | MChannelActor>, 263 loader: () => Promise<MAccountHost | MChannelHost>,
264 req: express.Request, 264 req: express.Request,
265 res: express.Response 265 res: express.Response
266 ) { 266 ) {
@@ -280,7 +280,7 @@ class ClientHtml {
280 let customHtml = ClientHtml.addTitleTag(html, entity.getDisplayName()) 280 let customHtml = ClientHtml.addTitleTag(html, entity.getDisplayName())
281 customHtml = ClientHtml.addDescriptionTag(customHtml, description) 281 customHtml = ClientHtml.addDescriptionTag(customHtml, description)
282 282
283 const url = entity.getLocalUrl() 283 const url = entity.getClientUrl()
284 const originUrl = entity.Actor.url 284 const originUrl = entity.Actor.url
285 const siteName = CONFIG.INSTANCE.NAME 285 const siteName = CONFIG.INSTANCE.NAME
286 const title = entity.getDisplayName() 286 const title = entity.getDisplayName()
diff --git a/server/lib/files-cache/videos-preview-cache.ts b/server/lib/files-cache/videos-preview-cache.ts
index 48d2cb52c..d19c3f4f4 100644
--- a/server/lib/files-cache/videos-preview-cache.ts
+++ b/server/lib/files-cache/videos-preview-cache.ts
@@ -37,7 +37,7 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
37 37
38 const preview = video.getPreview() 38 const preview = video.getPreview()
39 const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, preview.filename) 39 const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, preview.filename)
40 const remoteUrl = preview.getFileUrl(video) 40 const remoteUrl = preview.getOriginFileUrl(video)
41 41
42 try { 42 try {
43 await doRequestAndSaveToFile(remoteUrl, destPath) 43 await doRequestAndSaveToFile(remoteUrl, destPath)
diff --git a/server/lib/internal-event-emitter.ts b/server/lib/internal-event-emitter.ts
new file mode 100644
index 000000000..08b46a5c3
--- /dev/null
+++ b/server/lib/internal-event-emitter.ts
@@ -0,0 +1,35 @@
1import { MChannel, MVideo } from '@server/types/models'
2import { EventEmitter } from 'events'
3
4export interface PeerTubeInternalEvents {
5 'video-created': (options: { video: MVideo }) => void
6 'video-updated': (options: { video: MVideo }) => void
7 'video-deleted': (options: { video: MVideo }) => void
8
9 'channel-created': (options: { channel: MChannel }) => void
10 'channel-updated': (options: { channel: MChannel }) => void
11 'channel-deleted': (options: { channel: MChannel }) => void
12}
13
14declare interface InternalEventEmitter {
15 on<U extends keyof PeerTubeInternalEvents>(
16 event: U, listener: PeerTubeInternalEvents[U]
17 ): this
18
19 emit<U extends keyof PeerTubeInternalEvents>(
20 event: U, ...args: Parameters<PeerTubeInternalEvents[U]>
21 ): boolean
22}
23
24class InternalEventEmitter extends EventEmitter {
25
26 private static instance: InternalEventEmitter
27
28 static get Instance () {
29 return this.instance || (this.instance = new this())
30 }
31}
32
33export {
34 InternalEventEmitter
35}
diff --git a/server/lib/live/live-manager.ts b/server/lib/live/live-manager.ts
index 5c6e69806..acb7af274 100644
--- a/server/lib/live/live-manager.ts
+++ b/server/lib/live/live-manager.ts
@@ -399,6 +399,8 @@ class LiveManager {
399 } 399 }
400 400
401 PeerTubeSocket.Instance.sendVideoLiveNewState(video) 401 PeerTubeSocket.Instance.sendVideoLiveNewState(video)
402
403 Hooks.runAction('action:live.video.state.updated', { video })
402 } catch (err) { 404 } catch (err) {
403 logger.error('Cannot save/federate live video %d.', videoId, { err, ...localLTags }) 405 logger.error('Cannot save/federate live video %d.', videoId, { err, ...localLTags })
404 } 406 }
@@ -466,6 +468,8 @@ class LiveManager {
466 PeerTubeSocket.Instance.sendVideoLiveNewState(fullVideo) 468 PeerTubeSocket.Instance.sendVideoLiveNewState(fullVideo)
467 469
468 await federateVideoIfNeeded(fullVideo, false) 470 await federateVideoIfNeeded(fullVideo, false)
471
472 Hooks.runAction('action:live.video.state.updated', { video: fullVideo })
469 } catch (err) { 473 } catch (err) {
470 logger.error('Cannot save/federate new video state of live streaming of video %s.', videoUUID, { err, ...lTags(videoUUID) }) 474 logger.error('Cannot save/federate new video state of live streaming of video %s.', videoUUID, { err, ...lTags(videoUUID) })
471 } 475 }
diff --git a/server/lib/plugins/plugin-helpers-builder.ts b/server/lib/plugins/plugin-helpers-builder.ts
index 92ef87cca..d235f52c0 100644
--- a/server/lib/plugins/plugin-helpers-builder.ts
+++ b/server/lib/plugins/plugin-helpers-builder.ts
@@ -133,7 +133,7 @@ function buildVideosHelpers () {
133 133
134 const thumbnails = video.Thumbnails.map(t => ({ 134 const thumbnails = video.Thumbnails.map(t => ({
135 type: t.type, 135 type: t.type,
136 url: t.getFileUrl(video), 136 url: t.getOriginFileUrl(video),
137 path: t.getPath() 137 path: t.getPath()
138 })) 138 }))
139 139
diff --git a/server/middlewares/cache/cache.ts b/server/middlewares/cache/cache.ts
index e14160ba8..6041c76c3 100644
--- a/server/middlewares/cache/cache.ts
+++ b/server/middlewares/cache/cache.ts
@@ -17,12 +17,22 @@ function cacheRoute (duration: string) {
17function cacheRouteFactory (options: APICacheOptions) { 17function cacheRouteFactory (options: APICacheOptions) {
18 const instance = new ApiCache({ ...defaultOptions, ...options }) 18 const instance = new ApiCache({ ...defaultOptions, ...options })
19 19
20 return instance.buildMiddleware.bind(instance) 20 return { instance, middleware: instance.buildMiddleware.bind(instance) }
21}
22
23// ---------------------------------------------------------------------------
24
25function buildPodcastGroupsCache (options: {
26 channelId: number
27}) {
28 return 'podcast-feed-' + options.channelId
21} 29}
22 30
23// --------------------------------------------------------------------------- 31// ---------------------------------------------------------------------------
24 32
25export { 33export {
26 cacheRoute, 34 cacheRoute,
27 cacheRouteFactory 35 cacheRouteFactory,
36
37 buildPodcastGroupsCache
28} 38}
diff --git a/server/middlewares/cache/shared/api-cache.ts b/server/middlewares/cache/shared/api-cache.ts
index 7c366db00..c6197b972 100644
--- a/server/middlewares/cache/shared/api-cache.ts
+++ b/server/middlewares/cache/shared/api-cache.ts
@@ -27,7 +27,13 @@ export class ApiCache {
27 private readonly options: APICacheOptions 27 private readonly options: APICacheOptions
28 private readonly timers: { [ id: string ]: NodeJS.Timeout } = {} 28 private readonly timers: { [ id: string ]: NodeJS.Timeout } = {}
29 29
30 private readonly index: { all: string[] } = { all: [] } 30 private readonly index = {
31 groups: [] as string[],
32 all: [] as string[]
33 }
34
35 // Cache keys per group
36 private groups: { [groupIndex: string]: string[] } = {}
31 37
32 constructor (options: APICacheOptions) { 38 constructor (options: APICacheOptions) {
33 this.options = { 39 this.options = {
@@ -43,7 +49,7 @@ export class ApiCache {
43 49
44 return asyncMiddleware( 50 return asyncMiddleware(
45 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 51 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
46 const key = Redis.Instance.getPrefix() + 'api-cache-' + req.originalUrl 52 const key = this.getCacheKey(req)
47 const redis = Redis.Instance.getClient() 53 const redis = Redis.Instance.getClient()
48 54
49 if (!Redis.Instance.isConnected()) return this.makeResponseCacheable(res, next, key, duration) 55 if (!Redis.Instance.isConnected()) return this.makeResponseCacheable(res, next, key, duration)
@@ -62,6 +68,29 @@ export class ApiCache {
62 ) 68 )
63 } 69 }
64 70
71 clearGroupSafe (group: string) {
72 const run = async () => {
73 const cacheKeys = this.groups[group]
74 if (!cacheKeys) return
75
76 for (const key of cacheKeys) {
77 try {
78 await this.clear(key)
79 } catch (err) {
80 logger.error('Cannot clear ' + key, { err })
81 }
82 }
83
84 delete this.groups[group]
85 }
86
87 void run()
88 }
89
90 private getCacheKey (req: express.Request) {
91 return Redis.Instance.getPrefix() + 'api-cache-' + req.originalUrl
92 }
93
65 private shouldCacheResponse (response: express.Response) { 94 private shouldCacheResponse (response: express.Response) {
66 if (!response) return false 95 if (!response) return false
67 if (this.options.excludeStatus.includes(response.statusCode)) return false 96 if (this.options.excludeStatus.includes(response.statusCode)) return false
@@ -69,8 +98,16 @@ export class ApiCache {
69 return true 98 return true
70 } 99 }
71 100
72 private addIndexEntries (key: string) { 101 private addIndexEntries (key: string, res: express.Response) {
73 this.index.all.unshift(key) 102 this.index.all.unshift(key)
103
104 const groups = res.locals.apicacheGroups || []
105
106 for (const group of groups) {
107 if (!this.groups[group]) this.groups[group] = []
108
109 this.groups[group].push(key)
110 }
74 } 111 }
75 112
76 private filterBlacklistedHeaders (headers: OutgoingHttpHeaders) { 113 private filterBlacklistedHeaders (headers: OutgoingHttpHeaders) {
@@ -177,7 +214,7 @@ export class ApiCache {
177 self.accumulateContent(res, content) 214 self.accumulateContent(res, content)
178 215
179 if (res.locals.apicache.cacheable && res.locals.apicache.content) { 216 if (res.locals.apicache.cacheable && res.locals.apicache.content) {
180 self.addIndexEntries(key) 217 self.addIndexEntries(key, res)
181 218
182 const headers = res.locals.apicache.headers || res.getHeaders() 219 const headers = res.locals.apicache.headers || res.getHeaders()
183 const cacheObject = self.createCacheObject( 220 const cacheObject = self.createCacheObject(
diff --git a/server/middlewares/validators/feeds.ts b/server/middlewares/validators/feeds.ts
index 0bfe89e6f..ee8615cae 100644
--- a/server/middlewares/validators/feeds.ts
+++ b/server/middlewares/validators/feeds.ts
@@ -3,6 +3,7 @@ import { param, query } from 'express-validator'
3import { HttpStatusCode } from '../../../shared/models/http/http-error-codes' 3import { HttpStatusCode } from '../../../shared/models/http/http-error-codes'
4import { isValidRSSFeed } from '../../helpers/custom-validators/feeds' 4import { isValidRSSFeed } from '../../helpers/custom-validators/feeds'
5import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc' 5import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc'
6import { buildPodcastGroupsCache } from '../cache'
6import { 7import {
7 areValidationErrors, 8 areValidationErrors,
8 checkCanSeeVideo, 9 checkCanSeeVideo,
@@ -43,6 +44,21 @@ function setFeedFormatContentType (req: express.Request, res: express.Response,
43 acceptableContentTypes = [ 'application/xml', 'text/xml' ] 44 acceptableContentTypes = [ 'application/xml', 'text/xml' ]
44 } 45 }
45 46
47 return feedContentTypeResponse(req, res, next, acceptableContentTypes)
48}
49
50function setFeedPodcastContentType (req: express.Request, res: express.Response, next: express.NextFunction) {
51 const acceptableContentTypes = [ 'application/rss+xml', 'application/xml', 'text/xml' ]
52
53 return feedContentTypeResponse(req, res, next, acceptableContentTypes)
54}
55
56function feedContentTypeResponse (
57 req: express.Request,
58 res: express.Response,
59 next: express.NextFunction,
60 acceptableContentTypes: string[]
61) {
46 if (req.accepts(acceptableContentTypes)) { 62 if (req.accepts(acceptableContentTypes)) {
47 res.set('Content-Type', req.accepts(acceptableContentTypes) as string) 63 res.set('Content-Type', req.accepts(acceptableContentTypes) as string)
48 } else { 64 } else {
@@ -55,6 +71,8 @@ function setFeedFormatContentType (req: express.Request, res: express.Response,
55 return next() 71 return next()
56} 72}
57 73
74// ---------------------------------------------------------------------------
75
58const videoFeedsValidator = [ 76const videoFeedsValidator = [
59 query('accountId') 77 query('accountId')
60 .optional() 78 .optional()
@@ -82,6 +100,31 @@ const videoFeedsValidator = [
82 } 100 }
83] 101]
84 102
103// ---------------------------------------------------------------------------
104
105const videoFeedsPodcastValidator = [
106 query('videoChannelId')
107 .custom(isIdValid),
108
109 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
110 if (areValidationErrors(req, res)) return
111 if (!await doesVideoChannelIdExist(req.query.videoChannelId, res)) return
112
113 return next()
114 }
115]
116
117const videoFeedsPodcastSetCacheKey = [
118 (req: express.Request, res: express.Response, next: express.NextFunction) => {
119 if (req.query.videoChannelId) {
120 res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ]
121 }
122
123 return next()
124 }
125]
126// ---------------------------------------------------------------------------
127
85const videoSubscriptionFeedsValidator = [ 128const videoSubscriptionFeedsValidator = [
86 query('accountId') 129 query('accountId')
87 .custom(isIdValid), 130 .custom(isIdValid),
@@ -126,7 +169,10 @@ const videoCommentsFeedsValidator = [
126export { 169export {
127 feedsFormatValidator, 170 feedsFormatValidator,
128 setFeedFormatContentType, 171 setFeedFormatContentType,
172 setFeedPodcastContentType,
129 videoFeedsValidator, 173 videoFeedsValidator,
174 videoFeedsPodcastValidator,
130 videoSubscriptionFeedsValidator, 175 videoSubscriptionFeedsValidator,
176 videoFeedsPodcastSetCacheKey,
131 videoCommentsFeedsValidator 177 videoCommentsFeedsValidator
132} 178}
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts
index 7ebea048d..3d311b15b 100644
--- a/server/middlewares/validators/users.ts
+++ b/server/middlewares/validators/users.ts
@@ -11,6 +11,7 @@ import {
11 isUserBlockedReasonValid, 11 isUserBlockedReasonValid,
12 isUserDescriptionValid, 12 isUserDescriptionValid,
13 isUserDisplayNameValid, 13 isUserDisplayNameValid,
14 isUserEmailPublicValid,
14 isUserNoModal, 15 isUserNoModal,
15 isUserNSFWPolicyValid, 16 isUserNSFWPolicyValid,
16 isUserP2PEnabledValid, 17 isUserP2PEnabledValid,
@@ -213,6 +214,9 @@ const usersUpdateMeValidator = [
213 body('password') 214 body('password')
214 .optional() 215 .optional()
215 .custom(isUserPasswordValid), 216 .custom(isUserPasswordValid),
217 body('emailPublic')
218 .optional()
219 .custom(isUserEmailPublicValid),
216 body('email') 220 body('email')
217 .optional() 221 .optional()
218 .isEmail(), 222 .isEmail(),
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index ec4e8d946..396959352 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -28,8 +28,9 @@ import {
28 MAccountAP, 28 MAccountAP,
29 MAccountDefault, 29 MAccountDefault,
30 MAccountFormattable, 30 MAccountFormattable,
31 MAccountHost,
31 MAccountSummaryFormattable, 32 MAccountSummaryFormattable,
32 MChannelActor 33 MChannelHost
33} from '../../types/models' 34} from '../../types/models'
34import { ActorModel } from '../actor/actor' 35import { ActorModel } from '../actor/actor'
35import { ActorFollowModel } from '../actor/actor-follow' 36import { ActorFollowModel } from '../actor/actor-follow'
@@ -410,10 +411,6 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
410 .findAll(query) 411 .findAll(query)
411 } 412 }
412 413
413 getClientUrl () {
414 return WEBSERVER.URL + '/accounts/' + this.Actor.getIdentifier()
415 }
416
417 toFormattedJSON (this: MAccountFormattable): Account { 414 toFormattedJSON (this: MAccountFormattable): Account {
418 return { 415 return {
419 ...this.Actor.toFormattedJSON(), 416 ...this.Actor.toFormattedJSON(),
@@ -463,8 +460,9 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
463 return this.name 460 return this.name
464 } 461 }
465 462
466 getLocalUrl (this: MAccountActor | MChannelActor) { 463 // Avoid error when running this method on MAccount... | MChannel...
467 return WEBSERVER.URL + `/accounts/` + this.Actor.preferredUsername 464 getClientUrl (this: MAccountHost | MChannelHost) {
465 return WEBSERVER.URL + '/a/' + this.Actor.getIdentifier()
468 } 466 }
469 467
470 isBlocked () { 468 isBlocked () {
diff --git a/server/models/actor/actor.ts b/server/models/actor/actor.ts
index 80a646c77..dccb47a10 100644
--- a/server/models/actor/actor.ts
+++ b/server/models/actor/actor.ts
@@ -46,8 +46,8 @@ import {
46 MActorFormattable, 46 MActorFormattable,
47 MActorFull, 47 MActorFull,
48 MActorHost, 48 MActorHost,
49 MActorHostOnly,
49 MActorId, 50 MActorId,
50 MActorServer,
51 MActorSummaryFormattable, 51 MActorSummaryFormattable,
52 MActorUrl, 52 MActorUrl,
53 MActorWithInboxes 53 MActorWithInboxes
@@ -663,15 +663,15 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
663 return this.serverId === null 663 return this.serverId === null
664 } 664 }
665 665
666 getWebfingerUrl (this: MActorServer) { 666 getWebfingerUrl (this: MActorHost) {
667 return 'acct:' + this.preferredUsername + '@' + this.getHost() 667 return 'acct:' + this.preferredUsername + '@' + this.getHost()
668 } 668 }
669 669
670 getIdentifier () { 670 getIdentifier (this: MActorHost) {
671 return this.Server ? `${this.preferredUsername}@${this.Server.host}` : this.preferredUsername 671 return this.Server ? `${this.preferredUsername}@${this.Server.host}` : this.preferredUsername
672 } 672 }
673 673
674 getHost (this: MActorHost) { 674 getHost (this: MActorHostOnly) {
675 return this.Server ? this.Server.host : WEBSERVER.HOST 675 return this.Server ? this.Server.host : WEBSERVER.HOST
676 } 676 }
677 677
diff --git a/server/models/user/user.ts b/server/models/user/user.ts
index 735b5c171..4f6a8fce4 100644
--- a/server/models/user/user.ts
+++ b/server/models/user/user.ts
@@ -404,6 +404,11 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
404 @Column 404 @Column
405 lastLoginDate: Date 405 lastLoginDate: Date
406 406
407 @AllowNull(false)
408 @Default(false)
409 @Column
410 emailPublic: boolean
411
407 @AllowNull(true) 412 @AllowNull(true)
408 @Default(null) 413 @Default(null)
409 @Column 414 @Column
@@ -880,6 +885,7 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
880 theme: getThemeOrDefault(this.theme, DEFAULT_USER_THEME_NAME), 885 theme: getThemeOrDefault(this.theme, DEFAULT_USER_THEME_NAME),
881 886
882 pendingEmail: this.pendingEmail, 887 pendingEmail: this.pendingEmail,
888 emailPublic: this.emailPublic,
883 emailVerified: this.emailVerified, 889 emailVerified: this.emailVerified,
884 890
885 nsfwPolicy: this.nsfwPolicy, 891 nsfwPolicy: this.nsfwPolicy,
diff --git a/server/models/video/formatter/video-format-utils.ts b/server/models/video/formatter/video-format-utils.ts
index 6f05dbdc8..f2001e432 100644
--- a/server/models/video/formatter/video-format-utils.ts
+++ b/server/models/video/formatter/video-format-utils.ts
@@ -459,7 +459,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
459 459
460 icon: icons.map(i => ({ 460 icon: icons.map(i => ({
461 type: 'Image', 461 type: 'Image',
462 url: i.getFileUrl(video), 462 url: i.getOriginFileUrl(video),
463 mediaType: 'image/jpeg', 463 mediaType: 'image/jpeg',
464 width: i.width, 464 width: i.width,
465 height: i.height 465 height: i.height
diff --git a/server/models/video/thumbnail.ts b/server/models/video/thumbnail.ts
index f33bd3179..a4ac581e5 100644
--- a/server/models/video/thumbnail.ts
+++ b/server/models/video/thumbnail.ts
@@ -164,7 +164,7 @@ export class ThumbnailModel extends Model<Partial<AttributesOnly<ThumbnailModel>
164 return join(directory, filename) 164 return join(directory, filename)
165 } 165 }
166 166
167 getFileUrl (video: MVideo) { 167 getOriginFileUrl (video: MVideo) {
168 const staticPath = ThumbnailModel.types[this.type].staticPath + this.filename 168 const staticPath = ThumbnailModel.types[this.type].staticPath + this.filename
169 169
170 if (video.isOwned()) return WEBSERVER.URL + staticPath 170 if (video.isOwned()) return WEBSERVER.URL + staticPath
@@ -172,6 +172,10 @@ export class ThumbnailModel extends Model<Partial<AttributesOnly<ThumbnailModel>
172 return this.fileUrl 172 return this.fileUrl
173 } 173 }
174 174
175 getLocalStaticPath () {
176 return ThumbnailModel.types[this.type].staticPath + this.filename
177 }
178
175 getPath () { 179 getPath () {
176 return ThumbnailModel.buildPath(this.type, this.filename) 180 return ThumbnailModel.buildPath(this.type, this.filename)
177 } 181 }
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts
index 2eaa77407..1fb1cae82 100644
--- a/server/models/video/video-caption.ts
+++ b/server/models/video/video-caption.ts
@@ -1,6 +1,6 @@
1import { remove } from 'fs-extra' 1import { remove } from 'fs-extra'
2import { join } from 'path' 2import { join } from 'path'
3import { OrderItem, Transaction } from 'sequelize' 3import { Op, OrderItem, Transaction } from 'sequelize'
4import { 4import {
5 AllowNull, 5 AllowNull,
6 BeforeDestroy, 6 BeforeDestroy,
@@ -166,6 +166,31 @@ export class VideoCaptionModel extends Model<Partial<AttributesOnly<VideoCaption
166 return VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll(query) 166 return VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll(query)
167 } 167 }
168 168
169 static async listCaptionsOfMultipleVideos (videoIds: number[], transaction?: Transaction) {
170 const query = {
171 order: [ [ 'language', 'ASC' ] ] as OrderItem[],
172 where: {
173 videoId: {
174 [Op.in]: videoIds
175 }
176 },
177 transaction
178 }
179
180 const captions = await VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll<MVideoCaptionVideo>(query)
181 const result: { [ id: number ]: MVideoCaptionVideo[] } = {}
182
183 for (const id of videoIds) {
184 result[id] = []
185 }
186
187 for (const caption of captions) {
188 result[caption.videoId].push(caption)
189 }
190
191 return result
192 }
193
169 static getLanguageLabel (language: string) { 194 static getLanguageLabel (language: string) {
170 return VIDEO_LANGUAGES[language] || 'Unknown' 195 return VIDEO_LANGUAGES[language] || 'Unknown'
171 } 196 }
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index 0fb52827e..19dd681a7 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -1,5 +1,8 @@
1import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction, WhereOptions } from 'sequelize' 1import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
2import { 2import {
3 AfterCreate,
4 AfterDestroy,
5 AfterUpdate,
3 AllowNull, 6 AllowNull,
4 BeforeDestroy, 7 BeforeDestroy,
5 BelongsTo, 8 BelongsTo,
@@ -18,7 +21,8 @@ import {
18 UpdatedAt 21 UpdatedAt
19} from 'sequelize-typescript' 22} from 'sequelize-typescript'
20import { CONFIG } from '@server/initializers/config' 23import { CONFIG } from '@server/initializers/config'
21import { MAccountActor } from '@server/types/models' 24import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
25import { MAccountHost } from '@server/types/models'
22import { forceNumber, pick } from '@shared/core-utils' 26import { forceNumber, pick } from '@shared/core-utils'
23import { AttributesOnly } from '@shared/typescript-utils' 27import { AttributesOnly } from '@shared/typescript-utils'
24import { ActivityPubActor } from '../../../shared/models/activitypub' 28import { ActivityPubActor } from '../../../shared/models/activitypub'
@@ -36,6 +40,7 @@ import {
36 MChannelAP, 40 MChannelAP,
37 MChannelBannerAccountDefault, 41 MChannelBannerAccountDefault,
38 MChannelFormattable, 42 MChannelFormattable,
43 MChannelHost,
39 MChannelSummaryFormattable 44 MChannelSummaryFormattable
40} from '../../types/models/video' 45} from '../../types/models/video'
41import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account' 46import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
@@ -416,6 +421,21 @@ export class VideoChannelModel extends Model<Partial<AttributesOnly<VideoChannel
416 }) 421 })
417 VideoPlaylists: VideoPlaylistModel[] 422 VideoPlaylists: VideoPlaylistModel[]
418 423
424 @AfterCreate
425 static notifyCreate (channel: MChannel) {
426 InternalEventEmitter.Instance.emit('channel-created', { channel })
427 }
428
429 @AfterUpdate
430 static notifyUpdate (channel: MChannel) {
431 InternalEventEmitter.Instance.emit('channel-updated', { channel })
432 }
433
434 @AfterDestroy
435 static notifyDestroy (channel: MChannel) {
436 InternalEventEmitter.Instance.emit('channel-deleted', { channel })
437 }
438
419 @BeforeDestroy 439 @BeforeDestroy
420 static async sendDeleteIfOwned (instance: VideoChannelModel, options) { 440 static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
421 if (!instance.Actor) { 441 if (!instance.Actor) {
@@ -827,8 +847,9 @@ export class VideoChannelModel extends Model<Partial<AttributesOnly<VideoChannel
827 }) 847 })
828 } 848 }
829 849
830 getLocalUrl (this: MAccountActor | MChannelActor) { 850 // Avoid error when running this method on MAccount... | MChannel...
831 return WEBSERVER.URL + `/video-channels/` + this.Actor.preferredUsername 851 getClientUrl (this: MAccountHost | MChannelHost) {
852 return WEBSERVER.URL + '/c/' + this.Actor.getIdentifier()
832 } 853 }
833 854
834 getDisplayName () { 855 getDisplayName () {
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index baa8c120a..8e3af62a4 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -1,9 +1,11 @@
1import Bluebird from 'bluebird' 1import Bluebird from 'bluebird'
2import { remove } from 'fs-extra' 2import { remove } from 'fs-extra'
3import { maxBy, minBy } from 'lodash' 3import { maxBy, minBy } from 'lodash'
4import { join } from 'path'
5import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' 4import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
6import { 5import {
6 AfterCreate,
7 AfterDestroy,
8 AfterUpdate,
7 AllowNull, 9 AllowNull,
8 BeforeDestroy, 10 BeforeDestroy,
9 BelongsTo, 11 BelongsTo,
@@ -25,6 +27,7 @@ import {
25 UpdatedAt 27 UpdatedAt
26} from 'sequelize-typescript' 28} from 'sequelize-typescript'
27import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video' 29import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
30import { InternalEventEmitter } from '@server/lib/internal-event-emitter'
28import { LiveManager } from '@server/lib/live/live-manager' 31import { LiveManager } from '@server/lib/live/live-manager'
29import { removeHLSFileObjectStorageByFilename, removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage' 32import { removeHLSFileObjectStorageByFilename, removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage'
30import { tracer } from '@server/lib/opentelemetry/tracing' 33import { tracer } from '@server/lib/opentelemetry/tracing'
@@ -66,7 +69,7 @@ import {
66} from '../../helpers/custom-validators/videos' 69} from '../../helpers/custom-validators/videos'
67import { logger } from '../../helpers/logger' 70import { logger } from '../../helpers/logger'
68import { CONFIG } from '../../initializers/config' 71import { CONFIG } from '../../initializers/config'
69import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants' 72import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
70import { sendDeleteVideo } from '../../lib/activitypub/send' 73import { sendDeleteVideo } from '../../lib/activitypub/send'
71import { 74import {
72 MChannel, 75 MChannel,
@@ -740,8 +743,23 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
740 }) 743 })
741 VideoJobInfo: VideoJobInfoModel 744 VideoJobInfo: VideoJobInfoModel
742 745
746 @AfterCreate
747 static notifyCreate (video: MVideo) {
748 InternalEventEmitter.Instance.emit('video-created', { video })
749 }
750
751 @AfterUpdate
752 static notifyUpdate (video: MVideo) {
753 InternalEventEmitter.Instance.emit('video-updated', { video })
754 }
755
756 @AfterDestroy
757 static notifyDestroy (video: MVideo) {
758 InternalEventEmitter.Instance.emit('video-deleted', { video })
759 }
760
743 @BeforeDestroy 761 @BeforeDestroy
744 static async sendDelete (instance: MVideoAccountLight, options) { 762 static async sendDelete (instance: MVideoAccountLight, options: { transaction: Transaction }) {
745 if (!instance.isOwned()) return undefined 763 if (!instance.isOwned()) return undefined
746 764
747 // Lazy load channels 765 // Lazy load channels
@@ -1686,15 +1704,14 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1686 const thumbnail = this.getMiniature() 1704 const thumbnail = this.getMiniature()
1687 if (!thumbnail) return null 1705 if (!thumbnail) return null
1688 1706
1689 return join(STATIC_PATHS.THUMBNAILS, thumbnail.filename) 1707 return thumbnail.getLocalStaticPath()
1690 } 1708 }
1691 1709
1692 getPreviewStaticPath () { 1710 getPreviewStaticPath () {
1693 const preview = this.getPreview() 1711 const preview = this.getPreview()
1694 if (!preview) return null 1712 if (!preview) return null
1695 1713
1696 // We use a local cache, so specify our cache endpoint instead of potential remote URL 1714 return preview.getLocalStaticPath()
1697 return join(LAZY_STATIC_PATHS.PREVIEWS, preview.filename)
1698 } 1715 }
1699 1716
1700 toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video { 1717 toFormattedJSON (this: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
@@ -1705,17 +1722,29 @@ export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
1705 return videoModelToFormattedDetailsJSON(this) 1722 return videoModelToFormattedDetailsJSON(this)
1706 } 1723 }
1707 1724
1708 getFormattedVideoFilesJSON (includeMagnet = true): VideoFile[] { 1725 getFormattedWebVideoFilesJSON (includeMagnet = true): VideoFile[] {
1726 return videoFilesModelToFormattedJSON(this, this.VideoFiles, { includeMagnet })
1727 }
1728
1729 getFormattedHLSVideoFilesJSON (includeMagnet = true): VideoFile[] {
1730 let acc: VideoFile[] = []
1731
1732 for (const p of this.VideoStreamingPlaylists) {
1733 acc = acc.concat(videoFilesModelToFormattedJSON(this, p.VideoFiles, { includeMagnet }))
1734 }
1735
1736 return acc
1737 }
1738
1739 getFormattedAllVideoFilesJSON (includeMagnet = true): VideoFile[] {
1709 let files: VideoFile[] = [] 1740 let files: VideoFile[] = []
1710 1741
1711 if (Array.isArray(this.VideoFiles)) { 1742 if (Array.isArray(this.VideoFiles)) {
1712 const result = videoFilesModelToFormattedJSON(this, this.VideoFiles, { includeMagnet }) 1743 files = files.concat(this.getFormattedWebVideoFilesJSON(includeMagnet))
1713 files = files.concat(result)
1714 } 1744 }
1715 1745
1716 for (const p of (this.VideoStreamingPlaylists || [])) { 1746 if (Array.isArray(this.VideoStreamingPlaylists)) {
1717 const result = videoFilesModelToFormattedJSON(this, p.VideoFiles, { includeMagnet }) 1747 files = files.concat(this.getFormattedHLSVideoFilesJSON(includeMagnet))
1718 files = files.concat(result)
1719 } 1748 }
1720 1749
1721 return files 1750 return files
diff --git a/server/tests/client.ts b/server/tests/client.ts
index 9a20c2a10..e84251561 100644
--- a/server/tests/client.ts
+++ b/server/tests/client.ts
@@ -172,7 +172,7 @@ describe('Test a client controllers', function () {
172 expect(text).to.contain(`<meta property="og:title" content="${account.displayName}" />`) 172 expect(text).to.contain(`<meta property="og:title" content="${account.displayName}" />`)
173 expect(text).to.contain(`<meta property="og:description" content="${account.description}" />`) 173 expect(text).to.contain(`<meta property="og:description" content="${account.description}" />`)
174 expect(text).to.contain('<meta property="og:type" content="website" />') 174 expect(text).to.contain('<meta property="og:type" content="website" />')
175 expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/accounts/${servers[0].store.user.username}" />`) 175 expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/a/${servers[0].store.user.username}" />`)
176 } 176 }
177 177
178 async function channelPageTest (path: string) { 178 async function channelPageTest (path: string) {
@@ -182,7 +182,7 @@ describe('Test a client controllers', function () {
182 expect(text).to.contain(`<meta property="og:title" content="${servers[0].store.channel.displayName}" />`) 182 expect(text).to.contain(`<meta property="og:title" content="${servers[0].store.channel.displayName}" />`)
183 expect(text).to.contain(`<meta property="og:description" content="${channelDescription}" />`) 183 expect(text).to.contain(`<meta property="og:description" content="${channelDescription}" />`)
184 expect(text).to.contain('<meta property="og:type" content="website" />') 184 expect(text).to.contain('<meta property="og:type" content="website" />')
185 expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/video-channels/${servers[0].store.channel.name}" />`) 185 expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/c/${servers[0].store.channel.name}" />`)
186 } 186 }
187 187
188 async function watchVideoPageTest (path: string) { 188 async function watchVideoPageTest (path: string) {
diff --git a/server/tests/feeds/feeds.ts b/server/tests/feeds/feeds.ts
index ecd1badc1..57eefff6d 100644
--- a/server/tests/feeds/feeds.ts
+++ b/server/tests/feeds/feeds.ts
@@ -11,6 +11,7 @@ import {
11 makeGetRequest, 11 makeGetRequest,
12 makeRawRequest, 12 makeRawRequest,
13 PeerTubeServer, 13 PeerTubeServer,
14 PluginsCommand,
14 setAccessTokensToServers, 15 setAccessTokensToServers,
15 setDefaultChannelAvatar, 16 setDefaultChannelAvatar,
16 stopFfmpeg, 17 stopFfmpeg,
@@ -26,12 +27,15 @@ const expect = chai.expect
26describe('Test syndication feeds', () => { 27describe('Test syndication feeds', () => {
27 let servers: PeerTubeServer[] = [] 28 let servers: PeerTubeServer[] = []
28 let serverHLSOnly: PeerTubeServer 29 let serverHLSOnly: PeerTubeServer
30
29 let userAccessToken: string 31 let userAccessToken: string
30 let rootAccountId: number 32 let rootAccountId: number
31 let rootChannelId: number 33 let rootChannelId: number
34
32 let userAccountId: number 35 let userAccountId: number
33 let userChannelId: number 36 let userChannelId: number
34 let userFeedToken: string 37 let userFeedToken: string
38
35 let liveId: string 39 let liveId: string
36 40
37 before(async function () { 41 before(async function () {
@@ -93,7 +97,11 @@ describe('Test syndication feeds', () => {
93 await servers[0].comments.createThread({ videoId: id, text: 'comment on unlisted video' }) 97 await servers[0].comments.createThread({ videoId: id, text: 'comment on unlisted video' })
94 } 98 }
95 99
96 await waitJobs(servers) 100 await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } })
101
102 await waitJobs([ ...servers, serverHLSOnly ])
103
104 await servers[0].plugins.install({ path: PluginsCommand.getPluginTestPath('-podcast-custom-tags') })
97 }) 105 })
98 106
99 describe('All feed', function () { 107 describe('All feed', function () {
@@ -108,6 +116,11 @@ describe('Test syndication feeds', () => {
108 } 116 }
109 }) 117 })
110 118
119 it('Should be well formed XML (covers Podcast endpoint)', async function () {
120 const podcast = await servers[0].feed.getPodcastXML({ ignoreCache: true, channelId: rootChannelId })
121 expect(podcast).xml.to.be.valid()
122 })
123
111 it('Should be well formed JSON (covers JSON feed 1.0 endpoint)', async function () { 124 it('Should be well formed JSON (covers JSON feed 1.0 endpoint)', async function () {
112 for (const feed of [ 'video-comments' as 'video-comments', 'videos' as 'videos' ]) { 125 for (const feed of [ 'video-comments' as 'video-comments', 'videos' as 'videos' ]) {
113 const jsonText = await servers[0].feed.getJSON({ feed, ignoreCache: true }) 126 const jsonText = await servers[0].feed.getJSON({ feed, ignoreCache: true })
@@ -153,168 +166,290 @@ describe('Test syndication feeds', () => {
153 166
154 describe('Videos feed', function () { 167 describe('Videos feed', function () {
155 168
156 it('Should contain a valid enclosure (covers RSS 2.0 endpoint)', async function () { 169 describe('Podcast feed', function () {
157 for (const server of servers) { 170
158 const rss = await server.feed.getXML({ feed: 'videos', ignoreCache: true }) 171 it('Should contain a valid podcast:alternateEnclosure', async function () {
172 // Since podcast feeds should only work on the server they originate on,
173 // only test the first server where the videos reside
174 const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
159 expect(XMLValidator.validate(rss)).to.be.true 175 expect(XMLValidator.validate(rss)).to.be.true
160 176
161 const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false }) 177 const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
162 const xmlDoc = parser.parse(rss) 178 const xmlDoc = parser.parse(rss)
163 179
164 const enclosure = xmlDoc.rss.channel.item[0].enclosure 180 const enclosure = xmlDoc.rss.channel.item.enclosure
165 expect(enclosure).to.exist 181 expect(enclosure).to.exist
182 const alternateEnclosure = xmlDoc.rss.channel.item['podcast:alternateEnclosure']
183 expect(alternateEnclosure).to.exist
184
185 expect(alternateEnclosure['@_type']).to.equal('video/webm')
186 expect(alternateEnclosure['@_length']).to.equal(218910)
187 expect(alternateEnclosure['@_lang']).to.equal('zh')
188 expect(alternateEnclosure['@_title']).to.equal('720p')
189 expect(alternateEnclosure['@_default']).to.equal(true)
190
191 expect(alternateEnclosure['podcast:source'][0]['@_uri']).to.contain('-720.webm')
192 expect(alternateEnclosure['podcast:source'][0]['@_uri']).to.equal(enclosure['@_url'])
193 expect(alternateEnclosure['podcast:source'][1]['@_uri']).to.contain('-720.torrent')
194 expect(alternateEnclosure['podcast:source'][1]['@_contentType']).to.equal('application/x-bittorrent')
195 expect(alternateEnclosure['podcast:source'][2]['@_uri']).to.contain('magnet:?')
196 })
166 197
167 expect(enclosure['@_type']).to.equal('video/webm') 198 it('Should contain a valid podcast:alternateEnclosure with HLS only', async function () {
168 expect(enclosure['@_length']).to.equal(218910) 199 const rss = await serverHLSOnly.feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
169 expect(enclosure['@_url']).to.contain('-720.webm') 200 expect(XMLValidator.validate(rss)).to.be.true
170 }
171 })
172 201
173 it('Should contain a valid \'attachments\' object (covers JSON feed 1.0 endpoint)', async function () { 202 const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
174 for (const server of servers) { 203 const xmlDoc = parser.parse(rss)
175 const json = await server.feed.getJSON({ feed: 'videos', ignoreCache: true }) 204
176 const jsonObj = JSON.parse(json) 205 const enclosure = xmlDoc.rss.channel.item.enclosure
177 expect(jsonObj.items.length).to.be.equal(2) 206 const alternateEnclosure = xmlDoc.rss.channel.item['podcast:alternateEnclosure']
178 expect(jsonObj.items[0].attachments).to.exist 207 expect(alternateEnclosure).to.exist
179 expect(jsonObj.items[0].attachments.length).to.be.eq(1) 208
180 expect(jsonObj.items[0].attachments[0].mime_type).to.be.eq('application/x-bittorrent') 209 expect(alternateEnclosure['@_type']).to.equal('application/x-mpegURL')
181 expect(jsonObj.items[0].attachments[0].size_in_bytes).to.be.eq(218910) 210 expect(alternateEnclosure['@_lang']).to.equal('zh')
182 expect(jsonObj.items[0].attachments[0].url).to.contain('720.torrent') 211 expect(alternateEnclosure['@_title']).to.equal('HLS')
183 } 212 expect(alternateEnclosure['@_default']).to.equal(true)
213
214 expect(alternateEnclosure['podcast:source']['@_uri']).to.contain('-master.m3u8')
215 expect(alternateEnclosure['podcast:source']['@_uri']).to.equal(enclosure['@_url'])
216 })
217
218 it('Should contain a valid podcast:socialInteract', async function () {
219 const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
220 expect(XMLValidator.validate(rss)).to.be.true
221
222 const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
223 const xmlDoc = parser.parse(rss)
224
225 const item = xmlDoc.rss.channel.item
226 const socialInteract = item['podcast:socialInteract']
227 expect(socialInteract).to.exist
228 expect(socialInteract['@_protocol']).to.equal('activitypub')
229 expect(socialInteract['@_uri']).to.exist
230 expect(socialInteract['@_accountUrl']).to.exist
231 })
232
233 it('Should contain a valid support custom tags for plugins', async function () {
234 const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: userChannelId })
235 expect(XMLValidator.validate(rss)).to.be.true
236
237 const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
238 const xmlDoc = parser.parse(rss)
239
240 const fooTag = xmlDoc.rss.channel.fooTag
241 expect(fooTag).to.exist
242 expect(fooTag['@_bar']).to.equal('baz')
243 expect(fooTag['#text']).to.equal(42)
244
245 const bizzBuzzItem = xmlDoc.rss.channel['biz:buzzItem']
246 expect(bizzBuzzItem).to.exist
247
248 let nestedTag = bizzBuzzItem.nestedTag
249 expect(nestedTag).to.exist
250 expect(nestedTag).to.equal('example nested tag')
251
252 const item = xmlDoc.rss.channel.item
253 const fizzTag = item.fizzTag
254 expect(fizzTag).to.exist
255 expect(fizzTag['@_bar']).to.equal('baz')
256 expect(fizzTag['#text']).to.equal(21)
257
258 const bizzBuzz = item['biz:buzz']
259 expect(bizzBuzz).to.exist
260
261 nestedTag = bizzBuzz.nestedTag
262 expect(nestedTag).to.exist
263 expect(nestedTag).to.equal('example nested tag')
264 })
265
266 it('Should contain a valid podcast:liveItem for live streams', async function () {
267 this.timeout(120000)
268
269 const { uuid } = await servers[0].live.create({
270 fields: {
271 name: 'live-0',
272 privacy: VideoPrivacy.PUBLIC,
273 channelId: rootChannelId,
274 permanentLive: false
275 }
276 })
277 liveId = uuid
278
279 const ffmpeg = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveId, copyCodecs: true, fixtureName: 'video_short.mp4' })
280 await servers[0].live.waitUntilPublished({ videoId: liveId })
281
282 const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
283 expect(XMLValidator.validate(rss)).to.be.true
284
285 const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
286 const xmlDoc = parser.parse(rss)
287 const liveItem = xmlDoc.rss.channel['podcast:liveItem']
288 expect(liveItem.title).to.equal('live-0')
289 expect(liveItem['@_status']).to.equal('live')
290
291 const enclosure = liveItem.enclosure
292 const alternateEnclosure = liveItem['podcast:alternateEnclosure']
293 expect(alternateEnclosure).to.exist
294 expect(alternateEnclosure['@_type']).to.equal('application/x-mpegURL')
295 expect(alternateEnclosure['@_title']).to.equal('HLS live stream')
296 expect(alternateEnclosure['@_default']).to.equal(true)
297
298 expect(alternateEnclosure['podcast:source']['@_uri']).to.contain('/master.m3u8')
299 expect(alternateEnclosure['podcast:source']['@_uri']).to.equal(enclosure['@_url'])
300
301 await stopFfmpeg(ffmpeg)
302
303 await servers[0].live.waitUntilEnded({ videoId: liveId })
304
305 await waitJobs(servers)
306 })
184 }) 307 })
185 308
186 it('Should filter by account', async function () { 309 describe('JSON feed', function () {
187 {
188 const json = await servers[0].feed.getJSON({ feed: 'videos', query: { accountId: rootAccountId }, ignoreCache: true })
189 const jsonObj = JSON.parse(json)
190 expect(jsonObj.items.length).to.be.equal(1)
191 expect(jsonObj.items[0].title).to.equal('my super name for server 1')
192 expect(jsonObj.items[0].author.name).to.equal('Main root channel')
193 }
194 310
195 { 311 it('Should contain a valid \'attachments\' object', async function () {
196 const json = await servers[0].feed.getJSON({ feed: 'videos', query: { accountId: userAccountId }, ignoreCache: true }) 312 for (const server of servers) {
197 const jsonObj = JSON.parse(json) 313 const json = await server.feed.getJSON({ feed: 'videos', ignoreCache: true })
198 expect(jsonObj.items.length).to.be.equal(1) 314 const jsonObj = JSON.parse(json)
199 expect(jsonObj.items[0].title).to.equal('user video') 315 expect(jsonObj.items.length).to.be.equal(2)
200 expect(jsonObj.items[0].author.name).to.equal('Main john channel') 316 expect(jsonObj.items[0].attachments).to.exist
201 } 317 expect(jsonObj.items[0].attachments.length).to.be.eq(1)
318 expect(jsonObj.items[0].attachments[0].mime_type).to.be.eq('application/x-bittorrent')
319 expect(jsonObj.items[0].attachments[0].size_in_bytes).to.be.eq(218910)
320 expect(jsonObj.items[0].attachments[0].url).to.contain('720.torrent')
321 }
322 })
202 323
203 for (const server of servers) { 324 it('Should filter by account', async function () {
204 { 325 {
205 const json = await server.feed.getJSON({ feed: 'videos', query: { accountName: 'root@' + servers[0].host }, ignoreCache: true }) 326 const json = await servers[0].feed.getJSON({ feed: 'videos', query: { accountId: rootAccountId }, ignoreCache: true })
206 const jsonObj = JSON.parse(json) 327 const jsonObj = JSON.parse(json)
207 expect(jsonObj.items.length).to.be.equal(1) 328 expect(jsonObj.items.length).to.be.equal(1)
208 expect(jsonObj.items[0].title).to.equal('my super name for server 1') 329 expect(jsonObj.items[0].title).to.equal('my super name for server 1')
330 expect(jsonObj.items[0].author.name).to.equal('Main root channel')
209 } 331 }
210 332
211 { 333 {
212 const json = await server.feed.getJSON({ feed: 'videos', query: { accountName: 'john@' + servers[0].host }, ignoreCache: true }) 334 const json = await servers[0].feed.getJSON({ feed: 'videos', query: { accountId: userAccountId }, ignoreCache: true })
213 const jsonObj = JSON.parse(json) 335 const jsonObj = JSON.parse(json)
214 expect(jsonObj.items.length).to.be.equal(1) 336 expect(jsonObj.items.length).to.be.equal(1)
215 expect(jsonObj.items[0].title).to.equal('user video') 337 expect(jsonObj.items[0].title).to.equal('user video')
338 expect(jsonObj.items[0].author.name).to.equal('Main john channel')
216 } 339 }
217 }
218 })
219 340
220 it('Should filter by video channel', async function () { 341 for (const server of servers) {
221 { 342 {
222 const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: rootChannelId }, ignoreCache: true }) 343 const json = await server.feed.getJSON({ feed: 'videos', query: { accountName: 'root@' + servers[0].host }, ignoreCache: true })
223 const jsonObj = JSON.parse(json) 344 const jsonObj = JSON.parse(json)
224 expect(jsonObj.items.length).to.be.equal(1) 345 expect(jsonObj.items.length).to.be.equal(1)
225 expect(jsonObj.items[0].title).to.equal('my super name for server 1') 346 expect(jsonObj.items[0].title).to.equal('my super name for server 1')
226 expect(jsonObj.items[0].author.name).to.equal('Main root channel') 347 }
227 } 348
228 349 {
229 { 350 const json = await server.feed.getJSON({ feed: 'videos', query: { accountName: 'john@' + servers[0].host }, ignoreCache: true })
230 const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: userChannelId }, ignoreCache: true }) 351 const jsonObj = JSON.parse(json)
231 const jsonObj = JSON.parse(json) 352 expect(jsonObj.items.length).to.be.equal(1)
232 expect(jsonObj.items.length).to.be.equal(1) 353 expect(jsonObj.items[0].title).to.equal('user video')
233 expect(jsonObj.items[0].title).to.equal('user video') 354 }
234 expect(jsonObj.items[0].author.name).to.equal('Main john channel') 355 }
235 } 356 })
236 357
237 for (const server of servers) { 358 it('Should filter by video channel', async function () {
238 { 359 {
239 const query = { videoChannelName: 'root_channel@' + servers[0].host } 360 const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: rootChannelId }, ignoreCache: true })
240 const json = await server.feed.getJSON({ feed: 'videos', query, ignoreCache: true })
241 const jsonObj = JSON.parse(json) 361 const jsonObj = JSON.parse(json)
242 expect(jsonObj.items.length).to.be.equal(1) 362 expect(jsonObj.items.length).to.be.equal(1)
243 expect(jsonObj.items[0].title).to.equal('my super name for server 1') 363 expect(jsonObj.items[0].title).to.equal('my super name for server 1')
364 expect(jsonObj.items[0].author.name).to.equal('Main root channel')
244 } 365 }
245 366
246 { 367 {
247 const query = { videoChannelName: 'john_channel@' + servers[0].host } 368 const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: userChannelId }, ignoreCache: true })
248 const json = await server.feed.getJSON({ feed: 'videos', query, ignoreCache: true })
249 const jsonObj = JSON.parse(json) 369 const jsonObj = JSON.parse(json)
250 expect(jsonObj.items.length).to.be.equal(1) 370 expect(jsonObj.items.length).to.be.equal(1)
251 expect(jsonObj.items[0].title).to.equal('user video') 371 expect(jsonObj.items[0].title).to.equal('user video')
372 expect(jsonObj.items[0].author.name).to.equal('Main john channel')
252 } 373 }
253 }
254 })
255 374
256 it('Should correctly have videos feed with HLS only', async function () { 375 for (const server of servers) {
257 this.timeout(120000) 376 {
258 377 const query = { videoChannelName: 'root_channel@' + servers[0].host }
259 await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } }) 378 const json = await server.feed.getJSON({ feed: 'videos', query, ignoreCache: true })
379 const jsonObj = JSON.parse(json)
380 expect(jsonObj.items.length).to.be.equal(1)
381 expect(jsonObj.items[0].title).to.equal('my super name for server 1')
382 }
383
384 {
385 const query = { videoChannelName: 'john_channel@' + servers[0].host }
386 const json = await server.feed.getJSON({ feed: 'videos', query, ignoreCache: true })
387 const jsonObj = JSON.parse(json)
388 expect(jsonObj.items.length).to.be.equal(1)
389 expect(jsonObj.items[0].title).to.equal('user video')
390 }
391 }
392 })
260 393
261 await waitJobs([ serverHLSOnly ]) 394 it('Should correctly have videos feed with HLS only', async function () {
395 this.timeout(120000)
262 396
263 const json = await serverHLSOnly.feed.getJSON({ feed: 'videos', ignoreCache: true }) 397 const json = await serverHLSOnly.feed.getJSON({ feed: 'videos', ignoreCache: true })
264 const jsonObj = JSON.parse(json) 398 const jsonObj = JSON.parse(json)
265 expect(jsonObj.items.length).to.be.equal(1) 399 expect(jsonObj.items.length).to.be.equal(1)
266 expect(jsonObj.items[0].attachments).to.exist 400 expect(jsonObj.items[0].attachments).to.exist
267 expect(jsonObj.items[0].attachments.length).to.be.eq(4) 401 expect(jsonObj.items[0].attachments.length).to.be.eq(4)
268
269 for (let i = 0; i < 4; i++) {
270 expect(jsonObj.items[0].attachments[i].mime_type).to.be.eq('application/x-bittorrent')
271 expect(jsonObj.items[0].attachments[i].size_in_bytes).to.be.greaterThan(0)
272 expect(jsonObj.items[0].attachments[i].url).to.exist
273 }
274 })
275 402
276 it('Should not display waiting live videos', async function () { 403 for (let i = 0; i < 4; i++) {
277 const { uuid } = await servers[0].live.create({ 404 expect(jsonObj.items[0].attachments[i].mime_type).to.be.eq('application/x-bittorrent')
278 fields: { 405 expect(jsonObj.items[0].attachments[i].size_in_bytes).to.be.greaterThan(0)
279 name: 'live', 406 expect(jsonObj.items[0].attachments[i].url).to.exist
280 privacy: VideoPrivacy.PUBLIC,
281 channelId: rootChannelId
282 } 407 }
283 }) 408 })
284 liveId = uuid
285 409
286 const json = await servers[0].feed.getJSON({ feed: 'videos', ignoreCache: true }) 410 it('Should not display waiting live videos', async function () {
411 const { uuid } = await servers[0].live.create({
412 fields: {
413 name: 'live',
414 privacy: VideoPrivacy.PUBLIC,
415 channelId: rootChannelId
416 }
417 })
418 liveId = uuid
287 419
288 const jsonObj = JSON.parse(json) 420 const json = await servers[0].feed.getJSON({ feed: 'videos', ignoreCache: true })
289 expect(jsonObj.items.length).to.be.equal(2) 421
290 expect(jsonObj.items[0].title).to.equal('my super name for server 1') 422 const jsonObj = JSON.parse(json)
291 expect(jsonObj.items[1].title).to.equal('user video') 423 expect(jsonObj.items.length).to.be.equal(2)
292 }) 424 expect(jsonObj.items[0].title).to.equal('my super name for server 1')
425 expect(jsonObj.items[1].title).to.equal('user video')
426 })
293 427
294 it('Should display published live videos', async function () { 428 it('Should display published live videos', async function () {
295 this.timeout(120000) 429 this.timeout(120000)
296 430
297 const ffmpeg = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveId, copyCodecs: true, fixtureName: 'video_short.mp4' }) 431 const ffmpeg = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveId, copyCodecs: true, fixtureName: 'video_short.mp4' })
298 await servers[0].live.waitUntilPublished({ videoId: liveId }) 432 await servers[0].live.waitUntilPublished({ videoId: liveId })
299 433
300 const json = await servers[0].feed.getJSON({ feed: 'videos', ignoreCache: true }) 434 const json = await servers[0].feed.getJSON({ feed: 'videos', ignoreCache: true })
301 435
302 const jsonObj = JSON.parse(json) 436 const jsonObj = JSON.parse(json)
303 expect(jsonObj.items.length).to.be.equal(3) 437 expect(jsonObj.items.length).to.be.equal(3)
304 expect(jsonObj.items[0].title).to.equal('live') 438 expect(jsonObj.items[0].title).to.equal('live')
305 expect(jsonObj.items[1].title).to.equal('my super name for server 1') 439 expect(jsonObj.items[1].title).to.equal('my super name for server 1')
306 expect(jsonObj.items[2].title).to.equal('user video') 440 expect(jsonObj.items[2].title).to.equal('user video')
307 441
308 await stopFfmpeg(ffmpeg) 442 await stopFfmpeg(ffmpeg)
309 }) 443 })
310 444
311 it('Should have the channel avatar as feed icon', async function () { 445 it('Should have the channel avatar as feed icon', async function () {
312 const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: rootChannelId }, ignoreCache: true }) 446 const json = await servers[0].feed.getJSON({ feed: 'videos', query: { videoChannelId: rootChannelId }, ignoreCache: true })
313 447
314 const jsonObj = JSON.parse(json) 448 const jsonObj = JSON.parse(json)
315 const imageUrl = jsonObj.icon 449 const imageUrl = jsonObj.icon
316 expect(imageUrl).to.include('/lazy-static/avatars/') 450 expect(imageUrl).to.include('/lazy-static/avatars/')
317 await makeRawRequest({ url: imageUrl, expectedStatus: HttpStatusCode.OK_200 }) 451 await makeRawRequest({ url: imageUrl, expectedStatus: HttpStatusCode.OK_200 })
452 })
318 }) 453 })
319 }) 454 })
320 455
@@ -470,6 +605,8 @@ describe('Test syndication feeds', () => {
470 }) 605 })
471 606
472 after(async function () { 607 after(async function () {
608 await servers[0].plugins.uninstall({ npmName: 'peertube-plugin-test-podcast-custom-tags' })
609
473 await cleanupTests([ ...servers, serverHLSOnly ]) 610 await cleanupTests([ ...servers, serverHLSOnly ])
474 }) 611 })
475}) 612})
diff --git a/server/tests/fixtures/peertube-plugin-test-podcast-custom-tags/main.js b/server/tests/fixtures/peertube-plugin-test-podcast-custom-tags/main.js
new file mode 100644
index 000000000..ada4a70fe
--- /dev/null
+++ b/server/tests/fixtures/peertube-plugin-test-podcast-custom-tags/main.js
@@ -0,0 +1,82 @@
1async function register ({ registerHook, registerSetting, settingsManager, storageManager, peertubeHelpers }) {
2 registerHook({
3 target: 'filter:feed.podcast.rss.create-custom-xmlns.result',
4 handler: (result, params) => {
5 return result.concat([
6 {
7 name: "biz",
8 value: "https://example.com/biz-xmlns",
9 },
10 ])
11 }
12 })
13
14 registerHook({
15 target: 'filter:feed.podcast.channel.create-custom-tags.result',
16 handler: (result, params) => {
17 const { videoChannel } = params
18 return result.concat([
19 {
20 name: "fooTag",
21 attributes: { "bar": "baz" },
22 value: "42",
23 },
24 {
25 name: "biz:videoChannel",
26 attributes: { "name": videoChannel.name, "id": videoChannel.id },
27 },
28 {
29 name: "biz:buzzItem",
30 value: [
31 {
32 name: "nestedTag",
33 value: "example nested tag",
34 },
35 ],
36 },
37 ])
38 }
39 })
40
41 registerHook({
42 target: 'filter:feed.podcast.video.create-custom-tags.result',
43 handler: (result, params) => {
44 const { video, liveItem } = params
45 return result.concat([
46 {
47 name: "fizzTag",
48 attributes: { "bar": "baz" },
49 value: "21",
50 },
51 {
52 name: "biz:video",
53 attributes: { "name": video.name, "id": video.id, "isLive": liveItem },
54 },
55 {
56 name: "biz:buzz",
57 value: [
58 {
59 name: "nestedTag",
60 value: "example nested tag",
61 },
62 ],
63 }
64 ])
65 }
66 })
67}
68
69async function unregister () {
70 return
71}
72
73module.exports = {
74 register,
75 unregister
76}
77
78// ############################################################################
79
80function addToCount (obj) {
81 return Object.assign({}, obj, { count: obj.count + 1 })
82}
diff --git a/server/tests/fixtures/peertube-plugin-test-podcast-custom-tags/package.json b/server/tests/fixtures/peertube-plugin-test-podcast-custom-tags/package.json
new file mode 100644
index 000000000..0f5a05a79
--- /dev/null
+++ b/server/tests/fixtures/peertube-plugin-test-podcast-custom-tags/package.json
@@ -0,0 +1,19 @@
1{
2 "name": "peertube-plugin-test-podcast-custom-tags",
3 "version": "0.0.1",
4 "description": "Plugin test custom tags in Podcast RSS feeds",
5 "engine": {
6 "peertube": ">=1.3.0"
7 },
8 "keywords": [
9 "peertube",
10 "plugin"
11 ],
12 "homepage": "https://github.com/Chocobozzz/PeerTube",
13 "author": "Chocobozzz",
14 "bugs": "https://github.com/Chocobozzz/PeerTube/issues",
15 "library": "./main.js",
16 "staticDirs": {},
17 "css": [],
18 "clientScripts": []
19}
diff --git a/server/tests/fixtures/peertube-plugin-test/main.js b/server/tests/fixtures/peertube-plugin-test/main.js
index 36dd08d27..17032f6d9 100644
--- a/server/tests/fixtures/peertube-plugin-test/main.js
+++ b/server/tests/fixtures/peertube-plugin-test/main.js
@@ -14,6 +14,7 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
14 'action:api.video-channel.deleted', 14 'action:api.video-channel.deleted',
15 15
16 'action:api.live-video.created', 16 'action:api.live-video.created',
17 'action:live.video.state.updated',
17 18
18 'action:api.video-thread.created', 19 'action:api.video-thread.created',
19 'action:api.video-comment-reply.created', 20 'action:api.video-comment-reply.created',
diff --git a/server/tests/plugins/action-hooks.ts b/server/tests/plugins/action-hooks.ts
index e8d03ee0f..34b4e1891 100644
--- a/server/tests/plugins/action-hooks.ts
+++ b/server/tests/plugins/action-hooks.ts
@@ -9,7 +9,9 @@ import {
9 PeerTubeServer, 9 PeerTubeServer,
10 PluginsCommand, 10 PluginsCommand,
11 setAccessTokensToServers, 11 setAccessTokensToServers,
12 setDefaultVideoChannel 12 setDefaultVideoChannel,
13 stopFfmpeg,
14 waitJobs
13} from '@shared/server-commands' 15} from '@shared/server-commands'
14 16
15describe('Test plugin action hooks', function () { 17describe('Test plugin action hooks', function () {
@@ -17,8 +19,8 @@ describe('Test plugin action hooks', function () {
17 let videoUUID: string 19 let videoUUID: string
18 let threadId: number 20 let threadId: number
19 21
20 function checkHook (hook: ServerHookName, strictCount = true) { 22 function checkHook (hook: ServerHookName, strictCount = true, count = 1) {
21 return servers[0].servers.waitUntilLog('Run hook ' + hook, 1, strictCount) 23 return servers[0].servers.waitUntilLog('Run hook ' + hook, count, strictCount)
22 } 24 }
23 25
24 before(async function () { 26 before(async function () {
@@ -115,6 +117,29 @@ describe('Test plugin action hooks', function () {
115 117
116 await checkHook('action:api.live-video.created') 118 await checkHook('action:api.live-video.created')
117 }) 119 })
120
121 it('Should run action:live.video.state.updated', async function () {
122 this.timeout(60000)
123
124 const attributes = {
125 name: 'live',
126 privacy: VideoPrivacy.PUBLIC,
127 channelId: servers[0].store.channel.id
128 }
129
130 const { uuid: liveVideoId } = await servers[0].live.create({ fields: attributes })
131 const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoId })
132 await servers[0].live.waitUntilPublished({ videoId: liveVideoId })
133 await waitJobs(servers)
134
135 await checkHook('action:live.video.state.updated', true, 1)
136
137 await stopFfmpeg(ffmpegCommand)
138 await servers[0].live.waitUntilEnded({ videoId: liveVideoId })
139 await waitJobs(servers)
140
141 await checkHook('action:live.video.state.updated', true, 2)
142 })
118 }) 143 })
119 144
120 describe('Comments hooks', function () { 145 describe('Comments hooks', function () {
diff --git a/server/types/express.d.ts b/server/types/express.d.ts
index a8aeabb3a..510b9f94e 100644
--- a/server/types/express.d.ts
+++ b/server/types/express.d.ts
@@ -110,6 +110,8 @@ declare module 'express' {
110 locals: { 110 locals: {
111 requestStart: number 111 requestStart: number
112 112
113 apicacheGroups: string[]
114
113 apicache: { 115 apicache: {
114 content: string | Buffer 116 content: string | Buffer
115 write: Writable['write'] 117 write: Writable['write']
diff --git a/server/types/models/account/account.ts b/server/types/models/account/account.ts
index 282a2971b..d10b904ab 100644
--- a/server/types/models/account/account.ts
+++ b/server/types/models/account/account.ts
@@ -8,8 +8,8 @@ import {
8 MActorDefault, 8 MActorDefault,
9 MActorDefaultLight, 9 MActorDefaultLight,
10 MActorFormattable, 10 MActorFormattable,
11 MActorHost,
11 MActorId, 12 MActorId,
12 MActorServer,
13 MActorSummary, 13 MActorSummary,
14 MActorSummaryFormattable, 14 MActorSummaryFormattable,
15 MActorUrl 15 MActorUrl
@@ -68,10 +68,9 @@ export type MAccountActor =
68 MAccount & 68 MAccount &
69 Use<'Actor', MActor> 69 Use<'Actor', MActor>
70 70
71// Full actor with server 71export type MAccountHost =
72export type MAccountServer =
73 MAccount & 72 MAccount &
74 Use<'Actor', MActorServer> 73 Use<'Actor', MActorHost>
75 74
76// ############################################################################ 75// ############################################################################
77 76
diff --git a/server/types/models/actor/actor-follow.ts b/server/types/models/actor/actor-follow.ts
index 338158561..84042e228 100644
--- a/server/types/models/actor/actor-follow.ts
+++ b/server/types/models/actor/actor-follow.ts
@@ -7,7 +7,7 @@ import {
7 MActorDefaultAccountChannel, 7 MActorDefaultAccountChannel,
8 MActorDefaultChannelId, 8 MActorDefaultChannelId,
9 MActorFormattable, 9 MActorFormattable,
10 MActorHost, 10 MActorHostOnly,
11 MActorUsername 11 MActorUsername
12} from './actor' 12} from './actor'
13 13
@@ -21,7 +21,7 @@ export type MActorFollow = Omit<ActorFollowModel, 'ActorFollower' | 'ActorFollow
21 21
22export type MActorFollowFollowingHost = 22export type MActorFollowFollowingHost =
23 MActorFollow & 23 MActorFollow &
24 Use<'ActorFollowing', MActorUsername & MActorHost> 24 Use<'ActorFollowing', MActorUsername & MActorHostOnly>
25 25
26// ############################################################################ 26// ############################################################################
27 27
diff --git a/server/types/models/actor/actor.ts b/server/types/models/actor/actor.ts
index 280256bab..47e7b7091 100644
--- a/server/types/models/actor/actor.ts
+++ b/server/types/models/actor/actor.ts
@@ -29,7 +29,11 @@ export type MActorLight = Omit<MActor, 'privateKey' | 'privateKey'>
29 29
30// Some association attributes 30// Some association attributes
31 31
32export type MActorHost = Use<'Server', MServerHost> 32export type MActorHostOnly = Use<'Server', MServerHost>
33export type MActorHost =
34 MActorLight &
35 Use<'Server', MServerHost>
36
33export type MActorRedundancyAllowedOpt = PickWithOpt<ActorModel, 'Server', MServerRedundancyAllowed> 37export type MActorRedundancyAllowedOpt = PickWithOpt<ActorModel, 'Server', MServerRedundancyAllowed>
34 38
35export type MActorDefaultLight = 39export type MActorDefaultLight =
@@ -68,8 +72,8 @@ export type MActorChannel =
68 72
69export type MActorDefaultAccountChannel = MActorDefault & MActorAccount & MActorChannel 73export type MActorDefaultAccountChannel = MActorDefault & MActorAccount & MActorChannel
70 74
71export type MActorServer = 75export type MActorServerLight =
72 MActor & 76 MActorLight &
73 Use<'Server', MServer> 77 Use<'Server', MServer>
74 78
75// ############################################################################ 79// ############################################################################
diff --git a/server/types/models/video/video-channels.ts b/server/types/models/video/video-channels.ts
index af8c2ffe4..57e991494 100644
--- a/server/types/models/video/video-channels.ts
+++ b/server/types/models/video/video-channels.ts
@@ -21,6 +21,7 @@ import {
21 MActorDefaultLight, 21 MActorDefaultLight,
22 MActorFormattable, 22 MActorFormattable,
23 MActorHost, 23 MActorHost,
24 MActorHostOnly,
24 MActorLight, 25 MActorLight,
25 MActorSummary, 26 MActorSummary,
26 MActorSummaryFormattable, 27 MActorSummaryFormattable,
@@ -77,9 +78,13 @@ export type MChannelAccountLight =
77 Use<'Account', MAccountLight> 78 Use<'Account', MAccountLight>
78 79
79export type MChannelHost = 80export type MChannelHost =
80 MChannelId & 81 MChannel &
81 Use<'Actor', MActorHost> 82 Use<'Actor', MActorHost>
82 83
84export type MChannelHostOnly =
85 MChannelId &
86 Use<'Actor', MActorHostOnly>
87
83// ############################################################################ 88// ############################################################################
84 89
85// Account associations 90// Account associations
diff --git a/server/types/models/video/video.ts b/server/types/models/video/video.ts
index d1af53b92..58ae7baad 100644
--- a/server/types/models/video/video.ts
+++ b/server/types/models/video/video.ts
@@ -13,7 +13,7 @@ import {
13 MChannelAccountSummaryFormattable, 13 MChannelAccountSummaryFormattable,
14 MChannelActor, 14 MChannelActor,
15 MChannelFormattable, 15 MChannelFormattable,
16 MChannelHost, 16 MChannelHostOnly,
17 MChannelUserId 17 MChannelUserId
18} from './video-channels' 18} from './video-channels'
19import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file' 19import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file'
@@ -146,7 +146,7 @@ export type MVideoWithChannelActor =
146 146
147export type MVideoWithHost = 147export type MVideoWithHost =
148 MVideo & 148 MVideo &
149 Use<'VideoChannel', MChannelHost> 149 Use<'VideoChannel', MChannelHostOnly>
150 150
151export type MVideoFullLight = 151export type MVideoFullLight =
152 MVideo & 152 MVideo &
diff --git a/shared/models/plugins/server/server-hook.model.ts b/shared/models/plugins/server/server-hook.model.ts
index 4c9d86079..0ec62222d 100644
--- a/shared/models/plugins/server/server-hook.model.ts
+++ b/shared/models/plugins/server/server-hook.model.ts
@@ -122,7 +122,17 @@ export const serverFilterHookObject = {
122 122
123 // Filter the result of video JSON LD builder 123 // Filter the result of video JSON LD builder
124 // You may also need to use filter:activity-pub.activity.context.build.result to also update JSON LD context 124 // You may also need to use filter:activity-pub.activity.context.build.result to also update JSON LD context
125 'filter:activity-pub.video.json-ld.build.result': true 125 'filter:activity-pub.video.json-ld.build.result': true,
126
127 // Filter result to allow custom XMLNS definitions in podcast RSS feeds
128 // Peertube >= 5.2
129 'filter:feed.podcast.rss.create-custom-xmlns.result': true,
130
131 // Filter result to allow custom tags in podcast RSS feeds
132 // Peertube >= 5.2
133 'filter:feed.podcast.channel.create-custom-tags.result': true,
134 // Peertube >= 5.2
135 'filter:feed.podcast.video.create-custom-tags.result': true
126} 136}
127 137
128export type ServerFilterHookName = keyof typeof serverFilterHookObject 138export type ServerFilterHookName = keyof typeof serverFilterHookObject
@@ -154,6 +164,9 @@ export const serverActionHookObject = {
154 164
155 // Fired when a live video is created 165 // Fired when a live video is created
156 'action:api.live-video.created': true, 166 'action:api.live-video.created': true,
167 // Fired when a live video starts or ends
168 // Peertube >= 5.2
169 'action:live.video.state.updated': true,
157 170
158 // Fired when a thread is created 171 // Fired when a thread is created
159 'action:api.video-thread.created': true, 172 'action:api.video-thread.created': true,
diff --git a/shared/models/users/user-update-me.model.ts b/shared/models/users/user-update-me.model.ts
index e664e44b5..f3cceb5f2 100644
--- a/shared/models/users/user-update-me.model.ts
+++ b/shared/models/users/user-update-me.model.ts
@@ -16,6 +16,7 @@ export interface UserUpdateMe {
16 videoLanguages?: string[] 16 videoLanguages?: string[]
17 17
18 email?: string 18 email?: string
19 emailPublic?: boolean
19 currentPassword?: string 20 currentPassword?: string
20 password?: string 21 password?: string
21 22
diff --git a/shared/models/users/user.model.ts b/shared/models/users/user.model.ts
index 761a2edba..0761c1e32 100644
--- a/shared/models/users/user.model.ts
+++ b/shared/models/users/user.model.ts
@@ -13,6 +13,7 @@ export interface User {
13 pendingEmail: string | null 13 pendingEmail: string | null
14 14
15 emailVerified: boolean 15 emailVerified: boolean
16 emailPublic: boolean
16 nsfwPolicy: NSFWPolicyType 17 nsfwPolicy: NSFWPolicyType
17 18
18 adminFlags?: UserAdminFlag 19 adminFlags?: UserAdminFlag
diff --git a/shared/models/videos/video-include.enum.ts b/shared/models/videos/video-include.enum.ts
index 7e16b129a..32ee12e86 100644
--- a/shared/models/videos/video-include.enum.ts
+++ b/shared/models/videos/video-include.enum.ts
@@ -3,5 +3,6 @@ export const enum VideoInclude {
3 NOT_PUBLISHED_STATE = 1 << 0, 3 NOT_PUBLISHED_STATE = 1 << 0,
4 BLACKLISTED = 1 << 1, 4 BLACKLISTED = 1 << 1,
5 BLOCKED_OWNER = 1 << 2, 5 BLOCKED_OWNER = 1 << 2,
6 FILES = 1 << 3 6 FILES = 1 << 3,
7 CAPTIONS = 1 << 4
7} 8}
diff --git a/shared/server-commands/feeds/feeds-command.ts b/shared/server-commands/feeds/feeds-command.ts
index 939b18dee..26763b43e 100644
--- a/shared/server-commands/feeds/feeds-command.ts
+++ b/shared/server-commands/feeds/feeds-command.ts
@@ -30,6 +30,29 @@ export class FeedCommand extends AbstractCommand {
30 }) 30 })
31 } 31 }
32 32
33 getPodcastXML (options: OverrideCommandOptions & {
34 ignoreCache: boolean
35 channelId: number
36 }) {
37 const { ignoreCache, channelId } = options
38 const path = `/feeds/podcast/videos.xml`
39
40 const query: { [id: string]: string } = {}
41
42 if (ignoreCache) query.v = buildUUID()
43 if (channelId) query.videoChannelId = channelId + ''
44
45 return this.getRequestText({
46 ...options,
47
48 path,
49 query,
50 accept: 'application/xml',
51 implicitToken: false,
52 defaultExpectedStatus: HttpStatusCode.OK_200
53 })
54 }
55
33 getJSON (options: OverrideCommandOptions & { 56 getJSON (options: OverrideCommandOptions & {
34 feed: FeedType 57 feed: FeedType
35 ignoreCache: boolean 58 ignoreCache: boolean
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index 4230fc827..cd50e86a6 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -433,7 +433,7 @@ paths:
433 get: 433 get:
434 tags: 434 tags:
435 - Video Feeds 435 - Video Feeds
436 summary: List comments on videos 436 summary: Comments on videos feeds
437 operationId: getSyndicatedComments 437 operationId: getSyndicatedComments
438 parameters: 438 parameters:
439 - name: format 439 - name: format
@@ -476,7 +476,7 @@ paths:
476 schema: 476 schema:
477 type: string 477 type: string
478 responses: 478 responses:
479 '204': 479 '200':
480 description: successful operation 480 description: successful operation
481 headers: 481 headers:
482 Cache-Control: 482 Cache-Control:
@@ -528,7 +528,7 @@ paths:
528 get: 528 get:
529 tags: 529 tags:
530 - Video Feeds 530 - Video Feeds
531 summary: List videos 531 summary: Common videos feeds
532 operationId: getSyndicatedVideos 532 operationId: getSyndicatedVideos
533 parameters: 533 parameters:
534 - name: format 534 - name: format
@@ -573,7 +573,7 @@ paths:
573 - $ref: '#/components/parameters/hasHLSFiles' 573 - $ref: '#/components/parameters/hasHLSFiles'
574 - $ref: '#/components/parameters/hasWebtorrentFiles' 574 - $ref: '#/components/parameters/hasWebtorrentFiles'
575 responses: 575 responses:
576 '204': 576 '200':
577 description: successful operation 577 description: successful operation
578 headers: 578 headers:
579 Cache-Control: 579 Cache-Control:
@@ -620,7 +620,7 @@ paths:
620 get: 620 get:
621 tags: 621 tags:
622 - Video Feeds 622 - Video Feeds
623 summary: List videos of subscriptions tied to a token 623 summary: Videos of subscriptions feeds
624 operationId: getSyndicatedSubscriptionVideos 624 operationId: getSyndicatedSubscriptionVideos
625 parameters: 625 parameters:
626 - name: format 626 - name: format
@@ -657,7 +657,7 @@ paths:
657 - $ref: '#/components/parameters/hasHLSFiles' 657 - $ref: '#/components/parameters/hasHLSFiles'
658 - $ref: '#/components/parameters/hasWebtorrentFiles' 658 - $ref: '#/components/parameters/hasWebtorrentFiles'
659 responses: 659 responses:
660 '204': 660 '200':
661 description: successful operation 661 description: successful operation
662 headers: 662 headers:
663 Cache-Control: 663 Cache-Control:
@@ -683,6 +683,30 @@ paths:
683 '406': 683 '406':
684 description: accept header unsupported 684 description: accept header unsupported
685 685
686 '/feeds/podcast/videos.xml':
687 get:
688 tags:
689 - Video Feeds
690 summary: Videos podcast feed
691 operationId: getVideosPodcastFeed
692 parameters:
693 - name: videoChannelId
694 in: query
695 description: 'Limit listing to a specific video channel'
696 required: true
697 schema:
698 type: string
699 responses:
700 '200':
701 description: successful operation
702 headers:
703 Cache-Control:
704 schema:
705 type: string
706 default: 'max-age=900' # 15 min cache
707 '404':
708 description: video channel not found
709
686 '/api/v1/accounts/{name}': 710 '/api/v1/accounts/{name}':
687 get: 711 get:
688 tags: 712 tags:
diff --git a/yarn.lock b/yarn.lock
index e2258ee4a..d9fbc5e0f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1836,10 +1836,10 @@
1836 resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.9.1.tgz#ad3367684a57879392513479e0a436cb2ac46dad" 1836 resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.9.1.tgz#ad3367684a57879392513479e0a436cb2ac46dad"
1837 integrity sha512-oPQdbFDmZvjXk5ZDoBGXG8B4tSB/qW5vQunJWQMFUBp7Xe8O1ByPANueJ+Jzg58esEBegyyxZ7LRmfJr7kFcFg== 1837 integrity sha512-oPQdbFDmZvjXk5ZDoBGXG8B4tSB/qW5vQunJWQMFUBp7Xe8O1ByPANueJ+Jzg58esEBegyyxZ7LRmfJr7kFcFg==
1838 1838
1839"@peertube/feed@^5.0.1": 1839"@peertube/feed@^5.1.0":
1840 version "5.0.2" 1840 version "5.1.0"
1841 resolved "https://registry.yarnpkg.com/@peertube/feed/-/feed-5.0.2.tgz#d9ae7f38f1ccc75d353a5e24ad335a982bc4df74" 1841 resolved "https://registry.yarnpkg.com/@peertube/feed/-/feed-5.1.0.tgz#e2fec950459ebaa32ea35791c45177f8b6fa85e9"
1842 integrity sha512-5c8NkeIDx6J8lOzYiaTGipich/7hTO+CzZjIHFb1SY3+c14BvNJxrFb8b/9aZ8tekIYxKspqb8hg7WcVYg4NXA== 1842 integrity sha512-ggwIbjxh4oc1aAGYV7ZxtIpiEIGq3Rkg6FxvOSrk/EPZ76rExoIJCjKeSyd4zb/sGkyKldy+bGs1OUUVidWWTQ==
1843 dependencies: 1843 dependencies:
1844 xml-js "^1.6.11" 1844 xml-js "^1.6.11"
1845 1845
@@ -6362,7 +6362,7 @@ lodash.merge@4.6.2, lodash.merge@^4.6.2:
6362 resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" 6362 resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
6363 integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== 6363 integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
6364 6364
6365lodash@4.17.21, lodash@>=4.17.13, lodash@^4.17.10, lodash@^4.17.14, lodash@^4.17.20, lodash@^4.17.21: 6365lodash@4.17.21, lodash@>=4.17.13, lodash@^4.17.14, lodash@^4.17.20, lodash@^4.17.21:
6366 version "4.17.21" 6366 version "4.17.21"
6367 resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" 6367 resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
6368 integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== 6368 integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==