Improve podcast feed
This commit is contained in:
parent
21f0fbde0d
commit
c88cb21663
|
@ -2,7 +2,7 @@ import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http'
|
|||
import { Injectable } from '@angular/core'
|
||||
import { AuthService, ComponentPaginationLight, ConfirmService, RestExtractor, RestService, ServerService, UserService } from '@app/core'
|
||||
import { objectToFormData } from '@app/helpers'
|
||||
import { arrayify } from '@peertube/peertube-core-utils'
|
||||
import { arrayify, buildDownloadFilesUrl } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
BooleanBothQuery,
|
||||
FeedFormat,
|
||||
|
@ -57,7 +57,6 @@ export type CommonVideoParams = {
|
|||
|
||||
@Injectable()
|
||||
export class VideoService {
|
||||
static BASE_VIDEO_DOWNLOAD_URL = environment.originServerUrl + '/download/videos/generate'
|
||||
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'
|
||||
|
@ -385,14 +384,12 @@ export class VideoService {
|
|||
}) {
|
||||
const { video, files, videoFileToken } = options
|
||||
|
||||
if (files.length === 0) throw new Error('Cannot generate download URL without files')
|
||||
|
||||
let url = `${VideoService.BASE_VIDEO_DOWNLOAD_URL}/${video.uuid}?`
|
||||
url += files.map(f => 'videoFileIds=' + f.id).join('&')
|
||||
|
||||
if (videoFileToken) url += `&videoFileToken=${videoFileToken}`
|
||||
|
||||
return url
|
||||
return buildDownloadFilesUrl({
|
||||
baseUrl: environment.originServerUrl,
|
||||
videoFiles: files.map(f => f.id),
|
||||
videoUUID: video.uuid,
|
||||
videoFileToken
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
@ -116,7 +116,7 @@
|
|||
"@opentelemetry/sdk-trace-node": "^1.15.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.15.1",
|
||||
"@peertube/bittorrent-tracker-server": "^11.1.2",
|
||||
"@peertube/feed": "^5.1.3",
|
||||
"@peertube/feed": "^5.2.0",
|
||||
"@peertube/http-signature": "^1.7.0",
|
||||
"@smithy/node-http-handler": "^4.0.2",
|
||||
"@uploadx/core": "^6.0.0",
|
||||
|
@ -188,6 +188,7 @@
|
|||
"srt-to-vtt": "^1.1.2",
|
||||
"tslib": "^2.0.0",
|
||||
"useragent": "^2.3.0",
|
||||
"uuid": "^11.0.5",
|
||||
"validator": "^13.0.0",
|
||||
"webfinger.js": "^2.6.6",
|
||||
"winston": "3.17.0",
|
||||
|
|
|
@ -31,6 +31,22 @@ function queryParamsToObject (entries: URLSearchParams) {
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildDownloadFilesUrl (options: {
|
||||
baseUrl: string
|
||||
videoUUID: string
|
||||
videoFiles: number[]
|
||||
videoFileToken?: string
|
||||
}) {
|
||||
const { baseUrl, videoFiles, videoUUID, videoFileToken } = options
|
||||
|
||||
let url = `${baseUrl}/download/videos/generate/${videoUUID}?`
|
||||
url += videoFiles.map(f => 'videoFileIds=' + f).join('&')
|
||||
|
||||
if (videoFileToken) url += `&videoFileToken=${videoFileToken}`
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
function buildPlaylistLink (playlist: Pick<VideoPlaylist, 'shortUUID'>, base?: string) {
|
||||
return (base ?? window.location.origin) + buildPlaylistWatchPath(playlist)
|
||||
}
|
||||
|
@ -141,6 +157,8 @@ export {
|
|||
removeQueryParams,
|
||||
queryParamsToObject,
|
||||
|
||||
buildDownloadFilesUrl,
|
||||
|
||||
buildPlaylistLink,
|
||||
buildVideoLink,
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import short, { SUUID } from 'short-uuid'
|
||||
import { v5 } from 'uuid'
|
||||
|
||||
const translator = short()
|
||||
|
||||
|
@ -28,4 +29,8 @@ export function isShortUUID (value: string) {
|
|||
return value.length === translator.maxLength
|
||||
}
|
||||
|
||||
export function buildUUIDv5FromURL (url: string) {
|
||||
return v5(url, v5.URL)
|
||||
}
|
||||
|
||||
export type { SUUID }
|
||||
|
|
|
@ -51,7 +51,7 @@ describe('Test syndication feeds', () => {
|
|||
serverHLSOnly = await createSingleServer(3)
|
||||
|
||||
await setAccessTokensToServers([ ...servers, serverHLSOnly ])
|
||||
await setDefaultChannelAvatar(servers[0])
|
||||
await setDefaultChannelAvatar([ servers[0], serverHLSOnly ])
|
||||
await setDefaultVideoChannel(servers)
|
||||
await doubleFollow(servers[0], servers[1])
|
||||
|
||||
|
@ -107,7 +107,7 @@ describe('Test syndication feeds', () => {
|
|||
await servers[0].comments.createThread({ videoId: id, text: 'comment on password protected video' })
|
||||
}
|
||||
|
||||
await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } })
|
||||
await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video', nsfw: true } })
|
||||
|
||||
await waitJobs([ ...servers, serverHLSOnly ])
|
||||
|
||||
|
@ -156,7 +156,7 @@ describe('Test syndication feeds', () => {
|
|||
|
||||
describe('Podcast feed', function () {
|
||||
|
||||
it('Should contain a valid podcast:alternateEnclosure', async function () {
|
||||
it('Should contain a valid podcast enclosures', async function () {
|
||||
// Since podcast feeds should only work on the server they originate on,
|
||||
// only test the first server where the videos reside
|
||||
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
|
||||
|
@ -171,6 +171,9 @@ describe('Test syndication feeds', () => {
|
|||
|
||||
const enclosure = xmlDoc.rss.channel.item.enclosure
|
||||
expect(enclosure).to.exist
|
||||
expect(enclosure['@_url']).to.contain(`${servers[0].url}/static/web-videos/`)
|
||||
expect(enclosure['@_type']).to.equal('video/webm')
|
||||
|
||||
const alternateEnclosure = xmlDoc.rss.channel.item['podcast:alternateEnclosure']
|
||||
expect(alternateEnclosure).to.exist
|
||||
|
||||
|
@ -187,7 +190,7 @@ describe('Test syndication feeds', () => {
|
|||
expect(alternateEnclosure['podcast:source'][2]['@_uri']).to.contain('magnet:?')
|
||||
})
|
||||
|
||||
it('Should contain a valid podcast:alternateEnclosure with HLS only', async function () {
|
||||
it('Should contain a valid podcast enclosures with HLS only', async function () {
|
||||
const rss = await serverHLSOnly.feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
|
||||
expect(XMLValidator.validate(rss)).to.be.true
|
||||
|
||||
|
@ -199,16 +202,25 @@ describe('Test syndication feeds', () => {
|
|||
expect(itemGuid['@_isPermaLink']).to.equal(true)
|
||||
|
||||
const enclosure = xmlDoc.rss.channel.item.enclosure
|
||||
const alternateEnclosure = xmlDoc.rss.channel.item['podcast:alternateEnclosure']
|
||||
expect(alternateEnclosure).to.exist
|
||||
expect(enclosure).to.exist
|
||||
expect(enclosure['@_url']).to.contain(`${serverHLSOnly.url}/download/videos/generate/`)
|
||||
expect(enclosure['@_type']).to.equal('audio/mp4')
|
||||
|
||||
expect(alternateEnclosure['@_type']).to.equal('application/x-mpegURL')
|
||||
expect(alternateEnclosure['@_lang']).to.equal('zh')
|
||||
expect(alternateEnclosure['@_title']).to.equal('HLS')
|
||||
expect(alternateEnclosure['@_default']).to.equal(true)
|
||||
const alternateEnclosures = xmlDoc.rss.channel.item['podcast:alternateEnclosure']
|
||||
expect(alternateEnclosures).to.be.an('array')
|
||||
|
||||
expect(alternateEnclosure['podcast:source']['@_uri']).to.contain('-master.m3u8')
|
||||
expect(alternateEnclosure['podcast:source']['@_uri']).to.equal(enclosure['@_url'])
|
||||
const audioEnclosure = alternateEnclosures.find(e => e['@_type'] === 'audio/mp4')
|
||||
expect(audioEnclosure).to.exist
|
||||
expect(audioEnclosure['@_default']).to.equal(true)
|
||||
expect(audioEnclosure['podcast:source']['@_uri']).to.equal(enclosure['@_url'])
|
||||
|
||||
const hlsEnclosure = alternateEnclosures.find(e => e['@_type'] === 'application/x-mpegURL')
|
||||
expect(hlsEnclosure).to.exist
|
||||
expect(hlsEnclosure['@_lang']).to.equal('zh')
|
||||
expect(hlsEnclosure['@_title']).to.equal('HLS')
|
||||
expect(hlsEnclosure['@_default']).to.equal(false)
|
||||
|
||||
expect(hlsEnclosure['podcast:source']['@_uri']).to.contain('-master.m3u8')
|
||||
})
|
||||
|
||||
it('Should contain a valid podcast:socialInteract', async function () {
|
||||
|
@ -282,8 +294,7 @@ describe('Test syndication feeds', () => {
|
|||
const xmlDoc = parser.parse(rss)
|
||||
const liveItem = xmlDoc.rss.channel['podcast:liveItem']
|
||||
expect(liveItem.title).to.equal('live-0')
|
||||
expect(liveItem.guid['@_isPermaLink']).to.equal(false)
|
||||
expect(liveItem.guid['#text']).to.contain(`${uuid}_`)
|
||||
expect(liveItem.guid['@_isPermaLink']).to.equal(true)
|
||||
expect(liveItem['@_status']).to.equal('live')
|
||||
|
||||
const enclosure = liveItem.enclosure
|
||||
|
@ -302,6 +313,26 @@ describe('Test syndication feeds', () => {
|
|||
|
||||
await waitJobs(servers)
|
||||
})
|
||||
|
||||
it('Should have valid itunes metadata', async function () {
|
||||
const rss = await serverHLSOnly.feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
|
||||
expect(XMLValidator.validate(rss)).to.be.true
|
||||
|
||||
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
|
||||
const xmlDoc = parser.parse(rss)
|
||||
|
||||
const channel = xmlDoc.rss.channel
|
||||
|
||||
expect(channel['language']).to.equal('zh')
|
||||
|
||||
expect(channel['category']).to.equal('Sports')
|
||||
expect(channel['itunes:category']['@_text']).to.equal('Sports')
|
||||
|
||||
expect(channel['itunes:explicit']).to.equal(true)
|
||||
|
||||
expect(channel['itunes:image']['@_href']).to.exist
|
||||
await makeRawRequest({ url: channel['itunes:image']['@_href'], expectedStatus: HttpStatusCode.OK_200 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('JSON feed', function () {
|
||||
|
|
|
@ -252,7 +252,6 @@ async function downloadGeneratedVideoFile (req: express.Request, res: express.Re
|
|||
: maxResolutionFile.extname
|
||||
|
||||
const downloadFilename = buildDownloadFilename({ video, extname })
|
||||
res.setHeader('Content-Length', videoFiles.reduce((p, f) => p + f.size, 0))
|
||||
res.setHeader('Content-disposition', `attachment; filename="${encodeURI(downloadFilename)}`)
|
||||
|
||||
await muxToMergeVideoFiles({ video, videoFiles, output: res })
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Feed } from '@peertube/feed'
|
||||
import { CustomTag, CustomXMLNS, Person } from '@peertube/feed/lib/typings/index.js'
|
||||
import { maxBy, pick } from '@peertube/peertube-core-utils'
|
||||
import { pick } from '@peertube/peertube-core-utils'
|
||||
import { ActorImageType } from '@peertube/peertube-models'
|
||||
import { mdToPlainText } from '@server/helpers/markdown.js'
|
||||
import { CONFIG } from '@server/initializers/config.js'
|
||||
|
@ -14,13 +14,16 @@ export function initFeed (parameters: {
|
|||
description: string
|
||||
imageUrl: string
|
||||
isPodcast: boolean
|
||||
nsfw?: boolean
|
||||
guid?: string
|
||||
link?: string
|
||||
locked?: { isLocked: boolean, email: string }
|
||||
author?: {
|
||||
name: string
|
||||
link: string
|
||||
imageUrl: string
|
||||
}
|
||||
category?: string
|
||||
language?: string
|
||||
person?: Person[]
|
||||
resourceType?: 'videos' | 'video-comments'
|
||||
queryString?: string
|
||||
|
@ -31,20 +34,29 @@ export function initFeed (parameters: {
|
|||
customTags?: CustomTag[]
|
||||
}) {
|
||||
const webserverUrl = WEBSERVER.URL
|
||||
const { name, description, link, imageUrl, isPodcast, resourceType, queryString, medium } = parameters
|
||||
const { name, description, link, imageUrl, category, isPodcast, resourceType, queryString, medium, nsfw } = parameters
|
||||
|
||||
return new Feed({
|
||||
const feed = new Feed({
|
||||
title: name,
|
||||
description: mdToPlainText(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: `PeerTube - ${webserverUrl}`,
|
||||
|
||||
medium: medium || 'video',
|
||||
|
||||
nsfw: nsfw ?? false,
|
||||
|
||||
feedLinks: {
|
||||
json: `${webserverUrl}/feeds/${resourceType}.json${queryString}`,
|
||||
atom: `${webserverUrl}/feeds/${resourceType}.atom${queryString}`,
|
||||
|
@ -53,8 +65,24 @@ export function initFeed (parameters: {
|
|||
: `${webserverUrl}/feeds/${resourceType}.xml${queryString}`
|
||||
},
|
||||
|
||||
...pick(parameters, [ 'stunServers', 'trackers', 'customXMLNS', 'customTags', 'author', 'person', 'locked' ])
|
||||
...pick(parameters, [
|
||||
'guid',
|
||||
'language',
|
||||
'stunServers',
|
||||
'trackers',
|
||||
'customXMLNS',
|
||||
'customTags',
|
||||
'author',
|
||||
'person',
|
||||
'locked'
|
||||
])
|
||||
})
|
||||
|
||||
if (category) {
|
||||
feed.addCategory(category)
|
||||
}
|
||||
|
||||
return feed
|
||||
}
|
||||
|
||||
export function sendFeed (feed: Feed, req: express.Request, res: express.Response) {
|
||||
|
@ -88,43 +116,33 @@ export async function buildFeedMetadata (options: {
|
|||
const { video, videoChannel, account } = options
|
||||
|
||||
let imageUrl = WEBSERVER.URL + '/client/assets/images/icons/icon-96x96.png'
|
||||
let accountImageUrl: string
|
||||
let ownerImageUrl: string
|
||||
let name: string
|
||||
let userName: string
|
||||
let description: string
|
||||
let email: string
|
||||
let link: string
|
||||
let accountLink: string
|
||||
let ownerLink: string
|
||||
let user: MUser
|
||||
|
||||
if (videoChannel) {
|
||||
name = videoChannel.getDisplayName()
|
||||
description = videoChannel.description
|
||||
link = videoChannel.getClientUrl()
|
||||
accountLink = videoChannel.Account.getClientUrl()
|
||||
ownerLink = link = videoChannel.getClientUrl()
|
||||
|
||||
if (videoChannel.Actor.hasImage(ActorImageType.AVATAR)) {
|
||||
const videoChannelAvatar = maxBy(videoChannel.Actor.Avatars, 'width')
|
||||
imageUrl = WEBSERVER.URL + videoChannelAvatar.getStaticPath()
|
||||
}
|
||||
|
||||
if (videoChannel.Account.Actor.hasImage(ActorImageType.AVATAR)) {
|
||||
const accountAvatar = maxBy(videoChannel.Account.Actor.Avatars, 'width')
|
||||
accountImageUrl = WEBSERVER.URL + accountAvatar.getStaticPath()
|
||||
imageUrl = WEBSERVER.URL + videoChannel.Actor.getMaxQualityImage(ActorImageType.AVATAR).getStaticPath()
|
||||
ownerImageUrl = imageUrl
|
||||
}
|
||||
|
||||
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
|
||||
ownerLink = link = account.getClientUrl()
|
||||
|
||||
if (account.Actor.hasImage(ActorImageType.AVATAR)) {
|
||||
const accountAvatar = maxBy(account.Actor.Avatars, 'width')
|
||||
imageUrl = WEBSERVER.URL + accountAvatar?.getStaticPath()
|
||||
accountImageUrl = imageUrl
|
||||
imageUrl = WEBSERVER.URL + account.Actor.getMaxQualityImage(ActorImageType.AVATAR).getStaticPath()
|
||||
ownerImageUrl = imageUrl
|
||||
}
|
||||
|
||||
user = await UserModel.loadById(account.userId)
|
||||
|
@ -144,5 +162,5 @@ export async function buildFeedMetadata (options: {
|
|||
email = user.email
|
||||
}
|
||||
|
||||
return { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink }
|
||||
return { name, description, imageUrl, ownerImageUrl, email, link, ownerLink }
|
||||
}
|
||||
|
|
|
@ -1,23 +1,24 @@
|
|||
import express from 'express'
|
||||
import { extname } from 'path'
|
||||
import { Feed } from '@peertube/feed'
|
||||
import { buildDownloadFilesUrl } from '@peertube/peertube-core-utils'
|
||||
import { VideoInclude, VideoResolution } from '@peertube/peertube-models'
|
||||
import { getVideoFileMimeType } from '@server/lib/video-file.js'
|
||||
import { cacheRouteFactory } from '@server/middlewares/index.js'
|
||||
import { VideoModel } from '@server/models/video/video.js'
|
||||
import { VideoInclude, VideoResolution } from '@peertube/peertube-models'
|
||||
import express from 'express'
|
||||
import { extname } from 'path'
|
||||
import { buildNSFWFilter } from '../../helpers/express-utils.js'
|
||||
import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants.js'
|
||||
import {
|
||||
asyncMiddleware,
|
||||
commonVideosFiltersValidator,
|
||||
feedsAccountOrChannelFiltersValidator,
|
||||
feedsFormatValidator,
|
||||
setDefaultVideosSort,
|
||||
setFeedFormatContentType,
|
||||
feedsAccountOrChannelFiltersValidator,
|
||||
videosSortValidator,
|
||||
videoSubscriptionFeedsValidator
|
||||
} from '../../middlewares/index.js'
|
||||
import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed, sendFeed } from './shared/index.js'
|
||||
import { getVideoFileMimeType } from '@server/lib/video-file.js'
|
||||
|
||||
const videoFeedsRouter = express.Router()
|
||||
|
||||
|
@ -61,15 +62,15 @@ 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 { name, description, imageUrl, ownerImageUrl, link, ownerLink } = await buildFeedMetadata({ videoChannel, account })
|
||||
|
||||
const feed = initFeed({
|
||||
name,
|
||||
description,
|
||||
link,
|
||||
isPodcast: false,
|
||||
imageUrl,
|
||||
author: { name, link: accountLink, imageUrl: accountImageUrl },
|
||||
imageUrl: ownerImageUrl || imageUrl,
|
||||
author: { name, link: ownerLink },
|
||||
resourceType: 'videos',
|
||||
queryString: new URL(WEBSERVER.URL + req.url).search
|
||||
})
|
||||
|
@ -149,6 +150,9 @@ function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
|
|||
}
|
||||
})
|
||||
|
||||
const { videoFile: bestFile, separatedAudioFile: bestAudioFile } = video.getMaxQualityAudioAndVideoFiles()
|
||||
const bestFiles = [ bestFile, bestAudioFile ].filter(f => !!f)
|
||||
|
||||
feed.addItem({
|
||||
...getCommonVideoFeedAttributes(video),
|
||||
|
||||
|
@ -162,11 +166,11 @@ function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
|
|||
torrents,
|
||||
|
||||
// Enclosure
|
||||
video: videoFiles.length !== 0
|
||||
video: bestFiles.length !== 0
|
||||
? {
|
||||
url: videoFiles[0].url,
|
||||
length: videoFiles[0].fileSize,
|
||||
type: videoFiles[0].type
|
||||
url: buildDownloadFilesUrl({ baseUrl: WEBSERVER.URL, videoFiles: bestFiles.map(f => f.id), videoUUID: video.uuid }),
|
||||
length: bestFiles.reduce((p, f) => p + f.size, 0),
|
||||
type: getVideoFileMimeType('.mp4', bestFile.resolution === VideoResolution.H_NOVIDEO)
|
||||
}
|
||||
: undefined,
|
||||
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { Feed } from '@peertube/feed'
|
||||
import { CustomTag, CustomXMLNS, LiveItemStatus } from '@peertube/feed/lib/typings/index.js'
|
||||
import { maxBy, sortObjectComparator } from '@peertube/peertube-core-utils'
|
||||
import { buildDownloadFilesUrl, getResolutionLabel, maxBy, sortObjectComparator } from '@peertube/peertube-core-utils'
|
||||
import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@peertube/peertube-models'
|
||||
import { buildUUIDv5FromURL } from '@peertube/peertube-node-utils'
|
||||
import { buildNSFWFilter } from '@server/helpers/express-utils.js'
|
||||
import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
|
||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||
import { getVideoFileMimeType } from '@server/lib/video-file.js'
|
||||
|
@ -9,8 +11,7 @@ import { buildPodcastGroupsCache, cacheRouteFactory, videoFeedsPodcastSetCacheKe
|
|||
import { MVideo, MVideoCaptionVideo, MVideoFullLight } from '@server/types/models/index.js'
|
||||
import express from 'express'
|
||||
import { extname } from 'path'
|
||||
import { buildNSFWFilter } from '../../helpers/express-utils.js'
|
||||
import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants.js'
|
||||
import { MIMETYPES, ROUTE_CACHE_LIFETIME, VIDEO_CATEGORIES, WEBSERVER } from '../../initializers/constants.js'
|
||||
import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares/index.js'
|
||||
import { VideoCaptionModel } from '../../models/video/video-caption.js'
|
||||
import { VideoModel } from '../../models/video/video.js'
|
||||
|
@ -59,11 +60,16 @@ export {
|
|||
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 { name, description, imageUrl, ownerImageUrl, email, link, ownerLink } = await buildFeedMetadata({ videoChannel })
|
||||
|
||||
const nsfw = buildNSFWFilter()
|
||||
|
||||
const data = await getVideosForFeeds({
|
||||
sort: '-publishedAt',
|
||||
nsfw: buildNSFWFilter(),
|
||||
|
||||
// Only list non-NSFW videos (for Apple)
|
||||
nsfw,
|
||||
|
||||
// Prevent podcast feeds from listing videos in other instances
|
||||
// helps prevent duplicates when they are indexed -- only the author should control them
|
||||
isLocal: true,
|
||||
|
@ -71,6 +77,12 @@ async function generateVideoPodcastFeed (req: express.Request, res: express.Resp
|
|||
videoChannelId: videoChannel?.id
|
||||
})
|
||||
|
||||
const language = await VideoModel.guessLanguageOrCategoryOfChannel(videoChannel.id, 'language')
|
||||
const category = await VideoModel.guessLanguageOrCategoryOfChannel(videoChannel.id, 'category')
|
||||
const hasNSFW = nsfw !== false
|
||||
? await VideoModel.channelHasNSFWContent(videoChannel.id)
|
||||
: false
|
||||
|
||||
const customTags: CustomTag[] = await Hooks.wrapObject(
|
||||
[],
|
||||
'filter:feed.podcast.channel.create-custom-tags.result',
|
||||
|
@ -89,11 +101,17 @@ async function generateVideoPodcastFeed (req: express.Request, res: express.Resp
|
|||
isPodcast: true,
|
||||
imageUrl,
|
||||
|
||||
language: language || 'en',
|
||||
category: categoryToItunes(category),
|
||||
nsfw: hasNSFW,
|
||||
|
||||
guid: buildUUIDv5FromURL(videoChannel.Actor.url),
|
||||
|
||||
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 } ],
|
||||
person: [ { name, href: ownerLink, img: ownerImageUrl } ],
|
||||
resourceType: 'videos',
|
||||
queryString: new URL(WEBSERVER.URL + req.url).search,
|
||||
medium: 'video',
|
||||
|
@ -144,8 +162,8 @@ async function generatePodcastItem (options: {
|
|||
|
||||
const commonAttributes = getCommonVideoFeedAttributes(video)
|
||||
const guid = liveItem
|
||||
? `${video.uuid}_${video.publishedAt.toISOString()}`
|
||||
: commonAttributes.link
|
||||
? `${video.url}?publishedAt=${video.publishedAt.toISOString()}`
|
||||
: video.url
|
||||
|
||||
let personImage: string
|
||||
|
||||
|
@ -204,7 +222,7 @@ async function addVODPodcastItem (options: {
|
|||
|
||||
const webVideos = video.getFormattedWebVideoFilesJSON(true)
|
||||
.map(f => buildVODWebVideoFile(video, f))
|
||||
.sort(sortObjectComparator('bitrate', 'desc'))
|
||||
.sort(sortObjectComparator('bitrate', 'asc'))
|
||||
|
||||
const streamingPlaylistFiles = buildVODStreamingPlaylists(video)
|
||||
|
||||
|
@ -266,7 +284,31 @@ function buildVODStreamingPlaylists (video: MVideoFullLight) {
|
|||
const hls = video.getHLSPlaylist()
|
||||
if (!hls) return []
|
||||
|
||||
const { separatedAudioFile } = video.getMaxQualityAudioAndVideoFiles()
|
||||
|
||||
return [
|
||||
...hls.VideoFiles
|
||||
.sort(sortObjectComparator('resolution', 'asc'))
|
||||
.map(videoFile => {
|
||||
const files = [ videoFile ]
|
||||
|
||||
if (videoFile.resolution !== VideoResolution.H_NOVIDEO && separatedAudioFile) {
|
||||
files.push(separatedAudioFile)
|
||||
}
|
||||
|
||||
return {
|
||||
type: getVideoFileMimeType(videoFile.extname, videoFile.resolution === VideoResolution.H_NOVIDEO),
|
||||
title: getResolutionLabel(videoFile),
|
||||
length: files.reduce((p, f) => p + f.size, 0),
|
||||
language: video.language,
|
||||
sources: [
|
||||
{
|
||||
uri: buildDownloadFilesUrl({ baseUrl: WEBSERVER.URL, videoFiles: files.map(f => f.id), videoUUID: video.uuid })
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
|
||||
{
|
||||
type: 'application/x-mpegURL',
|
||||
title: 'HLS',
|
||||
|
@ -306,3 +348,28 @@ function buildVODCaptions (video: MVideo, videoCaptions: MVideoCaptionVideo[]) {
|
|||
}
|
||||
}).filter(c => c)
|
||||
}
|
||||
|
||||
function categoryToItunes (category: number) {
|
||||
const itunesMap: { [ id in keyof typeof VIDEO_CATEGORIES ]: string } = {
|
||||
1: 'Music',
|
||||
2: 'TV & Film',
|
||||
3: 'Leisure',
|
||||
4: 'Arts',
|
||||
5: 'Sports',
|
||||
6: 'Places & Travel',
|
||||
7: 'Video Games',
|
||||
8: 'Society & Culture',
|
||||
9: 'Comedy',
|
||||
10: 'Fiction',
|
||||
11: 'News',
|
||||
12: 'Leisure',
|
||||
13: 'Education',
|
||||
14: 'Society & Culture',
|
||||
15: 'Technology',
|
||||
16: 'Pets & Animals',
|
||||
17: 'Kids & Family',
|
||||
18: 'Food'
|
||||
}
|
||||
|
||||
return itunesMap[category]
|
||||
}
|
||||
|
|
|
@ -750,6 +750,7 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
|
|||
if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field)
|
||||
|
||||
if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
|
||||
if (field.toLowerCase() === 'total') return `ORDER BY "total" ${direction}`
|
||||
|
||||
if ([ 'trending', 'hot', 'best' ].includes(field.toLowerCase())) { // Sort by aggregation
|
||||
return `ORDER BY "score" ${direction}, "video"."views" ${direction}`
|
||||
|
|
|
@ -122,6 +122,7 @@ import {
|
|||
SequelizeModel,
|
||||
buildTrigramSearchIndex,
|
||||
buildWhereIdOrUUID,
|
||||
doesExist,
|
||||
getVideoSort,
|
||||
isOutdated,
|
||||
setAsUpdated,
|
||||
|
@ -1616,6 +1617,8 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
|||
return videos.map(v => v.id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// threshold corresponds to how many video the field should have to be returned
|
||||
static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
|
||||
const serverActor = await getServerActor()
|
||||
|
@ -1655,6 +1658,44 @@ export class VideoModel extends SequelizeModel<VideoModel> {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static guessLanguageOrCategoryOfChannel (channelId: number, type: 'category'): Promise<number>
|
||||
static guessLanguageOrCategoryOfChannel (channelId: number, type: 'language'): Promise<string>
|
||||
static guessLanguageOrCategoryOfChannel (channelId: number, type: 'language' | 'category') {
|
||||
const queryOptions: BuildVideosListQueryOptions = {
|
||||
attributes: [ `COUNT("${type}") AS "total"`, `"${type}"` ],
|
||||
group: `GROUP BY "${type}"`,
|
||||
having: `HAVING COUNT("${type}") > 0`,
|
||||
start: 0,
|
||||
count: 1,
|
||||
sort: '-total',
|
||||
videoChannelId: channelId,
|
||||
displayOnlyForFollower: null,
|
||||
serverAccountIdForBlock: null
|
||||
}
|
||||
|
||||
const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
|
||||
|
||||
return queryBuilder.queryVideoIds(queryOptions)
|
||||
.then(rows => {
|
||||
const result = rows[0]?.[type]
|
||||
if (!result) return undefined
|
||||
|
||||
if (type === 'category') return parseInt(result, 10)
|
||||
|
||||
return result as string
|
||||
})
|
||||
}
|
||||
|
||||
static channelHasNSFWContent (channelId: number) {
|
||||
const query = 'SELECT 1 FROM "video" WHERE "nsfw" IS TRUE AND "channelId" = $channelId LIMIT 1'
|
||||
|
||||
return doesExist({ sequelize: this.sequelize, query, bind: { channelId } })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private static async getAvailableForApi (
|
||||
options: BuildVideosListQueryOptions,
|
||||
countVideos = true
|
||||
|
|
|
@ -267,12 +267,6 @@ app.use((_req, res: express.Response) => {
|
|||
|
||||
// Catch thrown errors
|
||||
app.use((err, req, res: express.Response, _next) => {
|
||||
// Format error to be logged
|
||||
let error = 'Unknown error.'
|
||||
if (err) {
|
||||
error = err.stack || err.message || err
|
||||
}
|
||||
|
||||
// Handling Sequelize error traces
|
||||
const sql = err?.parent ? err.parent.sql : undefined
|
||||
|
||||
|
@ -281,7 +275,7 @@ app.use((err, req, res: express.Response, _next) => {
|
|||
? (process as any)._getActiveRequests()
|
||||
: undefined
|
||||
|
||||
logger.error('Error in controller.', { err: error, sql, activeRequests, url: req.originalUrl })
|
||||
logger.error('Error in controller.', { err, sql, activeRequests, url: req.originalUrl })
|
||||
|
||||
return res.fail({
|
||||
status: err.status || HttpStatusCode.INTERNAL_SERVER_ERROR_500,
|
||||
|
|
13
yarn.lock
13
yarn.lock
|
@ -1849,10 +1849,10 @@
|
|||
bufferutil "^4.0.8"
|
||||
utf-8-validate "^6.0.4"
|
||||
|
||||
"@peertube/feed@^5.1.3":
|
||||
version "5.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@peertube/feed/-/feed-5.1.3.tgz#ebf7ae180f0b3b0f14aea8dc8a980cd2a6091f2c"
|
||||
integrity sha512-73eRzZnpYBZMNS4FOsDjCfiu2CM60lCO1o3En/AB+LthYUd1dXEOG36PwQLMVlujAcHgQmGXdk08PMAUMj/Pug==
|
||||
"@peertube/feed@^5.2.0":
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@peertube/feed/-/feed-5.2.0.tgz#67b355f33e06f0217ef42a8a27e3804deafc2a96"
|
||||
integrity sha512-YyF1Ud3kW23eQ+N7RSbHM9w629o0+P2E0A8oLRmBUVpJa5O0osmW4KAh+Z/YbfJslH7dJrHHLnIFMgzg3rYKEQ==
|
||||
dependencies:
|
||||
xml-js "^1.6.11"
|
||||
|
||||
|
@ -11052,6 +11052,11 @@ uuid-parse@^1.1.0:
|
|||
resolved "https://registry.yarnpkg.com/uuid-parse/-/uuid-parse-1.1.0.tgz#7061c5a1384ae0e1f943c538094597e1b5f3a65b"
|
||||
integrity sha512-OdmXxA8rDsQ7YpNVbKSJkNzTw2I+S5WsbMDnCtIWSQaosNAcWtFuI/YK1TjzUI6nbkgiqEyh8gWngfcv8Asd9A==
|
||||
|
||||
uuid@^11.0.5:
|
||||
version "11.0.5"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.0.5.tgz#07b46bdfa6310c92c3fb3953a8720f170427fc62"
|
||||
integrity sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==
|
||||
|
||||
uuid@^8.3.2:
|
||||
version "8.3.2"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
|
||||
|
|
Loading…
Reference in New Issue