Podcast feed support for playlists

This commit is contained in:
Chocobozzz
2026-04-17 09:18:36 +02:00
parent d3131da696
commit c00613f0ac
58 changed files with 1123 additions and 431 deletions
@@ -30,7 +30,7 @@ import { LiveVideoService } from '@app/shared/shared-video-live/live-video.servi
import { VideoPlaylist } from '@app/shared/shared-video-playlist/video-playlist.model'
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
import { PlayerSettingsService } from '@app/shared/shared-video/player-settings.service'
import { getVideoRSSFeeds, timeToInt } from '@peertube/peertube-core-utils'
import { getPlaylistRSSFeeds, getVideoRSSFeeds, timeToInt } from '@peertube/peertube-core-utils'
import {
HTMLServerConfig,
HttpStatusCode,
@@ -1043,13 +1043,28 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.metaService.setDescription(video.description)
if (this.playlist?.isLocal) {
this.metaService.setRSSFeeds(
getPlaylistRSSFeeds({
url: getOriginUrl(),
playlist: this.playlist,
titles: {
instanceVideosFeed: $localize`${this.serverConfig.instance.name} - Videos feed`,
playlistPodcastFeed: $localize`${this.playlist.displayName} - Podcast feed`
}
})
)
return
}
this.metaService.setRSSFeeds(
getVideoRSSFeeds({
url: getOriginUrl(),
video: { ...video, privacy: video.privacy.id },
titles: {
instanceVideosFeed: `${this.serverConfig.instance.name} - Videos feed`,
videoCommentsFeed: `${video.name} - Comments feed`
instanceVideosFeed: $localize`${this.serverConfig.instance.name} - Videos feed`,
videoCommentsFeed: $localize`${video.name} - Comments feed`
}
})
)
@@ -8,7 +8,6 @@ import { VideoCommentService } from '@app/shared/shared-video-comment/video-comm
import { BulkRemoveCommentsOfBody, UserRight } from '@peertube/peertube-models'
import { switchMap } from 'rxjs'
import { ActorAvatarComponent } from '../shared-actor-image/actor-avatar.component'
import { Actor } from '../shared-main/account/actor.model'
import { AdvancedFilterDef } from '../shared-forms/advanced-input-filter.component'
import { GlobalIconComponent } from '../shared-icons/global-icon.component'
import { buildDropdownSimpleAndBulkActions } from '../shared-main/buttons/action-dropdown-helpers'
@@ -3,7 +3,6 @@ import { AuthService, ConfirmService, Notifier, ScreenService, ServerService } f
import { NgbDropdown, NgbDropdownAnchor, NgbDropdownMenu } from '@ng-bootstrap/ng-bootstrap'
import { VideoCaption } from '@peertube/peertube-models'
import { of } from 'rxjs'
import { Actor } from '../shared-main/account/actor.model'
import {
ActionDropdownComponent,
DropdownAction,
+1 -1
View File
@@ -111,7 +111,7 @@
"@opentelemetry/sdk-trace-node": "^2.5.1",
"@opentelemetry/semantic-conventions": "^1.39.0",
"@peertube/bittorrent-tracker-server": "^11.1.2",
"@peertube/feed": "^5.4.1",
"@peertube/feed": "^5.5.0",
"@peertube/peertube-core-utils": "workspace:*",
"@peertube/peertube-ffmpeg": "workspace:*",
"@peertube/peertube-models": "workspace:*",
+24
View File
@@ -62,3 +62,27 @@ export function getVideoRSSFeeds (options: {
export function getChannelPodcastFeed (url: string, channel: { id: number }) {
return `${url}/feeds/podcast/videos.xml?videoChannelId=${channel.id}`
}
export function getPlaylistRSSFeeds (options: {
url: string
playlist: { displayName: string, id: number }
titles: {
instanceVideosFeed: string
playlistPodcastFeed: string
}
}) {
const { url, titles, playlist } = options
return [
{
url: getPlaylistPodcastFeed(url, playlist),
title: titles.playlistPodcastFeed
},
getInstanceRSSFeed({ url, title: titles.instanceVideosFeed })
]
}
export function getPlaylistPodcastFeed (url: string, playlist: { id: number }) {
return `${url}/feeds/podcast/videos.xml?playlistId=${playlist.id}`
}
@@ -14,7 +14,7 @@ export interface PlaylistObject {
totalItems: number
attributedTo: ActivityPubAttributedTo[]
icon?: ActivityIconObject
icon?: ActivityIconObject | ActivityIconObject[]
published: string
updated: string
@@ -1,5 +1,6 @@
import { VideoPlaylistPrivacyType } from '../../videos/playlist/video-playlist-privacy.model.js'
import { VideoPlaylistType_Type } from '../../videos/playlist/video-playlist-type.model.js'
import { ImageExportJSON } from './image-export.model.js'
export interface VideoPlaylistsExportJSON {
videoPlaylists: {
@@ -19,6 +20,7 @@ export interface VideoPlaylistsExportJSON {
updatedAt: string
thumbnailUrl: string
thumbnails: ImageExportJSON[]
elements: {
videoUrl: string
@@ -141,6 +141,8 @@ export const serverFilterHookObject = {
// Filter result to allow custom tags in podcast RSS feeds
// Peertube >= 5.2
'filter:feed.podcast.channel.create-custom-tags.result': true,
// Peertube >= 8.2
'filter:feed.podcast.video-playlist.create-custom-tags.result': true,
// Peertube >= 5.2
'filter:feed.podcast.video.create-custom-tags.result': true,
// Peertube >= 6.1
@@ -35,16 +35,18 @@ export class FeedCommand extends AbstractCommand {
getPodcastXML (
options: OverrideCommandOptions & {
ignoreCache: boolean
channelId: number
channelId?: number
playlistId?: number
}
) {
const { ignoreCache, channelId } = options
const { ignoreCache, channelId, playlistId } = options
const path = `/feeds/podcast/videos.xml`
const query: { [id: string]: string } = {}
if (ignoreCache) query.v = buildUUID()
if (channelId) query.videoChannelId = channelId + ''
if (playlistId) query.playlistId = playlistId + ''
return this.getRequestText({
...options,
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+2 -2
View File
@@ -115,8 +115,8 @@ describe('Test services', function () {
expect(res.body.width).to.equal(560)
expect(res.body.height).to.equal(315)
expect(res.body.thumbnail_url).exist
expect(res.body.thumbnail_width).to.equal(280)
expect(res.body.thumbnail_height).to.equal(157)
expect(res.body.thumbnail_width).to.equal(1280)
expect(res.body.thumbnail_height).to.equal(720)
}
}
})
@@ -84,7 +84,12 @@ describe('Playlist thumbnail', function () {
for (const server of servers) {
const p = await getPlaylistWithoutThumbnail(server)
await checkThumbnails({ server, playlist: p, thumbnails: [ 'thumbnail-playlist-280x157.jpg' ] })
await checkThumbnails({
server,
playlist: p,
thumbnails: [ 'thumbnail-playlist-280x157.jpg' ],
remotePlaylist: server !== servers[1]
})
}
})
@@ -112,7 +117,12 @@ describe('Playlist thumbnail', function () {
for (const server of servers) {
const p = await getPlaylistWithThumbnail(server)
await checkThumbnails({ server, playlist: p, thumbnails: [ 'custom-thumbnail-280x157.jpg' ] })
await checkThumbnails({
server,
playlist: p,
thumbnails: [ 'custom-thumbnail-280x157.jpg' ],
remotePlaylist: server !== servers[1]
})
}
})
@@ -138,7 +148,12 @@ describe('Playlist thumbnail', function () {
for (const server of servers) {
const p = await getPlaylistWithoutThumbnail(server)
await checkThumbnails({ server, playlist: p, thumbnails: [ 'thumbnail-playlist-280x157.jpg' ] })
await checkThumbnails({
server,
playlist: p,
thumbnails: [ 'thumbnail-playlist-280x157.jpg' ],
remotePlaylist: server !== servers[1]
})
}
})
@@ -164,7 +179,12 @@ describe('Playlist thumbnail', function () {
for (const server of servers) {
const p = await getPlaylistWithThumbnail(server)
await checkThumbnails({ server, playlist: p, thumbnails: [ 'custom-thumbnail-280x157.jpg' ] })
await checkThumbnails({
server,
playlist: p,
thumbnails: [ 'custom-thumbnail-280x157.jpg' ],
remotePlaylist: server !== servers[1]
})
}
})
@@ -181,7 +201,12 @@ describe('Playlist thumbnail', function () {
for (const server of servers) {
const p = await getPlaylistWithoutThumbnail(server)
await checkThumbnails({ server, playlist: p, thumbnails: [ 'thumbnail-playlist-280x157.jpg' ] })
await checkThumbnails({
server,
playlist: p,
thumbnails: [ 'thumbnail-playlist-280x157.jpg' ],
remotePlaylist: server !== servers[1]
})
}
})
@@ -198,7 +223,12 @@ describe('Playlist thumbnail', function () {
for (const server of servers) {
const p = await getPlaylistWithThumbnail(server)
await checkThumbnails({ server, playlist: p, thumbnails: [ 'custom-thumbnail-280x157.jpg' ] })
await checkThumbnails({
server,
playlist: p,
thumbnails: [ 'custom-thumbnail-280x157.jpg' ],
remotePlaylist: server !== servers[1]
})
}
})
@@ -233,7 +263,12 @@ describe('Playlist thumbnail', function () {
for (const server of servers) {
const p = await getPlaylistWithThumbnail(server)
await checkThumbnails({ server, playlist: p, thumbnails: [ 'custom-thumbnail-280x157.jpg' ] })
await checkThumbnails({
server,
playlist: p,
thumbnails: [ 'custom-thumbnail-280x157.jpg' ],
remotePlaylist: server !== servers[1]
})
}
})
@@ -316,19 +316,44 @@ describe('Test video playlists', function () {
const playlist2 = body.data.find(p => p.displayName === 'playlist 2')
expect(playlist2).to.not.be.undefined
await checkThumbnails({
server,
thumbnails: [ 'thumbnail-playlist-280x157.jpg' ],
playlist: playlist2
})
const playlist3 = body.data.find(p => p.displayName === 'playlist 3')
expect(playlist3).to.not.be.undefined
await checkThumbnails({
server,
thumbnails: [ 'custom-thumbnail-280x157.jpg' ],
playlist: playlist3
})
if (server === servers[1]) {
await checkThumbnails({
server,
thumbnails: [
'thumbnail-playlist-280x157.jpg',
'thumbnail-playlist-850x480.jpg',
'thumbnail-playlist-1280x720.jpg',
'thumbnail-playlist-1920x1080.jpg',
'thumbnail-playlist-1400x1400.jpg'
],
playlist: playlist2
})
await checkThumbnails({
server,
thumbnails: [ 'custom-thumbnail-280x157.jpg' ],
playlist: playlist3
})
} else {
await checkThumbnails({
server,
thumbnails: [ 'thumbnail-playlist-280x157.jpg' ],
playlist: playlist2,
remotePlaylist: true
})
await checkThumbnails({
server,
thumbnails: [ 'custom-thumbnail-280x157.jpg' ],
playlist: playlist3,
remotePlaylist: true
})
}
}
const body = await servers[2].playlists.list({ start: 0, count: 5 })
@@ -350,7 +375,8 @@ describe('Test video playlists', function () {
await checkThumbnails({
server: servers[2],
thumbnails: [ 'thumbnail-playlist-280x157.jpg' ],
playlist: playlist2
playlist: playlist2,
remotePlaylist: true
})
expect(body.data.find(p => p.displayName === 'playlist 3')).to.not.be.undefined
+2 -3
View File
@@ -95,9 +95,8 @@ describe('Test prune storage CLI', function () {
expect(torrentsCount).to.equal(12)
const thumbnailsCount = await server.servers.countFiles('thumbnails')
// 15 of 3 local videos (5 sizes for each)
// 1 local playlist
expect(thumbnailsCount).to.equal(16)
// 15 of 3 local videos + 1 playlist (5 sizes for each)
expect(thumbnailsCount).to.equal(20)
const avatarsCount = await server.servers.countFiles('avatars')
expect(avatarsCount).to.equal(4)
@@ -1,5 +1,5 @@
import { minBy } from '@peertube/peertube-core-utils'
import { HttpStatusCode, Thumbnail, Video } from '@peertube/peertube-models'
import { HttpStatusCode, Thumbnail, Video, VideoPlaylist } from '@peertube/peertube-models'
import {
cleanupTests,
createMultipleServers,
@@ -22,18 +22,30 @@ async function testCurrentThumbnail (server: PeerTubeServer, videoId: number | s
}
}
async function testCurrentPlaylistThumbnail (server: PeerTubeServer, playlistId: number | string) {
const playlist = await server.playlists.get({ playlistId })
for (const thumbnail of playlist.thumbnails) {
const { body } = await makeRawRequest({ url: thumbnail.fileUrl, expectedStatus: HttpStatusCode.OK_200 })
expect(body).to.not.have.lengthOf(0)
}
}
describe('Test regenerate thumbnails CLI', function () {
let servers: PeerTubeServer[]
let video1: Video
let video2: Video
let remoteVideo: Video
let playlist1: VideoPlaylist
let localSmallestThumbnailPath: string
let localSmallestPlaylistThumbnailPath: string
let remoteSmallestThumbnailPath: string
function isTruncatedThumbnail (thumbnail: Thumbnail) {
return basename(thumbnail.fileUrl) === basename(localSmallestThumbnailPath) ||
basename(thumbnail.fileUrl) === basename(localSmallestPlaylistThumbnailPath) ||
basename(thumbnail.fileUrl) === basename(remoteSmallestThumbnailPath)
}
@@ -54,6 +66,16 @@ describe('Test regenerate thumbnails CLI', function () {
const videoUUID2 = (await servers[0].videos.quickUpload({ name: 'video 2' })).uuid
video2 = await servers[0].videos.get({ id: videoUUID2 })
const playlistUUID = (await servers[0].playlists.quickCreate({ displayName: 'playlist 1' })).uuid
await servers[0].playlists.addElement({ playlistId: playlistUUID, attributes: { videoId: videoUUID1 } })
playlist1 = await servers[0].playlists.get({ playlistId: playlistUUID })
const smallestPlaylistThumbnail = minBy(playlist1.thumbnails, 'width')
localSmallestPlaylistThumbnailPath = join(
join(servers[0].servers.buildDirectory('thumbnails'), basename(smallestPlaylistThumbnail.fileUrl))
)
}
{
@@ -75,6 +97,7 @@ describe('Test regenerate thumbnails CLI', function () {
}
await writeFile(localSmallestThumbnailPath, '')
await writeFile(localSmallestPlaylistThumbnailPath, '')
await writeFile(remoteSmallestThumbnailPath, '')
})
@@ -93,6 +116,16 @@ describe('Test regenerate thumbnails CLI', function () {
const { body } = await makeRawRequest({ url: thumbnail.fileUrl, expectedStatus: HttpStatusCode.OK_200 })
expect(body).to.not.have.lengthOf(0)
}
for (const thumbnail of playlist1.thumbnails) {
const { body } = await makeRawRequest({ url: thumbnail.fileUrl, expectedStatus: HttpStatusCode.OK_200 })
if (isTruncatedThumbnail(thumbnail)) {
expect(body).to.have.lengthOf(0)
} else {
expect(body).to.not.have.lengthOf(0)
}
}
})
it('Should regenerate local thumbnails from the CLI', async function () {
@@ -102,10 +135,11 @@ describe('Test regenerate thumbnails CLI', function () {
it('Should have generated new thumbnail files', async function () {
await testCurrentThumbnail(servers[0], video1.uuid)
await testCurrentThumbnail(servers[0], video2.uuid)
await testCurrentPlaylistThumbnail(servers[0], playlist1.uuid)
})
it('Should have deleted old local thumbnail files', async function () {
for (const thumbnail of [ ...video1.thumbnails, ...video2.thumbnails ]) {
it('Should have deleted old local thumbnails files', async function () {
for (const thumbnail of [ ...video1.thumbnails, ...video2.thumbnails, ...playlist1.thumbnails ]) {
await makeRawRequest({ url: thumbnail.fileUrl, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
}
})
@@ -133,7 +167,7 @@ describe('Test regenerate thumbnails CLI', function () {
})
it('Should have the appropriate thumbnails count', async function () {
expect(await servers[0].servers.countFiles('thumbnails')).to.equal(10)
expect(await servers[0].servers.countFiles('thumbnails')).to.equal(15)
expect(await servers[0].servers.countFiles('cache/thumbnails')).to.equal(5)
})
+6
View File
@@ -372,6 +372,12 @@ describe('Test <head> HTML tags', function () {
for (const path of getWatchPlaylistBasePaths()) {
for (const id of playlistIds) {
await commonPageTest(path + id)
const res = await makeGetRequest({ url: servers[0].url, path: path + id, accept: 'text/html', expectedStatus: HttpStatusCode.OK_200 })
const text = res.text
const feedUrl = `${servers[0].url}/feeds/podcast/videos.xml?playlistId=${playlist.id}`
expect(text).to.contain(`<link rel="alternate" type="application/rss+xml" title="${playlistName} - Podcast feed" href="${feedUrl}" />`)
}
}
})
+2 -2
View File
@@ -1,6 +1,6 @@
/* oxlint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { maxBy } from '@peertube/peertube-core-utils'
import { maxBy, minBy } from '@peertube/peertube-core-utils'
import { HttpStatusCode, VideoCreateResult, VideoPlaylistCreateResult, VideoPrivacy } from '@peertube/peertube-models'
import {
PeerTubeServer,
@@ -213,7 +213,7 @@ describe('Test JSONLD HTML tags', function () {
for (const server of servers) {
const jsonld = await getJSONLD(server, getWatchPlaylistBasePaths()[0] + publicPlaylist.uuid)
const playlist = await server.playlists.get({ playlistId: publicPlaylist.uuid })
const thumbnailUrl = maxBy(playlist.thumbnails, 'width').fileUrl
const thumbnailUrl = minBy(playlist.thumbnails, 'width').fileUrl
expect(jsonld).to.deep.equal({
'@context': 'http://schema.org',
+1 -2
View File
@@ -16,7 +16,6 @@ describe('Test oEmbed HTML tags', function () {
before(async function () {
this.timeout(120000)
;({ servers, playlistIds, videoIds, playlist, playlistName } = await prepareClientTests())
})
@@ -77,7 +76,7 @@ describe('Test oEmbed HTML tags', function () {
expect(res.text).to.contain(expectedLink)
})
it('Should forward query params to playlist oEmbed discrovery URL', async function () {
it('Should forward query params to playlist oEmbed discovery URL', async function () {
const res = await makeGetRequest({
url: servers[0].url,
path: '/w/p/' + playlistIds[0],
@@ -1,7 +1,6 @@
/* oxlint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { HttpStatusCode, UserRegistrationState } from '@peertube/peertube-models'
import { HttpStatusCode } from '@peertube/peertube-models'
import {
cleanupTests,
createMultipleServers,
@@ -10,6 +9,7 @@ import {
setAccessTokensToServers,
waitJobs
} from '@peertube/peertube-server-commands'
import { expect } from 'chai'
describe('Official plugin Akismet', function () {
let servers: PeerTubeServer[]
+319 -205
View File
@@ -43,6 +43,7 @@ describe('Test syndication feeds', () => {
let videoIdWithComments: string
let videoIdWithoutComments: string
let liveId: string
let podcastPlaylistId: number
before(async function () {
this.timeout(120000)
@@ -92,6 +93,17 @@ describe('Test syndication feeds', () => {
await servers[0].comments.createThread({ videoId: uuid, text: 'super comment 1' })
await servers[0].comments.createThread({ videoId: uuid, text: 'super comment 2' })
const playlist = await servers[0].playlists.quickCreate({
displayName: 'Podcast playlist',
channelId: rootChannelIdServer1
})
podcastPlaylistId = playlist.id
await servers[0].playlists.addElement({ playlistId: podcastPlaylistId, attributes: { videoId: uuid } })
const privateVideo = await servers[0].videos.quickUpload({ name: 'private', privacy: VideoPrivacy.PRIVATE })
await servers[0].playlists.addElement({ playlistId: podcastPlaylistId, attributes: { videoId: privateVideo.id } })
}
{
@@ -138,6 +150,9 @@ describe('Test syndication feeds', () => {
it('Should be well formed XML (covers Podcast endpoint)', async function () {
const podcast = await servers[0].feed.getPodcastXML({ ignoreCache: true, channelId: rootChannelIdServer1 })
expect(podcast).xml.to.be.valid()
const playlistPodcast = await servers[0].feed.getPodcastXML({ ignoreCache: true, playlistId: podcastPlaylistId })
expect(playlistPodcast).xml.to.be.valid()
})
it('Should be well formed JSON (covers JSON feed 1.0 endpoint)', async function () {
@@ -163,251 +178,350 @@ describe('Test syndication feeds', () => {
describe('Videos feed', function () {
describe('Podcast feed', 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: servers[0].store.channel.id })
expect(XMLValidator.validate(rss)).to.be.true
describe('Channel podcast feed', 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: servers[0].store.channel.id })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const itemGuid = xmlDoc.rss.channel.item.guid
expect(itemGuid).to.exist
expect(itemGuid['@_isPermaLink']).to.equal(true)
const itemGuid = xmlDoc.rss.channel.item.guid
expect(itemGuid).to.exist
expect(itemGuid['@_isPermaLink']).to.equal(true)
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 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
const alternateEnclosure = xmlDoc.rss.channel.item['podcast:alternateEnclosure']
expect(alternateEnclosure).to.exist
expect(alternateEnclosure['@_type']).to.equal('video/webm')
expect(alternateEnclosure['@_length']).to.equal(218910)
expect(alternateEnclosure['@_lang']).to.equal('zh')
expect(alternateEnclosure['@_title']).to.equal('720p')
expect(alternateEnclosure['@_default']).to.equal(true)
expect(alternateEnclosure['@_type']).to.equal('video/webm')
expect(alternateEnclosure['@_length']).to.equal(218910)
expect(alternateEnclosure['@_lang']).to.equal('zh')
expect(alternateEnclosure['@_title']).to.equal('720p')
expect(alternateEnclosure['@_default']).to.equal(true)
expect(alternateEnclosure['podcast:source'][0]['@_uri']).to.contain('-720.webm')
expect(alternateEnclosure['podcast:source'][0]['@_uri']).to.equal(enclosure['@_url'])
expect(alternateEnclosure['podcast:source'][1]['@_uri']).to.contain('-720.torrent')
expect(alternateEnclosure['podcast:source'][1]['@_contentType']).to.equal('application/x-bittorrent')
expect(alternateEnclosure['podcast:source'][2]['@_uri']).to.contain('magnet:?')
})
expect(alternateEnclosure['podcast:source'][0]['@_uri']).to.contain('-720.webm')
expect(alternateEnclosure['podcast:source'][0]['@_uri']).to.equal(enclosure['@_url'])
expect(alternateEnclosure['podcast:source'][1]['@_uri']).to.contain('-720.torrent')
expect(alternateEnclosure['podcast:source'][1]['@_contentType']).to.equal('application/x-bittorrent')
expect(alternateEnclosure['podcast:source'][2]['@_uri']).to.contain('magnet:?')
})
it('Should contain a valid podcast enclosures with HLS only', async function () {
const rss = await serverHLSOnly.feed.getPodcastXML({ ignoreCache: false, channelId: serverHLSOnly.store.channel.id })
expect(XMLValidator.validate(rss)).to.be.true
it('Should contain a valid podcast enclosures with HLS only', async function () {
const rss = await serverHLSOnly.feed.getPodcastXML({ ignoreCache: false, channelId: serverHLSOnly.store.channel.id })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const itemGuid = xmlDoc.rss.channel.item.guid
expect(itemGuid).to.exist
expect(itemGuid['@_isPermaLink']).to.equal(true)
const itemGuid = xmlDoc.rss.channel.item.guid
expect(itemGuid).to.exist
expect(itemGuid['@_isPermaLink']).to.equal(true)
const enclosure = xmlDoc.rss.channel.item.enclosure
expect(enclosure).to.exist
expectStartWith(enclosure['@_url'], `${serverHLSOnly.url}/static/web-videos/`)
expect(enclosure['@_url']).to.contain('.mp4')
expect(enclosure['@_type']).to.equal('audio/x-m4a')
const enclosure = xmlDoc.rss.channel.item.enclosure
expect(enclosure).to.exist
expectStartWith(enclosure['@_url'], `${serverHLSOnly.url}/static/web-videos/`)
expect(enclosure['@_url']).to.contain('.mp4')
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('video/mp4')
expect(res.headers['content-disposition']).to.not.exist
const res = await makeRawRequest({ url: enclosure['@_url'], expectedStatus: HttpStatusCode.OK_200 })
expect(res.headers['content-type']).to.equal('video/mp4')
expect(res.headers['content-disposition']).to.not.exist
const alternateEnclosures = xmlDoc.rss.channel.item['podcast:alternateEnclosure']
expect(alternateEnclosures).to.be.an('array')
const alternateEnclosures = xmlDoc.rss.channel.item['podcast:alternateEnclosure']
expect(alternateEnclosures).to.be.an('array')
expect(alternateEnclosures.length).to.equal(7) // Web video audio + HLS (144p, 240p, 360p, 480p, 720p) + m3u8 master playlist
expect(alternateEnclosures.length).to.equal(7) // Web video audio + HLS (144p, 240p, 360p, 480p, 720p) + m3u8 master playlist
const audioEnclosure = alternateEnclosures.find(e => e['@_type'] === 'audio/x-m4a')
expect(audioEnclosure).to.exist
expect(audioEnclosure['@_default']).to.equal(true)
expect(audioEnclosure['podcast:source'][0]['@_uri']).to.equal(enclosure['@_url'])
const audioEnclosure = alternateEnclosures.find(e => e['@_type'] === 'audio/x-m4a')
expect(audioEnclosure).to.exist
expect(audioEnclosure['@_default']).to.equal(true)
expect(audioEnclosure['podcast:source'][0]['@_uri']).to.equal(enclosure['@_url'])
const videoEnclosure = alternateEnclosures.find(e => e['@_type'] === 'video/mp4')
expect(videoEnclosure).to.exist
expect(videoEnclosure['@_default']).to.equal(false)
expectStartWith(videoEnclosure['podcast:source']['@_uri'], `${serverHLSOnly.url}/download/videos/generate/`)
const videoEnclosure = alternateEnclosures.find(e => e['@_type'] === 'video/mp4')
expect(videoEnclosure).to.exist
expect(videoEnclosure['@_default']).to.equal(false)
expectStartWith(videoEnclosure['podcast:source']['@_uri'], `${serverHLSOnly.url}/download/videos/generate/`)
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)
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')
})
expect(hlsEnclosure['podcast:source']['@_uri']).to.contain('-master.m3u8')
})
it('Should contain a valid podcast:socialInteract', async function () {
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: servers[0].store.channel.id })
expect(XMLValidator.validate(rss)).to.be.true
it('Should contain a valid podcast:socialInteract', async function () {
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: servers[0].store.channel.id })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const item = xmlDoc.rss.channel.item
const socialInteract = item['podcast:socialInteract']
expect(socialInteract).to.exist
expect(socialInteract['@_protocol']).to.equal('activitypub')
expect(socialInteract['@_uri']).to.exist
expect(socialInteract['@_accountUrl']).to.exist
})
const item = xmlDoc.rss.channel.item
const socialInteract = item['podcast:socialInteract']
expect(socialInteract).to.exist
expect(socialInteract['@_protocol']).to.equal('activitypub')
expect(socialInteract['@_uri']).to.exist
expect(socialInteract['@_accountUrl']).to.exist
})
it('Should contain a valid support custom tags for plugins', async function () {
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: userChannelId })
expect(XMLValidator.validate(rss)).to.be.true
it('Should contain a valid support custom tags for plugins', async function () {
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: userChannelId })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const fooTag = xmlDoc.rss.channel.fooTag
expect(fooTag).to.exist
expect(fooTag['@_bar']).to.equal('baz')
expect(fooTag['#text']).to.equal(42)
const fooTag = xmlDoc.rss.channel.fooTag
expect(fooTag).to.exist
expect(fooTag['@_bar']).to.equal('baz')
expect(fooTag['#text']).to.equal(42)
const bizzBuzzItem = xmlDoc.rss.channel['biz:buzzItem']
expect(bizzBuzzItem).to.exist
const bizzBuzzItem = xmlDoc.rss.channel['biz:buzzItem']
expect(bizzBuzzItem).to.exist
let nestedTag = bizzBuzzItem.nestedTag
expect(nestedTag).to.exist
expect(nestedTag).to.equal('example nested tag')
let nestedTag = bizzBuzzItem.nestedTag
expect(nestedTag).to.exist
expect(nestedTag).to.equal('example nested tag')
const item = xmlDoc.rss.channel.item
const fizzTag = item.fizzTag
expect(fizzTag).to.exist
expect(fizzTag['@_bar']).to.equal('baz')
expect(fizzTag['#text']).to.equal(21)
const item = xmlDoc.rss.channel.item
const fizzTag = item.fizzTag
expect(fizzTag).to.exist
expect(fizzTag['@_bar']).to.equal('baz')
expect(fizzTag['#text']).to.equal(21)
const bizzBuzz = item['biz:buzz']
expect(bizzBuzz).to.exist
const bizzBuzz = item['biz:buzz']
expect(bizzBuzz).to.exist
nestedTag = bizzBuzz.nestedTag
expect(nestedTag).to.exist
expect(nestedTag).to.equal('example nested tag')
})
nestedTag = bizzBuzz.nestedTag
expect(nestedTag).to.exist
expect(nestedTag).to.equal('example nested tag')
})
it('Should contain a valid podcast:liveItem for live streams', async function () {
this.timeout(120000)
it('Should contain a valid podcast:liveItem for live streams', async function () {
this.timeout(120000)
const { uuid } = await servers[0].live.create({
fields: {
name: 'live-0',
privacy: VideoPrivacy.PUBLIC,
channelId: rootChannelIdServer1,
permanentLive: false
const { uuid } = await servers[0].live.create({
fields: {
name: 'live-0',
privacy: VideoPrivacy.PUBLIC,
channelId: rootChannelIdServer1,
permanentLive: false
}
})
liveId = uuid
const ffmpeg = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveId, copyCodecs: true, fixtureName: 'video_short.mp4' })
await servers[0].live.waitUntilPublished({ videoId: liveId })
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: servers[0].store.channel.id })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
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(true)
expect(liveItem['@_status']).to.equal('live')
const enclosure = liveItem.enclosure
const alternateEnclosure = liveItem['podcast:alternateEnclosure']
expect(alternateEnclosure).to.exist
expect(alternateEnclosure['@_type']).to.equal('application/x-mpegURL')
expect(alternateEnclosure['@_title']).to.equal('HLS live stream')
expect(alternateEnclosure['@_default']).to.equal(true)
expect(alternateEnclosure['podcast:source']['@_uri']).to.contain('/master.m3u8')
expect(alternateEnclosure['podcast:source']['@_uri']).to.equal(enclosure['@_url'])
await stopFfmpeg(ffmpeg)
await servers[0].live.waitUntilEnded({ videoId: liveId })
await waitJobs(servers)
})
it('Should have valid itunes metadata', async function () {
const rss = await serverHLSOnly.feed.getPodcastXML({ ignoreCache: false, channelId: serverHLSOnly.store.channel.id })
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:author']).to.equal('PeerTube')
{
expect(channel['itunes:image']['@_href']).to.exist
const { body } = await makeRawRequest({ url: channel['itunes:image']['@_href'], expectedStatus: HttpStatusCode.OK_200 })
await testImageSize({ buffer: body, width: 1500, height: 1500 })
}
const item = xmlDoc.rss.channel.item
expect(item['itunes:duration']).to.equal(5)
{
expect(item['itunes:image']['@_href']).to.exist
const { body } = await makeRawRequest({ url: item['itunes:image']['@_href'], expectedStatus: HttpStatusCode.OK_200 })
await testImageSize({ buffer: body, width: 1400, height: 1400 })
}
})
liveId = uuid
const ffmpeg = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveId, copyCodecs: true, fixtureName: 'video_short.mp4' })
await servers[0].live.waitUntilPublished({ videoId: liveId })
it('Should have p20url podcast txt attribute with local podcast feed', async function () {
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: servers[0].store.channel.id })
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: servers[0].store.channel.id })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
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(true)
expect(liveItem['@_status']).to.equal('live')
const enclosure = liveItem.enclosure
const alternateEnclosure = liveItem['podcast:alternateEnclosure']
expect(alternateEnclosure).to.exist
expect(alternateEnclosure['@_type']).to.equal('application/x-mpegURL')
expect(alternateEnclosure['@_title']).to.equal('HLS live stream')
expect(alternateEnclosure['@_default']).to.equal(true)
expect(alternateEnclosure['podcast:source']['@_uri']).to.contain('/master.m3u8')
expect(alternateEnclosure['podcast:source']['@_uri']).to.equal(enclosure['@_url'])
await stopFfmpeg(ffmpeg)
await servers[0].live.waitUntilEnded({ videoId: liveId })
await waitJobs(servers)
})
it('Should have valid itunes metadata', async function () {
const rss = await serverHLSOnly.feed.getPodcastXML({ ignoreCache: false, channelId: serverHLSOnly.store.channel.id })
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:author']).to.equal('PeerTube')
{
expect(channel['itunes:image']['@_href']).to.exist
const { body } = await makeRawRequest({ url: channel['itunes:image']['@_href'], expectedStatus: HttpStatusCode.OK_200 })
await testImageSize({ buffer: body, width: 1500, height: 1500 })
}
const item = xmlDoc.rss.channel.item
expect(item['itunes:duration']).to.equal(5)
{
expect(item['itunes:image']['@_href']).to.exist
const { body } = await makeRawRequest({ url: item['itunes:image']['@_href'], expectedStatus: HttpStatusCode.OK_200 })
await testImageSize({ buffer: body, width: 1400, height: 1400 })
}
})
it('Should have p20url podcast txt attribute with local podcast feed', async function () {
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: servers[0].store.channel.id })
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const podcastUrlEl = xmlDoc.rss.channel['podcast:txt']
expect(podcastUrlEl).to.exist
expect(podcastUrlEl['@_purpose']).to.equal('p20url')
expect(podcastUrlEl['#text']).to.equal(
servers[0].url + '/feeds/podcast/videos.xml?videoChannelId=' + servers[0].store.channel.id
)
})
it('Should have p20url podcast txt attribute with remote classic RSS feed with channel', async function () {
const videoChannelId = await servers[1].channels.getIdOf({ channelName: 'root_channel@' + servers[0].host })
const rss = await servers[1].feed.getXML({
feed: 'videos',
ignoreCache: true,
query: { videoChannelId }
const podcastUrlEl = xmlDoc.rss.channel['podcast:txt']
expect(podcastUrlEl).to.exist
expect(podcastUrlEl['@_purpose']).to.equal('p20url')
expect(podcastUrlEl['#text']).to.equal(
servers[0].url + '/feeds/podcast/videos.xml?videoChannelId=' + servers[0].store.channel.id
)
})
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
it('Should have p20url podcast txt attribute with remote classic RSS feed with channel', async function () {
const videoChannelId = await servers[1].channels.getIdOf({ channelName: 'root_channel@' + servers[0].host })
const podcastUrlEl = xmlDoc.rss.channel['podcast:txt']
expect(podcastUrlEl).to.exist
expect(podcastUrlEl['@_purpose']).to.equal('p20url')
expect(podcastUrlEl['#text']).to.equal(servers[1].url + '/feeds/podcast/videos.xml?videoChannelId=' + videoChannelId)
const rss = await servers[1].feed.getXML({
feed: 'videos',
ignoreCache: true,
query: { videoChannelId }
})
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const podcastUrlEl = xmlDoc.rss.channel['podcast:txt']
expect(podcastUrlEl).to.exist
expect(podcastUrlEl['@_purpose']).to.equal('p20url')
expect(podcastUrlEl['#text']).to.equal(servers[1].url + '/feeds/podcast/videos.xml?videoChannelId=' + videoChannelId)
})
it('Should not have p20url podcast txt attribute with classic RSS feed without channel', async function () {
const rss = await serverHLSOnly.feed.getXML({ feed: 'videos', ignoreCache: true })
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const podcastUrlEl = xmlDoc.rss.channel['podcast:txt']
expect(podcastUrlEl).to.not.exist
})
})
it('Should not have p20url podcast txt attribute with classic RSS feed without channel', async function () {
const rss = await serverHLSOnly.feed.getXML({ feed: 'videos', ignoreCache: true })
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
describe('Playlist podcast feed', function () {
let xmlDoc: any
const podcastUrlEl = xmlDoc.rss.channel['podcast:txt']
expect(podcastUrlEl).to.not.exist
it('Should contain a valid podcast enclosures', async function () {
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, playlistId: podcastPlaylistId })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
xmlDoc = parser.parse(rss)
const channel = xmlDoc.rss.channel
expect(channel.title).to.equal('Podcast playlist')
})
it('Should not contain private video', async function () {
const items = Array.isArray(xmlDoc.rss.channel.item)
? xmlDoc.rss.channel.item
: [ xmlDoc.rss.channel.item ]
expect(items.length).to.equal(1)
expect(items[0].title).to.equal('my super name for server 1')
})
it('Should have valid itunes metadata', async function () {
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:author']).to.equal('Main root channel')
{
const itunesImageHref = channel['itunes:image']['@_href']
expect(itunesImageHref).to.exist
expect(itunesImageHref).to.contain('/thumbnails/')
const { body } = await makeRawRequest({ url: itunesImageHref, expectedStatus: HttpStatusCode.OK_200 })
await testImageSize({ buffer: body, width: 1400, height: 1400 })
}
const item = xmlDoc.rss.channel.item
expect(item['itunes:duration']).to.equal(5)
{
expect(item['itunes:image']['@_href']).to.exist
const { body } = await makeRawRequest({ url: item['itunes:image']['@_href'], expectedStatus: HttpStatusCode.OK_200 })
await testImageSize({ buffer: body, width: 1400, height: 1400 })
}
})
it('Should have p20url podcast txt attribute with local podcast feed', async function () {
const podcastUrlEl = xmlDoc.rss.channel['podcast:txt']
expect(podcastUrlEl).to.exist
expect(podcastUrlEl['@_purpose']).to.equal('p20url')
expect(podcastUrlEl['#text']).to.equal(
servers[0].url + '/feeds/podcast/videos.xml?playlistId=' + podcastPlaylistId
)
})
it('Should invalidate the podcast cache if an element is added or updated', async function () {
const newVideo = await servers[0].videos.quickUpload({ name: 'new video for playlist' })
await servers[0].playlists.addElement({ playlistId: podcastPlaylistId, attributes: { videoId: newVideo.id } })
const getItem = async () => {
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, playlistId: podcastPlaylistId })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const items = Array.isArray(xmlDoc.rss.channel.item)
? xmlDoc.rss.channel.item
: [ xmlDoc.rss.channel.item ]
expect(items.length).to.equal(2)
return items[0]
}
expect((await getItem()).title).to.equal('new video for playlist')
await servers[0].videos.update({ id: newVideo.id, attributes: { name: 'updated name for new video' } })
expect((await getItem()).title).to.equal('updated name for new video')
await servers[0].videos.remove({ id: newVideo.id })
await waitJobs(servers)
})
})
})
+8 -7
View File
@@ -448,6 +448,7 @@ export async function checkThumbnails (options: {
server: PeerTubeServer
video?: Video
playlist?: VideoPlaylist
remotePlaylist?: boolean
thumbnails: string[]
}) {
const { server, video, playlist } = options
@@ -474,17 +475,17 @@ export async function checkThumbnails (options: {
aspectRatio: t.aspectRatio
}))
if (video) {
if (options.remotePlaylist !== true) {
expect(toCheck).to.deep.include.members([
{ width: 1400, height: 1400, aspectRatio: '1:1' },
{ width: 280, height: 157, aspectRatio: '16:9' },
{ width: 850, height: 480, aspectRatio: '16:9' },
{ width: 1280, height: 720, aspectRatio: '16:9' },
{ width: 1920, height: 1080, aspectRatio: '16:9' }
{ width: 1920, height: 1080, aspectRatio: '16:9' },
{ width: 1400, height: 1400, aspectRatio: '1:1' }
])
expect(toCheck).to.have.lengthOf(5)
} else if (playlist) {
} else {
expect(toCheck).to.deep.include.members([
{ width: 280, height: 157, aspectRatio: '16:9' }
])
@@ -493,11 +494,11 @@ export async function checkThumbnails (options: {
}
for (const thumbnail of thumbnails) {
const videoThumbnail = entity.thumbnails.find(t => t.width === thumbnail.width && t.height === thumbnail.height)
const entityThumbnail = entity.thumbnails.find(t => t.width === thumbnail.width && t.height === thumbnail.height)
expectStartWith(videoThumbnail.fileUrl, server.url)
expectStartWith(entityThumbnail.fileUrl, server.url)
await testImageGeneratedByFFmpeg({ name: thumbnail.filename, url: videoThumbnail.fileUrl })
await testImageGeneratedByFFmpeg({ name: thumbnail.filename, url: entityThumbnail.fileUrl })
}
// oxlint-disable-next-line @typescript-eslint/no-deprecated
+25 -7
View File
@@ -93,8 +93,8 @@ importers:
specifier: ^11.1.2
version: 11.1.2
'@peertube/feed':
specifier: ^5.4.1
version: 5.4.1
specifier: ^5.5.0
version: 5.5.0
'@peertube/peertube-core-utils':
specifier: workspace:*
version: link:packages/core-utils
@@ -3227,8 +3227,8 @@ packages:
resolution: {integrity: sha512-PGKk9dAUVZwDUaGFsGFWwEEDY9C2biBXAkp4kQLnzqhGKw9Kesp5+Tug5rlb5IOpsnKei+DnA5n7XjAQ2xYVCA==}
engines: {node: '>=16.0.0'}
'@peertube/feed@5.4.1':
resolution: {integrity: sha512-WgqTn5NuejZ+HSon8JzwTapDu3SzjR1FqajpbvpX/g20z8Bd8i4mO0jmrC6Zk4XVK2LrRN2Bx0jdAhBmtgLa/Q==}
'@peertube/feed@5.5.0':
resolution: {integrity: sha512-5+dUdm/9eqZzdg+UuO/9awUKWxh3cxf98bvQeeIsib6RRCA9DLgxnHN8QbrbkGSGnj1QWAeQynZixBeAXdkfKA==}
engines: {node: '>=0.4.0'}
'@peertube/maildev@1.2.0':
@@ -14103,7 +14103,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@peertube/feed@5.4.1':
'@peertube/feed@5.5.0':
dependencies:
xml-js: 1.6.11
@@ -15532,7 +15532,7 @@ snapshots:
'@vitejs/devtools-rpc': 0.1.11(typescript@5.9.3)(ws@8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))
birpc: 4.0.0
ohash: 2.0.11
vite: 8.0.2(@types/node@20.19.33)(@vitejs/devtools@0.1.11)(esbuild@0.27.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(tsx@4.21.0)(yaml@2.8.2)
vite: 8.0.2(@types/node@25.3.0)(@vitejs/devtools@0.1.11)(esbuild@0.27.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- typescript
- ws
@@ -15621,7 +15621,7 @@ snapshots:
perfect-debounce: 2.1.0
sirv: 3.0.2
tinyexec: 1.0.4
vite: 8.0.2(@types/node@20.19.33)(@vitejs/devtools@0.1.11)(esbuild@0.27.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(tsx@4.21.0)(yaml@2.8.2)
vite: 8.0.2(@types/node@25.3.0)(@vitejs/devtools@0.1.11)(esbuild@0.27.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(tsx@4.21.0)(yaml@2.8.2)
vue: 3.5.30(typescript@5.9.3)
ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
transitivePeerDependencies:
@@ -23697,6 +23697,24 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.2
vite@8.0.2(@types/node@25.3.0)(@vitejs/devtools@0.1.11)(esbuild@0.27.3)(jiti@2.6.1)(sass-embedded@1.97.3)(sass@1.97.3)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.8
rolldown: 1.0.0-rc.11
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 25.3.0
'@vitejs/devtools': 0.1.11(@pnpm/logger@1001.0.1)(bufferutil@4.1.0)(ioredis@5.9.3)(typescript@5.9.3)(utf-8-validate@6.0.6)(vite@8.0.2)
esbuild: 0.27.3
fsevents: 2.3.3
jiti: 2.6.1
sass: 1.97.3
sass-embedded: 1.97.3
tsx: 4.21.0
yaml: 2.8.2
vm-browserify@1.1.2: {}
void-elements@3.1.0: {}
+10 -12
View File
@@ -32,7 +32,7 @@ import { MIMETYPES, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constant
import { sequelizeTypescript } from '../../initializers/database.js'
import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send/index.js'
import { getLocalVideoPlaylistActivityPubUrl, getLocalVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url.js'
import { createLocalPlaylistThumbnailFromImage } from '../../lib/thumbnail.js'
import { createLocalPlaylistThumbnailsFromImage } from '../../lib/thumbnail.js'
import {
apiRateLimiter,
asyncMiddleware,
@@ -196,8 +196,8 @@ async function createVideoPlaylist (req: express.Request, res: express.Response)
}
const thumbnailField = req.files?.['thumbnailfile']
const thumbnailModel = thumbnailField
? await createLocalPlaylistThumbnailFromImage({
const thumbnailModels = thumbnailField
? await createLocalPlaylistThumbnailsFromImage({
inputPath: thumbnailField[0].path,
playlist: videoPlaylist,
automaticallyGenerated: false
@@ -215,8 +215,8 @@ async function createVideoPlaylist (req: express.Request, res: express.Response)
const videoPlaylistCreated = await videoPlaylist.save({ transaction: t }) as MVideoPlaylistFull
if (thumbnailModel) {
await videoPlaylistCreated.setAndSaveThumbnail(thumbnailModel, t)
if (thumbnailModels) {
await videoPlaylistCreated.replaceAndSaveThumbnails(thumbnailModels, t)
}
// We need more attributes for the federation
@@ -258,8 +258,8 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
let removedFromChannel: { id: number, position: number }
const thumbnailField = req.files?.['thumbnailfile']
const thumbnailModel = thumbnailField
? await createLocalPlaylistThumbnailFromImage({
const thumbnailModels = thumbnailField
? await createLocalPlaylistThumbnailsFromImage({
inputPath: thumbnailField[0].path,
playlist,
automaticallyGenerated: false
@@ -329,9 +329,8 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
const playlistUpdated = await playlist.save({ transaction: t })
if (thumbnailModel) {
thumbnailModel.automaticallyGenerated = false
await playlistUpdated.setAndSaveThumbnail(thumbnailModel, t)
if (thumbnailModels) {
await playlistUpdated.replaceAndSaveThumbnails(thumbnailModels, t)
}
const isNewPlaylist = wasPrivatePlaylist && playlistUpdated.privacy !== VideoPlaylistPrivacy.PRIVATE
@@ -611,8 +610,7 @@ async function listVideosOfPlaylist (req: express.Request, res: express.Response
}
async function regeneratePlaylistThumbnail (videoPlaylist: MVideoPlaylistThumbnail) {
await videoPlaylist.Thumbnail.destroy()
videoPlaylist.Thumbnail = null
await videoPlaylist.removeThumbnails(undefined)
const firstElement = await VideoPlaylistElementModel.loadFirstElementWithVideoThumbnail(videoPlaylist.id)
if (firstElement) await generateThumbnailForPlaylist(videoPlaylist, firstElement.Video)
@@ -9,7 +9,14 @@ import { regenerateActorImageFiles } from '@server/lib/local-actor.js'
import { ServerConfigManager } from '@server/lib/server-config-manager.js'
import { getServerActor } from '@server/models/application/application.js'
import { UserModel } from '@server/models/user/user.js'
import { MAccountDefault, MChannelBannerAccountDefault, MChannelDefault, MUser, MVideo } from '@server/types/models/index.js'
import {
MAccountDefault,
MChannelBannerAccountDefault,
MChannelDefault,
MUser,
MVideo,
MVideoPlaylistFull
} from '@server/types/models/index.js'
import express from 'express'
export async function initFeed (parameters: {
@@ -112,11 +119,12 @@ export function sendFeed (feed: Feed, req: express.Request, res: express.Respons
}
export async function buildFeedMetadata (options: {
videoPlaylist?: MVideoPlaylistFull
videoChannel?: MChannelBannerAccountDefault
account?: MAccountDefault
video?: MVideo
}) {
const { video, videoChannel, account } = options
const { videoPlaylist, video, videoChannel, account } = options
let imageUrl = ServerConfigManager.Instance.getLogoUrl(await getServerActor(), 1500)
let ownerImageUrl: string
@@ -127,14 +135,32 @@ export async function buildFeedMetadata (options: {
let ownerLink: string
let user: MUser
if (videoChannel) {
if (videoPlaylist) {
name = videoPlaylist.name
description = videoPlaylist.description
link = WEBSERVER.URL + videoPlaylist.getWatchStaticPath()
const thumbnail = videoPlaylist.getBestThumbnail('1:1')
if (thumbnail) {
imageUrl = thumbnail?.getLocalFileUrl()
}
const channel = videoPlaylist.VideoChannel
ownerLink = channel.getClientUrl()
if (channel.Actor.hasImage(ActorImageType.AVATAR)) {
ownerImageUrl = await getOrGenerateActorImageUrl(channel)
}
user = await UserModel.loadById(videoPlaylist.OwnerAccount.userId)
} else if (videoChannel) {
name = videoChannel.getDisplayName()
description = videoChannel.description
ownerLink = videoChannel.getClientUrl()
link = ownerLink
if (videoChannel.Actor.hasImage(ActorImageType.AVATAR)) {
imageUrl = await getOrGenerateImageUrl(videoChannel)
imageUrl = await getOrGenerateActorImageUrl(videoChannel)
ownerImageUrl = imageUrl
}
@@ -146,7 +172,7 @@ export async function buildFeedMetadata (options: {
link = ownerLink
if (account.Actor.hasImage(ActorImageType.AVATAR)) {
imageUrl = await getOrGenerateImageUrl(account)
imageUrl = await getOrGenerateActorImageUrl(account)
ownerImageUrl = imageUrl
}
@@ -174,7 +200,7 @@ export async function buildFeedMetadata (options: {
// Private
// ---------------------------------------------------------------------------
async function getOrGenerateImageUrl (accountOrChannel: MChannelDefault | MAccountDefault) {
async function getOrGenerateActorImageUrl (accountOrChannel: MChannelDefault | MAccountDefault) {
let image = accountOrChannel.Actor.getMaxQualityImage(ActorImageType.AVATAR)
if (!image) throw new Error('No avatar image found for the account or channel')
@@ -1,4 +1,4 @@
import { getChannelPodcastFeed } from '@peertube/peertube-core-utils'
import { getChannelPodcastFeed, getPlaylistPodcastFeed } from '@peertube/peertube-core-utils'
import { VideoIncludeType } from '@peertube/peertube-models'
import { mdToPlainText, toSafeHtml } from '@server/helpers/markdown.js'
import { CONFIG } from '@server/initializers/config.js'
@@ -8,7 +8,7 @@ import { getServerActor } from '@server/models/application/application.js'
import { getCategoryLabel } from '@server/models/video/formatter/index.js'
import { DisplayOnlyForFollowerOptions } from '@server/models/video/sql/video/index.js'
import { VideoModel } from '@server/models/video/video.js'
import { MChannelHostOnly, MUserDefault } from '@server/types/models/index.js'
import { MChannelHostOnly, MUserDefault, MVideoPlaylist } from '@server/types/models/index.js'
export async function getVideosForFeeds (options: {
sort: string
@@ -18,6 +18,7 @@ export async function getVideosForFeeds (options: {
accountId?: number
videoChannelId?: number
videoPlaylistId?: number
displayOnlyForFollower?: DisplayOnlyForFollowerOptions
user?: MUserDefault
}) {
@@ -70,7 +71,7 @@ export function getCommonVideoFeedAttributes (video: VideoModel) {
}
}
export function getPodcastFeedUrlCustomTag (videoChannel: MChannelHostOnly) {
export function getPodcastChannelFeedUrlCustomTag (videoChannel: MChannelHostOnly) {
return {
name: 'podcast:txt',
attributes: {
@@ -80,3 +81,14 @@ export function getPodcastFeedUrlCustomTag (videoChannel: MChannelHostOnly) {
value: getChannelPodcastFeed(WEBSERVER.URL, videoChannel)
}
}
export function getPodcastPlaylistFeedUrlCustomTag (playlist: Pick<MVideoPlaylist, 'id'>) {
return {
name: 'podcast:txt',
attributes: {
purpose: 'p20url'
},
// TODO: use remote channel podcast feed URL
value: getPlaylistPodcastFeed(WEBSERVER.URL, playlist)
}
}
+2 -2
View File
@@ -21,7 +21,7 @@ import {
import {
buildFeedMetadata,
getCommonVideoFeedAttributes,
getPodcastFeedUrlCustomTag,
getPodcastChannelFeedUrlCustomTag,
getVideosForFeeds,
initFeed,
sendFeed
@@ -90,7 +90,7 @@ async function generateVideoFeed (req: express.Request, res: express.Response) {
}
],
customTags: videoChannel
? [ getPodcastFeedUrlCustomTag(videoChannel) ]
? [ getPodcastChannelFeedUrlCustomTag(videoChannel) ]
: []
})
@@ -4,11 +4,18 @@ import { buildDownloadFilesUrl, getResolutionLabel, sortObjectComparator } from
import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@peertube/peertube-models'
import { buildUUIDv5FromURL } from '@peertube/peertube-node-utils'
import { buildNSFWFilters } from '@server/helpers/express-utils.js'
import { logger } from '@server/helpers/logger.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'
import { buildPodcastGroupsCache, cacheRouteFactory, videoFeedsPodcastSetCacheKey } from '@server/middlewares/index.js'
import {
buildPodcastChannelGroupsCache,
buildPodcastPlaylistGroupsCache,
cacheRouteFactory,
videoFeedsPodcastSetCacheKey
} from '@server/middlewares/index.js'
import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element.js'
import { MVideo, MVideoCaptionVideo, MVideoFull } from '@server/types/models/index.js'
import express from 'express'
import { extname } from 'path'
@@ -16,7 +23,14 @@ import { MIMETYPES, ROUTE_CACHE_LIFETIME, VIDEO_CATEGORIES, WEBSERVER } from '..
import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares/index.js'
import { VideoCaptionModel } from '../../models/video/video-caption.js'
import { VideoModel } from '../../models/video/video.js'
import { buildFeedMetadata, getCommonVideoFeedAttributes, getPodcastFeedUrlCustomTag, getVideosForFeeds, initFeed } from './shared/index.js'
import {
buildFeedMetadata,
getCommonVideoFeedAttributes,
getPodcastChannelFeedUrlCustomTag,
getPodcastPlaylistFeedUrlCustomTag,
getVideosForFeeds,
initFeed
} from './shared/index.js'
const videoPodcastFeedsRouter = express.Router()
@@ -27,16 +41,36 @@ const { middleware: podcastCacheRouteMiddleware, instance: podcastApiCache } = c
})
for (const event of ([ 'video-created', 'video-updated', 'video-deleted' ] as const)) {
InternalEventEmitter.Instance.on(event, ({ video }) => {
if (video.remote) return
InternalEventEmitter.Instance.on(event, async ({ video }) => {
podcastApiCache.clearGroupSafe(buildPodcastChannelGroupsCache({ channelId: video.channelId }))
podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: video.channelId }))
try {
const playlistIds = await VideoPlaylistElementModel.listPlaylistIdsForVideoId(video.id)
for (const playlistId of playlistIds) {
podcastApiCache.clearGroupSafe(buildPodcastPlaylistGroupsCache({ playlistId }))
}
} catch (err) {
logger.error('Error while clearing podcast feed cache after video update', { videoId: video.id, error: err })
}
})
}
for (const event of ([ 'channel-updated', 'channel-deleted' ] as const)) {
InternalEventEmitter.Instance.on(event, ({ channel }) => {
podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: channel.id }))
podcastApiCache.clearGroupSafe(buildPodcastChannelGroupsCache({ channelId: channel.id }))
})
}
for (const event of ([ 'playlist-updated', 'playlist-deleted' ] as const)) {
InternalEventEmitter.Instance.on(event, ({ playlist }) => {
podcastApiCache.clearGroupSafe(buildPodcastPlaylistGroupsCache({ playlistId: playlist.id }))
})
}
for (const event of ([ 'playlist-element-created', 'playlist-element-updated', 'playlist-element-deleted' ] as const)) {
InternalEventEmitter.Instance.on(event, ({ playlistElement }) => {
podcastApiCache.clearGroupSafe(buildPodcastPlaylistGroupsCache({ playlistId: playlistElement.videoPlaylistId }))
})
}
@@ -60,6 +94,16 @@ export {
// ---------------------------------------------------------------------------
async function generateVideoPodcastFeed (req: express.Request, res: express.Response) {
const playlist = res.locals.videoPlaylistFull
if (playlist) {
return generatePlaylistPodcastFeed(req, res)
}
return generateChannelPodcastFeed(req, res)
}
async function generateChannelPodcastFeed (req: express.Request, res: express.Response) {
const videoChannel = res.locals.videoChannel
const { name, description, imageUrl, ownerImageUrl, email, link, ownerLink } = await buildFeedMetadata({ videoChannel })
@@ -78,14 +122,14 @@ 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 language = await VideoModel.guessLanguageOrCategoryOf('language', { videoChannelId: videoChannel.id })
const category = await VideoModel.guessLanguageOrCategoryOf('category', { videoChannelId: videoChannel.id })
const hasNSFW = nsfwOptions.nsfw !== false
? await VideoModel.channelHasNSFWContent(videoChannel.id)
: false
const customTags: CustomTag[] = await Hooks.wrapObject(
[ getPodcastFeedUrlCustomTag(videoChannel) ],
[ getPodcastChannelFeedUrlCustomTag(videoChannel) ],
'filter:feed.podcast.channel.create-custom-tags.result',
{ videoChannel }
)
@@ -127,6 +171,75 @@ async function generateVideoPodcastFeed (req: express.Request, res: express.Resp
return res.send(feed.podcast()).end()
}
async function generatePlaylistPodcastFeed (req: express.Request, res: express.Response) {
const playlist = res.locals.videoPlaylistFull
const { name, description, imageUrl, ownerImageUrl, email, link, ownerLink } = await buildFeedMetadata({ videoPlaylist: playlist })
const nsfwOptions = buildNSFWFilters()
const data = await getVideosForFeeds({
...nsfwOptions,
sort: '-publishedAt',
// 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,
videoPlaylistId: playlist.id
})
const language = await VideoModel.guessLanguageOrCategoryOf('language', { videoPlaylistId: playlist.id })
const category = await VideoModel.guessLanguageOrCategoryOf('category', { videoPlaylistId: playlist.id })
const hasNSFW = nsfwOptions.nsfw !== false
? await VideoModel.playlistHasPublicNSFWContent(playlist.id)
: false
const customTags: CustomTag[] = await Hooks.wrapObject(
[ getPodcastPlaylistFeedUrlCustomTag(playlist) ],
'filter:feed.podcast.video-playlist.create-custom-tags.result',
{ videoPlaylist: playlist }
)
const customXMLNS: CustomXMLNS[] = await Hooks.wrapObject(
[],
'filter:feed.podcast.rss.create-custom-xmlns.result'
)
const channelName = playlist.VideoChannel.getDisplayName()
const feed = await initFeed({
name,
description,
link,
isPodcast: true,
imageUrl,
language: language || 'en',
category: categoryToItunes(category),
nsfw: hasNSFW,
guid: buildUUIDv5FromURL(playlist.url),
locked: email
? { isLocked: true, email } // Default to true because we have no way of offering a redirect yet
: undefined,
person: [ { name: channelName, href: ownerLink, img: ownerImageUrl } ],
author: { name: channelName, link: ownerLink },
resourceType: 'videos',
queryString: new URL(WEBSERVER.URL + req.url).search,
medium: 'video',
customXMLNS,
customTags
})
await addVideosToPodcastFeed(feed, data)
return res.send(feed.podcast()).end()
}
type PodcastMedia =
| {
type: string
+1 -1
View File
@@ -61,7 +61,7 @@ function generatePlaylistOEmbed (req: express.Request, res: express.Response) {
channel: playlist.VideoChannel,
title: playlist.name,
embedPath: playlist.getEmbedStaticPath() + buildPlayerURLQuery(req.query.url),
thumbnail: playlist.Thumbnail,
thumbnail: playlist.getBestThumbnail('16:9', 1280),
req
})
@@ -1,8 +1,8 @@
import { ActivityHtmlUrlObject } from '@peertube/peertube-models'
import { CONFIG } from '@server/initializers/config.js'
import validator from 'validator'
import { CONSTRAINTS_FIELDS } from '../../../initializers/constants.js'
import { exists } from '../misc.js'
import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../../initializers/constants.js'
import { exists, isArray } from '../misc.js'
import { arrayify } from '@peertube/peertube-core-utils'
export function isUrlValid (url: string) {
@@ -62,6 +62,21 @@ export function setValidAttributedTo (obj: any) {
return true
}
export function setValidRemoteIcon (entity: any) {
if (entity.icon && !isArray(entity.icon)) entity.icon = [ entity.icon ]
if (!entity.icon) entity.icon = []
entity.icon = entity.icon.filter(icon => {
return icon.type === 'Image' &&
isActivityPubUrlValid(icon.url) &&
!!MIMETYPES.IMAGE.MIMETYPE_EXT[icon.mediaType] &&
validator.default.isInt(icon.width + '', { min: 0 }) &&
validator.default.isInt(icon.height + '', { min: 0 })
})
return true
}
export function isActivityPubVideoDurationValid (value: string) {
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
return exists(value) &&
@@ -2,13 +2,15 @@ import { PlaylistElementObject, PlaylistObject } from '@peertube/peertube-models
import validator from 'validator'
import { exists, isDateValid, isUUIDValid } from '../misc.js'
import { isVideoPlaylistNameValid } from '../video-playlists.js'
import { isActivityPubUrlValid, setValidAttributedTo } from './misc.js'
import { isActivityPubUrlValid, setValidAttributedTo, setValidRemoteIcon } from './misc.js'
export function isPlaylistObjectValid (object: PlaylistObject) {
if (object?.type !== 'Playlist') return false
setValidAttributedTo(object)
if (!setValidRemoteIcon(object)) return false
return validator.default.isInt(object.totalItems + '') &&
isVideoPlaylistNameValid(object.name) &&
isUUIDValid(object.uuid) &&
@@ -25,7 +25,13 @@ import {
isVideoTagValid,
isVideoViewsValid
} from '../videos.js'
import { isActivityPubUrlValid, isActivityPubVideoDurationValid, isBaseActivityValid, setValidAttributedTo } from './misc.js'
import {
isActivityPubUrlValid,
isActivityPubVideoDurationValid,
isBaseActivityValid,
setValidAttributedTo,
setValidRemoteIcon
} from './misc.js'
import { getDurationFromActivityStream } from '@server/lib/activitypub/activity.js'
export function sanitizeAndCheckVideoTorrentUpdateActivity (activity: any) {
@@ -46,7 +52,7 @@ export function sanitizeAndCheckVideoTorrentObject (video: VideoObject) {
if (!setRemoteVideoContent(video)) return fail('content')
if (!setValidAttributedTo(video)) return fail('attributedTo')
if (!setValidRemoteCaptions(video)) return fail('captions')
if (!setValidRemoteIcon(video)) return fail('icons')
if (!setValidRemoteIcon(video) || video.icon.length === 0) return fail('icons')
if (!setValidStoryboard(video)) return fail('preview (storyboard)')
if (!setValidLicence(video)) return fail('licence')
@@ -209,21 +215,6 @@ function isRemoteVideoContentValid (mediaType: string, content: string) {
return (mediaType === 'text/markdown' || mediaType === 'text/html') && isVideoDescriptionValid(content)
}
function setValidRemoteIcon (video: any) {
if (video.icon && !isArray(video.icon)) video.icon = [ video.icon ]
if (!video.icon) video.icon = []
video.icon = video.icon.filter(icon => {
return icon.type === 'Image' &&
isActivityPubUrlValid(icon.url) &&
!!MIMETYPES.IMAGE.MIMETYPE_EXT[icon.mediaType] &&
validator.default.isInt(icon.width + '', { min: 0 }) &&
validator.default.isInt(icon.height + '', { min: 0 })
})
return video.icon.length !== 0
}
function setValidRemoteVideoUrls (video: any) {
if (Array.isArray(video.url) === false) return false
+1 -1
View File
@@ -62,7 +62,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'
// ---------------------------------------------------------------------------
export const LAST_MIGRATION_VERSION = 1025
export const LAST_MIGRATION_VERSION = 1030
// ---------------------------------------------------------------------------
@@ -0,0 +1,18 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
}): Promise<void> {
await utils.sequelize.query('DROP INDEX IF EXISTS "thumbnail_video_playlist_id"', { transaction: utils.transaction })
}
function down (options) {
throw new Error('Not implemented.')
}
export {
down,
up
}
@@ -1,4 +1,5 @@
import { HttpStatusCode, PlaylistObject } from '@peertube/peertube-models'
import { guessAspectRatio } from '@peertube/peertube-core-utils'
import { ActivityIconObject, HttpStatusCode, PlaylistObject } from '@peertube/peertube-models'
import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
@@ -9,7 +10,7 @@ import { updateRemotePlaylistThumbnailFromUrl } from '@server/lib/thumbnail.js'
import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element.js'
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
import { FilteredModelAttributes } from '@server/types/index.js'
import { MAccountHost, MThumbnail, MVideoPlaylist, MVideoPlaylistFull, MVideoPlaylistVideosLength } from '@server/types/models/index.js'
import { MAccountHost, MVideoPlaylist, MVideoPlaylistFull, MVideoPlaylistVideosLength } from '@server/types/models/index.js'
import Bluebird from 'bluebird'
import { getAPId } from '../activity.js'
import { getOrCreateAPActor } from '../actors/index.js'
@@ -130,26 +131,25 @@ async function fetchElementUrls (playlistObject: PlaylistObject) {
}
async function updatePlaylistThumbnail (playlistObject: PlaylistObject, playlist: MVideoPlaylistFull) {
if (playlistObject.icon) {
let thumbnailModel: MThumbnail
// This field has been sanitized in the validator
const icons = playlistObject.icon as ActivityIconObject[]
try {
thumbnailModel = updateRemotePlaylistThumbnailFromUrl({ fileUrl: playlistObject.icon.url, playlist })
await playlist.setAndSaveThumbnail(thumbnailModel, undefined)
} catch (err) {
logger.warn('Cannot set thumbnail of %s.', playlistObject.id, { err, ...lTags(playlistObject.id, playlist.uuid, playlist.url) })
if (thumbnailModel) await thumbnailModel.removeFile()
}
// Playlist does not have an icon, destroy existing one
if (icons.length === 0) {
await playlist.removeThumbnails(undefined)
return
}
// Playlist does not have an icon, destroy existing one
if (playlist.hasThumbnail()) {
await playlist.Thumbnail.destroy()
playlist.Thumbnail = null
}
const thumbnails = icons.map(icon => {
return updateRemotePlaylistThumbnailFromUrl({
fileUrl: icon.url,
playlist,
size: { ...icon, aspectRatio: guessAspectRatio(icon.width, icon.height) }
})
})
await playlist.replaceAndSaveThumbnails(thumbnails, undefined)
}
async function rebuildVideoPlaylistElements (elementUrls: string[], playlist: MVideoPlaylist) {
+5 -4
View File
@@ -1,7 +1,7 @@
import { addQueryParams, escapeHTML } from '@peertube/peertube-core-utils'
import { HttpStatusCode, VideoPlaylistPrivacy } from '@peertube/peertube-models'
import { Memoize } from '@server/helpers/memoize.js'
import { getDefaultRSSFeeds } from '@server/lib/rss.js'
import { getPlaylistRSSFeeds } from '@server/lib/rss.js'
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
import { MVideoPlaylist, MVideoPlaylistFull } from '@server/types/models/index.js'
import express from 'express'
@@ -88,6 +88,7 @@ export class PlaylistHtml {
}) {
const { req, html, playlist, addOG, addTwitterCard, isEmbed, currentQuery = {} } = options
const escapedTruncatedDescription = TagsHtml.buildEscapedTruncatedDescription(playlist.description)
const thumbnail = playlist.getBestThumbnail('16:9')
let htmlResult = TagsHtml.addTitleTag(html, playlist.name)
htmlResult = TagsHtml.addDescriptionTag(htmlResult, escapedTruncatedDescription)
@@ -105,8 +106,8 @@ export class PlaylistHtml {
embedIndexation: isEmbed,
image: playlist.hasThumbnail()
? { url: playlist.getThumbnailUrl(), width: playlist.Thumbnail.width, height: playlist.Thumbnail.height }
image: thumbnail
? { url: playlist.getThumbnailUrl(), width: thumbnail.width, height: thumbnail.height }
: undefined,
list: { numberOfItems: playlist.get('videosLength') as number },
@@ -136,7 +137,7 @@ export class PlaylistHtml {
},
rssFeeds: req
? getDefaultRSSFeeds(req)
? getPlaylistRSSFeeds({ displayName: playlist.name, id: playlist.id }, req)
: []
}, { playlist })
}
+9 -1
View File
@@ -1,4 +1,4 @@
import { MChannel, MVideo, MVideoImmutable } from '@server/types/models/index.js'
import { MChannel, MVideo, MVideoImmutable, MVideoPlaylist, MVideoPlaylistElement } from '@server/types/models/index.js'
import { EventEmitter } from 'events'
export interface PeerTubeInternalEvents {
@@ -10,6 +10,14 @@ export interface PeerTubeInternalEvents {
'channel-updated': (options: { channel: MChannel }) => void
'channel-deleted': (options: { channel: MChannel }) => void
'playlist-created': (options: { playlist: MVideoPlaylist }) => void
'playlist-updated': (options: { playlist: MVideoPlaylist }) => void
'playlist-deleted': (options: { playlist: MVideoPlaylist }) => void
'playlist-element-created': (options: { playlistElement: MVideoPlaylistElement }) => void
'playlist-element-updated': (options: { playlistElement: MVideoPlaylistElement }) => void
'playlist-element-deleted': (options: { playlistElement: MVideoPlaylistElement }) => void
'chapters-updated': (options: { video: MVideoImmutable }) => void
}
+12
View File
@@ -1,6 +1,7 @@
import {
getChannelRSSFeeds as getChannelRSSFeedsCore,
getInstanceRSSFeed as getDefaultRSSFeedCore,
getPlaylistRSSFeeds as getPlaylistRSSFeedsCore,
getVideoRSSFeeds as getVideoWatchRSSFeedsCore
} from '@peertube/peertube-core-utils'
import { CONFIG } from '@server/initializers/config.js'
@@ -39,3 +40,14 @@ export function getVideoRSSFeeds (video: MVideo, req: express.Request) {
}
})
}
export function getPlaylistRSSFeeds (playlist: { displayName: string, id: number }, req: express.Request) {
return getPlaylistRSSFeedsCore({
url: WEBSERVER.URL,
playlist,
titles: {
instanceVideosFeed: req.t('{instanceName} - Videos feed', { instanceName: CONFIG.INSTANCE.NAME }),
playlistPodcastFeed: req.t('{name} - Podcast feed', { name: playlist.displayName })
}
})
}
+30 -5
View File
@@ -20,14 +20,36 @@ const lTags = loggerTagsFactory('thumbnail')
type ImageSize = { height: number, width: number, aspectRatio: ThumbnailAspectRatio }
export function createLocalPlaylistThumbnailFromImage (options: {
export function createLocalPlaylistThumbnailsFromImage (options: {
inputPath: string
playlist: MVideoPlaylistThumbnail
automaticallyGenerated: boolean
keepOriginal?: boolean // default to false
}) {
const { inputPath, playlist, automaticallyGenerated, keepOriginal = false } = options
const size = CONFIG.THUMBNAILS.SIZES[0] // Minimum size
return Promise.all(
CONFIG.THUMBNAILS.SIZES.map((size, i) => {
return _createLocalPlaylistThumbnailFromImage({
inputPath,
playlist,
automaticallyGenerated,
size,
// Keep original image until the last thumbnail is generated
keepOriginal: keepOriginal || i !== CONFIG.THUMBNAILS.SIZES.length - 1
})
})
)
}
function _createLocalPlaylistThumbnailFromImage (options: {
inputPath: string
playlist: MVideoPlaylistThumbnail
automaticallyGenerated: boolean
size: ImageSize
keepOriginal: boolean
}) {
const { inputPath, playlist, automaticallyGenerated, size, keepOriginal } = options
const { filename, outputPath, height, width, aspectRatio } = buildMetadataFromPlaylist({
playlist,
@@ -53,9 +75,10 @@ export function createLocalPlaylistThumbnailFromImage (options: {
export function updateRemotePlaylistThumbnailFromUrl (options: {
fileUrl: string
playlist: MVideoPlaylistThumbnail
size: ImageSize
}) {
const { fileUrl, playlist } = options
const size = CONFIG.THUMBNAILS.SIZES[0] // Minimum size
const { fileUrl, playlist, size } = options
const extension = getImageExtension(fileUrl)
const { filename: generatedFilename, height, width, aspectRatio, existingThumbnail } = buildMetadataFromPlaylist({
@@ -341,7 +364,9 @@ function buildMetadataFromPlaylist (options: {
return {
filename,
basePath: CONFIG.STORAGE.THUMBNAILS_DIR,
existingThumbnail: playlist.Thumbnail,
existingThumbnail: Array.isArray(playlist.Thumbnails)
? playlist.Thumbnails.find(t => t.height === size.height && t.width === size.width)
: undefined,
outputPath: join(CONFIG.STORAGE.THUMBNAILS_DIR, filename),
height: size.height,
width: size.width,
@@ -20,9 +20,8 @@ export class VideoPlaylistsExporter extends AbstractUserExporter<VideoPlaylistsE
thumbnail: null as string
}
if (playlist.hasThumbnail()) {
const thumbnail = playlist.Thumbnail
const thumbnail = playlist.getBestThumbnail('16:9')
if (thumbnail) {
staticFiles.push({
archivePath: this.getArchiveThumbnailPath(playlist, thumbnail),
readStreamFactory: () => Promise.resolve(createReadStream(thumbnail.getFSPath()))
@@ -47,7 +46,14 @@ export class VideoPlaylistsExporter extends AbstractUserExporter<VideoPlaylistsE
createdAt: playlist.createdAt.toISOString(),
updatedAt: playlist.updatedAt.toISOString(),
thumbnailUrl: playlist.Thumbnail?.getLocalFileUrl(),
thumbnailUrl: thumbnail?.getLocalFileUrl(),
thumbnails: playlist.Thumbnails.map(t => ({
width: t.width,
height: t.height,
url: t.getLocalFileUrl(),
createdAt: t.createdAt.toISOString(),
updatedAt: t.updatedAt.toISOString()
})),
elements: elements.map(e => ({
videoUrl: e.Video.url,
@@ -8,7 +8,7 @@ import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
import { AbstractUserImporter } from './abstract-user-importer.js'
import { sendCreateVideoPlaylist } from '@server/lib/activitypub/send/send-create.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { createLocalPlaylistThumbnailFromImage } from '@server/lib/thumbnail.js'
import { createLocalPlaylistThumbnailsFromImage } from '@server/lib/thumbnail.js'
import { CONSTRAINTS_FIELDS, USER_IMPORT } from '@server/initializers/constants.js'
import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element.js'
import { loadOrCreateVideoIfAllowedForUser } from '@server/lib/model-loaders/video.js'
@@ -122,13 +122,13 @@ export class VideoPlaylistsImporter extends AbstractUserImporter<VideoPlaylistsE
if (!await this.isFileValidOrLog(thumbnailPath, CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.IMAGE.FILE_SIZE.max)) return undefined
const thumbnail = await createLocalPlaylistThumbnailFromImage({
const thumbnails = await createLocalPlaylistThumbnailsFromImage({
inputPath: thumbnailPath,
playlist,
automaticallyGenerated: false
})
await playlist.setAndSaveThumbnail(thumbnail, undefined)
await playlist.replaceAndSaveThumbnails(thumbnails, undefined)
}
private async createElements (playlist: MVideoPlaylistThumbnail, playlistImportData: SanitizedObject) {
+4 -10
View File
@@ -13,7 +13,7 @@ import { MVideoPlaylistOwner, MVideoPlaylistThumbnail } from '../types/models/vi
import { sendUpdateVideoPlaylist } from './activitypub/send/send-update.js'
import { getLocalVideoPlaylistActivityPubUrl } from './activitypub/url.js'
import downloadImage from './image-downloader.js'
import { createLocalPlaylistThumbnailFromImage } from './thumbnail.js'
import { createLocalPlaylistThumbnailsFromImage } from './thumbnail.js'
export async function createWatchLaterPlaylist (account: MAccount, t: Transaction) {
const videoPlaylist: MVideoPlaylistOwner = new VideoPlaylistModel({
@@ -49,24 +49,18 @@ export async function generateThumbnailForPlaylist (videoPlaylist: MVideoPlaylis
await downloadImage({
url: videoThumbnail.fileUrl,
destDir: CONFIG.STORAGE.TMP_DIR,
destName: tmpImageName,
size: {
height: videoThumbnail.height || 280,
width: videoThumbnail.width || 157
}
destName: tmpImageName
})
}
const thumbnailModel = await createLocalPlaylistThumbnailFromImage({
const thumbnails = await createLocalPlaylistThumbnailsFromImage({
inputPath: join(CONFIG.STORAGE.TMP_DIR, tmpImageName),
playlist: videoPlaylist,
automaticallyGenerated: true,
keepOriginal: false
})
thumbnailModel.videoPlaylistId = videoPlaylist.id
videoPlaylist.Thumbnail = await thumbnailModel.save()
await videoPlaylist.replaceAndSaveThumbnails(thumbnails, undefined)
}
export async function reorderPlaylistOrElementsPosition<T extends typeof VideoPlaylistElementModel | typeof VideoPlaylistModel> (options: {
+11 -3
View File
@@ -23,10 +23,16 @@ export function cacheRouteFactory (options: APICacheOptions = {}) {
// ---------------------------------------------------------------------------
export function buildPodcastGroupsCache (options: {
export function buildPodcastChannelGroupsCache (options: {
channelId: number
}) {
return 'podcast-feed-' + options.channelId
return 'podcast-feed-channel-' + options.channelId
}
export function buildPodcastPlaylistGroupsCache (options: {
playlistId: number
}) {
return 'podcast-feed-playlist-' + options.playlistId
}
export function buildAPVideoChaptersGroupsCache (options: {
@@ -40,7 +46,9 @@ export function buildAPVideoChaptersGroupsCache (options: {
export const videoFeedsPodcastSetCacheKey = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.query.videoChannelId) {
res.locals.apicacheGroups = [ buildPodcastGroupsCache({ channelId: req.query.videoChannelId }) ]
res.locals.apicacheGroups = [ buildPodcastChannelGroupsCache({ channelId: req.query.videoChannelId }) ]
} else if (req.query.playlistId) {
res.locals.apicacheGroups = [ buildPodcastPlaylistGroupsCache({ playlistId: req.query.playlistId }) ]
}
return next()
+31 -2
View File
@@ -1,4 +1,4 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { HttpStatusCode, VideoPlaylistPrivacy } from '@peertube/peertube-models'
import express from 'express'
import { param, query } from 'express-validator'
import { isValidRSSFeed } from '../../helpers/custom-validators/feeds.js'
@@ -10,6 +10,7 @@ import {
doesAccountIdExist,
doesChannelHandleExist,
doesChannelIdExist,
doesVideoPlaylistExist,
doesUserFeedTokenCorrespond,
doesVideoExist
} from './shared/index.js'
@@ -107,13 +108,27 @@ export const feedsAccountOrChannelFiltersValidator = [
export const videoFeedsPodcastValidator = [
query('videoChannelId')
.optional()
.custom(isIdValid),
query('playlistId')
.optional()
.customSanitizer(toCompleteUUID)
.custom(isIdOrUUIDValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!req.query.videoChannelId && !req.query.playlistId) {
return res.fail({ message: req.t('One of videoChannelId or playlistId is required.') })
}
if (req.query.videoChannelId && req.query.playlistId) {
return res.fail({ message: req.t('videoChannelId and playlistId cannot be mixed.') })
}
if (
!await doesChannelIdExist({
req.query.videoChannelId && !await doesChannelIdExist({
id: req.query.videoChannelId,
checkCanManage: false,
checkIsLocal: false,
@@ -123,6 +138,20 @@ export const videoFeedsPodcastValidator = [
})
) return
if (req.query.playlistId) {
if (!await doesVideoPlaylistExist({ id: req.query.playlistId, req, res, fetchType: 'all' })) return
const playlist = res.locals.videoPlaylistFull
if (playlist.privacy !== VideoPlaylistPrivacy.PUBLIC) {
return res.fail({ message: req.t('This playlist feed is only available for public playlists.') })
}
if (!playlist.VideoChannel) {
return res.fail({ message: req.t('The channel associated with this playlist could not be found.') })
}
}
return next()
}
]
@@ -218,7 +218,7 @@ export class VideoPlaylistListQueryBuilder extends AbstractListQuery {
private buildThumbnailJoin () {
if (this.builtThumbnailJoin) return
this.join += ' LEFT JOIN "thumbnail" "Thumbnail" ON "Thumbnail"."videoPlaylistId" = "VideoPlaylistModel"."id" '
this.join += ' LEFT JOIN "thumbnail" "Thumbnails" ON "Thumbnails"."videoPlaylistId" = "VideoPlaylistModel"."id" '
this.builtThumbnailJoin = true
}
@@ -237,7 +237,7 @@ export class VideoPlaylistListQueryBuilder extends AbstractListQuery {
this.tableAttributes.getAccountAvatarAttributes(),
this.tableAttributes.getChannelAvatarAttributes(),
this.tableAttributes.getThumbnailAttributes()
this.tableAttributes.getThumbnailsAttributes()
]
}
@@ -60,7 +60,7 @@ export class VideoPlaylistTableAttributes {
// ---------------------------------------------------------------------------
@Memoize()
getThumbnailAttributes () {
return ThumbnailModel.getSQLAttributes('Thumbnail', 'Thumbnail.').join(', ')
getThumbnailsAttributes () {
return ThumbnailModel.getSQLAttributes('Thumbnails', 'Thumbnails.').join(', ')
}
}
@@ -160,7 +160,7 @@ export class AbstractVideoQueryBuilder extends AbstractRunQuery {
protected includePlaylist (playlistId: number) {
this.addJoin(
'INNER JOIN "videoPlaylistElement" as "VideoPlaylistElement" ON "videoPlaylistElement"."videoId" = "video"."id" ' +
'INNER JOIN "videoPlaylistElement" as "VideoPlaylistElement" ON "VideoPlaylistElement"."videoId" = "video"."id" ' +
'AND "VideoPlaylistElement"."videoPlaylistId" = :videoPlaylistId'
)
@@ -437,7 +437,7 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
private joinPlaylist (playlistId: number) {
this.joins.push(
'INNER JOIN "videoPlaylistElement" "video"."id" = "videoPlaylistElement"."videoId" ' +
'INNER JOIN "videoPlaylistElement" ON "video"."id" = "videoPlaylistElement"."videoId" ' +
'AND "videoPlaylistElement"."videoPlaylistId" = :videoPlaylistId'
)
+1 -2
View File
@@ -39,8 +39,7 @@ export const thumbnailAPIAttributes = [
fields: [ 'videoId' ]
},
{
fields: [ 'videoPlaylistId' ],
unique: true
fields: [ 'videoPlaylistId' ]
},
{
fields: [ 'filename' ],
@@ -5,9 +5,10 @@ import {
VideoPrivacy,
VideoPrivacyType
} from '@peertube/peertube-models'
import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
import { MUserAccountId } from '@server/types/models/index.js'
import {
MVideoPlaylistElement,
type MVideoPlaylistElement,
MVideoPlaylistElementAP,
MVideoPlaylistElementFormattable,
MVideoPlaylistElementVideoThumbnail,
@@ -16,6 +17,9 @@ import {
} from '@server/types/models/video/video-playlist-element.js'
import { ScopeOptions, Transaction } from 'sequelize'
import {
AfterCreate,
AfterDestroy,
AfterUpdate,
AllowNull,
BelongsTo,
Column,
@@ -108,6 +112,25 @@ export class VideoPlaylistElementModel extends SequelizeModel<VideoPlaylistEleme
})
declare Video: Awaited<VideoModel>
// ---------------------------------------------------------------------------
@AfterCreate
static notifyCreate (playlistElement: MVideoPlaylistElement) {
InternalEventEmitter.Instance.emit('playlist-element-created', { playlistElement })
}
@AfterUpdate
static notifyUpdate (playlistElement: MVideoPlaylistElement) {
InternalEventEmitter.Instance.emit('playlist-element-updated', { playlistElement })
}
@AfterDestroy
static notifyDestroy (playlistElement: MVideoPlaylistElement) {
InternalEventEmitter.Instance.emit('playlist-element-deleted', { playlistElement })
}
// ---------------------------------------------------------------------------
static deleteAllOf (videoPlaylistId: number, transaction?: Transaction) {
const query = {
where: {
@@ -276,6 +299,17 @@ export class VideoPlaylistElementModel extends SequelizeModel<VideoPlaylistEleme
})
}
static listPlaylistIdsForVideoId (videoId: number): Promise<number[]> {
return VideoPlaylistElementModel.findAll({
attributes: [ 'videoPlaylistId' ],
raw: true,
where: {
videoId
},
limit: 1000
}).then(rows => rows.map(r => r.videoPlaylistId))
}
// ---------------------------------------------------------------------------
static getNextPositionOf (videoPlaylistId: number, transaction?: Transaction) {
+113 -32
View File
@@ -1,7 +1,7 @@
import { buildPlaylistEmbedPath, buildPlaylistWatchPath } from '@peertube/peertube-core-utils'
import { buildPlaylistEmbedPath, buildPlaylistWatchPath, maxBy } from '@peertube/peertube-core-utils'
import {
ActivityIconObject,
PlaylistObject,
ThumbnailAspectRatio,
VideoPlaylist,
VideoPlaylistPrivacy,
VideoPlaylistType,
@@ -11,10 +11,14 @@ import {
import { uuidToShort } from '@peertube/peertube-node-utils'
import { generateImageFilename } from '@server/helpers/image-utils.js'
import { activityPubCollectionPagination } from '@server/lib/activitypub/collection.js'
import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
import { MAccountId, MChannelId, MVideoPlaylistElement } from '@server/types/models/index.js'
import { join } from 'path'
import { FindOptions, Op, Transaction, literal } from 'sequelize'
import {
AfterCreate,
AfterDestroy,
AfterUpdate,
AllowNull,
BelongsTo,
Column,
@@ -23,7 +27,6 @@ import {
Default,
ForeignKey,
HasMany,
HasOne,
Is,
IsUUID,
Scopes,
@@ -47,13 +50,14 @@ import {
} from '../../initializers/constants.js'
import { MThumbnail } from '../../types/models/video/thumbnail.js'
import {
MVideoPlaylist,
MVideoPlaylistAP,
MVideoPlaylistAccountThumbnail,
MVideoPlaylistFormattable,
MVideoPlaylistFull,
MVideoPlaylistFullSummary,
MVideoPlaylistSummaryWithElements
MVideoPlaylistSummaryWithElements,
MVideoPlaylistThumbnail,
type MVideoPlaylist
} from '../../types/models/video/video-playlist.js'
import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account.js'
import { ActorModel } from '../actor/actor.js'
@@ -229,7 +233,7 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
})
declare VideoPlaylistElements: Awaited<VideoPlaylistElementModel>[]
@HasOne(() => ThumbnailModel, {
@HasMany(() => ThumbnailModel, {
foreignKey: {
name: 'videoPlaylistId',
allowNull: true
@@ -237,7 +241,24 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
onDelete: 'CASCADE',
hooks: true
})
declare Thumbnail: Awaited<ThumbnailModel>
declare Thumbnails: Awaited<ThumbnailModel>[]
// ---------------------------------------------------------------------------
@AfterCreate
static notifyCreate (playlist: MVideoPlaylist) {
InternalEventEmitter.Instance.emit('playlist-created', { playlist })
}
@AfterUpdate
static notifyUpdate (playlist: MVideoPlaylist) {
InternalEventEmitter.Instance.emit('playlist-updated', { playlist })
}
@AfterDestroy
static notifyDestroy (playlist: MVideoPlaylist) {
InternalEventEmitter.Instance.emit('playlist-deleted', { playlist })
}
// ---------------------------------------------------------------------------
@@ -381,6 +402,32 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
})
}
static async listLocalIds (): Promise<number[]> {
const rows = await VideoPlaylistModel.unscoped().findAll({
attributes: [ 'id' ],
raw: true,
include: [
{
attributes: [],
model: AccountModel.unscoped(),
required: true,
include: [
{
attributes: [],
model: ActorModel.unscoped(),
required: true,
where: {
serverId: null
}
}
]
}
]
})
return rows.map(r => r.id)
}
// ---------------------------------------------------------------------------
static doesPlaylistExist (url: string) {
@@ -574,22 +621,45 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
// ---------------------------------------------------------------------------
async setAndSaveThumbnail (thumbnail: MThumbnail, t: Transaction) {
thumbnail.videoPlaylistId = this.id
if (this.Thumbnail && thumbnail.id !== this.Thumbnail?.id) {
await this.Thumbnail.destroy({ transaction: t })
async replaceAndSaveThumbnails (thumbnails: MThumbnail[], transaction?: Transaction) {
if (thumbnails.length === 0) {
throw new Error('Cannot replace thumbnails with an empty array, at least one thumbnail is required')
}
this.Thumbnail = await thumbnail.save({ transaction: t })
let oldThumbnails = Array.isArray(this.Thumbnails)
? [ ...this.Thumbnails ]
: []
this.Thumbnails = []
for (const thumbnail of thumbnails) {
thumbnail.videoPlaylistId = this.id
const savedThumbnail = await thumbnail.save({ transaction })
oldThumbnails = oldThumbnails.filter(t => t.id !== savedThumbnail.id)
this.Thumbnails.push(savedThumbnail)
}
for (const oldThumbnail of oldThumbnails) {
await oldThumbnail.destroy({ transaction })
}
}
async removeThumbnails (t: Transaction) {
for (const thumbnail of this.Thumbnails) {
await thumbnail.destroy({ transaction: t })
}
this.Thumbnails = []
}
hasThumbnail () {
return !!this.Thumbnail
return this.Thumbnails.length !== 0
}
hasGeneratedThumbnail () {
return this.hasThumbnail() && this.Thumbnail.automaticallyGenerated === true
return this.Thumbnails.some(t => t.automaticallyGenerated === true)
}
shouldGenerateThumbnailWithNewElement (newElement: MVideoPlaylistElement) {
@@ -603,18 +673,36 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
return 'playlist-' + generateImageFilename(extension)
}
getBestThumbnail (
this: Pick<MVideoPlaylistThumbnail, 'Thumbnails' | 'filterThumbnails'>,
ratio: ThumbnailAspectRatio,
maxWidth?: number
) {
if (!this.Thumbnails || this.Thumbnails.length === 0) return undefined
return maxBy(this.filterThumbnails(ratio, maxWidth), 'width')
}
filterThumbnails (this: Pick<MVideoPlaylistThumbnail, 'Thumbnails'>, ratio: ThumbnailAspectRatio, maxWidth?: number) {
if (!this.Thumbnails) return []
return this.Thumbnails.filter(t => t.aspectRatio === ratio && (!maxWidth || t.width <= maxWidth))
}
// ---------------------------------------------------------------------------
getThumbnailUrl () {
if (!this.hasThumbnail()) return null
const staticPath = this.getThumbnailStaticPath()
if (!staticPath) return null
return WEBSERVER.URL + LAZY_STATIC_PATHS.THUMBNAILS + this.Thumbnail.filename
return WEBSERVER.URL + staticPath
}
getThumbnailStaticPath () {
if (!this.hasThumbnail()) return null
const thumbnail = this.getBestThumbnail('16:9', 300)
if (!thumbnail) return null
return join(LAZY_STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename)
return join(LAZY_STATIC_PATHS.THUMBNAILS, thumbnail.filename)
}
// ---------------------------------------------------------------------------
@@ -690,9 +778,7 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
},
thumbnailPath: this.getThumbnailStaticPath(),
thumbnails: this.Thumbnail
? [ this.Thumbnail.toFormattedJSON() ]
: [],
thumbnails: this.Thumbnails.map(t => t.toFormattedJSON()),
embedPath: this.getEmbedStaticPath(),
@@ -720,16 +806,11 @@ export class VideoPlaylistModel extends SequelizeModel<VideoPlaylistModel> {
return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t)
}
let icon: ActivityIconObject
if (this.hasThumbnail()) {
icon = {
type: 'Image' as 'Image',
url: this.getThumbnailUrl(),
mediaType: 'image/jpeg' as 'image/jpeg',
width: this.Thumbnail.width,
height: this.Thumbnail.height
}
}
// TODO: send an array of icons in 9.0
const thumbnail = this.getBestThumbnail('16:9', 300)
const icon = thumbnail
? thumbnail.toActivityPubObject()
: undefined
return activityPubCollectionPagination(this.url, handler, page)
.then(o => {
+18 -6
View File
@@ -1720,9 +1720,11 @@ 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') {
static guessLanguageOrCategoryOf (type: 'category', options: { videoChannelId?: number, videoPlaylistId?: number }): Promise<number>
static guessLanguageOrCategoryOf (type: 'language', options: { videoChannelId?: number, videoPlaylistId?: number }): Promise<string>
static guessLanguageOrCategoryOf (type: 'language' | 'category', options: { videoChannelId?: number, videoPlaylistId?: number }) {
const { videoChannelId, videoPlaylistId } = options
const queryOptions: BuildVideosListQueryOptions = {
attributes: [ `COUNT("${type}") AS "total"`, `"${type}"` ],
group: `GROUP BY "${type}"`,
@@ -1730,7 +1732,8 @@ export class VideoModel extends SequelizeModel<VideoModel> {
start: 0,
count: 1,
sort: '-total',
videoChannelId: channelId,
videoChannelId,
videoPlaylistId,
displayOnlyForFollower: null,
serverAccountIdForBlock: null,
trendingDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
@@ -1750,9 +1753,18 @@ export class VideoModel extends SequelizeModel<VideoModel> {
}
static channelHasNSFWContent (channelId: number) {
const query = 'SELECT 1 FROM "video" WHERE "nsfw" IS TRUE AND "channelId" = $channelId LIMIT 1'
const query = 'SELECT 1 FROM "video" WHERE "nsfw" IS TRUE AND "channelId" = $channelId AND privacy = $privacy LIMIT 1'
return doesExist({ sequelize: this.sequelize, query, bind: { channelId } })
return doesExist({ sequelize: this.sequelize, query, bind: { channelId, privacy: VideoPrivacy.PUBLIC } })
}
static playlistHasPublicNSFWContent (playlistId: number) {
const query = 'SELECT 1 FROM "video" ' +
'INNER JOIN "videoPlaylistElement" ON "videoPlaylistElement"."videoId" = "video"."id" ' +
'WHERE "video"."nsfw" IS TRUE AND "videoPlaylistElement"."videoPlaylistId" = $playlistId AND privacy = $privacy ' +
'LIMIT 1'
return doesExist({ sequelize: this.sequelize, query, bind: { playlistId, privacy: VideoPrivacy.PUBLIC } })
}
// ---------------------------------------------------------------------------
@@ -9,7 +9,7 @@ type Use<K extends keyof VideoPlaylistModel, M> = PickWith<VideoPlaylistModel, K
// ############################################################################
export type MVideoPlaylist = Omit<VideoPlaylistModel, 'OwnerAccount' | 'VideoChannel' | 'VideoPlaylistElements' | 'Thumbnail'>
export type MVideoPlaylist = Omit<VideoPlaylistModel, 'OwnerAccount' | 'VideoChannel' | 'VideoPlaylistElements' | 'Thumbnails'>
// ############################################################################
@@ -48,12 +48,12 @@ export type MVideoPlaylistOwnerDefault =
export type MVideoPlaylistThumbnail =
& MVideoPlaylist
& Use<'Thumbnail', MThumbnail>
& Use<'Thumbnails', MThumbnail[]>
export type MVideoPlaylistAccountThumbnail =
& MVideoPlaylist
& Use<'OwnerAccount', MAccountDefault>
& Use<'Thumbnail', MThumbnail>
& Use<'Thumbnails', MThumbnail[]>
// ############################################################################
@@ -72,7 +72,7 @@ export type MVideoPlaylistFull =
& MVideoPlaylistVideosLength
& Use<'OwnerAccount', MAccountDefault>
& Use<'VideoChannel', MChannelDefault>
& Use<'Thumbnail', MThumbnail>
& Use<'Thumbnails', MThumbnail[]>
// ############################################################################
@@ -85,7 +85,7 @@ export type MVideoPlaylistAccountChannelSummary =
export type MVideoPlaylistFullSummary =
& MVideoPlaylistVideosLength
& Use<'Thumbnail', MThumbnail>
& Use<'Thumbnails', MThumbnail[]>
& Use<'OwnerAccount', MAccountSummary>
& Use<'VideoChannel', MChannelSummary>
@@ -95,11 +95,11 @@ export type MVideoPlaylistFullSummary =
export type MVideoPlaylistFormattable =
& MVideoPlaylistVideosLength
& Use<'Thumbnail', MThumbnail>
& Use<'Thumbnails', MThumbnail[]>
& Use<'OwnerAccount', MAccountSummaryFormattable>
& Use<'VideoChannel', MChannelSummaryFormattable>
export type MVideoPlaylistAP =
& MVideoPlaylist
& Use<'Thumbnail', MThumbnail>
& Use<'Thumbnails', MThumbnail[]>
& Use<'VideoChannel', MChannelUrl>
+42 -9
View File
@@ -1,16 +1,19 @@
import { generateImageFilename, processImage } from '@server/helpers/image-utils.js'
import { CONFIG } from '@server/initializers/config.js'
import { initDatabaseModels } from '@server/initializers/database.js'
import { sendUpdateVideoPlaylist } from '@server/lib/activitypub/send/send-update.js'
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/index.js'
import { JobQueue } from '@server/lib/job-queue/job-queue.js'
import { ThumbnailModel } from '@server/models/video/thumbnail.js'
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
import { VideoModel } from '@server/models/video/video.js'
import { MVideoFull, MVideoPlaylistFull } from '@server/types/models/index.js'
import Bluebird from 'bluebird'
import { program } from 'commander'
import { pathExists } from 'fs-extra/esm'
program
.description('Regenerate local thumbnails using preview files')
.description('Regenerate local video and playlist thumbnails using existing images')
.parse(process.argv)
run()
@@ -22,12 +25,19 @@ async function run () {
JobQueue.Instance.init()
const ids = await VideoModel.listLocalIds()
const videoIds = await VideoModel.listLocalIds()
await Bluebird.map(ids, id => {
await Bluebird.map(videoIds, id => {
return processVideo(id)
.catch(err => console.error('Cannot process video %d.', id, err))
}, { concurrency: 20 })
const playlistIds = await VideoPlaylistModel.listLocalIds()
await Bluebird.map(playlistIds, id => {
return processPlaylist(id)
.catch(err => console.error('Cannot process playlist %d.', id, err))
}, { concurrency: 20 })
}
async function processVideo (id: number) {
@@ -35,17 +45,42 @@ async function processVideo (id: number) {
console.log('Processing video %s.', video.name)
const bestImage = video.getBestThumbnail('16:9')
await processThumbnails({ entity: video, generateFilename: () => generateImageFilename() })
await federateVideoIfNeeded(video, false)
}
async function processPlaylist (id: number) {
const playlist = await VideoPlaylistModel.loadWithAccountAndChannel(id, undefined)
console.log('Processing playlist %s.', playlist.name)
await processThumbnails({
entity: playlist,
generateFilename: () => generateImageFilename()
})
await sendUpdateVideoPlaylist(playlist, undefined)
}
async function processThumbnails (options: {
entity: MVideoFull | MVideoPlaylistFull
generateFilename: () => string
}) {
const { entity, generateFilename } = options
const bestImage = entity.getBestThumbnail('16:9')
if (!bestImage) return
if (!await pathExists(bestImage.getFSPath())) {
throw new Error(`Thumbnail ${bestImage.getFSPath()} does not exist on disk`)
}
const oldThumbnails = [ ...video.Thumbnails ]
const oldThumbnails = [ ...entity.Thumbnails ]
const thumbnails = await Bluebird.mapSeries(CONFIG.THUMBNAILS.SIZES, async size => {
const thumbnail = new ThumbnailModel({
filename: generateImageFilename(),
filename: generateFilename(),
height: size.height,
width: size.width,
aspectRatio: size.aspectRatio,
@@ -64,13 +99,11 @@ async function processVideo (id: number) {
return thumbnail
})
await video.replaceAndSaveThumbnails(thumbnails)
await entity.replaceAndSaveThumbnails(thumbnails)
for (const oldThumbnail of oldThumbnails) {
// Explicitly remove the file
// We have a destroy hook but it's async, so ensure we wait for it to be done before ending the script
await oldThumbnail.removeFile()
}
await federateVideoIfNeeded(video, false)
}
+1 -1
View File
@@ -10384,7 +10384,7 @@ components:
deprecated: true
type: string
thumbnails:
description: "**PeerTube >= 8.1** Array of thumbnails for the video"
description: "**PeerTube >= 8.1** Array of thumbnails for the playlist"
type: array
items:
$ref: '#/components/schemas/Thumbnail'
+2 -2
View File
@@ -491,9 +491,9 @@ cd /var/www/peertube-docker; \
:::
### Regenerate video thumbnails
### Regenerate video and playlist thumbnails
Regenerating local video thumbnails could be useful because new PeerTube releases may increase thumbnail sizes:
Regenerating local video and playlist thumbnails could be useful because new PeerTube releases may increase thumbnail sizes:
::: code-group