diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts index 3ac6039b1..a8f1f8a6d 100644 --- a/client/src/app/shared/shared-main/video/video.service.ts +++ b/client/src/app/shared/shared-main/video/video.service.ts @@ -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 + }) } // --------------------------------------------------------------------------- diff --git a/package.json b/package.json index b988222ef..3e55fa26f 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "@opentelemetry/sdk-trace-node": "^1.15.1", "@opentelemetry/semantic-conventions": "^1.15.1", "@peertube/bittorrent-tracker-server": "^11.1.2", - "@peertube/feed": "^5.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", diff --git a/packages/core-utils/src/common/url.ts b/packages/core-utils/src/common/url.ts index b829ead3c..3020e40be 100644 --- a/packages/core-utils/src/common/url.ts +++ b/packages/core-utils/src/common/url.ts @@ -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, base?: string) { return (base ?? window.location.origin) + buildPlaylistWatchPath(playlist) } @@ -141,6 +157,8 @@ export { removeQueryParams, queryParamsToObject, + buildDownloadFilesUrl, + buildPlaylistLink, buildVideoLink, diff --git a/packages/node-utils/src/uuid.ts b/packages/node-utils/src/uuid.ts index 4eb5c49d7..1cc1181d2 100644 --- a/packages/node-utils/src/uuid.ts +++ b/packages/node-utils/src/uuid.ts @@ -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 } diff --git a/packages/tests/src/feeds/feeds.ts b/packages/tests/src/feeds/feeds.ts index 755cce2ef..cde9a2557 100644 --- a/packages/tests/src/feeds/feeds.ts +++ b/packages/tests/src/feeds/feeds.ts @@ -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 () { diff --git a/server/core/controllers/download.ts b/server/core/controllers/download.ts index eb20dddd9..f3a32cc0e 100644 --- a/server/core/controllers/download.ts +++ b/server/core/controllers/download.ts @@ -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 }) diff --git a/server/core/controllers/feeds/shared/common-feed-utils.ts b/server/core/controllers/feeds/shared/common-feed-utils.ts index 8e94288f1..9249d1e0d 100644 --- a/server/core/controllers/feeds/shared/common-feed-utils.ts +++ b/server/core/controllers/feeds/shared/common-feed-utils.ts @@ -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 } } diff --git a/server/core/controllers/feeds/video-feeds.ts b/server/core/controllers/feeds/video-feeds.ts index 679d73fff..29efecf85 100644 --- a/server/core/controllers/feeds/video-feeds.ts +++ b/server/core/controllers/feeds/video-feeds.ts @@ -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, diff --git a/server/core/controllers/feeds/video-podcast-feeds.ts b/server/core/controllers/feeds/video-podcast-feeds.ts index 7e0dca265..f6c91e6af 100644 --- a/server/core/controllers/feeds/video-podcast-feeds.ts +++ b/server/core/controllers/feeds/video-podcast-feeds.ts @@ -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 & Film', + 3: 'Leisure', + 4: 'Arts', + 5: 'Sports', + 6: 'Places & Travel', + 7: 'Video Games', + 8: 'Society & Culture', + 9: 'Comedy', + 10: 'Fiction', + 11: 'News', + 12: 'Leisure', + 13: 'Education', + 14: 'Society & Culture', + 15: 'Technology', + 16: 'Pets & Animals', + 17: 'Kids & Family', + 18: 'Food' + } + + return itunesMap[category] +} diff --git a/server/core/models/video/sql/video/videos-id-list-query-builder.ts b/server/core/models/video/sql/video/videos-id-list-query-builder.ts index 02b3f98e0..bbfc5f50d 100644 --- a/server/core/models/video/sql/video/videos-id-list-query-builder.ts +++ b/server/core/models/video/sql/video/videos-id-list-query-builder.ts @@ -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}` diff --git a/server/core/models/video/video.ts b/server/core/models/video/video.ts index cfd891a31..377e5d405 100644 --- a/server/core/models/video/video.ts +++ b/server/core/models/video/video.ts @@ -122,6 +122,7 @@ import { SequelizeModel, buildTrigramSearchIndex, buildWhereIdOrUUID, + doesExist, getVideoSort, isOutdated, setAsUpdated, @@ -1616,6 +1617,8 @@ export class VideoModel extends SequelizeModel { 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 { } } + // --------------------------------------------------------------------------- + + static guessLanguageOrCategoryOfChannel (channelId: number, type: 'category'): Promise + static guessLanguageOrCategoryOfChannel (channelId: number, type: 'language'): Promise + 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 diff --git a/server/server.ts b/server/server.ts index 132a07ad7..fbd827e73 100644 --- a/server/server.ts +++ b/server/server.ts @@ -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, diff --git a/yarn.lock b/yarn.lock index 0003a8e81..52466dc94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"