* Initial test implementation of Podcast RSS This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option. I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort. * Update to pfeed-podcast 1.2.2 * Initial test implementation of Podcast RSS This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option. I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort. * Update to pfeed-podcast 1.2.2 * Initial test implementation of Podcast RSS This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option. I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort. * Update to pfeed-podcast 1.2.2 * Add correct feed image to RSS channel * Prefer HLS videos for podcast RSS Remove video/stream titles, add optional height attribute to podcast RSS * Prefix podcast RSS images with root server URL * Add optional video query support to include captions * Add transcripts & person images to podcast RSS feed * Prefer webseed/webtorrent files over HLS fragmented mp4s * Experimentally adding podcast fields to basic config page * Add validation for new basic config fields * Don't include "content" in podcast feed, use full description for "description" * Initial test implementation of Podcast RSS This is a pretty simple implementation to add support for The Podcast Namespace in RSS -- instead of affecting the existing RSS implementation, this adds a new UI option. I attempted to retain compatibility with the rest of the RSS feed implementation as much as possible and have created a temporary fork of the "pfeed" library to support this effort. * Update to pfeed-podcast 1.2.2 * Add correct feed image to RSS channel * Prefer HLS videos for podcast RSS Remove video/stream titles, add optional height attribute to podcast RSS * Prefix podcast RSS images with root server URL * Add optional video query support to include captions * Add transcripts & person images to podcast RSS feed * Prefer webseed/webtorrent files over HLS fragmented mp4s * Experimentally adding podcast fields to basic config page * Add validation for new basic config fields * Don't include "content" in podcast feed, use full description for "description" * Add medium/socialInteract to podcast RSS feeds. Use HTML for description * Change base production image to bullseye, install prosody in image * Add liveItem and trackers to Podcast RSS feeds Remove height from alternateEnclosure, replaced with title. * Clear Podcast RSS feed cache when live streams start/end * Upgrade to Node 16 * Refactor clearCacheRoute to use ApiCache * Remove unnecessary type hint * Update dockerfile to node 16, install python-is-python2 * Use new file paths for captions/playlists * Fix legacy videos in RSS after migration to object storage * Improve method of identifying non-fragmented mp4s in podcast RSS feeds * Don't include fragmented MP4s in podcast RSS feeds * Add experimental support for podcast:categories on the podcast RSS item * Fix undefined category when no videos exist Allows for empty feeds to exist (important for feeds that might only go live) * Add support for podcast:locked -- user has to opt in to show their email * Use comma for podcast:categories delimiter * Make cache clearing async * Fix merge, temporarily test with pfeed-podcast * Syntax changes * Add EXT_MIMETYPE constants for captions * Update & fix tests, fix enclosure mimetypes, remove admin email * Add test for podacst:socialInteract * Add filters hooks for podcast customTags * Remove showdown, updated to pfeed-podcast 6.1.2 * Add 'action:api.live-video.state.updated' hook * Avoid assigning undefined category to podcast feeds * Remove nvmrc * Remove comment * Remove unused podcast config * Remove more unused podcast config * Fix MChannelAccountDefault type hint missed in merge * Remove extra line * Re-add newline in config * Fix lint errors for isEmailPublic * Fix thumbnails in podcast feeds * Requested changes based on review * Provide podcast rss 2.0 only on video channels * Misc cleanup for a less messy PR * Lint fixes * Remove pfeed-podcast * Add peertube version to new hooks * Don't use query include, remove TODO * Remove film medium hack * Clear podcast rss cache before video/channel update hooks * Clear podcast rss cache before video uploaded/deleted hooks * Refactor podcast feed cache clearing * Set correct person name from video channel * Styling * Fix tests --------- Co-authored-by: Chocobozzz <me@florianbigard.com>pull/5340/merge
@@ -1,7 +1,7 @@ | |||
import { forkJoin } from 'rxjs' | |||
import { tap } from 'rxjs/operators' | |||
import { Component, OnInit } from '@angular/core' | |||
import { AuthService, ServerService, UserService } from '@app/core' | |||
import { AuthService, Notifier, ServerService, UserService } from '@app/core' | |||
import { USER_EMAIL_VALIDATOR, USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators' | |||
import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' | |||
import { HttpStatusCode, User } from '@shared/models' | |||
@@ -20,7 +20,8 @@ export class MyAccountChangeEmailComponent extends FormReactive implements OnIni | |||
protected formReactiveService: FormReactiveService, | |||
private authService: AuthService, | |||
private userService: UserService, | |||
private serverService: ServerService | |||
private serverService: ServerService, | |||
private notifier: Notifier | |||
) { | |||
super() | |||
} | |||
@@ -0,0 +1 @@ | |||
export * from './my-account-email-preferences.component' |
@@ -0,0 +1,15 @@ | |||
<form role="form" (ngSubmit)="updateEmailPublic()" [formGroup]="form"> | |||
<div class="form-group"> | |||
<my-peertube-checkbox | |||
inputName="email-public" formControlName="email-public" | |||
i18n-labelText labelText="Allow email to be publicly displayed" | |||
> | |||
<ng-container ngProjectAs="description"> | |||
<span i18n>Necessary to claim podcast RSS feeds.</span> | |||
</ng-container> | |||
</my-peertube-checkbox> | |||
</div> | |||
<input class="peertube-button orange-button" type="submit" i18n-value value="Save" [disabled]="!form.valid"> | |||
</form> |
@@ -0,0 +1,51 @@ | |||
import { Subject } from 'rxjs' | |||
import { Component, Input, OnInit } from '@angular/core' | |||
import { Notifier, UserService } from '@app/core' | |||
import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' | |||
import { User, UserUpdateMe } from '@shared/models' | |||
@Component({ | |||
selector: 'my-account-email-preferences', | |||
templateUrl: './my-account-email-preferences.component.html', | |||
styleUrls: [ './my-account-email-preferences.component.scss' ] | |||
}) | |||
export class MyAccountEmailPreferencesComponent extends FormReactive implements OnInit { | |||
@Input() user: User = null | |||
@Input() userInformationLoaded: Subject<any> | |||
constructor ( | |||
protected formReactiveService: FormReactiveService, | |||
private userService: UserService, | |||
private notifier: Notifier | |||
) { | |||
super() | |||
} | |||
ngOnInit () { | |||
this.buildForm({ | |||
'email-public': null | |||
}) | |||
this.userInformationLoaded.subscribe(() => { | |||
this.form.patchValue({ 'email-public': this.user.emailPublic }) | |||
}) | |||
} | |||
updateEmailPublic () { | |||
const details: UserUpdateMe = { | |||
emailPublic: this.form.value['email-public'] | |||
} | |||
this.userService.updateMyProfile(details) | |||
.subscribe({ | |||
next: () => { | |||
if (details.emailPublic) this.notifier.success($localize`Email is now public`) | |||
else this.notifier.success($localize`Email is now private`) | |||
this.user.emailPublic = details.emailPublic | |||
}, | |||
error: err => console.log(err.message) | |||
}) | |||
} | |||
} |
@@ -68,7 +68,7 @@ | |||
</div> | |||
<div class="col-12 col-lg-8 col-xl-9"> | |||
<my-account-two-factor-button [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-two-factor-button> | |||
<my-account-two-factor-button [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-two-factor-button> | |||
</div> | |||
</div> | |||
@@ -78,6 +78,8 @@ | |||
</div> | |||
<div class="col-12 col-lg-8 col-xl-9"> | |||
<my-account-email-preferences class="d-block mb-5" [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-email-preferences> | |||
<my-account-change-email></my-account-change-email> | |||
</div> | |||
</div> | |||
@@ -22,6 +22,7 @@ import { MyAccountRoutingModule } from './my-account-routing.module' | |||
import { MyAccountChangeEmailComponent } from './my-account-settings/my-account-change-email' | |||
import { MyAccountChangePasswordComponent } from './my-account-settings/my-account-change-password/my-account-change-password.component' | |||
import { MyAccountDangerZoneComponent } from './my-account-settings/my-account-danger-zone' | |||
import { MyAccountEmailPreferencesComponent } from './my-account-settings/my-account-email-preferences' | |||
import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences' | |||
import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component' | |||
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' | |||
@@ -65,7 +66,9 @@ import { MyAccountComponent } from './my-account.component' | |||
MyAccountAbusesListComponent, | |||
MyAccountServerBlocklistComponent, | |||
MyAccountNotificationsComponent, | |||
MyAccountNotificationPreferencesComponent | |||
MyAccountNotificationPreferencesComponent, | |||
MyAccountEmailPreferencesComponent | |||
], | |||
exports: [ | |||
@@ -19,6 +19,7 @@ export class User implements UserServerModel { | |||
pendingEmail: string | null | |||
emailVerified: boolean | |||
emailPublic: boolean | |||
nsfwPolicy: NSFWPolicyType | |||
adminFlags?: UserAdminFlag | |||
@@ -54,6 +54,7 @@ export type CommonVideoParams = { | |||
export class VideoService { | |||
static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos' | |||
static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.' | |||
static PODCAST_FEEDS_URL = environment.apiUrl + '/feeds/podcast/videos.xml' | |||
static BASE_SUBSCRIPTION_FEEDS_URL = environment.apiUrl + '/feeds/subscriptions.' | |||
constructor ( | |||
@@ -266,7 +267,15 @@ export class VideoService { | |||
let params = this.restService.addRestGetParams(new HttpParams()) | |||
params = params.set('videoChannelId', videoChannelId.toString()) | |||
return this.buildBaseFeedUrls(params) | |||
const feedUrls = this.buildBaseFeedUrls(params) | |||
feedUrls.push({ | |||
format: FeedFormat.RSS, | |||
label: 'podcast rss 2.0', | |||
url: VideoService.PODCAST_FEEDS_URL + `?videoChannelId=${videoChannelId}` | |||
}) | |||
return feedUrls | |||
} | |||
getVideoSubscriptionFeedUrls (accountId: number, feedToken: string) { | |||
@@ -97,7 +97,7 @@ | |||
"@opentelemetry/sdk-trace-base": "^1.3.1", | |||
"@opentelemetry/sdk-trace-node": "^1.3.1", | |||
"@opentelemetry/semantic-conventions": "^1.3.1", | |||
"@peertube/feed": "^5.0.1", | |||
"@peertube/feed": "^5.1.0", | |||
"@peertube/http-signature": "^1.7.0", | |||
"@uploadx/core": "^6.0.0", | |||
"async-lru": "^1.1.1", | |||
@@ -135,7 +135,7 @@ | |||
"jimp": "^0.22.4", | |||
"js-yaml": "^4.0.0", | |||
"jsonld": "~8.1.0", | |||
"lodash": "^4.17.10", | |||
"lodash": "^4.17.21", | |||
"lru-cache": "^7.13.0", | |||
"magnet-uri": "^6.1.0", | |||
"markdown-it": "^13.0.1", | |||
@@ -212,7 +212,8 @@ async function updateMe (req: express.Request, res: express.Response) { | |||
'theme', | |||
'noInstanceConfigWarningModal', | |||
'noAccountSetupWarningModal', | |||
'noWelcomeModal' | |||
'noWelcomeModal', | |||
'emailPublic' | |||
] | |||
for (const key of keysToUpdate) { | |||
@@ -2,10 +2,12 @@ import express from 'express' | |||
import { Transaction } from 'sequelize/types' | |||
import { changeVideoChannelShare } from '@server/lib/activitypub/share' | |||
import { addVideoJobsAfterUpdate, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' | |||
import { VideoPathManager } from '@server/lib/video-path-manager' | |||
import { setVideoPrivacy } from '@server/lib/video-privacy' | |||
import { openapiOperationDoc } from '@server/middlewares/doc' | |||
import { FilteredModelAttributes } from '@server/types' | |||
import { MVideoFullLight } from '@server/types/models' | |||
import { forceNumber } from '@shared/core-utils' | |||
import { HttpStatusCode, VideoUpdate } from '@shared/models' | |||
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' | |||
import { resetSequelizeInstance } from '../../../helpers/database-utils' | |||
@@ -18,8 +20,6 @@ import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' | |||
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares' | |||
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' | |||
import { VideoModel } from '../../../models/video/video' | |||
import { VideoPathManager } from '@server/lib/video-path-manager' | |||
import { forceNumber } from '@shared/core-utils' | |||
const lTags = loggerTagsFactory('api', 'video') | |||
const auditLogger = auditLoggerFactory('videos') | |||
@@ -1,389 +0,0 @@ | |||
import express from 'express' | |||
import { extname } from 'path' | |||
import { Feed } from '@peertube/feed' | |||
import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown' | |||
import { getServerActor } from '@server/models/application/application' | |||
import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils' | |||
import { MAccountDefault, MChannelBannerAccountDefault, MVideoFullLight } from '@server/types/models' | |||
import { ActorImageType, VideoInclude } from '@shared/models' | |||
import { buildNSFWFilter } from '../helpers/express-utils' | |||
import { CONFIG } from '../initializers/config' | |||
import { MIMETYPES, PREVIEWS_SIZE, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../initializers/constants' | |||
import { | |||
asyncMiddleware, | |||
commonVideosFiltersValidator, | |||
feedsFormatValidator, | |||
setDefaultVideosSort, | |||
setFeedFormatContentType, | |||
videoCommentsFeedsValidator, | |||
videoFeedsValidator, | |||
videosSortValidator, | |||
videoSubscriptionFeedsValidator | |||
} from '../middlewares' | |||
import { cacheRouteFactory } from '../middlewares/cache/cache' | |||
import { VideoModel } from '../models/video/video' | |||
import { VideoCommentModel } from '../models/video/video-comment' | |||
const feedsRouter = express.Router() | |||
const cacheRoute = cacheRouteFactory({ | |||
headerBlacklist: [ 'Content-Type' ] | |||
}) | |||
feedsRouter.get('/feeds/video-comments.:format', | |||
feedsFormatValidator, | |||
setFeedFormatContentType, | |||
cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS), | |||
asyncMiddleware(videoFeedsValidator), | |||
asyncMiddleware(videoCommentsFeedsValidator), | |||
asyncMiddleware(generateVideoCommentsFeed) | |||
) | |||
feedsRouter.get('/feeds/videos.:format', | |||
videosSortValidator, | |||
setDefaultVideosSort, | |||
feedsFormatValidator, | |||
setFeedFormatContentType, | |||
cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS), | |||
commonVideosFiltersValidator, | |||
asyncMiddleware(videoFeedsValidator), | |||
asyncMiddleware(generateVideoFeed) | |||
) | |||
feedsRouter.get('/feeds/subscriptions.:format', | |||
videosSortValidator, | |||
setDefaultVideosSort, | |||
feedsFormatValidator, | |||
setFeedFormatContentType, | |||
cacheRoute(ROUTE_CACHE_LIFETIME.FEEDS), | |||
commonVideosFiltersValidator, | |||
asyncMiddleware(videoSubscriptionFeedsValidator), | |||
asyncMiddleware(generateVideoFeedForSubscriptions) | |||
) | |||
// --------------------------------------------------------------------------- | |||
export { | |||
feedsRouter | |||
} | |||
// --------------------------------------------------------------------------- | |||
async function generateVideoCommentsFeed (req: express.Request, res: express.Response) { | |||
const start = 0 | |||
const video = res.locals.videoAll | |||
const account = res.locals.account | |||
const videoChannel = res.locals.videoChannel | |||
const comments = await VideoCommentModel.listForFeed({ | |||
start, | |||
count: CONFIG.FEEDS.COMMENTS.COUNT, | |||
videoId: video ? video.id : undefined, | |||
accountId: account ? account.id : undefined, | |||
videoChannelId: videoChannel ? videoChannel.id : undefined | |||
}) | |||
const { name, description, imageUrl } = buildFeedMetadata({ video, account, videoChannel }) | |||
const feed = initFeed({ | |||
name, | |||
description, | |||
imageUrl, | |||
resourceType: 'video-comments', | |||
queryString: new URL(WEBSERVER.URL + req.originalUrl).search | |||
}) | |||
// Adding video items to the feed, one at a time | |||
for (const comment of comments) { | |||
const localLink = WEBSERVER.URL + comment.getCommentStaticPath() | |||
let title = comment.Video.name | |||
const author: { name: string, link: string }[] = [] | |||
if (comment.Account) { | |||
title += ` - ${comment.Account.getDisplayName()}` | |||
author.push({ | |||
name: comment.Account.getDisplayName(), | |||
link: comment.Account.Actor.url | |||
}) | |||
} | |||
feed.addItem({ | |||
title, | |||
id: localLink, | |||
link: localLink, | |||
content: toSafeHtml(comment.text), | |||
author, | |||
date: comment.createdAt | |||
}) | |||
} | |||
// Now the feed generation is done, let's send it! | |||
return sendFeed(feed, req, res) | |||
} | |||
async function generateVideoFeed (req: express.Request, res: express.Response) { | |||
const start = 0 | |||
const account = res.locals.account | |||
const videoChannel = res.locals.videoChannel | |||
const nsfw = buildNSFWFilter(res, req.query.nsfw) | |||
const { name, description, imageUrl } = buildFeedMetadata({ videoChannel, account }) | |||
const feed = initFeed({ | |||
name, | |||
description, | |||
imageUrl, | |||
resourceType: 'videos', | |||
queryString: new URL(WEBSERVER.URL + req.url).search | |||
}) | |||
const options = { | |||
accountId: account ? account.id : null, | |||
videoChannelId: videoChannel ? videoChannel.id : null | |||
} | |||
const server = await getServerActor() | |||
const { data } = await VideoModel.listForApi({ | |||
start, | |||
count: CONFIG.FEEDS.VIDEOS.COUNT, | |||
sort: req.query.sort, | |||
displayOnlyForFollower: { | |||
actorId: server.id, | |||
orLocalVideos: true | |||
}, | |||
nsfw, | |||
isLocal: req.query.isLocal, | |||
include: req.query.include | VideoInclude.FILES, | |||
hasFiles: true, | |||
countVideos: false, | |||
...options | |||
}) | |||
addVideosToFeed(feed, data) | |||
// Now the feed generation is done, let's send it! | |||
return sendFeed(feed, req, res) | |||
} | |||
async function generateVideoFeedForSubscriptions (req: express.Request, res: express.Response) { | |||
const start = 0 | |||
const account = res.locals.account | |||
const nsfw = buildNSFWFilter(res, req.query.nsfw) | |||
const { name, description, imageUrl } = buildFeedMetadata({ account }) | |||
const feed = initFeed({ | |||
name, | |||
description, | |||
imageUrl, | |||
resourceType: 'videos', | |||
queryString: new URL(WEBSERVER.URL + req.url).search | |||
}) | |||
const { data } = await VideoModel.listForApi({ | |||
start, | |||
count: CONFIG.FEEDS.VIDEOS.COUNT, | |||
sort: req.query.sort, | |||
nsfw, | |||
isLocal: req.query.isLocal, | |||
hasFiles: true, | |||
include: req.query.include | VideoInclude.FILES, | |||
countVideos: false, | |||
displayOnlyForFollower: { | |||
actorId: res.locals.user.Account.Actor.id, | |||
orLocalVideos: false | |||
}, | |||
user: res.locals.user | |||
}) | |||
addVideosToFeed(feed, data) | |||
// Now the feed generation is done, let's send it! | |||
return sendFeed(feed, req, res) | |||
} | |||
function initFeed (parameters: { | |||
name: string | |||
description: string | |||
imageUrl: string | |||
resourceType?: 'videos' | 'video-comments' | |||
queryString?: string | |||
}) { | |||
const webserverUrl = WEBSERVER.URL | |||
const { name, description, resourceType, queryString, imageUrl } = parameters | |||
return new Feed({ | |||
title: name, | |||
description: mdToOneLinePlainText(description), | |||
// updated: TODO: somehowGetLatestUpdate, // optional, default = today | |||
id: webserverUrl, | |||
link: webserverUrl, | |||
image: imageUrl, | |||
favicon: webserverUrl + '/client/assets/images/favicon.png', | |||
copyright: `All rights reserved, unless otherwise specified in the terms specified at ${webserverUrl}/about` + | |||
` and potential licenses granted by each content's rightholder.`, | |||
generator: `Toraifลsu`, // ^.~ | |||
feedLinks: { | |||
json: `${webserverUrl}/feeds/${resourceType}.json${queryString}`, | |||
atom: `${webserverUrl}/feeds/${resourceType}.atom${queryString}`, | |||
rss: `${webserverUrl}/feeds/${resourceType}.xml${queryString}` | |||
}, | |||
author: { | |||
name: 'Instance admin of ' + CONFIG.INSTANCE.NAME, | |||
email: CONFIG.ADMIN.EMAIL, | |||
link: `${webserverUrl}/about` | |||
} | |||
}) | |||
} | |||
function addVideosToFeed (feed: Feed, videos: VideoModel[]) { | |||
for (const video of videos) { | |||
const formattedVideoFiles = video.getFormattedVideoFilesJSON(false) | |||
const torrents = formattedVideoFiles.map(videoFile => ({ | |||
title: video.name, | |||
url: videoFile.torrentUrl, | |||
size_in_bytes: videoFile.size | |||
})) | |||
const videoFiles = formattedVideoFiles.map(videoFile => { | |||
const result = { | |||
type: MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)], | |||
medium: 'video', | |||
height: videoFile.resolution.id, | |||
fileSize: videoFile.size, | |||
url: videoFile.fileUrl, | |||
framerate: videoFile.fps, | |||
duration: video.duration | |||
} | |||
if (video.language) Object.assign(result, { lang: video.language }) | |||
return result | |||
}) | |||
const categories: { value: number, label: string }[] = [] | |||
if (video.category) { | |||
categories.push({ | |||
value: video.category, | |||
label: getCategoryLabel(video.category) | |||
}) | |||
} | |||
const localLink = WEBSERVER.URL + video.getWatchStaticPath() | |||
feed.addItem({ | |||
title: video.name, | |||
id: localLink, | |||
link: localLink, | |||
description: mdToOneLinePlainText(video.getTruncatedDescription()), | |||
content: toSafeHtml(video.description), | |||
author: [ | |||
{ | |||
name: video.VideoChannel.getDisplayName(), | |||
link: video.VideoChannel.Actor.url | |||
} | |||
], | |||
date: video.publishedAt, | |||
nsfw: video.nsfw, | |||
torrents, | |||
// Enclosure | |||
video: videoFiles.length !== 0 | |||
? { | |||
url: videoFiles[0].url, | |||
length: videoFiles[0].fileSize, | |||
type: videoFiles[0].type | |||
} | |||
: undefined, | |||
// Media RSS | |||
videos: videoFiles, | |||
embed: { | |||
url: WEBSERVER.URL + video.getEmbedStaticPath(), | |||
allowFullscreen: true | |||
}, | |||
player: { | |||
url: WEBSERVER.URL + video.getWatchStaticPath() | |||
}, | |||
categories, | |||
community: { | |||
statistics: { | |||
views: video.views | |||
} | |||
}, | |||
thumbnails: [ | |||
{ | |||
url: WEBSERVER.URL + video.getPreviewStaticPath(), | |||
height: PREVIEWS_SIZE.height, | |||
width: PREVIEWS_SIZE.width | |||
} | |||
] | |||
}) | |||
} | |||
} | |||
function sendFeed (feed: Feed, req: express.Request, res: express.Response) { | |||
const format = req.params.format | |||
if (format === 'atom' || format === 'atom1') { | |||
return res.send(feed.atom1()).end() | |||
} | |||
if (format === 'json' || format === 'json1') { | |||
return res.send(feed.json1()).end() | |||
} | |||
if (format === 'rss' || format === 'rss2') { | |||
return res.send(feed.rss2()).end() | |||
} | |||
// We're in the ambiguous '.xml' case and we look at the format query parameter | |||
if (req.query.format === 'atom' || req.query.format === 'atom1') { | |||
return res.send(feed.atom1()).end() | |||
} | |||
return res.send(feed.rss2()).end() | |||
} | |||
function buildFeedMetadata (options: { | |||
videoChannel?: MChannelBannerAccountDefault | |||
account?: MAccountDefault | |||
video?: MVideoFullLight | |||
}) { | |||
const { video, videoChannel, account } = options | |||
let imageUrl = WEBSERVER.URL + '/client/assets/images/icons/icon-96x96.png' | |||
let name: string | |||
let description: string | |||
if (videoChannel) { | |||
name = videoChannel.getDisplayName() | |||
description = videoChannel.description | |||
if (videoChannel.Actor.hasImage(ActorImageType.AVATAR)) { | |||
imageUrl = WEBSERVER.URL + videoChannel.Actor.Avatars[0].getStaticPath() | |||
} | |||
} else if (account) { | |||
name = account.getDisplayName() | |||
description = account.description | |||
if (account.Actor.hasImage(ActorImageType.AVATAR)) { | |||
imageUrl = WEBSERVER.URL + account.Actor.Avatars[0].getStaticPath() | |||
} | |||
} else if (video) { | |||
name = video.name | |||
description = video.description | |||
} else { | |||
name = CONFIG.INSTANCE.NAME | |||
description = CONFIG.INSTANCE.DESCRIPTION | |||
} | |||
return { name, description, imageUrl } | |||
} |
@@ -0,0 +1,96 @@ | |||
import express from 'express' | |||
import { toSafeHtml } from '@server/helpers/markdown' | |||
import { cacheRouteFactory } from '@server/middlewares' | |||
import { CONFIG } from '../../initializers/config' | |||
import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants' | |||
import { | |||
asyncMiddleware, | |||
feedsFormatValidator, | |||
setFeedFormatContentType, | |||
videoCommentsFeedsValidator, | |||
videoFeedsValidator | |||
} from '../../middlewares' | |||
import { VideoCommentModel } from '../../models/video/video-comment' | |||
import { buildFeedMetadata, initFeed, sendFeed } from './shared' | |||
const commentFeedsRouter = express.Router() | |||
// --------------------------------------------------------------------------- | |||
const { middleware: cacheRouteMiddleware } = cacheRouteFactory({ | |||
headerBlacklist: [ 'Content-Type' ] | |||
}) | |||
// --------------------------------------------------------------------------- | |||
commentFeedsRouter.get('/feeds/video-comments.:format', | |||
feedsFormatValidator, | |||
setFeedFormatContentType, | |||
cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS), | |||
asyncMiddleware(videoFeedsValidator), | |||
asyncMiddleware(videoCommentsFeedsValidator), | |||
asyncMiddleware(generateVideoCommentsFeed) | |||
) | |||
// --------------------------------------------------------------------------- | |||
export { | |||
commentFeedsRouter | |||
} | |||
// --------------------------------------------------------------------------- | |||
async function generateVideoCommentsFeed (req: express.Request, res: express.Response) { | |||
const start = 0 | |||
const video = res.locals.videoAll | |||
const account = res.locals.account | |||
const videoChannel = res.locals.videoChannel | |||
const comments = await VideoCommentModel.listForFeed({ | |||
start, | |||
count: CONFIG.FEEDS.COMMENTS.COUNT, | |||
videoId: video ? video.id : undefined, | |||
accountId: account ? account.id : undefined, | |||
videoChannelId: videoChannel ? videoChannel.id : undefined | |||
}) | |||
const { name, description, imageUrl, link } = await buildFeedMetadata({ video, account, videoChannel }) | |||
const feed = initFeed({ | |||
name, | |||
description, | |||
imageUrl, | |||
isPodcast: false, | |||
link, | |||
resourceType: 'video-comments', | |||
queryString: new URL(WEBSERVER.URL + req.originalUrl).search | |||
}) | |||
// Adding video items to the feed, one at a time | |||
for (const comment of comments) { | |||
const localLink = WEBSERVER.URL + comment.getCommentStaticPath() | |||
let title = comment.Video.name | |||
const author: { name: string, link: string }[] = [] | |||
if (comment.Account) { | |||
title += ` - ${comment.Account.getDisplayName()}` | |||
author.push({ | |||
name: comment.Account.getDisplayName(), | |||
link: comment.Account.Actor.url | |||
}) | |||
} | |||
feed.addItem({ | |||
title, | |||
id: localLink, | |||
link: localLink, | |||
content: toSafeHtml(comment.text), | |||
author, | |||
date: comment.createdAt | |||
}) | |||
} | |||
// Now the feed generation is done, let's send it! | |||
return sendFeed(feed, req, res) | |||
} |
@@ -0,0 +1,16 @@ | |||
import express from 'express' | |||
import { commentFeedsRouter } from './comment-feeds' | |||
import { videoFeedsRouter } from './video-feeds' | |||
import { videoPodcastFeedsRouter } from './video-podcast-feeds' | |||
const feedsRouter = express.Router() | |||
feedsRouter.use('/', commentFeedsRouter) | |||
feedsRouter.use('/', videoFeedsRouter) | |||
feedsRouter.use('/', videoPodcastFeedsRouter) | |||
// --------------------------------------------------------------------------- | |||
export { | |||
feedsRouter | |||
} |
@@ -0,0 +1,145 @@ | |||
import express from 'express' | |||
import { Feed } from '@peertube/feed' | |||
import { CustomTag, CustomXMLNS, Person } from '@peertube/feed/lib/typings' | |||
import { mdToOneLinePlainText } from '@server/helpers/markdown' | |||
import { CONFIG } from '@server/initializers/config' | |||
import { WEBSERVER } from '@server/initializers/constants' | |||
import { UserModel } from '@server/models/user/user' | |||
import { MAccountDefault, MChannelBannerAccountDefault, MUser, MVideoFullLight } from '@server/types/models' | |||
import { pick } from '@shared/core-utils' | |||
import { ActorImageType } from '@shared/models' | |||
export function initFeed (parameters: { | |||
name: string | |||
description: string | |||
imageUrl: string | |||
isPodcast: boolean | |||
link?: string | |||
locked?: { isLocked: boolean, email: string } | |||
author?: { | |||
name: string | |||
link: string | |||
imageUrl: string | |||
} | |||
person?: Person[] | |||
resourceType?: 'videos' | 'video-comments' | |||
queryString?: string | |||
medium?: string | |||
stunServers?: string[] | |||
trackers?: string[] | |||
customXMLNS?: CustomXMLNS[] | |||
customTags?: CustomTag[] | |||
}) { | |||
const webserverUrl = WEBSERVER.URL | |||
const { name, description, link, imageUrl, isPodcast, resourceType, queryString, medium } = parameters | |||
return new Feed({ | |||
title: name, | |||
description: mdToOneLinePlainText(description), | |||
// updated: TODO: somehowGetLatestUpdate, // optional, default = today | |||
id: link || webserverUrl, | |||
link: link || webserverUrl, | |||
image: imageUrl, | |||
favicon: webserverUrl + '/client/assets/images/favicon.png', | |||
copyright: `All rights reserved, unless otherwise specified in the terms specified at ${webserverUrl}/about` + | |||
` and potential licenses granted by each content's rightholder.`, | |||
generator: `Toraifลsu`, // ^.~ | |||
medium: medium || 'video', | |||
feedLinks: { | |||
json: `${webserverUrl}/feeds/${resourceType}.json${queryString}`, | |||
atom: `${webserverUrl}/feeds/${resourceType}.atom${queryString}`, | |||
rss: isPodcast | |||
? `${webserverUrl}/feeds/podcast/videos.xml${queryString}` | |||
: `${webserverUrl}/feeds/${resourceType}.xml${queryString}` | |||
}, | |||
...pick(parameters, [ 'stunServers', 'trackers', 'customXMLNS', 'customTags', 'author', 'person', 'locked' ]) | |||
}) | |||
} | |||
export function sendFeed (feed: Feed, req: express.Request, res: express.Response) { | |||
const format = req.params.format | |||
if (format === 'atom' || format === 'atom1') { | |||
return res.send(feed.atom1()).end() | |||
} | |||
if (format === 'json' || format === 'json1') { | |||
return res.send(feed.json1()).end() | |||
} | |||
if (format === 'rss' || format === 'rss2') { | |||
return res.send(feed.rss2()).end() | |||
} | |||
// We're in the ambiguous '.xml' case and we look at the format query parameter | |||
if (req.query.format === 'atom' || req.query.format === 'atom1') { | |||
return res.send(feed.atom1()).end() | |||
} | |||
return res.send(feed.rss2()).end() | |||
} | |||
export async function buildFeedMetadata (options: { | |||
videoChannel?: MChannelBannerAccountDefault | |||
account?: MAccountDefault | |||
video?: MVideoFullLight | |||
}) { | |||
const { video, videoChannel, account } = options | |||
let imageUrl = WEBSERVER.URL + '/client/assets/images/icons/icon-96x96.png' | |||
let accountImageUrl: string | |||
let name: string | |||
let userName: string | |||
let description: string | |||
let email: string | |||
let link: string | |||
let accountLink: string | |||
let user: MUser | |||
if (videoChannel) { | |||
name = videoChannel.getDisplayName() | |||
description = videoChannel.description | |||
link = videoChannel.getClientUrl() | |||
accountLink = videoChannel.Account.getClientUrl() | |||
if (videoChannel.Actor.hasImage(ActorImageType.AVATAR)) { | |||
imageUrl = WEBSERVER.URL + videoChannel.Actor.Avatars[0].getStaticPath() | |||
} | |||
if (videoChannel.Account.Actor.hasImage(ActorImageType.AVATAR)) { | |||
accountImageUrl = WEBSERVER.URL + videoChannel.Account.Actor.Avatars[0].getStaticPath() | |||
} | |||
user = await UserModel.loadById(videoChannel.Account.userId) | |||
userName = videoChannel.Account.getDisplayName() | |||
} else if (account) { | |||
name = account.getDisplayName() | |||
description = account.description | |||
link = account.getClientUrl() | |||
accountLink = link | |||
if (account.Actor.hasImage(ActorImageType.AVATAR)) { | |||
imageUrl = WEBSERVER.URL + account.Actor.Avatars[0].getStaticPath() | |||
accountImageUrl = imageUrl | |||
} | |||
user = await UserModel.loadById(account.userId) | |||
} else if (video) { | |||
name = video.name | |||
description = video.description | |||
link = video.url | |||
} else { | |||
name = CONFIG.INSTANCE.NAME | |||
description = CONFIG.INSTANCE.DESCRIPTION | |||
link = WEBSERVER.URL | |||
} | |||
// If the user is local, has a verified email address, and allows it to be publicly displayed | |||
// Return it so the owner can prove ownership of their feed | |||
if (user && !user.pluginAuth && user.emailVerified && user.emailPublic) { | |||
email = user.email | |||
} | |||
return { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink } | |||
} |
@@ -0,0 +1,2 @@ | |||
export * from './video-feed-utils' | |||
export * from './common-feed-utils' |
@@ -0,0 +1,66 @@ | |||
import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown' | |||
import { CONFIG } from '@server/initializers/config' | |||
import { WEBSERVER } from '@server/initializers/constants' | |||
import { getServerActor } from '@server/models/application/application' | |||
import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils' | |||
import { DisplayOnlyForFollowerOptions } from '@server/models/video/sql/video' | |||
import { VideoModel } from '@server/models/video/video' | |||
import { MThumbnail, MUserDefault } from '@server/types/models' | |||
import { VideoInclude } from '@shared/models' | |||
export async function getVideosForFeeds (options: { | |||
sort: string | |||
nsfw: boolean | |||
isLocal: boolean | |||
include: VideoInclude | |||
accountId?: number | |||
videoChannelId?: number | |||
displayOnlyForFollower?: DisplayOnlyForFollowerOptions | |||
user?: MUserDefault | |||
}) { | |||
const server = await getServerActor() | |||
const { data } = await VideoModel.listForApi({ | |||
start: 0, | |||
count: CONFIG.FEEDS.VIDEOS.COUNT, | |||
displayOnlyForFollower: { | |||
actorId: server.id, | |||
orLocalVideos: true | |||
}, | |||
hasFiles: true, | |||
countVideos: false, | |||
...options | |||
}) | |||
return data | |||
} | |||
export function getCommonVideoFeedAttributes (video: VideoModel) { | |||
const localLink = WEBSERVER.URL + video.getWatchStaticPath() | |||
const thumbnailModels: MThumbnail[] = [] | |||
if (video.hasPreview()) thumbnailModels.push(video.getPreview()) | |||
thumbnailModels.push(video.getMiniature()) | |||
return { | |||
title: video.name, | |||
link: localLink, | |||
description: mdToOneLinePlainText(video.getTruncatedDescription()), | |||
content: toSafeHtml(video.description), | |||
date: video.publishedAt, | |||
nsfw: video.nsfw, | |||
category: video.category | |||
? [ { name: getCategoryLabel(video.category) } ] | |||
: undefined, | |||
thumbnails: thumbnailModels.map(t => ({ | |||
url: WEBSERVER.URL + t.getLocalStaticPath(), | |||
width: t.width, | |||
height: t.height | |||
})) | |||
} | |||
} |
@@ -0,0 +1,189 @@ | |||
import express from 'express' | |||
import { extname } from 'path' | |||
import { Feed } from '@peertube/feed' | |||
import { cacheRouteFactory } from '@server/middlewares' | |||
import { VideoModel } from '@server/models/video/video' | |||
import { VideoInclude } from '@shared/models' | |||
import { buildNSFWFilter } from '../../helpers/express-utils' | |||
import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants' | |||
import { | |||
asyncMiddleware, | |||
commonVideosFiltersValidator, | |||
feedsFormatValidator, | |||
setDefaultVideosSort, | |||
setFeedFormatContentType, | |||
videoFeedsValidator, | |||
videosSortValidator, | |||
videoSubscriptionFeedsValidator | |||
} from '../../middlewares' | |||
import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed, sendFeed } from './shared' | |||
const videoFeedsRouter = express.Router() | |||
const { middleware: cacheRouteMiddleware } = cacheRouteFactory({ | |||
headerBlacklist: [ 'Content-Type' ] | |||
}) | |||
// --------------------------------------------------------------------------- | |||
videoFeedsRouter.get('/feeds/videos.:format', | |||
videosSortValidator, | |||
setDefaultVideosSort, | |||
feedsFormatValidator, | |||
setFeedFormatContentType, | |||
cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS), | |||
commonVideosFiltersValidator, | |||
asyncMiddleware(videoFeedsValidator), | |||
asyncMiddleware(generateVideoFeed) | |||
) | |||
videoFeedsRouter.get('/feeds/subscriptions.:format', | |||
videosSortValidator, | |||
setDefaultVideosSort, | |||
feedsFormatValidator, | |||
setFeedFormatContentType, | |||
cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS), | |||
commonVideosFiltersValidator, | |||
asyncMiddleware(videoSubscriptionFeedsValidator), | |||
asyncMiddleware(generateVideoFeedForSubscriptions) | |||
) | |||
// --------------------------------------------------------------------------- | |||
export { | |||
videoFeedsRouter | |||
} | |||
// --------------------------------------------------------------------------- | |||
async function generateVideoFeed (req: express.Request, res: express.Response) { | |||
const account = res.locals.account | |||
const videoChannel = res.locals.videoChannel | |||
const { name, description, imageUrl, accountImageUrl, link, accountLink } = await buildFeedMetadata({ videoChannel, account }) | |||
const feed = initFeed({ | |||
name, | |||
description, | |||
link, | |||
isPodcast: false, | |||
imageUrl, | |||
author: { name, link: accountLink, imageUrl: accountImageUrl }, | |||
resourceType: 'videos', | |||
queryString: new URL(WEBSERVER.URL + req.url).search | |||
}) | |||
const data = await getVideosForFeeds({ | |||
sort: req.query.sort, | |||
nsfw: buildNSFWFilter(res, req.query.nsfw), | |||
isLocal: req.query.isLocal, | |||
include: req.query.include | VideoInclude.FILES, | |||
accountId: account?.id, | |||
videoChannelId: videoChannel?.id | |||
}) | |||
addVideosToFeed(feed, data) | |||
// Now the feed generation is done, let's send it! | |||
return sendFeed(feed, req, res) | |||
} | |||
async function generateVideoFeedForSubscriptions (req: express.Request, res: express.Response) { | |||
const account = res.locals.account | |||
const { name, description, imageUrl, link } = await buildFeedMetadata({ account }) | |||
const feed = initFeed({ | |||
name, | |||
description, | |||
link, | |||
isPodcast: false, | |||
imageUrl, | |||
resourceType: 'videos', | |||
queryString: new URL(WEBSERVER.URL + req.url).search | |||
}) | |||
const data = await getVideosForFeeds({ | |||
sort: req.query.sort, | |||
nsfw: buildNSFWFilter(res, req.query.nsfw), | |||
isLocal: req.query.isLocal, | |||
include: req.query.include | VideoInclude.FILES, | |||
displayOnlyForFollower: { | |||
actorId: res.locals.user.Account.Actor.id, | |||
orLocalVideos: false | |||
}, | |||
user: res.locals.user | |||
}) | |||
addVideosToFeed(feed, data) | |||
// Now the feed generation is done, let's send it! | |||
return sendFeed(feed, req, res) | |||
} | |||
// --------------------------------------------------------------------------- | |||
function addVideosToFeed (feed: Feed, videos: VideoModel[]) { | |||
/** | |||
* Adding video items to the feed object, one at a time | |||
*/ | |||
for (const video of videos) { | |||
const formattedVideoFiles = video.getFormattedAllVideoFilesJSON(false) | |||
const torrents = formattedVideoFiles.map(videoFile => ({ | |||
title: video.name, | |||
url: videoFile.torrentUrl, | |||
size_in_bytes: videoFile.size | |||
})) | |||
const videoFiles = formattedVideoFiles.map(videoFile => { | |||
return { | |||
type: MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)], | |||
medium: 'video', | |||
height: videoFile.resolution.id, | |||
fileSize: videoFile.size, | |||
url: videoFile.fileUrl, | |||
framerate: videoFile.fps, | |||
duration: video.duration, | |||
lang: video.language | |||
} | |||
}) | |||
feed.addItem({ | |||
...getCommonVideoFeedAttributes(video), | |||
id: WEBSERVER.URL + video.getWatchStaticPath(), | |||
author: [ | |||
{ | |||
name: video.VideoChannel.getDisplayName(), | |||
link: video.VideoChannel.getClientUrl() | |||
} | |||
], | |||
torrents, | |||
// Enclosure | |||
video: videoFiles.length !== 0 | |||
? { | |||
url: videoFiles[0].url, | |||
length: videoFiles[0].fileSize, | |||
type: videoFiles[0].type | |||
} | |||
: undefined, | |||
// Media RSS | |||
videos: videoFiles, | |||
embed: { | |||
url: WEBSERVER.URL + video.getEmbedStaticPath(), | |||
allowFullscreen: true | |||
}, | |||
player: { | |||
url: WEBSERVER.URL + video.getWatchStaticPath() | |||
}, | |||
community: { | |||
statistics: { | |||
views: video.views | |||
} | |||
} | |||
}) | |||
} | |||
} |
@@ -0,0 +1,301 @@ | |||
import express from 'express' | |||
import { extname } from 'path' | |||
import { Feed } from '@peertube/feed' | |||
import { CustomTag, CustomXMLNS, LiveItemStatus } from '@peertube/feed/lib/typings' | |||
import { InternalEventEmitter } from '@server/lib/internal-event-emitter' | |||
import { Hooks } from '@server/lib/plugins/hooks' | |||
import { buildPodcastGroupsCache, cacheRouteFactory, videoFeedsPodcastSetCacheKey } from '@server/middlewares' | |||
import { MVideo, MVideoCaptionVideo, MVideoFullLight } from '@server/types/models' | |||
import { sortObjectComparator } from '@shared/core-utils' | |||
import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@shared/models' | |||
import { buildNSFWFilter } from '../../helpers/express-utils' | |||
import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants' | |||
import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares' | |||
import { VideoModel } from '../../models/video/video' | |||
import { VideoCaptionModel } from '../../models/video/video-caption' | |||
import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed } from './shared' | |||
const videoPodcastFeedsRouter = express.Router() | |||
// --------------------------------------------------------------------------- | |||
const { middleware: podcastCacheRouteMiddleware, instance: podcastApiCache } = cacheRouteFactory({ | |||
headerBlacklist: [ 'Content-Type' ] | |||
}) | |||
for (const event of ([ 'video-created', 'video-updated', 'video-deleted' ] as const)) { | |||
InternalEventEmitter.Instance.on(event, ({ video }) => { | |||
if (video.remote) return | |||
podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: video.channelId })) | |||
}) | |||
} | |||
for (const event of ([ 'channel-updated', 'channel-deleted' ] as const)) { | |||
InternalEventEmitter.Instance.on(event, ({ channel }) => { | |||
podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: channel.id })) | |||
}) | |||
} | |||
// --------------------------------------------------------------------------- | |||
videoPodcastFeedsRouter.get('/feeds/podcast/videos.xml', | |||
setFeedPodcastContentType, | |||
videoFeedsPodcastSetCacheKey, | |||
podcastCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS), | |||
asyncMiddleware(videoFeedsPodcastValidator), | |||
asyncMiddleware(generateVideoPodcastFeed) | |||
) | |||
// --------------------------------------------------------------------------- | |||
export { | |||
videoPodcastFeedsRouter | |||
} | |||
// --------------------------------------------------------------------------- | |||
async function generateVideoPodcastFeed (req: express.Request, res: express.Response) { | |||
const videoChannel = res.locals.videoChannel | |||
const { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink } = await buildFeedMetadata({ videoChannel }) | |||
const data = await getVideosForFeeds({ | |||
sort: '-publishedAt', | |||
nsfw: buildNSFWFilter(), | |||
// Prevent podcast feeds from listing videos in other instances | |||
// helps prevent duplicates when they are indexed -- only the author should control them | |||
isLocal: true, | |||
include: VideoInclude.FILES, | |||
videoChannelId: videoChannel?.id | |||
}) | |||
const customTags: CustomTag[] = await Hooks.wrapObject( | |||
[], | |||
'filter:feed.podcast.channel.create-custom-tags.result', | |||
{ videoChannel } | |||
) | |||
const customXMLNS: CustomXMLNS[] = await Hooks.wrapObject( | |||
[], | |||
'filter:feed.podcast.rss.create-custom-xmlns.result' | |||
) | |||
const feed = initFeed({ | |||
name, | |||
description, | |||
link, | |||
isPodcast: true, | |||
imageUrl, | |||
locked: email | |||
? { isLocked: true, email } // Default to true because we have no way of offering a redirect yet | |||
: undefined, | |||
person: [ { name: userName, href: accountLink, img: accountImageUrl } ], | |||
resourceType: 'videos', | |||
queryString: new URL(WEBSERVER.URL + req.url).search, | |||
medium: 'video', | |||
customXMLNS, | |||
customTags | |||
}) | |||
await addVideosToPodcastFeed(feed, data) | |||
// Now the feed generation is done, let's send it! | |||
return res.send(feed.podcast()).end() | |||
} | |||
type PodcastMedia = | |||
{ | |||
type: string | |||
length: number | |||
bitrate: number | |||
sources: { uri: string, contentType?: string }[] | |||
title: string | |||
language?: string | |||
} | | |||
{ | |||
sources: { uri: string }[] | |||
type: string | |||
title: string | |||
} | |||
async function generatePodcastItem (options: { | |||
video: VideoModel | |||
liveItem: boolean | |||
media: PodcastMedia[] | |||
}) { | |||
const { video, liveItem, media } = options | |||
const customTags: CustomTag[] = await Hooks.wrapObject( | |||
[], | |||
'filter:feed.podcast.video.create-custom-tags.result', | |||
{ video, liveItem } | |||
) | |||
const account = video.VideoChannel.Account | |||
const author = { | |||
name: account.getDisplayName(), | |||
href: account.getClientUrl() | |||
} | |||
return { | |||
...getCommonVideoFeedAttributes(video), | |||
trackers: video.getTrackerUrls(), | |||
author: [ author ], | |||
person: [ | |||
{ | |||
...author, | |||
img: account.Actor.hasImage(ActorImageType.AVATAR) | |||
? WEBSERVER.URL + account.Actor.Avatars[0].getStaticPath() | |||
: undefined | |||
} | |||
], | |||
media, | |||
socialInteract: [ | |||
{ | |||
uri: video.url, | |||
protocol: 'activitypub', | |||
accountUrl: account.getClientUrl() | |||
} | |||
], | |||
customTags | |||
} | |||
} | |||
async function addVideosToPodcastFeed (feed: Feed, videos: VideoModel[]) { | |||
const captionsGroup = await VideoCaptionModel.listCaptionsOfMultipleVideos(videos.map(v => v.id)) | |||
for (const video of videos) { | |||
if (!video.isLive) { | |||
await addVODPodcastItem({ feed, video, captionsGroup }) | |||
} else if (video.isLive && video.state !== VideoState.LIVE_ENDED) { | |||
await addLivePodcastItem({ feed, video }) | |||
} | |||
} | |||
} | |||
async function addVODPodcastItem (options: { | |||
feed: Feed | |||
video: VideoModel | |||
captionsGroup: { [ id: number ]: MVideoCaptionVideo[] } | |||
}) { | |||
const { feed, video, captionsGroup } = options | |||
const webVideos = video.getFormattedWebVideoFilesJSON(true) | |||
.map(f => buildVODWebVideoFile(video, f)) | |||
.sort(sortObjectComparator('bitrate', 'desc')) | |||
const streamingPlaylistFiles = buildVODStreamingPlaylists(video) | |||
// Order matters here, the first media URI will be the "default" | |||
// So web videos are default if enabled | |||
const media = [ ...webVideos, ...streamingPlaylistFiles ] | |||
const videoCaptions = buildVODCaptions(video, captionsGroup[video.id]) | |||
const item = await generatePodcastItem({ video, liveItem: false, media }) | |||
feed.addPodcastItem({ ...item, subTitle: videoCaptions }) | |||
} | |||
async function addLivePodcastItem (options: { | |||
feed: Feed | |||
video: VideoModel | |||
}) { | |||
const { feed, video } = options | |||
let status: LiveItemStatus | |||
switch (video.state) { | |||
case VideoState.WAITING_FOR_LIVE: | |||
status = LiveItemStatus.pending | |||
break | |||
case VideoState.PUBLISHED: | |||
status = LiveItemStatus.live | |||
break | |||
} | |||
const item = await generatePodcastItem({ video, liveItem: true, media: buildLiveStreamingPlaylists(video) }) | |||
feed.addPodcastLiveItem({ ...item, status, start: video.updatedAt.toISOString() }) | |||
} | |||
// --------------------------------------------------------------------------- | |||
function buildVODWebVideoFile (video: MVideo, videoFile: VideoFile) { | |||
const isAudio = videoFile.resolution.id === VideoResolution.H_NOVIDEO | |||
const type = isAudio | |||
? MIMETYPES.AUDIO.EXT_MIMETYPE[extname(videoFile.fileUrl)] | |||
: MIMETYPES.VIDEO.EXT_MIMETYPE[extname(videoFile.fileUrl)] | |||
const sources = [ | |||
{ uri: videoFile.fileUrl }, | |||
{ uri: videoFile.torrentUrl, contentType: 'application/x-bittorrent' } | |||
] | |||
if (videoFile.magnetUri) { | |||
sources.push({ uri: videoFile.magnetUri }) | |||
} | |||
return { | |||
type, | |||
title: videoFile.resolution.label, | |||
length: videoFile.size, | |||
bitrate: videoFile.size / video.duration * 8, | |||
language: video.language, | |||
sources | |||
} | |||
} | |||
function buildVODStreamingPlaylists (video: MVideoFullLight) { | |||
const hls = video.getHLSPlaylist() | |||
if (!hls) return [] | |||
return [ | |||
{ | |||
type: 'application/x-mpegURL', | |||
title: 'HLS', | |||
sources: [ | |||
{ uri: hls.getMasterPlaylistUrl(video) } | |||
], | |||
language: video.language | |||
} | |||
] | |||
} | |||
function buildLiveStreamingPlaylists (video: MVideoFullLight) { | |||
const hls = video.getHLSPlaylist() | |||
return [ | |||
{ | |||
type: 'application/x-mpegURL', | |||
title: `HLS live stream`, | |||
sources: [ | |||
{ uri: hls.getMasterPlaylistUrl(video) } | |||
], | |||
language: video.language | |||
} | |||
] | |||
} | |||
function buildVODCaptions (video: MVideo, videoCaptions: MVideoCaptionVideo[]) { | |||
return videoCaptions.map(caption => { | |||
const type = MIMETYPES.VIDEO_CAPTIONS.EXT_MIMETYPE[extname(caption.filename)] | |||
if (!type) return null | |||
return { | |||
url: caption.getFileUrl(video), | |||
language: caption.language, | |||
type, | |||
rel: 'captions' | |||
} | |||
}).filter(c => c) | |||
} |
@@ -80,6 +80,10 @@ function isUserAutoPlayNextVideoPlaylistValid (value: any) { | |||
return isBooleanValid(value) | |||
} | |||
function isUserEmailPublicValid (value: any) { | |||
return isBooleanValid(value) | |||
} | |||
function isUserNoModal (value: any) { | |||
return isBooleanValid(value) | |||
} | |||
@@ -114,5 +118,6 @@ export { | |||
isUserAutoPlayNextVideoPlaylistValid, | |||
isUserDisplayNameValid, | |||
isUserDescriptionValid, | |||
isUserEmailPublicValid, | |||
isUserNoModal | |||
} |
@@ -27,7 +27,7 @@ import { CONFIG, registerConfigChangedHandler } from './config' | |||
// --------------------------------------------------------------------------- | |||
const LAST_MIGRATION_VERSION = 770 | |||
const LAST_MIGRATION_VERSION = 775 | |||
// --------------------------------------------------------------------------- | |||
@@ -634,7 +634,8 @@ const MIMETYPES = { | |||
'text/vtt': '.vtt', | |||
'application/x-subrip': '.srt', | |||
'text/plain': '.srt' | |||
} | |||
}, | |||
EXT_MIMETYPE: null as { [ id: string ]: string } | |||
}, | |||
TORRENT: { | |||
MIMETYPE_EXT: { | |||
@@ -649,6 +650,7 @@ const MIMETYPES = { | |||
} | |||
MIMETYPES.AUDIO.EXT_MIMETYPE = invert(MIMETYPES.AUDIO.MIMETYPE_EXT) | |||
MIMETYPES.IMAGE.EXT_MIMETYPE = invert(MIMETYPES.IMAGE.MIMETYPE_EXT) | |||
MIMETYPES.VIDEO_CAPTIONS.EXT_MIMETYPE = invert(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT) | |||
const BINARY_CONTENT_TYPES = new Set([ | |||
'binary/octet-stream', | |||
@@ -0,0 +1,25 @@ | |||
import * as Sequelize from 'sequelize' | |||
async function up (utils: { | |||
transaction: Sequelize.Transaction | |||
queryInterface: Sequelize.QueryInterface | |||
sequelize: Sequelize.Sequelize | |||
}): Promise<void> { | |||
const data = { | |||
type: Sequelize.BOOLEAN, | |||
allowNull: false, | |||
defaultValue: false | |||
} | |||
await utils.queryInterface.addColumn('user', 'emailPublic', data) | |||
} | |||
function down (options) { | |||
throw new Error('Not implemented.') | |||
} | |||
export { | |||
up, | |||
down | |||
} |
@@ -1,6 +1,6 @@ | |||
import { sequelizeTypescript } from '@server/initializers/database' | |||
import { getServerActor } from '@server/models/application/application' | |||
import { MAccountBlocklist, MAccountId, MAccountServer, MServerBlocklist } from '@server/types/models' | |||
import { MAccountBlocklist, MAccountId, MAccountHost, MServerBlocklist } from '@server/types/models' | |||
import { AccountBlocklistModel } from '../models/account/account-blocklist' | |||
import { ServerBlocklistModel } from '../models/server/server-blocklist' | |||
@@ -34,7 +34,7 @@ function removeServerFromBlocklist (serverBlock: MServerBlocklist) { | |||
}) | |||
} | |||
async function isBlockedByServerOrAccount (targetAccount: MAccountServer, userAccount?: MAccountId) { | |||
async function isBlockedByServerOrAccount (targetAccount: MAccountHost, userAccount?: MAccountId) { | |||
const serverAccountId = (await getServerActor()).Account.id | |||
const sourceAccounts = [ serverAccountId ] | |||
@@ -27,7 +27,7 @@ import { AccountModel } from '../models/account/account' | |||
import { VideoModel } from '../models/video/video' | |||
import { VideoChannelModel } from '../models/video/video-channel' | |||
import { VideoPlaylistModel } from '../models/video/video-playlist' | |||
import { MAccountActor, MChannelActor, MVideo, MVideoPlaylist } from '../types/models' | |||
import { MAccountHost, MChannelHost, MVideo, MVideoPlaylist } from '../types/models' | |||
import { getActivityStreamDuration } from './activitypub/activity' | |||
import { getBiggestActorImage } from './actor-image' | |||
import { Hooks } from './plugins/hooks' | |||
@@ -260,7 +260,7 @@ class ClientHtml { | |||
} | |||
private static async getAccountOrChannelHTMLPage ( | |||
loader: () => Promise<MAccountActor | MChannelActor>, | |||
loader: () => Promise<MAccountHost | MChannelHost>, | |||
req: express.Request, | |||
res: express.Response | |||
) { | |||
@@ -280,7 +280,7 @@ class ClientHtml { | |||
let customHtml = ClientHtml.addTitleTag(html, entity.getDisplayName()) | |||
customHtml = ClientHtml.addDescriptionTag(customHtml, description) | |||
const url = entity.getLocalUrl() | |||
const url = entity.getClientUrl() | |||
const originUrl = entity.Actor.url | |||
const siteName = CONFIG.INSTANCE.NAME | |||
const title = entity.getDisplayName() | |||
@@ -37,7 +37,7 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache <string> { | |||
const preview = video.getPreview() | |||
const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, preview.filename) | |||
const remoteUrl = preview.getFileUrl(video) | |||
const remoteUrl = preview.getOriginFileUrl(video) | |||
try { | |||
await doRequestAndSaveToFile(remoteUrl, destPath) | |||
@@ -0,0 +1,35 @@ | |||
import { MChannel, MVideo } from '@server/types/models' | |||
import { EventEmitter } from 'events' | |||
export interface PeerTubeInternalEvents { | |||
'video-created': (options: { video: MVideo }) => void | |||
'video-updated': (options: { video: MVideo }) => void | |||
'video-deleted': (options: { video: MVideo }) => void | |||
'channel-created': (options: { channel: MChannel }) => void | |||
'channel-updated': (options: { channel: MChannel }) => void | |||
'channel-deleted': (options: { channel: MChannel }) => void | |||
} | |||
declare interface InternalEventEmitter { | |||
on<U extends keyof PeerTubeInternalEvents>( | |||
event: U, listener: PeerTubeInternalEvents[U] | |||
): this | |||
emit<U extends keyof PeerTubeInternalEvents>( | |||
event: U, ...args: Parameters<PeerTubeInternalEvents[U]> | |||
): boolean | |||
} | |||
class InternalEventEmitter extends EventEmitter { | |||
private static instance: InternalEventEmitter | |||
static get Instance () { | |||
return this.instance || (this.instance = new this()) | |||
} | |||
} | |||
export { | |||
InternalEventEmitter | |||
} |
@@ -399,6 +399,8 @@ class LiveManager { | |||
} | |||
PeerTubeSocket.Instance.sendVideoLiveNewState(video) | |||
Hooks.runAction('action:live.video.state.updated', { video }) | |||
} catch (err) { | |||
logger.error('Cannot save/federate live video %d.', videoId, { err, ...localLTags }) | |||
} | |||
@@ -466,6 +468,8 @@ class LiveManager { | |||
PeerTubeSocket.Instance.sendVideoLiveNewState(fullVideo) | |||
await federateVideoIfNeeded(fullVideo, false) | |||
Hooks.runAction('action:live.video.state.updated', { video: fullVideo }) | |||
} catch (err) { | |||
logger.error('Cannot save/federate new video state of live streaming of video %s.', videoUUID, { err, ...lTags(videoUUID) }) | |||
} | |||
@@ -133,7 +133,7 @@ function buildVideosHelpers () { | |||
const thumbnails = video.Thumbnails.map(t => ({ | |||
type: t.type, | |||
url: t.getFileUrl(video), | |||
url: t.getOriginFileUrl(video), | |||
path: t.getPath() | |||
})) | |||
@@ -17,12 +17,22 @@ function cacheRoute (duration: string) { | |||
function cacheRouteFactory (options: APICacheOptions) { | |||
const instance = new ApiCache({ ...defaultOptions, ...options }) | |||
return instance.buildMiddleware.bind(instance) | |||
return { instance, middleware: instance.buildMiddleware.bind(instance) } | |||
} | |||
// --------------------------------------------------------------------------- | |||
function buildPodcastGroupsCache (options: { | |||
channelId: number | |||
}) { | |||
return 'podcast-feed-' + options.channelId | |||
} | |||
// --------------------------------------------------------------------------- | |||
export { | |||
cacheRoute, | |||
cacheRouteFactory | |||
cacheRouteFactory, | |||
buildPodcastGroupsCache | |||
} |
@@ -27,7 +27,13 @@ export class ApiCache { | |||
private readonly options: APICacheOptions | |||
private readonly timers: { [ id: string ]: NodeJS.Timeout } = {} | |||
private readonly index: { all: string[] } = { all: [] } | |||
private readonly index = { | |||
groups: [] as string[], | |||
all: [] as string[] | |||
} | |||
// Cache keys per group | |||
private groups: { [groupIndex: string]: string[] } = {} | |||
constructor (options: APICacheOptions) { | |||
this.options = { | |||
@@ -43,7 +49,7 @@ export class ApiCache { | |||
return asyncMiddleware( | |||
async (req: express.Request, res: express.Response, next: express.NextFunction) => { | |||
const key = Redis.Instance.getPrefix() + 'api-cache-' + req.originalUrl | |||
const key = this.getCacheKey(req) | |||
const redis = Redis.Instance.getClient() | |||
if (!Redis.Instance.isConnected()) return this.makeResponseCacheable(res, next, key, duration) | |||
@@ -62,6 +68,29 @@ export class ApiCache { | |||
) | |||
} | |||
clearGroupSafe (group: string) { | |||
const run = async () => { | |||
const cacheKeys = this.groups[group] | |||
if (!cacheKeys) return | |||
for (const key of cacheKeys) { | |||
try { | |||
await this.clear(key) | |||
} catch (err) { | |||
logger.error('Cannot clear ' + key, { err }) | |||
} | |||
} | |||
delete this.groups[group] | |||
} | |||
void run() | |||
} | |||
private getCacheKey (req: express.Request) { | |||
return Redis.Instance.getPrefix() + 'api-cache-' + req.originalUrl | |||
} | |||
private shouldCacheResponse (response: express.Response) { | |||
if (!response) return false | |||
if (this.options.excludeStatus.includes(response.statusCode)) return false | |||
@@ -69,8 +98,16 @@ export class ApiCache { | |||
return true | |||
} | |||
private addIndexEntries (key: string) { | |||
private addIndexEntries (key: string, res: express.Response) { | |||
this.index.all.unshift(key) | |||
const groups = res.locals.apicacheGroups || [] | |||
for (const group of groups) { | |||
if (!this.groups[group]) this.groups[group] = [] | |||
this.groups[group].push(key) | |||
} | |||
} | |||
private filterBlacklistedHeaders (headers: OutgoingHttpHeaders) { | |||
@@ -177,7 +214,7 @@ export class ApiCache { | |||
self.accumulateContent(res, content) | |||
if (res.locals.apicache.cacheable && res.locals.apicache.content) { | |||
self.addIndexEntries(key) | |||
self.addIndexEntries(key, res) | |||
const headers = res.locals.apicache.headers || res.getHeaders() | |||
const cacheObject = self.createCacheObject( | |||
@@ -3,6 +3,7 @@ import { param, query } from 'express-validator' | |||
import { HttpStatusCode } from '../../../shared/models/http/http-error-codes' | |||
import { isValidRSSFeed } from '../../helpers/custom-validators/feeds' | |||
import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc' | |||
import { buildPodcastGroupsCache } from '../cache' | |||
import { | |||
areValidationErrors, | |||
checkCanSeeVideo, | |||
@@ -43,6 +44,21 @@ function setFeedFormatContentType (req: express.Request, res: express.Response, | |||
acceptableContentTypes = [ 'application/xml', 'text/xml' ] | |||
} | |||
return feedContentTypeResponse(req, res, next, acceptableContentTypes) | |||
} | |||
function setFeedPodcastContentType (req: express.Request, res: express.Response, next: express.NextFunction) { | |||
const acceptableContentTypes = [ 'application/rss+xml', 'application/xml', 'text/xml' ] | |||
return feedContentTypeResponse(req, res, next, acceptableContentTypes) | |||
} | |||
function feedContentTypeResponse ( | |||
req: express.Request, | |||
res: express.Response, | |||
next: express.NextFunction, | |||
acceptableContentTypes: string[] | |||
) { | |||
if (req.accepts(acceptableContentTypes)) { | |||
res.set('Content-Type', req.accepts(acceptableContentTypes) as string) | |||
} else { | |||
@@ -55,6 +71,8 @@ function setFeedFormatContentType (req: express.Request, res: express.Response, | |||
return next() | |||
} | |||
// --------------------------------------------------------------------------- | |||
const videoFeedsValidator = [ | |||
query('accountId') | |||
.optional() | |||
@@ -82,6 +100,31 @@ const videoFeedsValidator = [ | |||
} | |||
] | |||
// --------------------------------------------------------------------------- | |||
const videoFeedsPodcastValidator = [ | |||
query('videoChannelId') | |||
.custom(isIdValid), | |||
async (req: express.Request, res: express.Response, next: express.NextFunction) => { | |||
if (areValidationErrors(req, res)) return | |||
if (!await doesVideoChannelIdExist(req.query.videoChannelId, res)) return | |||
return next() | |||
} | |||
] | |||
const videoFeedsPodcastSetCacheKey = [ | |||
(req: express.Request, res: express.Response, next: express.NextFunction) => { | |||
if (req.query.videoChannelId) { | |||
res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ] | |||
} | |||
return next() | |||
} | |||
] | |||
// --------------------------------------------------------------------------- | |||
const videoSubscriptionFeedsValidator = [ | |||
query('accountId') | |||
.custom(isIdValid), | |||
@@ -126,7 +169,10 @@ const videoCommentsFeedsValidator = [ | |||
export { | |||
feedsFormatValidator, | |||
setFeedFormatContentType, | |||
setFeedPodcastContentType, | |||
videoFeedsValidator, | |||
videoFeedsPodcastValidator, | |||
videoSubscriptionFeedsValidator, | |||
videoFeedsPodcastSetCacheKey, | |||
videoCommentsFeedsValidator | |||
} |
@@ -11,6 +11,7 @@ import { | |||
isUserBlockedReasonValid, | |||
isUserDescriptionValid, | |||
isUserDisplayNameValid, | |||
isUserEmailPublicValid, | |||
isUserNoModal, | |||
isUserNSFWPolicyValid, | |||
isUserP2PEnabledValid, | |||
@@ -213,6 +214,9 @@ const usersUpdateMeValidator = [ | |||
body('password') | |||
.optional() | |||
.custom(isUserPasswordValid), | |||
body('emailPublic') | |||
.optional() | |||
.custom(isUserEmailPublicValid), | |||
body('email') | |||
.optional() | |||
.isEmail(), | |||
@@ -28,8 +28,9 @@ import { | |||
MAccountAP, | |||
MAccountDefault, | |||
MAccountFormattable, | |||
MAccountHost, | |||
MAccountSummaryFormattable, | |||
MChannelActor | |||
MChannelHost | |||
} from '../../types/models' | |||
import { ActorModel } from '../actor/actor' | |||
import { ActorFollowModel } from '../actor/actor-follow' | |||
@@ -410,10 +411,6 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> { | |||
.findAll(query) | |||
} | |||
getClientUrl () { | |||
return WEBSERVER.URL + '/accounts/' + this.Actor.getIdentifier() | |||
} | |||
toFormattedJSON (this: MAccountFormattable): Account { | |||
return { | |||
...this.Actor.toFormattedJSON(), | |||
@@ -463,8 +460,9 @@ export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> { | |||
return this.name | |||
} | |||
getLocalUrl (this: MAccountActor | MChannelActor) { | |||
return WEBSERVER.URL + `/accounts/` + this.Actor.preferredUsername | |||
// Avoid error when running this method on MAccount... | MChannel... | |||
getClientUrl (this: MAccountHost | MChannelHost) { | |||
return WEBSERVER.URL + '/a/' + this.Actor.getIdentifier() | |||
} | |||
isBlocked () { | |||
@@ -46,8 +46,8 @@ import { | |||
MActorFormattable, | |||
MActorFull, | |||
MActorHost, | |||
MActorHostOnly, | |||
MActorId, | |||
MActorServer, | |||
MActorSummaryFormattable, | |||
MActorUrl, | |||
MActorWithInboxes | |||
@@ -663,15 +663,15 @@ export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> { | |||
return this.serverId === null | |||
} | |||
getWebfingerUrl (this: MActorServer) { | |||
getWebfingerUrl (this: MActorHost) { | |||
return 'acct:' + this.preferredUsername + '@' + this.getHost() | |||
} | |||
getIdentifier () { | |||
getIdentifier (this: MActorHost) { | |||
return this.Server ? `${this.preferredUsername}@${this.Server.host}` : this.preferredUsername | |||
} | |||
getHost (this: MActorHost) { | |||
getHost (this: MActorHostOnly) { | |||
return this.Server ? this.Server.host : WEBSERVER.HOST | |||
} | |||
@@ -404,6 +404,11 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> { | |||
@Column | |||
lastLoginDate: Date | |||
@AllowNull(false) | |||
@Default(false) | |||
@Column | |||
emailPublic: boolean | |||
@AllowNull(true) | |||
@Default(null) | |||
@Column | |||
@@ -880,6 +885,7 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> { | |||
theme: getThemeOrDefault(this.theme, DEFAULT_USER_THEME_NAME), | |||
pendingEmail: this.pendingEmail, | |||
emailPublic: this.emailPublic, | |||
emailVerified: this.emailVerified, | |||
nsfwPolicy: this.nsfwPolicy, | |||
@@ -459,7 +459,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject { | |||
icon: icons.map(i => ({ | |||
type: 'Image', | |||
url: i.getFileUrl(video), | |||
url: i.getOriginFileUrl(video), | |||
mediaType: 'image/jpeg', | |||
width: i.width, | |||
height: i.height | |||
@@ -164,7 +164,7 @@ export class ThumbnailModel extends Model<Partial<AttributesOnly<ThumbnailModel> | |||
return join(directory, filename) | |||
} | |||
getFileUrl (video: MVideo) { | |||
getOriginFileUrl (video: MVideo) { | |||
const staticPath = ThumbnailModel.types[this.type].staticPath + this.filename | |||