mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2026-09-03 20:53:09 -05:00
Podcast feed support for playlists
This commit is contained in:
@@ -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
@@ -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:*",
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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}" />`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+25
-7
@@ -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: {}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user