Improve podcast feed

This commit is contained in:
Chocobozzz 2025-02-18 13:47:58 +01:00
parent 29f55e9115
commit 1579d8ce1e
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
6 changed files with 62 additions and 36 deletions

View File

@ -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.2.0",
"@peertube/feed": "^5.3.0",
"@peertube/http-signature": "^1.7.0",
"@smithy/node-http-handler": "^4.0.2",
"@uploadx/core": "^6.0.0",

View File

@ -36,10 +36,11 @@ function buildDownloadFilesUrl (options: {
videoUUID: string
videoFiles: number[]
videoFileToken?: string
extension?: string
}) {
const { baseUrl, videoFiles, videoUUID, videoFileToken } = options
const { baseUrl, videoFiles, videoUUID, videoFileToken, extension = '' } = options
let url = `${baseUrl}/download/videos/generate/${videoUUID}?`
let url = `${baseUrl}/download/videos/generate/${videoUUID}${extension}?`
url += videoFiles.map(f => 'videoFileIds=' + f).join('&')
if (videoFileToken) url += `&videoFileToken=${videoFileToken}`

View File

@ -16,6 +16,7 @@ import {
stopFfmpeg,
waitJobs
} from '@peertube/peertube-server-commands'
import { expectStartWith } from '@tests/shared/checks.js'
import * as chai from 'chai'
import chaiJSONSChema from 'chai-json-schema'
import chaiXML from 'chai-xml'
@ -203,13 +204,17 @@ describe('Test syndication feeds', () => {
const enclosure = xmlDoc.rss.channel.item.enclosure
expect(enclosure).to.exist
expect(enclosure['@_url']).to.contain(`${serverHLSOnly.url}/download/videos/generate/`)
expect(enclosure['@_type']).to.equal('audio/mp4')
expectStartWith(enclosure['@_url'], `${serverHLSOnly.url}/download/videos/generate/`)
expect(enclosure['@_url']).to.contain('.m4a')
expect(enclosure['@_type']).to.equal('audio/x-m4a')
const res = await makeRawRequest({ url: enclosure['@_url'], expectedStatus: HttpStatusCode.OK_200 })
expect(res.headers['content-type']).to.equal('audio/mp4')
const alternateEnclosures = xmlDoc.rss.channel.item['podcast:alternateEnclosure']
expect(alternateEnclosures).to.be.an('array')
const audioEnclosure = alternateEnclosures.find(e => e['@_type'] === 'audio/mp4')
const audioEnclosure = alternateEnclosures.find(e => e['@_type'] === 'audio/x-m4a')
expect(audioEnclosure).to.exist
expect(audioEnclosure['@_default']).to.equal(true)
expect(audioEnclosure['podcast:source']['@_uri']).to.equal(enclosure['@_url'])
@ -330,8 +335,14 @@ describe('Test syndication feeds', () => {
expect(channel['itunes:explicit']).to.equal(true)
expect(channel['itunes:author']).to.equal('PeerTube')
expect(channel['itunes:image']['@_href']).to.exist
await makeRawRequest({ url: channel['itunes:image']['@_href'], expectedStatus: HttpStatusCode.OK_200 })
const item = xmlDoc.rss.channel.item
expect(item['itunes:duration']).to.equal(5)
})
})

View File

@ -68,7 +68,7 @@ const downloadGenerateRateLimiter = buildRateLimiter({
})
downloadRouter.use(
DOWNLOAD_PATHS.GENERATE_VIDEO + ':id',
[ DOWNLOAD_PATHS.GENERATE_VIDEO + ':id.m4a', DOWNLOAD_PATHS.GENERATE_VIDEO + ':id.mp4', DOWNLOAD_PATHS.GENERATE_VIDEO + ':id' ],
downloadGenerateRateLimiter,
optionalAuthenticate,
asyncMiddleware(videosDownloadValidator),
@ -251,8 +251,13 @@ async function downloadGeneratedVideoFile (req: express.Request, res: express.Re
? '.m4a'
: maxResolutionFile.extname
const downloadFilename = buildDownloadFilename({ video, extname })
res.setHeader('Content-disposition', `attachment; filename="${encodeURI(downloadFilename)}`)
// If there is the extension, we want to simulate a "raw file" and so not send the content disposition header
if (!req.path.endsWith('.mp4') && !req.path.endsWith('.m4a')) {
const downloadFilename = buildDownloadFilename({ video, extname })
res.setHeader('Content-disposition', `attachment; filename="${encodeURI(downloadFilename)}`)
}
res.type(extname)
await muxToMergeVideoFiles({ video, videoFiles, output: res })
}

View File

@ -1,9 +1,10 @@
import { Feed } from '@peertube/feed'
import { CustomTag, CustomXMLNS, LiveItemStatus } from '@peertube/feed/lib/typings/index.js'
import { buildDownloadFilesUrl, getResolutionLabel, maxBy, sortObjectComparator } from '@peertube/peertube-core-utils'
import { buildDownloadFilesUrl, getResolutionLabel, 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 { CONFIG } from '@server/initializers/config.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'
@ -112,6 +113,7 @@ async function generateVideoPodcastFeed (req: express.Request, res: express.Resp
: undefined,
person: [ { name, href: ownerLink, img: ownerImageUrl } ],
author: { name: CONFIG.INSTANCE.NAME, link: WEBSERVER.URL },
resourceType: 'videos',
queryString: new URL(WEBSERVER.URL + req.url).search,
medium: 'video',
@ -153,23 +155,19 @@ async function generatePodcastItem (options: {
{ video, liveItem }
)
const account = video.VideoChannel.Account
const author = {
name: account.getDisplayName(),
href: account.getClientUrl()
}
const commonAttributes = getCommonVideoFeedAttributes(video)
const guid = liveItem
? `${video.url}?publishedAt=${video.publishedAt.toISOString()}`
: video.url
let personImage: string
const account = video.VideoChannel.Account
const person = {
name: account.getDisplayName(),
href: account.getClientUrl(),
if (account.Actor.hasImage(ActorImageType.AVATAR)) {
const avatar = maxBy(account.Actor.Avatars, 'width')
personImage = WEBSERVER.URL + avatar.getStaticPath()
img: account.Actor.hasImage(ActorImageType.AVATAR)
? WEBSERVER.URL + account.Actor.getMaxQualityImage(ActorImageType.AVATAR).getStaticPath()
: undefined
}
return {
@ -178,14 +176,7 @@ async function generatePodcastItem (options: {
trackers: video.getTrackerUrls(),
author: [ author ],
person: [
{
...author,
img: personImage
}
],
person: [ person ],
media,
@ -197,6 +188,8 @@ async function generatePodcastItem (options: {
}
],
duration: video.duration,
customTags
}
}
@ -271,7 +264,7 @@ function buildVODWebVideoFile (video: MVideo, videoFile: VideoFile) {
}
return {
type: getVideoFileMimeType(extname(videoFile.fileUrl), videoFile.resolution.id === VideoResolution.H_NOVIDEO),
type: getAppleMimeType(extname(videoFile.fileUrl), videoFile.resolution.id === VideoResolution.H_NOVIDEO),
title: videoFile.resolution.label,
length: videoFile.size,
bitrate: videoFile.size / video.duration * 8,
@ -297,13 +290,20 @@ function buildVODStreamingPlaylists (video: MVideoFullLight) {
}
return {
type: getVideoFileMimeType(videoFile.extname, videoFile.resolution === VideoResolution.H_NOVIDEO),
type: getAppleMimeType(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 })
uri: buildDownloadFilesUrl({
baseUrl: WEBSERVER.URL,
videoFiles: files.map(f => f.id),
videoUUID: video.uuid,
extension: videoFile.hasVideo() && videoFile.hasAudio()
? '.mp4'
: '.m4a'
})
}
]
}
@ -373,3 +373,12 @@ function categoryToItunes (category: number) {
return itunesMap[category]
}
// Guidelines: https://help.apple.com/itc/podcasts_connect/#/itcb54353390
// "The type values for the supported file formats are: audio/x-m4a, audio/mpeg, video/quicktime, video/mp4, video/x-m4v, ..."
function getAppleMimeType (extname: string, isAudio: boolean) {
if (extname === '.mp4' && isAudio) return 'audio/x-m4a'
if (extname === '.mp3') return 'audio/mpeg'
return getVideoFileMimeType(extname, isAudio)
}

View File

@ -1854,10 +1854,10 @@
bufferutil "^4.0.8"
utf-8-validate "^6.0.4"
"@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==
"@peertube/feed@^5.3.0":
version "5.3.0"
resolved "https://registry.yarnpkg.com/@peertube/feed/-/feed-5.3.0.tgz#133f35aee89bec3af5505ef923f4271e1a75d283"
integrity sha512-NyAf+bBcDhgmxxLHGDSvjedPUE6nEWP2fsntKM8dWCwLCii/niMVrft/fYy1EE4sW1ERLBsuj/jgn4SihAYErw==
dependencies:
xml-js "^1.6.11"