Improve podcast feed

This commit is contained in:
Chocobozzz 2025-02-17 10:50:48 +01:00
parent 21f0fbde0d
commit c88cb21663
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
13 changed files with 263 additions and 82 deletions

View File

@ -2,7 +2,7 @@ import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { AuthService, ComponentPaginationLight, ConfirmService, RestExtractor, RestService, ServerService, UserService } from '@app/core'
import { objectToFormData } from '@app/helpers'
import { arrayify } from '@peertube/peertube-core-utils'
import { arrayify, buildDownloadFilesUrl } from '@peertube/peertube-core-utils'
import {
BooleanBothQuery,
FeedFormat,
@ -57,7 +57,6 @@ export type CommonVideoParams = {
@Injectable()
export class VideoService {
static BASE_VIDEO_DOWNLOAD_URL = environment.originServerUrl + '/download/videos/generate'
static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos'
static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
static PODCAST_FEEDS_URL = environment.apiUrl + '/feeds/podcast/videos.xml'
@ -385,14 +384,12 @@ export class VideoService {
}) {
const { video, files, videoFileToken } = options
if (files.length === 0) throw new Error('Cannot generate download URL without files')
let url = `${VideoService.BASE_VIDEO_DOWNLOAD_URL}/${video.uuid}?`
url += files.map(f => 'videoFileIds=' + f.id).join('&')
if (videoFileToken) url += `&videoFileToken=${videoFileToken}`
return url
return buildDownloadFilesUrl({
baseUrl: environment.originServerUrl,
videoFiles: files.map(f => f.id),
videoUUID: video.uuid,
videoFileToken
})
}
// ---------------------------------------------------------------------------

View File

@ -116,7 +116,7 @@
"@opentelemetry/sdk-trace-node": "^1.15.1",
"@opentelemetry/semantic-conventions": "^1.15.1",
"@peertube/bittorrent-tracker-server": "^11.1.2",
"@peertube/feed": "^5.1.3",
"@peertube/feed": "^5.2.0",
"@peertube/http-signature": "^1.7.0",
"@smithy/node-http-handler": "^4.0.2",
"@uploadx/core": "^6.0.0",
@ -188,6 +188,7 @@
"srt-to-vtt": "^1.1.2",
"tslib": "^2.0.0",
"useragent": "^2.3.0",
"uuid": "^11.0.5",
"validator": "^13.0.0",
"webfinger.js": "^2.6.6",
"winston": "3.17.0",

View File

@ -31,6 +31,22 @@ function queryParamsToObject (entries: URLSearchParams) {
// ---------------------------------------------------------------------------
function buildDownloadFilesUrl (options: {
baseUrl: string
videoUUID: string
videoFiles: number[]
videoFileToken?: string
}) {
const { baseUrl, videoFiles, videoUUID, videoFileToken } = options
let url = `${baseUrl}/download/videos/generate/${videoUUID}?`
url += videoFiles.map(f => 'videoFileIds=' + f).join('&')
if (videoFileToken) url += `&videoFileToken=${videoFileToken}`
return url
}
function buildPlaylistLink (playlist: Pick<VideoPlaylist, 'shortUUID'>, base?: string) {
return (base ?? window.location.origin) + buildPlaylistWatchPath(playlist)
}
@ -141,6 +157,8 @@ export {
removeQueryParams,
queryParamsToObject,
buildDownloadFilesUrl,
buildPlaylistLink,
buildVideoLink,

View File

@ -1,4 +1,5 @@
import short, { SUUID } from 'short-uuid'
import { v5 } from 'uuid'
const translator = short()
@ -28,4 +29,8 @@ export function isShortUUID (value: string) {
return value.length === translator.maxLength
}
export function buildUUIDv5FromURL (url: string) {
return v5(url, v5.URL)
}
export type { SUUID }

View File

@ -51,7 +51,7 @@ describe('Test syndication feeds', () => {
serverHLSOnly = await createSingleServer(3)
await setAccessTokensToServers([ ...servers, serverHLSOnly ])
await setDefaultChannelAvatar(servers[0])
await setDefaultChannelAvatar([ servers[0], serverHLSOnly ])
await setDefaultVideoChannel(servers)
await doubleFollow(servers[0], servers[1])
@ -107,7 +107,7 @@ describe('Test syndication feeds', () => {
await servers[0].comments.createThread({ videoId: id, text: 'comment on password protected video' })
}
await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video' } })
await serverHLSOnly.videos.upload({ attributes: { name: 'hls only video', nsfw: true } })
await waitJobs([ ...servers, serverHLSOnly ])
@ -156,7 +156,7 @@ describe('Test syndication feeds', () => {
describe('Podcast feed', function () {
it('Should contain a valid podcast:alternateEnclosure', async function () {
it('Should contain a valid podcast enclosures', async function () {
// Since podcast feeds should only work on the server they originate on,
// only test the first server where the videos reside
const rss = await servers[0].feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
@ -171,6 +171,9 @@ describe('Test syndication feeds', () => {
const enclosure = xmlDoc.rss.channel.item.enclosure
expect(enclosure).to.exist
expect(enclosure['@_url']).to.contain(`${servers[0].url}/static/web-videos/`)
expect(enclosure['@_type']).to.equal('video/webm')
const alternateEnclosure = xmlDoc.rss.channel.item['podcast:alternateEnclosure']
expect(alternateEnclosure).to.exist
@ -187,7 +190,7 @@ describe('Test syndication feeds', () => {
expect(alternateEnclosure['podcast:source'][2]['@_uri']).to.contain('magnet:?')
})
it('Should contain a valid podcast:alternateEnclosure with HLS only', async function () {
it('Should contain a valid podcast enclosures with HLS only', async function () {
const rss = await serverHLSOnly.feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
expect(XMLValidator.validate(rss)).to.be.true
@ -199,16 +202,25 @@ describe('Test syndication feeds', () => {
expect(itemGuid['@_isPermaLink']).to.equal(true)
const enclosure = xmlDoc.rss.channel.item.enclosure
const alternateEnclosure = xmlDoc.rss.channel.item['podcast:alternateEnclosure']
expect(alternateEnclosure).to.exist
expect(enclosure).to.exist
expect(enclosure['@_url']).to.contain(`${serverHLSOnly.url}/download/videos/generate/`)
expect(enclosure['@_type']).to.equal('audio/mp4')
expect(alternateEnclosure['@_type']).to.equal('application/x-mpegURL')
expect(alternateEnclosure['@_lang']).to.equal('zh')
expect(alternateEnclosure['@_title']).to.equal('HLS')
expect(alternateEnclosure['@_default']).to.equal(true)
const alternateEnclosures = xmlDoc.rss.channel.item['podcast:alternateEnclosure']
expect(alternateEnclosures).to.be.an('array')
expect(alternateEnclosure['podcast:source']['@_uri']).to.contain('-master.m3u8')
expect(alternateEnclosure['podcast:source']['@_uri']).to.equal(enclosure['@_url'])
const audioEnclosure = alternateEnclosures.find(e => e['@_type'] === 'audio/mp4')
expect(audioEnclosure).to.exist
expect(audioEnclosure['@_default']).to.equal(true)
expect(audioEnclosure['podcast:source']['@_uri']).to.equal(enclosure['@_url'])
const hlsEnclosure = alternateEnclosures.find(e => e['@_type'] === 'application/x-mpegURL')
expect(hlsEnclosure).to.exist
expect(hlsEnclosure['@_lang']).to.equal('zh')
expect(hlsEnclosure['@_title']).to.equal('HLS')
expect(hlsEnclosure['@_default']).to.equal(false)
expect(hlsEnclosure['podcast:source']['@_uri']).to.contain('-master.m3u8')
})
it('Should contain a valid podcast:socialInteract', async function () {
@ -282,8 +294,7 @@ describe('Test syndication feeds', () => {
const xmlDoc = parser.parse(rss)
const liveItem = xmlDoc.rss.channel['podcast:liveItem']
expect(liveItem.title).to.equal('live-0')
expect(liveItem.guid['@_isPermaLink']).to.equal(false)
expect(liveItem.guid['#text']).to.contain(`${uuid}_`)
expect(liveItem.guid['@_isPermaLink']).to.equal(true)
expect(liveItem['@_status']).to.equal('live')
const enclosure = liveItem.enclosure
@ -302,6 +313,26 @@ describe('Test syndication feeds', () => {
await waitJobs(servers)
})
it('Should have valid itunes metadata', async function () {
const rss = await serverHLSOnly.feed.getPodcastXML({ ignoreCache: false, channelId: rootChannelId })
expect(XMLValidator.validate(rss)).to.be.true
const parser = new XMLParser({ parseAttributeValue: true, ignoreAttributes: false })
const xmlDoc = parser.parse(rss)
const channel = xmlDoc.rss.channel
expect(channel['language']).to.equal('zh')
expect(channel['category']).to.equal('Sports')
expect(channel['itunes:category']['@_text']).to.equal('Sports')
expect(channel['itunes:explicit']).to.equal(true)
expect(channel['itunes:image']['@_href']).to.exist
await makeRawRequest({ url: channel['itunes:image']['@_href'], expectedStatus: HttpStatusCode.OK_200 })
})
})
describe('JSON feed', function () {

View File

@ -252,7 +252,6 @@ async function downloadGeneratedVideoFile (req: express.Request, res: express.Re
: maxResolutionFile.extname
const downloadFilename = buildDownloadFilename({ video, extname })
res.setHeader('Content-Length', videoFiles.reduce((p, f) => p + f.size, 0))
res.setHeader('Content-disposition', `attachment; filename="${encodeURI(downloadFilename)}`)
await muxToMergeVideoFiles({ video, videoFiles, output: res })

View File

@ -1,6 +1,6 @@
import { Feed } from '@peertube/feed'
import { CustomTag, CustomXMLNS, Person } from '@peertube/feed/lib/typings/index.js'
import { maxBy, pick } from '@peertube/peertube-core-utils'
import { pick } from '@peertube/peertube-core-utils'
import { ActorImageType } from '@peertube/peertube-models'
import { mdToPlainText } from '@server/helpers/markdown.js'
import { CONFIG } from '@server/initializers/config.js'
@ -14,13 +14,16 @@ export function initFeed (parameters: {
description: string
imageUrl: string
isPodcast: boolean
nsfw?: boolean
guid?: string
link?: string
locked?: { isLocked: boolean, email: string }
author?: {
name: string
link: string
imageUrl: string
}
category?: string
language?: string
person?: Person[]
resourceType?: 'videos' | 'video-comments'
queryString?: string
@ -31,20 +34,29 @@ export function initFeed (parameters: {
customTags?: CustomTag[]
}) {
const webserverUrl = WEBSERVER.URL
const { name, description, link, imageUrl, isPodcast, resourceType, queryString, medium } = parameters
const { name, description, link, imageUrl, category, isPodcast, resourceType, queryString, medium, nsfw } = parameters
return new Feed({
const feed = new Feed({
title: name,
description: mdToPlainText(description),
// updated: TODO: somehowGetLatestUpdate, // optional, default = today
id: link || webserverUrl,
link: link || webserverUrl,
image: imageUrl,
favicon: webserverUrl + '/client/assets/images/favicon.png',
copyright: `All rights reserved, unless otherwise specified in the terms specified at ${webserverUrl}/about` +
` and potential licenses granted by each content's rightholder.`,
generator: `PeerTube - ${webserverUrl}`,
medium: medium || 'video',
nsfw: nsfw ?? false,
feedLinks: {
json: `${webserverUrl}/feeds/${resourceType}.json${queryString}`,
atom: `${webserverUrl}/feeds/${resourceType}.atom${queryString}`,
@ -53,8 +65,24 @@ export function initFeed (parameters: {
: `${webserverUrl}/feeds/${resourceType}.xml${queryString}`
},
...pick(parameters, [ 'stunServers', 'trackers', 'customXMLNS', 'customTags', 'author', 'person', 'locked' ])
...pick(parameters, [
'guid',
'language',
'stunServers',
'trackers',
'customXMLNS',
'customTags',
'author',
'person',
'locked'
])
})
if (category) {
feed.addCategory(category)
}
return feed
}
export function sendFeed (feed: Feed, req: express.Request, res: express.Response) {
@ -88,43 +116,33 @@ export async function buildFeedMetadata (options: {
const { video, videoChannel, account } = options
let imageUrl = WEBSERVER.URL + '/client/assets/images/icons/icon-96x96.png'
let accountImageUrl: string
let ownerImageUrl: string
let name: string
let userName: string
let description: string
let email: string
let link: string
let accountLink: string
let ownerLink: string
let user: MUser
if (videoChannel) {
name = videoChannel.getDisplayName()
description = videoChannel.description
link = videoChannel.getClientUrl()
accountLink = videoChannel.Account.getClientUrl()
ownerLink = link = videoChannel.getClientUrl()
if (videoChannel.Actor.hasImage(ActorImageType.AVATAR)) {
const videoChannelAvatar = maxBy(videoChannel.Actor.Avatars, 'width')
imageUrl = WEBSERVER.URL + videoChannelAvatar.getStaticPath()
}
if (videoChannel.Account.Actor.hasImage(ActorImageType.AVATAR)) {
const accountAvatar = maxBy(videoChannel.Account.Actor.Avatars, 'width')
accountImageUrl = WEBSERVER.URL + accountAvatar.getStaticPath()
imageUrl = WEBSERVER.URL + videoChannel.Actor.getMaxQualityImage(ActorImageType.AVATAR).getStaticPath()
ownerImageUrl = imageUrl
}
user = await UserModel.loadById(videoChannel.Account.userId)
userName = videoChannel.Account.getDisplayName()
} else if (account) {
name = account.getDisplayName()
description = account.description
link = account.getClientUrl()
accountLink = link
ownerLink = link = account.getClientUrl()
if (account.Actor.hasImage(ActorImageType.AVATAR)) {
const accountAvatar = maxBy(account.Actor.Avatars, 'width')
imageUrl = WEBSERVER.URL + accountAvatar?.getStaticPath()
accountImageUrl = imageUrl
imageUrl = WEBSERVER.URL + account.Actor.getMaxQualityImage(ActorImageType.AVATAR).getStaticPath()
ownerImageUrl = imageUrl
}
user = await UserModel.loadById(account.userId)
@ -144,5 +162,5 @@ export async function buildFeedMetadata (options: {
email = user.email
}
return { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink }
return { name, description, imageUrl, ownerImageUrl, email, link, ownerLink }
}

View File

@ -1,23 +1,24 @@
import express from 'express'
import { extname } from 'path'
import { Feed } from '@peertube/feed'
import { buildDownloadFilesUrl } from '@peertube/peertube-core-utils'
import { VideoInclude, VideoResolution } from '@peertube/peertube-models'
import { getVideoFileMimeType } from '@server/lib/video-file.js'
import { cacheRouteFactory } from '@server/middlewares/index.js'
import { VideoModel } from '@server/models/video/video.js'
import { VideoInclude, VideoResolution } from '@peertube/peertube-models'
import express from 'express'
import { extname } from 'path'
import { buildNSFWFilter } from '../../helpers/express-utils.js'
import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants.js'
import {
asyncMiddleware,
commonVideosFiltersValidator,
feedsAccountOrChannelFiltersValidator,
feedsFormatValidator,
setDefaultVideosSort,
setFeedFormatContentType,
feedsAccountOrChannelFiltersValidator,
videosSortValidator,
videoSubscriptionFeedsValidator
} from '../../middlewares/index.js'
import { buildFeedMetadata, getCommonVideoFeedAttributes, getVideosForFeeds, initFeed, sendFeed } from './shared/index.js'
import { getVideoFileMimeType } from '@server/lib/video-file.js'
const videoFeedsRouter = express.Router()
@ -61,15 +62,15 @@ async function generateVideoFeed (req: express.Request, res: express.Response) {
const account = res.locals.account
const videoChannel = res.locals.videoChannel
const { name, description, imageUrl, accountImageUrl, link, accountLink } = await buildFeedMetadata({ videoChannel, account })
const { name, description, imageUrl, ownerImageUrl, link, ownerLink } = await buildFeedMetadata({ videoChannel, account })
const feed = initFeed({
name,
description,
link,
isPodcast: false,
imageUrl,
author: { name, link: accountLink, imageUrl: accountImageUrl },
imageUrl: ownerImageUrl || imageUrl,
author: { name, link: ownerLink },
resourceType: 'videos',
queryString: new URL(WEBSERVER.URL + req.url).search
})
@ -149,6 +150,9 @@ function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
}
})
const { videoFile: bestFile, separatedAudioFile: bestAudioFile } = video.getMaxQualityAudioAndVideoFiles()
const bestFiles = [ bestFile, bestAudioFile ].filter(f => !!f)
feed.addItem({
...getCommonVideoFeedAttributes(video),
@ -162,11 +166,11 @@ function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
torrents,
// Enclosure
video: videoFiles.length !== 0
video: bestFiles.length !== 0
? {
url: videoFiles[0].url,
length: videoFiles[0].fileSize,
type: videoFiles[0].type
url: buildDownloadFilesUrl({ baseUrl: WEBSERVER.URL, videoFiles: bestFiles.map(f => f.id), videoUUID: video.uuid }),
length: bestFiles.reduce((p, f) => p + f.size, 0),
type: getVideoFileMimeType('.mp4', bestFile.resolution === VideoResolution.H_NOVIDEO)
}
: undefined,

View File

@ -1,7 +1,9 @@
import { Feed } from '@peertube/feed'
import { CustomTag, CustomXMLNS, LiveItemStatus } from '@peertube/feed/lib/typings/index.js'
import { maxBy, sortObjectComparator } from '@peertube/peertube-core-utils'
import { buildDownloadFilesUrl, getResolutionLabel, maxBy, sortObjectComparator } from '@peertube/peertube-core-utils'
import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@peertube/peertube-models'
import { buildUUIDv5FromURL } from '@peertube/peertube-node-utils'
import { buildNSFWFilter } from '@server/helpers/express-utils.js'
import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { getVideoFileMimeType } from '@server/lib/video-file.js'
@ -9,8 +11,7 @@ import { buildPodcastGroupsCache, cacheRouteFactory, videoFeedsPodcastSetCacheKe
import { MVideo, MVideoCaptionVideo, MVideoFullLight } from '@server/types/models/index.js'
import express from 'express'
import { extname } from 'path'
import { buildNSFWFilter } from '../../helpers/express-utils.js'
import { MIMETYPES, ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants.js'
import { MIMETYPES, ROUTE_CACHE_LIFETIME, VIDEO_CATEGORIES, WEBSERVER } from '../../initializers/constants.js'
import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares/index.js'
import { VideoCaptionModel } from '../../models/video/video-caption.js'
import { VideoModel } from '../../models/video/video.js'
@ -59,11 +60,16 @@ export {
async function generateVideoPodcastFeed (req: express.Request, res: express.Response) {
const videoChannel = res.locals.videoChannel
const { name, userName, description, imageUrl, accountImageUrl, email, link, accountLink } = await buildFeedMetadata({ videoChannel })
const { name, description, imageUrl, ownerImageUrl, email, link, ownerLink } = await buildFeedMetadata({ videoChannel })
const nsfw = buildNSFWFilter()
const data = await getVideosForFeeds({
sort: '-publishedAt',
nsfw: buildNSFWFilter(),
// Only list non-NSFW videos (for Apple)
nsfw,
// Prevent podcast feeds from listing videos in other instances
// helps prevent duplicates when they are indexed -- only the author should control them
isLocal: true,
@ -71,6 +77,12 @@ async function generateVideoPodcastFeed (req: express.Request, res: express.Resp
videoChannelId: videoChannel?.id
})
const language = await VideoModel.guessLanguageOrCategoryOfChannel(videoChannel.id, 'language')
const category = await VideoModel.guessLanguageOrCategoryOfChannel(videoChannel.id, 'category')
const hasNSFW = nsfw !== false
? await VideoModel.channelHasNSFWContent(videoChannel.id)
: false
const customTags: CustomTag[] = await Hooks.wrapObject(
[],
'filter:feed.podcast.channel.create-custom-tags.result',
@ -89,11 +101,17 @@ async function generateVideoPodcastFeed (req: express.Request, res: express.Resp
isPodcast: true,
imageUrl,
language: language || 'en',
category: categoryToItunes(category),
nsfw: hasNSFW,
guid: buildUUIDv5FromURL(videoChannel.Actor.url),
locked: email
? { isLocked: true, email } // Default to true because we have no way of offering a redirect yet
: undefined,
person: [ { name: userName, href: accountLink, img: accountImageUrl } ],
person: [ { name, href: ownerLink, img: ownerImageUrl } ],
resourceType: 'videos',
queryString: new URL(WEBSERVER.URL + req.url).search,
medium: 'video',
@ -144,8 +162,8 @@ async function generatePodcastItem (options: {
const commonAttributes = getCommonVideoFeedAttributes(video)
const guid = liveItem
? `${video.uuid}_${video.publishedAt.toISOString()}`
: commonAttributes.link
? `${video.url}?publishedAt=${video.publishedAt.toISOString()}`
: video.url
let personImage: string
@ -204,7 +222,7 @@ async function addVODPodcastItem (options: {
const webVideos = video.getFormattedWebVideoFilesJSON(true)
.map(f => buildVODWebVideoFile(video, f))
.sort(sortObjectComparator('bitrate', 'desc'))
.sort(sortObjectComparator('bitrate', 'asc'))
const streamingPlaylistFiles = buildVODStreamingPlaylists(video)
@ -266,7 +284,31 @@ function buildVODStreamingPlaylists (video: MVideoFullLight) {
const hls = video.getHLSPlaylist()
if (!hls) return []
const { separatedAudioFile } = video.getMaxQualityAudioAndVideoFiles()
return [
...hls.VideoFiles
.sort(sortObjectComparator('resolution', 'asc'))
.map(videoFile => {
const files = [ videoFile ]
if (videoFile.resolution !== VideoResolution.H_NOVIDEO && separatedAudioFile) {
files.push(separatedAudioFile)
}
return {
type: getVideoFileMimeType(videoFile.extname, videoFile.resolution === VideoResolution.H_NOVIDEO),
title: getResolutionLabel(videoFile),
length: files.reduce((p, f) => p + f.size, 0),
language: video.language,
sources: [
{
uri: buildDownloadFilesUrl({ baseUrl: WEBSERVER.URL, videoFiles: files.map(f => f.id), videoUUID: video.uuid })
}
]
}
}),
{
type: 'application/x-mpegURL',
title: 'HLS',
@ -306,3 +348,28 @@ function buildVODCaptions (video: MVideo, videoCaptions: MVideoCaptionVideo[]) {
}
}).filter(c => c)
}
function categoryToItunes (category: number) {
const itunesMap: { [ id in keyof typeof VIDEO_CATEGORIES ]: string } = {
1: 'Music',
2: 'TV &amp; Film',
3: 'Leisure',
4: 'Arts',
5: 'Sports',
6: 'Places &amp; Travel',
7: 'Video Games',
8: 'Society &amp; Culture',
9: 'Comedy',
10: 'Fiction',
11: 'News',
12: 'Leisure',
13: 'Education',
14: 'Society &amp; Culture',
15: 'Technology',
16: 'Pets &amp; Animals',
17: 'Kids &amp; Family',
18: 'Food'
}
return itunesMap[category]
}

View File

@ -750,6 +750,7 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery {
if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field)
if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
if (field.toLowerCase() === 'total') return `ORDER BY "total" ${direction}`
if ([ 'trending', 'hot', 'best' ].includes(field.toLowerCase())) { // Sort by aggregation
return `ORDER BY "score" ${direction}, "video"."views" ${direction}`

View File

@ -122,6 +122,7 @@ import {
SequelizeModel,
buildTrigramSearchIndex,
buildWhereIdOrUUID,
doesExist,
getVideoSort,
isOutdated,
setAsUpdated,
@ -1616,6 +1617,8 @@ export class VideoModel extends SequelizeModel<VideoModel> {
return videos.map(v => v.id)
}
// ---------------------------------------------------------------------------
// threshold corresponds to how many video the field should have to be returned
static async getRandomFieldSamples (field: 'category' | 'channelId', threshold: number, count: number) {
const serverActor = await getServerActor()
@ -1655,6 +1658,44 @@ export class VideoModel extends SequelizeModel<VideoModel> {
}
}
// ---------------------------------------------------------------------------
static guessLanguageOrCategoryOfChannel (channelId: number, type: 'category'): Promise<number>
static guessLanguageOrCategoryOfChannel (channelId: number, type: 'language'): Promise<string>
static guessLanguageOrCategoryOfChannel (channelId: number, type: 'language' | 'category') {
const queryOptions: BuildVideosListQueryOptions = {
attributes: [ `COUNT("${type}") AS "total"`, `"${type}"` ],
group: `GROUP BY "${type}"`,
having: `HAVING COUNT("${type}") > 0`,
start: 0,
count: 1,
sort: '-total',
videoChannelId: channelId,
displayOnlyForFollower: null,
serverAccountIdForBlock: null
}
const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
return queryBuilder.queryVideoIds(queryOptions)
.then(rows => {
const result = rows[0]?.[type]
if (!result) return undefined
if (type === 'category') return parseInt(result, 10)
return result as string
})
}
static channelHasNSFWContent (channelId: number) {
const query = 'SELECT 1 FROM "video" WHERE "nsfw" IS TRUE AND "channelId" = $channelId LIMIT 1'
return doesExist({ sequelize: this.sequelize, query, bind: { channelId } })
}
// ---------------------------------------------------------------------------
private static async getAvailableForApi (
options: BuildVideosListQueryOptions,
countVideos = true

View File

@ -267,12 +267,6 @@ app.use((_req, res: express.Response) => {
// Catch thrown errors
app.use((err, req, res: express.Response, _next) => {
// Format error to be logged
let error = 'Unknown error.'
if (err) {
error = err.stack || err.message || err
}
// Handling Sequelize error traces
const sql = err?.parent ? err.parent.sql : undefined
@ -281,7 +275,7 @@ app.use((err, req, res: express.Response, _next) => {
? (process as any)._getActiveRequests()
: undefined
logger.error('Error in controller.', { err: error, sql, activeRequests, url: req.originalUrl })
logger.error('Error in controller.', { err, sql, activeRequests, url: req.originalUrl })
return res.fail({
status: err.status || HttpStatusCode.INTERNAL_SERVER_ERROR_500,

View File

@ -1849,10 +1849,10 @@
bufferutil "^4.0.8"
utf-8-validate "^6.0.4"
"@peertube/feed@^5.1.3":
version "5.1.3"
resolved "https://registry.yarnpkg.com/@peertube/feed/-/feed-5.1.3.tgz#ebf7ae180f0b3b0f14aea8dc8a980cd2a6091f2c"
integrity sha512-73eRzZnpYBZMNS4FOsDjCfiu2CM60lCO1o3En/AB+LthYUd1dXEOG36PwQLMVlujAcHgQmGXdk08PMAUMj/Pug==
"@peertube/feed@^5.2.0":
version "5.2.0"
resolved "https://registry.yarnpkg.com/@peertube/feed/-/feed-5.2.0.tgz#67b355f33e06f0217ef42a8a27e3804deafc2a96"
integrity sha512-YyF1Ud3kW23eQ+N7RSbHM9w629o0+P2E0A8oLRmBUVpJa5O0osmW4KAh+Z/YbfJslH7dJrHHLnIFMgzg3rYKEQ==
dependencies:
xml-js "^1.6.11"
@ -11052,6 +11052,11 @@ uuid-parse@^1.1.0:
resolved "https://registry.yarnpkg.com/uuid-parse/-/uuid-parse-1.1.0.tgz#7061c5a1384ae0e1f943c538094597e1b5f3a65b"
integrity sha512-OdmXxA8rDsQ7YpNVbKSJkNzTw2I+S5WsbMDnCtIWSQaosNAcWtFuI/YK1TjzUI6nbkgiqEyh8gWngfcv8Asd9A==
uuid@^11.0.5:
version "11.0.5"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.0.5.tgz#07b46bdfa6310c92c3fb3953a8720f170427fc62"
integrity sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==
uuid@^8.3.2:
version "8.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"