diff --git a/package.json b/package.json index 3e55fa26f..c2da66c03 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/core-utils/src/common/url.ts b/packages/core-utils/src/common/url.ts index 3020e40be..5eb00e54b 100644 --- a/packages/core-utils/src/common/url.ts +++ b/packages/core-utils/src/common/url.ts @@ -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}` diff --git a/packages/tests/src/feeds/feeds.ts b/packages/tests/src/feeds/feeds.ts index cde9a2557..e4ec9a0c2 100644 --- a/packages/tests/src/feeds/feeds.ts +++ b/packages/tests/src/feeds/feeds.ts @@ -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) }) }) diff --git a/server/core/controllers/download.ts b/server/core/controllers/download.ts index f3a32cc0e..b4e1bf1bf 100644 --- a/server/core/controllers/download.ts +++ b/server/core/controllers/download.ts @@ -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 }) } diff --git a/server/core/controllers/feeds/video-podcast-feeds.ts b/server/core/controllers/feeds/video-podcast-feeds.ts index f6c91e6af..28525a73e 100644 --- a/server/core/controllers/feeds/video-podcast-feeds.ts +++ b/server/core/controllers/feeds/video-podcast-feeds.ts @@ -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) +} diff --git a/yarn.lock b/yarn.lock index d48c38a04..d09f01925 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"