diff options
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 @@ | |||
1 | import { forkJoin } from 'rxjs' | 1 | import { forkJoin } from 'rxjs' |
2 | import { tap } from 'rxjs/operators' | 2 | import { tap } from 'rxjs/operators' |
3 | import { Component, OnInit } from '@angular/core' | 3 | import { Component, OnInit } from '@angular/core' |
4 | import { AuthService, ServerService, UserService } from '@app/core' | 4 | import { AuthService, Notifier, ServerService, UserService } from '@app/core' |
5 | import { USER_EMAIL_VALIDATOR, USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators' | 5 | import { USER_EMAIL_VALIDATOR, USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators' |
6 | import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' | 6 | import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' |
7 | import { HttpStatusCode, User } from '@shared/models' | 7 | import { 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 @@ | |||
1 | import { Subject } from 'rxjs' | ||
2 | import { Component, Input, OnInit } from '@angular/core' | ||
3 | import { Notifier, UserService } from '@app/core' | ||
4 | import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' | ||
5 | import { 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 | }) | ||
12 | export 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' | |||
22 | import { MyAccountChangeEmailComponent } from './my-account-settings/my-account-change-email' | 22 | import { MyAccountChangeEmailComponent } from './my-account-settings/my-account-change-email' |
23 | import { MyAccountChangePasswordComponent } from './my-account-settings/my-account-change-password/my-account-change-password.component' | 23 | import { MyAccountChangePasswordComponent } from './my-account-settings/my-account-change-password/my-account-change-password.component' |
24 | import { MyAccountDangerZoneComponent } from './my-account-settings/my-account-danger-zone' | 24 | import { MyAccountDangerZoneComponent } from './my-account-settings/my-account-danger-zone' |
25 | import { MyAccountEmailPreferencesComponent } from './my-account-settings/my-account-email-preferences' | ||
25 | import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences' | 26 | import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences' |
26 | import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component' | 27 | import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component' |
27 | import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' | 28 | import { 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 = { | |||
54 | export class VideoService { | 54 | export 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' | |||
2 | import { Transaction } from 'sequelize/types' | 2 | import { Transaction } from 'sequelize/types' |
3 | import { changeVideoChannelShare } from '@server/lib/activitypub/share' | 3 | import { changeVideoChannelShare } from '@server/lib/activitypub/share' |
4 | import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' | 4 | import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' |
5 | import { VideoPathManager } from '@server/lib/video-path-manager' | ||
5 | import { setVideoPrivacy } from '@server/lib/video-privacy' | 6 | import { setVideoPrivacy } from '@server/lib/video-privacy' |
6 | import { openapiOperationDoc } from '@server/middlewares/doc' | 7 | import { openapiOperationDoc } from '@server/middlewares/doc' |
7 | import { FilteredModelAttributes } from '@server/types' | 8 | import { FilteredModelAttributes } from '@server/types' |
8 | import { MVideoFullLight } from '@server/types/models' | 9 | import { MVideoFullLight } from '@server/types/models' |
10 | import { forceNumber } from '@shared/core-utils' | ||
9 | import { HttpStatusCode, VideoUpdate } from '@shared/models' | 11 | import { HttpStatusCode, VideoUpdate } from '@shared/models' |
10 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' | 12 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' |
11 | import { resetSequelizeInstance } from '../../../helpers/database-utils' | 13 | import { resetSequelizeInstance } from '../../../helpers/database-utils' |
@@ -18,8 +20,6 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' | |||
18 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares' | 20 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares' |
19 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' | 21 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' |
20 | import { VideoModel } from '../../../models/video/video' | 22 | import { VideoModel } from '../../../models/video/video' |
21 | import { VideoPathManager } from '@server/lib/video-path-manager' | ||
22 | import { forceNumber } from '@shared/core-utils' | ||
23 | 23 | ||
24 | const lTags = loggerTagsFactory('api', 'video') | 24 | const lTags = loggerTagsFactory('api', 'video') |
25 | const auditLogger = auditLoggerFactory('videos') | 25 | const 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 @@ | |||
1 | import express from 'express' | ||
2 | import { extname } from 'path' | ||
3 | import { Feed } from '@peertube/feed' | ||
4 | import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown' | ||
5 | import { getServerActor } from '@server/models/application/application' | ||
6 | import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils' | ||
7 | import { MAccountDefault, MChannelBannerAccountDefault, MVideoFullLight } from '@server/types/models' | ||
8 | import { ActorImageType, VideoInclude } from '@shared/models' | ||
9 | import { buildNSFWFilter } from '../helpers/express-utils' | ||
10 | import { CONFIG } from '../initializers/config' | ||
11 | import { MIMETYPES, PREVIEWS_SIZE, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../initializers/constants' | ||
12 | import { | ||
13 | asyncMiddleware, | ||
14 | commonVideosFiltersValidator, | ||
15 | feedsFormatValidator, | ||
16 | setDefaultVideosSort, | ||
17 | setFeedFormatContentType, | ||
18 | videoCommentsFeedsValidator, | ||
19 | videoFeedsValidator, | ||
20 | videosSortValidator, | ||
21 | videoSubscriptionFeedsValidator | ||
22 | } from '../middlewares' | ||
23 | import { cacheRouteFactory } from '../middlewares/cache/cache' | ||
24 | import { VideoModel } from '../models/video/video' | ||
25 | import { VideoCommentModel } from '../models/video/video-comment' | ||
26 | |||
27 | const feedsRouter = express.Router() | ||
28 | |||
29 | const cacheRoute = cacheRouteFactory({ | ||
30 | headerBlacklist: [ 'Content-Type' ] | ||
31 | }) | ||
32 | |||
33 | feedsRouter.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 | |||
42 | feedsRouter.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 | |||
53 | feedsRouter.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 | |||
66 | export { | ||
67 | feedsRouter | ||
68 | } | ||
69 | |||
70 | // --------------------------------------------------------------------------- | ||
71 | |||
72 | async 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 | |||
125 | async 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 | |||
169 | async 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 | |||
210 | function 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 | |||
244 | function 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 | |||
332 | function 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 | |||
355 | function 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 @@ | |||
1 | import express from 'express' | ||
2 | import { toSafeHtml } from '@server/helpers/markdown' | ||
3 | import { cacheRouteFactory } from '@server/middlewares' | ||
4 | import { CONFIG } from '../../initializers/config' | ||
5 | import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants' | ||
6 | import { | ||
7 | asyncMiddleware, | ||
8 | feedsFormatValidator, | ||
9 | setFeedFormatContentType, | ||
10 | videoCommentsFeedsValidator, | ||
11 | videoFeedsValidator | ||
12 | } from '../../middlewares' | ||
13 | import { VideoCommentModel } from '../../models/video/video-comment' | ||
14 | import { buildFeedMetadata, initFeed, sendFeed } from './shared' | ||
15 | |||
16 | const commentFeedsRouter = express.Router() | ||
17 | |||
18 | // --------------------------------------------------------------------------- | ||
19 | |||
20 | const { middleware: cacheRouteMiddleware } = cacheRouteFactory({ | ||
21 | headerBlacklist: [ 'Content-Type' ] | ||
22 | }) | ||
23 | |||
24 | // --------------------------------------------------------------------------- | ||
25 | |||
26 | commentFeedsRouter.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 | |||
37 | export { | ||
38 | commentFeedsRouter | ||
39 | } | ||
40 | |||
41 | // --------------------------------------------------------------------------- | ||
42 | |||
43 | async 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 @@ | |||
1 | import express from 'express' | ||
2 | import { commentFeedsRouter } from './comment-feeds' | ||
3 | import { videoFeedsRouter } from './video-feeds' | ||
4 | import { videoPodcastFeedsRouter } from './video-podcast-feeds' | ||
5 | |||
6 | const feedsRouter = express.Router() | ||
7 | |||
8 | feedsRouter.use('/', commentFeedsRouter) | ||
9 | feedsRouter.use('/', videoFeedsRouter) | ||
10 | feedsRouter.use('/', videoPodcastFeedsRouter) | ||
11 | |||
12 | // --------------------------------------------------------------------------- | ||
13 | |||
14 | export { | ||
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 @@ | |||
1 | import express from 'express' | ||
2 | import { Feed } from '@peertube/feed' | ||
3 | import { CustomTag, CustomXMLNS, Person } from '@peertube/feed/lib/typings' | ||
4 | import { mdToOneLinePlainText } from '@server/helpers/markdown' | ||
5 | import { CONFIG } from '@server/initializers/config' | ||
6 | import { WEBSERVER } from '@server/initializers/constants' | ||
7 | import { UserModel } from '@server/models/user/user' | ||
8 | import { MAccountDefault, MChannelBannerAccountDefault, MUser, MVideoFullLight } from '@server/types/models' | ||
9 | import { pick } from '@shared/core-utils' | ||
10 | import { ActorImageType } from '@shared/models' | ||
11 | |||
12 | export 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 | |||
60 | export 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 | |||
83 | export 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 @@ | |||
1 | export * from './video-feed-utils' | ||
2 | export * 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 @@ | |||
1 | import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown' | ||
2 | import { CONFIG } from '@server/initializers/config' | ||
3 | import { WEBSERVER } from '@server/initializers/constants' | ||
4 | import { getServerActor } from '@server/models/application/application' | ||
5 | import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils' | ||
6 | import { DisplayOnlyForFollowerOptions } from '@server/models/video/sql/video' | ||
7 | import { VideoModel } from '@server/models/video/video' | ||
8 | import { MThumbnail, MUserDefault } from '@server/types/models' | ||
9 | import { VideoInclude } from '@shared/models' | ||
10 | |||
11 | export 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 | |||
40 | export 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 @@ | |||
1 | import express from 'express' | ||
2 | import { extname } from 'path' | ||
3 | import { Feed } from '@peertube/feed' | ||
4 | import { cacheRouteFactory } from '@server/middlewares' | ||
5 | import { VideoModel } from '@server/models/video/video' | ||
6 | import { VideoInclude } from '@shared/models' | ||
7 | import { buildNSFWFilter } from '../../helpers/express-utils' | ||
8 | import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants' | ||
9 | import { | ||
10 | asyncMiddleware, | ||
11 | commonVideosFiltersValidator, | ||
12 | feedsFormatValidator, | ||
13 | setDefaultVideosSort, | ||
14 | setFeedFormatContentType, | ||
15 | videoFeedsValidator, | ||
16 | videosSortValidator, | ||
17 | videoSubscriptionFeedsValidator | ||
18 | } from '../../middlewares' | ||
19 | import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed, sendFeed } from './shared' | ||
20 | |||
21 | const videoFeedsRouter = express.Router() | ||
22 | |||
23 | const { middleware: cacheRouteMiddleware } = cacheRouteFactory({ | ||
24 | headerBlacklist: [ 'Content-Type' ] | ||
25 | }) | ||
26 | |||
27 | // --------------------------------------------------------------------------- | ||
28 | |||
29 | videoFeedsRouter.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 | |||
40 | videoFeedsRouter.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 | |||
53 | export { | ||
54 | videoFeedsRouter | ||
55 | } | ||
56 | |||
57 | // --------------------------------------------------------------------------- | ||
58 | |||
59 | async 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 | |||
91 | async 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 | |||
125 | function 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 @@ | |||
1 | import express from 'express' | ||
2 | import { extname } from 'path' | ||
3 | import { Feed } from '@peertube/feed' | ||
4 | import { CustomTag, CustomXMLNS, LiveItemStatus } from '@peertube/feed/lib/typings' | ||
5 | import { InternalEventEmitter } from '@server/lib/internal-event-emitter' | ||
6 | import { Hooks } from '@server/lib/plugins/hooks' | ||
7 | import { buildPodcastGroupsCache, cacheRouteFactory, videoFeedsPodcastSetCacheKey } from '@server/middlewares' | ||
8 | import { MVideo, MVideoCaptionVideo, MVideoFullLight } from '@server/types/models' | ||
9 | import { sortObjectComparator } from '@shared/core-utils' | ||
10 | import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@shared/models' | ||
11 | import { buildNSFWFilter } from '../../helpers/express-utils' | ||
12 | import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants' | ||
13 | import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares' | ||
14 | import { VideoModel } from '../../models/video/video' | ||
15 | import { VideoCaptionModel } from '../../models/video/video-caption' | ||
16 | import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed } from './shared' | ||
17 | |||
18 | const videoPodcastFeedsRouter = express.Router() | ||
19 | |||
20 | // --------------------------------------------------------------------------- | ||
21 | |||
22 | const { middleware: podcastCacheRouteMiddleware, instance: podcastApiCache } = cacheRouteFactory({ | ||
23 | headerBlacklist: [ 'Content-Type' ] | ||
24 | }) | ||
25 | |||
26 | for (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 | |||
34 | for (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 | |||
42 | videoPodcastFeedsRouter.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 | |||
52 | export { | ||
53 | videoPodcastFeedsRouter | ||
54 | } | ||
55 | |||
56 | // --------------------------------------------------------------------------- | ||
57 | |||
58 | async 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 | |||
109 | type 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 | |||
124 | async 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 | |||
174 | async 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 | |||
186 | async 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 | |||
209 | async 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 | |||
233 | function 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 | |||
258 | function 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 | |||
274 | function 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 | |||
289 | function 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 | ||
83 | function isUserEmailPublicValid (value: any) { | ||
84 | return isBooleanValid(value) | ||
85 | } | ||
86 | |||
83 | function isUserNoModal (value: any) { | 87 | function 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 | ||
30 | const LAST_MIGRATION_VERSION = 770 | 30 | const 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 | } |
650 | MIMETYPES.AUDIO.EXT_MIMETYPE = invert(MIMETYPES.AUDIO.MIMETYPE_EXT) | 651 | MIMETYPES.AUDIO.EXT_MIMETYPE = invert(MIMETYPES.AUDIO.MIMETYPE_EXT) |
651 | MIMETYPES.IMAGE.EXT_MIMETYPE = invert(MIMETYPES.IMAGE.MIMETYPE_EXT) | 652 | MIMETYPES.IMAGE.EXT_MIMETYPE = invert(MIMETYPES.IMAGE.MIMETYPE_EXT) |
653 | MIMETYPES.VIDEO_CAPTIONS.EXT_MIMETYPE = invert(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT) | ||
652 | 654 | ||
653 | const BINARY_CONTENT_TYPES = new Set([ | 655 | const 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 @@ | |||
1 | import * as Sequelize from 'sequelize' | ||
2 | |||
3 | async 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 | |||
18 | function down (options) { | ||
19 | throw new Error('Not implemented.') | ||
20 | } | ||
21 | |||
22 | export { | ||
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 @@ | |||
1 | import { sequelizeTypescript } from '@server/initializers/database' | 1 | import { sequelizeTypescript } from '@server/initializers/database' |
2 | import { getServerActor } from '@server/models/application/application' | 2 | import { getServerActor } from '@server/models/application/application' |
3 | import { MAccountBlocklist, MAccountId, MAccountServer, MServerBlocklist } from '@server/types/models' | 3 | import { MAccountBlocklist, MAccountId, MAccountHost, MServerBlocklist } from '@server/types/models' |
4 | import { AccountBlocklistModel } from '../models/account/account-blocklist' | 4 | import { AccountBlocklistModel } from '../models/account/account-blocklist' |
5 | import { ServerBlocklistModel } from '../models/server/server-blocklist' | 5 | import { ServerBlocklistModel } from '../models/server/server-blocklist' |
6 | 6 | ||
@@ -34,7 +34,7 @@ function removeServerFromBlocklist (serverBlock: MServerBlocklist) { | |||
34 | }) | 34 | }) |
35 | } | 35 | } |
36 | 36 | ||
37 | async function isBlockedByServerOrAccount (targetAccount: MAccountServer, userAccount?: MAccountId) { | 37 | async 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' | |||
27 | import { VideoModel } from '../models/video/video' | 27 | import { VideoModel } from '../models/video/video' |
28 | import { VideoChannelModel } from '../models/video/video-channel' | 28 | import { VideoChannelModel } from '../models/video/video-channel' |
29 | import { VideoPlaylistModel } from '../models/video/video-playlist' | 29 | import { VideoPlaylistModel } from '../models/video/video-playlist' |
30 | import { MAccountActor, MChannelActor, MVideo, MVideoPlaylist } from '../types/models' | 30 | import { MAccountHost, MChannelHost, MVideo, MVideoPlaylist } from '../types/models' |
31 | import { getActivityStreamDuration } from './activitypub/activity' | 31 | import { getActivityStreamDuration } from './activitypub/activity' |
32 | import { getBiggestActorImage } from './actor-image' | 32 | import { getBiggestActorImage } from './actor-image' |
33 | import { Hooks } from './plugins/hooks' | 33 | import { 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 @@ | |||
1 | import { MChannel, MVideo } from '@server/types/models' | ||
2 | import { EventEmitter } from 'events' | ||
3 | |||
4 | export 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 | |||
14 | declare 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 | |||
24 | class 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 | |||
33 | export { | ||
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) { | |||
17 | function cacheRouteFactory (options: APICacheOptions) { | 17 | function 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 | |||
25 | function buildPodcastGroupsCache (options: { | ||
26 | channelId: number | ||
27 | }) { | ||
28 | return 'podcast-feed-' + options.channelId | ||
21 | } | 29 | } |
22 | 30 | ||
23 | // --------------------------------------------------------------------------- | 31 | // --------------------------------------------------------------------------- |
24 | 32 | ||
25 | export { | 33 | export { |
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' | |||
3 | import { HttpStatusCode } from '../../../shared/models/http/http-error-codes' | 3 | import { HttpStatusCode } from '../../../shared/models/http/http-error-codes' |
4 | import { isValidRSSFeed } from '../../helpers/custom-validators/feeds' | 4 | import { isValidRSSFeed } from '../../helpers/custom-validators/feeds' |
5 | import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc' | 5 | import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc' |
6 | import { buildPodcastGroupsCache } from '../cache' | ||
6 | import { | 7 | import { |
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 | |||
50 | function 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 | |||
56 | function 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 | |||
58 | const videoFeedsValidator = [ | 76 | const 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 | |||
105 | const 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 | |||
117 | const 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 | |||
85 | const videoSubscriptionFeedsValidator = [ | 128 | const videoSubscriptionFeedsValidator = [ |
86 | query('accountId') | 129 | query('accountId') |
87 | .custom(isIdValid), | 130 | .custom(isIdValid), |
@@ -126,7 +169,10 @@ const videoCommentsFeedsValidator = [ | |||
126 | export { | 169 | export { |
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' |
34 | import { ActorModel } from '../actor/actor' | 35 | import { ActorModel } from '../actor/actor' |
35 | import { ActorFollowModel } from '../actor/actor-follow' | 36 | import { 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 @@ | |||
1 | import { remove } from 'fs-extra' | 1 | import { remove } from 'fs-extra' |
2 | import { join } from 'path' | 2 | import { join } from 'path' |
3 | import { OrderItem, Transaction } from 'sequelize' | 3 | import { Op, OrderItem, Transaction } from 'sequelize' |
4 | import { | 4 | import { |
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 @@ | |||
1 | import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction, WhereOptions } from 'sequelize' | 1 | import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction, WhereOptions } from 'sequelize' |
2 | import { | 2 | import { |
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' |
20 | import { CONFIG } from '@server/initializers/config' | 23 | import { CONFIG } from '@server/initializers/config' |
21 | import { MAccountActor } from '@server/types/models' | 24 | import { InternalEventEmitter } from '@server/lib/internal-event-emitter' |
25 | import { MAccountHost } from '@server/types/models' | ||
22 | import { forceNumber, pick } from '@shared/core-utils' | 26 | import { forceNumber, pick } from '@shared/core-utils' |
23 | import { AttributesOnly } from '@shared/typescript-utils' | 27 | import { AttributesOnly } from '@shared/typescript-utils' |
24 | import { ActivityPubActor } from '../../../shared/models/activitypub' | 28 | import { 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' |
41 | import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account' | 46 | import { 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 @@ | |||
1 | import Bluebird from 'bluebird' | 1 | import Bluebird from 'bluebird' |
2 | import { remove } from 'fs-extra' | 2 | import { remove } from 'fs-extra' |
3 | import { maxBy, minBy } from 'lodash' | 3 | import { maxBy, minBy } from 'lodash' |
4 | import { join } from 'path' | ||
5 | import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' | 4 | import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' |
6 | import { | 5 | import { |
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' |
27 | import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video' | 29 | import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video' |
30 | import { InternalEventEmitter } from '@server/lib/internal-event-emitter' | ||
28 | import { LiveManager } from '@server/lib/live/live-manager' | 31 | import { LiveManager } from '@server/lib/live/live-manager' |
29 | import { removeHLSFileObjectStorageByFilename, removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage' | 32 | import { removeHLSFileObjectStorageByFilename, removeHLSObjectStorage, removeWebTorrentObjectStorage } from '@server/lib/object-storage' |
30 | import { tracer } from '@server/lib/opentelemetry/tracing' | 33 | import { tracer } from '@server/lib/opentelemetry/tracing' |
@@ -66,7 +69,7 @@ import { | |||
66 | } from '../../helpers/custom-validators/videos' | 69 | } from '../../helpers/custom-validators/videos' |
67 | import { logger } from '../../helpers/logger' | 70 | import { logger } from '../../helpers/logger' |
68 | import { CONFIG } from '../../initializers/config' | 71 | import { CONFIG } from '../../initializers/config' |
69 | import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants' | 72 | import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' |
70 | import { sendDeleteVideo } from '../../lib/activitypub/send' | 73 | import { sendDeleteVideo } from '../../lib/activitypub/send' |
71 | import { | 74 | import { |
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 | |||
26 | describe('Test syndication feeds', () => { | 27 | describe('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 @@ | |||
1 | async 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 | |||
69 | async function unregister () { | ||
70 | return | ||
71 | } | ||
72 | |||
73 | module.exports = { | ||
74 | register, | ||
75 | unregister | ||
76 | } | ||
77 | |||
78 | // ############################################################################ | ||
79 | |||
80 | function 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 | ||
15 | describe('Test plugin action hooks', function () { | 17 | describe('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 | 71 | export type MAccountHost = |
72 | export 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 | ||
22 | export type MActorFollowFollowingHost = | 22 | export 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 | ||
32 | export type MActorHost = Use<'Server', MServerHost> | 32 | export type MActorHostOnly = Use<'Server', MServerHost> |
33 | export type MActorHost = | ||
34 | MActorLight & | ||
35 | Use<'Server', MServerHost> | ||
36 | |||
33 | export type MActorRedundancyAllowedOpt = PickWithOpt<ActorModel, 'Server', MServerRedundancyAllowed> | 37 | export type MActorRedundancyAllowedOpt = PickWithOpt<ActorModel, 'Server', MServerRedundancyAllowed> |
34 | 38 | ||
35 | export type MActorDefaultLight = | 39 | export type MActorDefaultLight = |
@@ -68,8 +72,8 @@ export type MActorChannel = | |||
68 | 72 | ||
69 | export type MActorDefaultAccountChannel = MActorDefault & MActorAccount & MActorChannel | 73 | export type MActorDefaultAccountChannel = MActorDefault & MActorAccount & MActorChannel |
70 | 74 | ||
71 | export type MActorServer = | 75 | export 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 | ||
79 | export type MChannelHost = | 80 | export type MChannelHost = |
80 | MChannelId & | 81 | MChannel & |
81 | Use<'Actor', MActorHost> | 82 | Use<'Actor', MActorHost> |
82 | 83 | ||
84 | export 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' |
19 | import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file' | 19 | import { MVideoFile, MVideoFileRedundanciesAll, MVideoFileRedundanciesOpt } from './video-file' |
@@ -146,7 +146,7 @@ export type MVideoWithChannelActor = | |||
146 | 146 | ||
147 | export type MVideoWithHost = | 147 | export type MVideoWithHost = |
148 | MVideo & | 148 | MVideo & |
149 | Use<'VideoChannel', MChannelHost> | 149 | Use<'VideoChannel', MChannelHostOnly> |
150 | 150 | ||
151 | export type MVideoFullLight = | 151 | export 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 | ||
128 | export type ServerFilterHookName = keyof typeof serverFilterHookObject | 138 | export 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: |
@@ -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 | ||
6365 | lodash@4.17.21, lodash@>=4.17.13, lodash@^4.17.10, lodash@^4.17.14, lodash@^4.17.20, lodash@^4.17.21: | 6365 | lodash@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== |