From d896fef7e21cacfb44967eb9059fe543a66f5dd0 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 5 Jun 2023 13:22:16 +0200 Subject: [PATCH] Refactor video formatter --- server/controllers/api/accounts.ts | 2 +- .../controllers/api/search/search-videos.ts | 2 +- .../controllers/api/users/my-subscriptions.ts | 4 +- server/controllers/api/video-channel.ts | 2 +- server/controllers/api/videos/index.ts | 2 +- .../feeds/shared/video-feed-utils.ts | 2 +- server/models/video/formatter/index.ts | 2 + server/models/video/formatter/shared/index.ts | 1 + .../formatter/shared/video-format-utils.ts | 7 + .../formatter/video-activity-pub-format.ts | 295 +++++++++ .../video/formatter/video-api-format.ts | 304 ++++++++++ .../video/formatter/video-format-utils.ts | 561 ------------------ server/models/video/video.ts | 4 +- shared/models/videos/video.model.ts | 24 +- 14 files changed, 631 insertions(+), 581 deletions(-) create mode 100644 server/models/video/formatter/index.ts create mode 100644 server/models/video/formatter/shared/index.ts create mode 100644 server/models/video/formatter/shared/video-format-utils.ts create mode 100644 server/models/video/formatter/video-activity-pub-format.ts create mode 100644 server/models/video/formatter/video-api-format.ts delete mode 100644 server/models/video/formatter/video-format-utils.ts diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts index 96f36bf6f..49cd7559a 100644 --- a/server/controllers/api/accounts.ts +++ b/server/controllers/api/accounts.ts @@ -2,7 +2,6 @@ import express from 'express' import { pickCommonVideoQuery } from '@server/helpers/query' import { ActorFollowModel } from '@server/models/actor/actor-follow' import { getServerActor } from '@server/models/application/application' -import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils' import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync' import { buildNSFWFilter, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' import { getFormattedObjects } from '../../helpers/utils' @@ -36,6 +35,7 @@ import { import { commonVideoPlaylistFiltersValidator, videoPlaylistsSearchValidator } from '../../middlewares/validators/videos/video-playlists' import { AccountModel } from '../../models/account/account' import { AccountVideoRateModel } from '../../models/account/account-video-rate' +import { guessAdditionalAttributesFromQuery } from '../../models/video/formatter' import { VideoModel } from '../../models/video/video' import { VideoChannelModel } from '../../models/video/video-channel' import { VideoPlaylistModel } from '../../models/video/video-playlist' diff --git a/server/controllers/api/search/search-videos.ts b/server/controllers/api/search/search-videos.ts index 1d7a7b7bc..034a63ace 100644 --- a/server/controllers/api/search/search-videos.ts +++ b/server/controllers/api/search/search-videos.ts @@ -8,7 +8,6 @@ import { getOrCreateAPVideo } from '@server/lib/activitypub/videos' import { Hooks } from '@server/lib/plugins/hooks' import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search' import { getServerActor } from '@server/models/application/application' -import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils' import { HttpStatusCode, ResultList, Video } from '@shared/models' import { VideosSearchQueryAfterSanitize } from '../../../../shared/models/search' import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils' @@ -25,6 +24,7 @@ import { videosSearchSortValidator, videosSearchValidator } from '../../../middlewares' +import { guessAdditionalAttributesFromQuery } from '../../../models/video/formatter' import { VideoModel } from '../../../models/video/video' import { MVideoAccountLightBlacklistAllFiles } from '../../../types/models' import { searchLocalUrl } from './shared' diff --git a/server/controllers/api/users/my-subscriptions.ts b/server/controllers/api/users/my-subscriptions.ts index 6e2aa3711..c4360f59d 100644 --- a/server/controllers/api/users/my-subscriptions.ts +++ b/server/controllers/api/users/my-subscriptions.ts @@ -3,7 +3,7 @@ import express from 'express' import { handlesToNameAndHost } from '@server/helpers/actors' import { pickCommonVideoQuery } from '@server/helpers/query' import { sendUndoFollow } from '@server/lib/activitypub/send' -import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils' +import { Hooks } from '@server/lib/plugins/hooks' import { VideoChannelModel } from '@server/models/video/video-channel' import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils' @@ -29,8 +29,8 @@ import { videosSortValidator } from '../../../middlewares/validators' import { ActorFollowModel } from '../../../models/actor/actor-follow' +import { guessAdditionalAttributesFromQuery } from '../../../models/video/formatter' import { VideoModel } from '../../../models/video/video' -import { Hooks } from '@server/lib/plugins/hooks' const mySubscriptionsRouter = express.Router() diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts index cdafa31dc..3d7ef31ee 100644 --- a/server/controllers/api/video-channel.ts +++ b/server/controllers/api/video-channel.ts @@ -4,7 +4,6 @@ import { getBiggestActorImage } from '@server/lib/actor-image' import { Hooks } from '@server/lib/plugins/hooks' import { ActorFollowModel } from '@server/models/actor/actor-follow' import { getServerActor } from '@server/models/application/application' -import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils' import { MChannelBannerAccountDefault } from '@server/types/models' import { ActorImageType, HttpStatusCode, VideoChannelCreate, VideoChannelUpdate, VideosImportInChannelCreate } from '@shared/models' import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../helpers/audit-logger' @@ -48,6 +47,7 @@ import { import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image' import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists' import { AccountModel } from '../../models/account/account' +import { guessAdditionalAttributesFromQuery } from '../../models/video/formatter' import { VideoModel } from '../../models/video/video' import { VideoChannelModel } from '../../models/video/video-channel' import { VideoPlaylistModel } from '../../models/video/video-playlist' diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index bbdda5b29..520d8cbbb 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -3,7 +3,6 @@ import { pickCommonVideoQuery } from '@server/helpers/query' import { doJSONRequest } from '@server/helpers/requests' import { openapiOperationDoc } from '@server/middlewares/doc' import { getServerActor } from '@server/models/application/application' -import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils' import { MVideoAccountLight } from '@server/types/models' import { HttpStatusCode } from '../../../../shared/models' import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' @@ -31,6 +30,7 @@ import { videosRemoveValidator, videosSortValidator } from '../../../middlewares' +import { guessAdditionalAttributesFromQuery } from '../../../models/video/formatter' import { VideoModel } from '../../../models/video/video' import { blacklistRouter } from './blacklist' import { videoCaptionsRouter } from './captions' diff --git a/server/controllers/feeds/shared/video-feed-utils.ts b/server/controllers/feeds/shared/video-feed-utils.ts index 3175cea59..b154e04fa 100644 --- a/server/controllers/feeds/shared/video-feed-utils.ts +++ b/server/controllers/feeds/shared/video-feed-utils.ts @@ -2,7 +2,7 @@ import { mdToOneLinePlainText, toSafeHtml } from '@server/helpers/markdown' import { CONFIG } from '@server/initializers/config' import { WEBSERVER } from '@server/initializers/constants' import { getServerActor } from '@server/models/application/application' -import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils' +import { getCategoryLabel } from '@server/models/video/formatter' import { DisplayOnlyForFollowerOptions } from '@server/models/video/sql/video' import { VideoModel } from '@server/models/video/video' import { MThumbnail, MUserDefault } from '@server/types/models' diff --git a/server/models/video/formatter/index.ts b/server/models/video/formatter/index.ts new file mode 100644 index 000000000..77b406559 --- /dev/null +++ b/server/models/video/formatter/index.ts @@ -0,0 +1,2 @@ +export * from './video-activity-pub-format' +export * from './video-api-format' diff --git a/server/models/video/formatter/shared/index.ts b/server/models/video/formatter/shared/index.ts new file mode 100644 index 000000000..d558fa7d6 --- /dev/null +++ b/server/models/video/formatter/shared/index.ts @@ -0,0 +1 @@ +export * from './video-format-utils' diff --git a/server/models/video/formatter/shared/video-format-utils.ts b/server/models/video/formatter/shared/video-format-utils.ts new file mode 100644 index 000000000..df3bbdf1c --- /dev/null +++ b/server/models/video/formatter/shared/video-format-utils.ts @@ -0,0 +1,7 @@ +import { MVideoFile } from '@server/types/models' + +export function sortByResolutionDesc (fileA: MVideoFile, fileB: MVideoFile) { + if (fileA.resolution < fileB.resolution) return 1 + if (fileA.resolution === fileB.resolution) return 0 + return -1 +} diff --git a/server/models/video/formatter/video-activity-pub-format.ts b/server/models/video/formatter/video-activity-pub-format.ts new file mode 100644 index 000000000..c0d3d5f3e --- /dev/null +++ b/server/models/video/formatter/video-activity-pub-format.ts @@ -0,0 +1,295 @@ + +import { isArray } from 'lodash' +import { generateMagnetUri } from '@server/helpers/webtorrent' +import { getActivityStreamDuration } from '@server/lib/activitypub/activity' +import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls' +import { + ActivityIconObject, + ActivityPlaylistUrlObject, + ActivityPubStoryboard, + ActivityTagObject, + ActivityTrackerUrlObject, + ActivityUrlObject, + VideoObject +} from '@shared/models' +import { MIMETYPES, WEBSERVER } from '../../../initializers/constants' +import { + getLocalVideoCommentsActivityPubUrl, + getLocalVideoDislikesActivityPubUrl, + getLocalVideoLikesActivityPubUrl, + getLocalVideoSharesActivityPubUrl +} from '../../../lib/activitypub/url' +import { MStreamingPlaylistFiles, MUserId, MVideo, MVideoAP, MVideoFile } from '../../../types/models' +import { VideoCaptionModel } from '../video-caption' +import { sortByResolutionDesc } from './shared' +import { getCategoryLabel, getLanguageLabel, getLicenceLabel } from './video-api-format' + +export function videoModelToActivityPubObject (video: MVideoAP): VideoObject { + const language = video.language + ? { identifier: video.language, name: getLanguageLabel(video.language) } + : undefined + + const category = video.category + ? { identifier: video.category + '', name: getCategoryLabel(video.category) } + : undefined + + const licence = video.licence + ? { identifier: video.licence + '', name: getLicenceLabel(video.licence) } + : undefined + + const url: ActivityUrlObject[] = [ + // HTML url should be the first element in the array so Mastodon correctly displays the embed + { + type: 'Link', + mediaType: 'text/html', + href: WEBSERVER.URL + '/videos/watch/' + video.uuid + } as ActivityUrlObject, + + ...buildVideoFileUrls({ video, files: video.VideoFiles }), + + ...buildStreamingPlaylistUrls(video), + + ...buildTrackerUrls(video) + ] + + return { + type: 'Video' as 'Video', + id: video.url, + name: video.name, + duration: getActivityStreamDuration(video.duration), + uuid: video.uuid, + category, + licence, + language, + views: video.views, + sensitive: video.nsfw, + waitTranscoding: video.waitTranscoding, + + state: video.state, + commentsEnabled: video.commentsEnabled, + downloadEnabled: video.downloadEnabled, + published: video.publishedAt.toISOString(), + + originallyPublishedAt: video.originallyPublishedAt + ? video.originallyPublishedAt.toISOString() + : null, + + updated: video.updatedAt.toISOString(), + + tag: buildTags(video), + + mediaType: 'text/markdown', + content: video.description, + support: video.support, + + subtitleLanguage: buildSubtitleLanguage(video), + + icon: buildIcon(video), + + preview: buildPreviewAPAttribute(video), + + url, + + likes: getLocalVideoLikesActivityPubUrl(video), + dislikes: getLocalVideoDislikesActivityPubUrl(video), + shares: getLocalVideoSharesActivityPubUrl(video), + comments: getLocalVideoCommentsActivityPubUrl(video), + + attributedTo: [ + { + type: 'Person', + id: video.VideoChannel.Account.Actor.url + }, + { + type: 'Group', + id: video.VideoChannel.Actor.url + } + ], + + ...buildLiveAPAttributes(video) + } +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +function buildLiveAPAttributes (video: MVideoAP) { + if (!video.isLive) { + return { + isLiveBroadcast: false, + liveSaveReplay: null, + permanentLive: null, + latencyMode: null + } + } + + return { + isLiveBroadcast: true, + liveSaveReplay: video.VideoLive.saveReplay, + permanentLive: video.VideoLive.permanentLive, + latencyMode: video.VideoLive.latencyMode + } +} + +function buildPreviewAPAttribute (video: MVideoAP): ActivityPubStoryboard[] { + if (!video.Storyboard) return undefined + + const storyboard = video.Storyboard + + return [ + { + type: 'Image', + rel: [ 'storyboard' ], + url: [ + { + mediaType: 'image/jpeg', + + href: storyboard.getOriginFileUrl(video), + + width: storyboard.totalWidth, + height: storyboard.totalHeight, + + tileWidth: storyboard.spriteWidth, + tileHeight: storyboard.spriteHeight, + tileDuration: getActivityStreamDuration(storyboard.spriteDuration) + } + ] + } + ] +} + +function buildVideoFileUrls (options: { + video: MVideo + files: MVideoFile[] + user?: MUserId +}): ActivityUrlObject[] { + const { video, files } = options + + if (!isArray(files)) return [] + + const urls: ActivityUrlObject[] = [] + + const trackerUrls = video.getTrackerUrls() + const sortedFiles = files + .filter(f => !f.isLive()) + .sort(sortByResolutionDesc) + + for (const file of sortedFiles) { + urls.push({ + type: 'Link', + mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] as any, + href: file.getFileUrl(video), + height: file.resolution, + size: file.size, + fps: file.fps + }) + + urls.push({ + type: 'Link', + rel: [ 'metadata', MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] ], + mediaType: 'application/json' as 'application/json', + href: getLocalVideoFileMetadataUrl(video, file), + height: file.resolution, + fps: file.fps + }) + + if (file.hasTorrent()) { + urls.push({ + type: 'Link', + mediaType: 'application/x-bittorrent' as 'application/x-bittorrent', + href: file.getTorrentUrl(), + height: file.resolution + }) + + urls.push({ + type: 'Link', + mediaType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet', + href: generateMagnetUri(video, file, trackerUrls), + height: file.resolution + }) + } + } + + return urls +} + +// --------------------------------------------------------------------------- + +function buildStreamingPlaylistUrls (video: MVideoAP): ActivityPlaylistUrlObject[] { + if (!isArray(video.VideoStreamingPlaylists)) return [] + + return video.VideoStreamingPlaylists + .map(playlist => ({ + type: 'Link', + mediaType: 'application/x-mpegURL' as 'application/x-mpegURL', + href: playlist.getMasterPlaylistUrl(video), + tag: buildStreamingPlaylistTags(video, playlist) + })) +} + +function buildStreamingPlaylistTags (video: MVideoAP, playlist: MStreamingPlaylistFiles) { + return [ + ...playlist.p2pMediaLoaderInfohashes.map(i => ({ type: 'Infohash' as 'Infohash', name: i })), + + { + type: 'Link', + name: 'sha256', + mediaType: 'application/json' as 'application/json', + href: playlist.getSha256SegmentsUrl(video) + }, + + ...buildVideoFileUrls({ video, files: playlist.VideoFiles }) + ] as ActivityTagObject[] +} + +// --------------------------------------------------------------------------- + +function buildTrackerUrls (video: MVideoAP): ActivityTrackerUrlObject[] { + return video.getTrackerUrls() + .map(trackerUrl => { + const rel2 = trackerUrl.startsWith('http') + ? 'http' + : 'websocket' + + return { + type: 'Link', + name: `tracker-${rel2}`, + rel: [ 'tracker', rel2 ], + href: trackerUrl + } + }) +} + +// --------------------------------------------------------------------------- + +function buildTags (video: MVideoAP) { + if (!isArray(video.Tags)) return [] + + return video.Tags.map(t => ({ + type: 'Hashtag' as 'Hashtag', + name: t.name + })) +} + +function buildIcon (video: MVideoAP): ActivityIconObject[] { + return [ video.getMiniature(), video.getPreview() ] + .map(i => ({ + type: 'Image', + url: i.getOriginFileUrl(video), + mediaType: 'image/jpeg', + width: i.width, + height: i.height + })) +} + +function buildSubtitleLanguage (video: MVideoAP) { + if (!isArray(video.VideoCaptions)) return [] + + return video.VideoCaptions + .map(caption => ({ + identifier: caption.language, + name: VideoCaptionModel.getLanguageLabel(caption.language), + url: caption.getFileUrl(video) + })) +} diff --git a/server/models/video/formatter/video-api-format.ts b/server/models/video/formatter/video-api-format.ts new file mode 100644 index 000000000..1af51d132 --- /dev/null +++ b/server/models/video/formatter/video-api-format.ts @@ -0,0 +1,304 @@ +import { generateMagnetUri } from '@server/helpers/webtorrent' +import { tracer } from '@server/lib/opentelemetry/tracing' +import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls' +import { VideoViewsManager } from '@server/lib/views/video-views-manager' +import { uuidToShort } from '@shared/extra-utils' +import { + Video, + VideoAdditionalAttributes, + VideoDetails, + VideoFile, + VideoInclude, + VideosCommonQueryAfterSanitize, + VideoStreamingPlaylist +} from '@shared/models' +import { isArray } from '../../../helpers/custom-validators/misc' +import { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES, VIDEO_STATES } from '../../../initializers/constants' +import { MServer, MStreamingPlaylistRedundanciesOpt, MVideoFormattable, MVideoFormattableDetails } from '../../../types/models' +import { MVideoFileRedundanciesOpt } from '../../../types/models/video/video-file' +import { sortByResolutionDesc } from './shared' + +export type VideoFormattingJSONOptions = { + completeDescription?: boolean + + additionalAttributes?: { + state?: boolean + waitTranscoding?: boolean + scheduledUpdate?: boolean + blacklistInfo?: boolean + files?: boolean + blockedOwner?: boolean + } +} + +export function guessAdditionalAttributesFromQuery (query: VideosCommonQueryAfterSanitize): VideoFormattingJSONOptions { + if (!query?.include) return {} + + return { + additionalAttributes: { + state: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE), + waitTranscoding: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE), + scheduledUpdate: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE), + blacklistInfo: !!(query.include & VideoInclude.BLACKLISTED), + files: !!(query.include & VideoInclude.FILES), + blockedOwner: !!(query.include & VideoInclude.BLOCKED_OWNER) + } + } +} + +// --------------------------------------------------------------------------- + +export function videoModelToFormattedJSON (video: MVideoFormattable, options: VideoFormattingJSONOptions = {}): Video { + const span = tracer.startSpan('peertube.VideoModel.toFormattedJSON') + + const userHistory = isArray(video.UserVideoHistories) + ? video.UserVideoHistories[0] + : undefined + + const videoObject: Video = { + id: video.id, + uuid: video.uuid, + shortUUID: uuidToShort(video.uuid), + + url: video.url, + + name: video.name, + category: { + id: video.category, + label: getCategoryLabel(video.category) + }, + licence: { + id: video.licence, + label: getLicenceLabel(video.licence) + }, + language: { + id: video.language, + label: getLanguageLabel(video.language) + }, + privacy: { + id: video.privacy, + label: getPrivacyLabel(video.privacy) + }, + nsfw: video.nsfw, + + truncatedDescription: video.getTruncatedDescription(), + description: options && options.completeDescription === true + ? video.description + : video.getTruncatedDescription(), + + isLocal: video.isOwned(), + duration: video.duration, + + views: video.views, + viewers: VideoViewsManager.Instance.getViewers(video), + + likes: video.likes, + dislikes: video.dislikes, + thumbnailPath: video.getMiniatureStaticPath(), + previewPath: video.getPreviewStaticPath(), + embedPath: video.getEmbedStaticPath(), + createdAt: video.createdAt, + updatedAt: video.updatedAt, + publishedAt: video.publishedAt, + originallyPublishedAt: video.originallyPublishedAt, + + isLive: video.isLive, + + account: video.VideoChannel.Account.toFormattedSummaryJSON(), + channel: video.VideoChannel.toFormattedSummaryJSON(), + + userHistory: userHistory + ? { currentTime: userHistory.currentTime } + : undefined, + + // Can be added by external plugins + pluginData: (video as any).pluginData, + + ...buildAdditionalAttributes(video, options) + } + + span.end() + + return videoObject +} + +export function videoModelToFormattedDetailsJSON (video: MVideoFormattableDetails): VideoDetails { + const span = tracer.startSpan('peertube.VideoModel.toFormattedDetailsJSON') + + const videoJSON = video.toFormattedJSON({ + completeDescription: true, + additionalAttributes: { + scheduledUpdate: true, + blacklistInfo: true, + files: true + } + }) as Video & Required> + + const tags = video.Tags + ? video.Tags.map(t => t.name) + : [] + + const detailsJSON = { + ...videoJSON, + + support: video.support, + descriptionPath: video.getDescriptionAPIPath(), + channel: video.VideoChannel.toFormattedJSON(), + account: video.VideoChannel.Account.toFormattedJSON(), + tags, + commentsEnabled: video.commentsEnabled, + downloadEnabled: video.downloadEnabled, + waitTranscoding: video.waitTranscoding, + state: { + id: video.state, + label: getStateLabel(video.state) + }, + + trackerUrls: video.getTrackerUrls() + } + + span.end() + + return detailsJSON +} + +export function streamingPlaylistsModelToFormattedJSON ( + video: MVideoFormattable, + playlists: MStreamingPlaylistRedundanciesOpt[] +): VideoStreamingPlaylist[] { + if (isArray(playlists) === false) return [] + + return playlists + .map(playlist => ({ + id: playlist.id, + type: playlist.type, + + playlistUrl: playlist.getMasterPlaylistUrl(video), + segmentsSha256Url: playlist.getSha256SegmentsUrl(video), + + redundancies: isArray(playlist.RedundancyVideos) + ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl })) + : [], + + files: videoFilesModelToFormattedJSON(video, playlist.VideoFiles) + })) +} + +export function videoFilesModelToFormattedJSON ( + video: MVideoFormattable, + videoFiles: MVideoFileRedundanciesOpt[], + options: { + includeMagnet?: boolean // default true + } = {} +): VideoFile[] { + const { includeMagnet = true } = options + + if (isArray(videoFiles) === false) return [] + + const trackerUrls = includeMagnet + ? video.getTrackerUrls() + : [] + + return videoFiles + .filter(f => !f.isLive()) + .sort(sortByResolutionDesc) + .map(videoFile => { + return { + id: videoFile.id, + + resolution: { + id: videoFile.resolution, + label: videoFile.resolution === 0 + ? 'Audio' + : `${videoFile.resolution}p` + }, + + magnetUri: includeMagnet && videoFile.hasTorrent() + ? generateMagnetUri(video, videoFile, trackerUrls) + : undefined, + + size: videoFile.size, + fps: videoFile.fps, + + torrentUrl: videoFile.getTorrentUrl(), + torrentDownloadUrl: videoFile.getTorrentDownloadUrl(), + + fileUrl: videoFile.getFileUrl(video), + fileDownloadUrl: videoFile.getFileDownloadUrl(video), + + metadataUrl: videoFile.metadataUrl ?? getLocalVideoFileMetadataUrl(video, videoFile) + } + }) +} + +// --------------------------------------------------------------------------- + +export function getCategoryLabel (id: number) { + return VIDEO_CATEGORIES[id] || 'Unknown' +} + +export function getLicenceLabel (id: number) { + return VIDEO_LICENCES[id] || 'Unknown' +} + +export function getLanguageLabel (id: string) { + return VIDEO_LANGUAGES[id] || 'Unknown' +} + +export function getPrivacyLabel (id: number) { + return VIDEO_PRIVACIES[id] || 'Unknown' +} + +export function getStateLabel (id: number) { + return VIDEO_STATES[id] || 'Unknown' +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +function buildAdditionalAttributes (video: MVideoFormattable, options: VideoFormattingJSONOptions) { + const add = options.additionalAttributes + + const result: Partial = {} + + if (add?.state === true) { + result.state = { + id: video.state, + label: getStateLabel(video.state) + } + } + + if (add?.waitTranscoding === true) { + result.waitTranscoding = video.waitTranscoding + } + + if (add?.scheduledUpdate === true && video.ScheduleVideoUpdate) { + result.scheduledUpdate = { + updateAt: video.ScheduleVideoUpdate.updateAt, + privacy: video.ScheduleVideoUpdate.privacy || undefined + } + } + + if (add?.blacklistInfo === true) { + result.blacklisted = !!video.VideoBlacklist + result.blacklistedReason = + video.VideoBlacklist + ? video.VideoBlacklist.reason + : null + } + + if (add?.blockedOwner === true) { + result.blockedOwner = video.VideoChannel.Account.isBlocked() + + const server = video.VideoChannel.Account.Actor.Server as MServer + result.blockedServer = !!(server?.isBlocked()) + } + + if (add?.files === true) { + result.streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists) + result.files = videoFilesModelToFormattedJSON(video, video.VideoFiles) + } + + return result +} diff --git a/server/models/video/formatter/video-format-utils.ts b/server/models/video/formatter/video-format-utils.ts deleted file mode 100644 index 4179545b8..000000000 --- a/server/models/video/formatter/video-format-utils.ts +++ /dev/null @@ -1,561 +0,0 @@ -import { generateMagnetUri } from '@server/helpers/webtorrent' -import { getActivityStreamDuration } from '@server/lib/activitypub/activity' -import { tracer } from '@server/lib/opentelemetry/tracing' -import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls' -import { VideoViewsManager } from '@server/lib/views/video-views-manager' -import { uuidToShort } from '@shared/extra-utils' -import { - ActivityPubStoryboard, - ActivityTagObject, - ActivityUrlObject, - Video, - VideoDetails, - VideoFile, - VideoInclude, - VideoObject, - VideosCommonQueryAfterSanitize, - VideoStreamingPlaylist -} from '@shared/models' -import { isArray } from '../../../helpers/custom-validators/misc' -import { - MIMETYPES, - VIDEO_CATEGORIES, - VIDEO_LANGUAGES, - VIDEO_LICENCES, - VIDEO_PRIVACIES, - VIDEO_STATES, - WEBSERVER -} from '../../../initializers/constants' -import { - getLocalVideoCommentsActivityPubUrl, - getLocalVideoDislikesActivityPubUrl, - getLocalVideoLikesActivityPubUrl, - getLocalVideoSharesActivityPubUrl -} from '../../../lib/activitypub/url' -import { - MServer, - MStreamingPlaylistRedundanciesOpt, - MUserId, - MVideo, - MVideoAP, - MVideoFile, - MVideoFormattable, - MVideoFormattableDetails -} from '../../../types/models' -import { MVideoFileRedundanciesOpt } from '../../../types/models/video/video-file' -import { VideoCaptionModel } from '../video-caption' - -export type VideoFormattingJSONOptions = { - completeDescription?: boolean - - additionalAttributes?: { - state?: boolean - waitTranscoding?: boolean - scheduledUpdate?: boolean - blacklistInfo?: boolean - files?: boolean - blockedOwner?: boolean - } -} - -function guessAdditionalAttributesFromQuery (query: VideosCommonQueryAfterSanitize): VideoFormattingJSONOptions { - if (!query?.include) return {} - - return { - additionalAttributes: { - state: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE), - waitTranscoding: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE), - scheduledUpdate: !!(query.include & VideoInclude.NOT_PUBLISHED_STATE), - blacklistInfo: !!(query.include & VideoInclude.BLACKLISTED), - files: !!(query.include & VideoInclude.FILES), - blockedOwner: !!(query.include & VideoInclude.BLOCKED_OWNER) - } - } -} - -function videoModelToFormattedJSON (video: MVideoFormattable, options: VideoFormattingJSONOptions = {}): Video { - const span = tracer.startSpan('peertube.VideoModel.toFormattedJSON') - - const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined - - const videoObject: Video = { - id: video.id, - uuid: video.uuid, - shortUUID: uuidToShort(video.uuid), - - url: video.url, - - name: video.name, - category: { - id: video.category, - label: getCategoryLabel(video.category) - }, - licence: { - id: video.licence, - label: getLicenceLabel(video.licence) - }, - language: { - id: video.language, - label: getLanguageLabel(video.language) - }, - privacy: { - id: video.privacy, - label: getPrivacyLabel(video.privacy) - }, - nsfw: video.nsfw, - - truncatedDescription: video.getTruncatedDescription(), - description: options && options.completeDescription === true - ? video.description - : video.getTruncatedDescription(), - - isLocal: video.isOwned(), - duration: video.duration, - - views: video.views, - viewers: VideoViewsManager.Instance.getViewers(video), - - likes: video.likes, - dislikes: video.dislikes, - thumbnailPath: video.getMiniatureStaticPath(), - previewPath: video.getPreviewStaticPath(), - embedPath: video.getEmbedStaticPath(), - createdAt: video.createdAt, - updatedAt: video.updatedAt, - publishedAt: video.publishedAt, - originallyPublishedAt: video.originallyPublishedAt, - - isLive: video.isLive, - - account: video.VideoChannel.Account.toFormattedSummaryJSON(), - channel: video.VideoChannel.toFormattedSummaryJSON(), - - userHistory: userHistory - ? { currentTime: userHistory.currentTime } - : undefined, - - // Can be added by external plugins - pluginData: (video as any).pluginData - } - - const add = options.additionalAttributes - if (add?.state === true) { - videoObject.state = { - id: video.state, - label: getStateLabel(video.state) - } - } - - if (add?.waitTranscoding === true) { - videoObject.waitTranscoding = video.waitTranscoding - } - - if (add?.scheduledUpdate === true && video.ScheduleVideoUpdate) { - videoObject.scheduledUpdate = { - updateAt: video.ScheduleVideoUpdate.updateAt, - privacy: video.ScheduleVideoUpdate.privacy || undefined - } - } - - if (add?.blacklistInfo === true) { - videoObject.blacklisted = !!video.VideoBlacklist - videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null - } - - if (add?.blockedOwner === true) { - videoObject.blockedOwner = video.VideoChannel.Account.isBlocked() - - const server = video.VideoChannel.Account.Actor.Server as MServer - videoObject.blockedServer = !!(server?.isBlocked()) - } - - if (add?.files === true) { - videoObject.streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists) - videoObject.files = videoFilesModelToFormattedJSON(video, video.VideoFiles) - } - - span.end() - - return videoObject -} - -function videoModelToFormattedDetailsJSON (video: MVideoFormattableDetails): VideoDetails { - const span = tracer.startSpan('peertube.VideoModel.toFormattedDetailsJSON') - - const videoJSON = video.toFormattedJSON({ - completeDescription: true, - additionalAttributes: { - scheduledUpdate: true, - blacklistInfo: true, - files: true - } - }) as Video & Required> - - const tags = video.Tags ? video.Tags.map(t => t.name) : [] - - const detailsJSON = { - support: video.support, - descriptionPath: video.getDescriptionAPIPath(), - channel: video.VideoChannel.toFormattedJSON(), - account: video.VideoChannel.Account.toFormattedJSON(), - tags, - commentsEnabled: video.commentsEnabled, - downloadEnabled: video.downloadEnabled, - waitTranscoding: video.waitTranscoding, - state: { - id: video.state, - label: getStateLabel(video.state) - }, - - trackerUrls: video.getTrackerUrls() - } - - span.end() - - return Object.assign(videoJSON, detailsJSON) -} - -function streamingPlaylistsModelToFormattedJSON ( - video: MVideoFormattable, - playlists: MStreamingPlaylistRedundanciesOpt[] -): VideoStreamingPlaylist[] { - if (isArray(playlists) === false) return [] - - return playlists - .map(playlist => { - const redundancies = isArray(playlist.RedundancyVideos) - ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl })) - : [] - - const files = videoFilesModelToFormattedJSON(video, playlist.VideoFiles) - - return { - id: playlist.id, - type: playlist.type, - playlistUrl: playlist.getMasterPlaylistUrl(video), - segmentsSha256Url: playlist.getSha256SegmentsUrl(video), - redundancies, - files - } - }) -} - -function sortByResolutionDesc (fileA: MVideoFile, fileB: MVideoFile) { - if (fileA.resolution < fileB.resolution) return 1 - if (fileA.resolution === fileB.resolution) return 0 - return -1 -} - -function videoFilesModelToFormattedJSON ( - video: MVideoFormattable, - videoFiles: MVideoFileRedundanciesOpt[], - options: { - includeMagnet?: boolean // default true - } = {} -): VideoFile[] { - const { includeMagnet = true } = options - - const trackerUrls = includeMagnet - ? video.getTrackerUrls() - : [] - - return (videoFiles || []) - .filter(f => !f.isLive()) - .sort(sortByResolutionDesc) - .map(videoFile => { - return { - id: videoFile.id, - - resolution: { - id: videoFile.resolution, - label: videoFile.resolution === 0 ? 'Audio' : `${videoFile.resolution}p` - }, - - magnetUri: includeMagnet && videoFile.hasTorrent() - ? generateMagnetUri(video, videoFile, trackerUrls) - : undefined, - - size: videoFile.size, - fps: videoFile.fps, - - torrentUrl: videoFile.getTorrentUrl(), - torrentDownloadUrl: videoFile.getTorrentDownloadUrl(), - - fileUrl: videoFile.getFileUrl(video), - fileDownloadUrl: videoFile.getFileDownloadUrl(video), - - metadataUrl: videoFile.metadataUrl ?? getLocalVideoFileMetadataUrl(video, videoFile) - } as VideoFile - }) -} - -function addVideoFilesInAPAcc (options: { - acc: ActivityUrlObject[] | ActivityTagObject[] - video: MVideo - files: MVideoFile[] - user?: MUserId -}) { - const { acc, video, files } = options - - const trackerUrls = video.getTrackerUrls() - - const sortedFiles = (files || []) - .filter(f => !f.isLive()) - .sort(sortByResolutionDesc) - - for (const file of sortedFiles) { - acc.push({ - type: 'Link', - mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] as any, - href: file.getFileUrl(video), - height: file.resolution, - size: file.size, - fps: file.fps - }) - - acc.push({ - type: 'Link', - rel: [ 'metadata', MIMETYPES.VIDEO.EXT_MIMETYPE[file.extname] ], - mediaType: 'application/json' as 'application/json', - href: getLocalVideoFileMetadataUrl(video, file), - height: file.resolution, - fps: file.fps - }) - - if (file.hasTorrent()) { - acc.push({ - type: 'Link', - mediaType: 'application/x-bittorrent' as 'application/x-bittorrent', - href: file.getTorrentUrl(), - height: file.resolution - }) - - acc.push({ - type: 'Link', - mediaType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet', - href: generateMagnetUri(video, file, trackerUrls), - height: file.resolution - }) - } - } -} - -function videoModelToActivityPubObject (video: MVideoAP): VideoObject { - if (!video.Tags) video.Tags = [] - - const tag = video.Tags.map(t => ({ - type: 'Hashtag' as 'Hashtag', - name: t.name - })) - - const language = video.language - ? { identifier: video.language, name: getLanguageLabel(video.language) } - : undefined - - const category = video.category - ? { identifier: video.category + '', name: getCategoryLabel(video.category) } - : undefined - - const licence = video.licence - ? { identifier: video.licence + '', name: getLicenceLabel(video.licence) } - : undefined - - const url: ActivityUrlObject[] = [ - // HTML url should be the first element in the array so Mastodon correctly displays the embed - { - type: 'Link', - mediaType: 'text/html', - href: WEBSERVER.URL + '/videos/watch/' + video.uuid - } - ] - - addVideoFilesInAPAcc({ acc: url, video, files: video.VideoFiles || [] }) - - for (const playlist of (video.VideoStreamingPlaylists || [])) { - const tag = playlist.p2pMediaLoaderInfohashes - .map(i => ({ type: 'Infohash' as 'Infohash', name: i })) as ActivityTagObject[] - tag.push({ - type: 'Link', - name: 'sha256', - mediaType: 'application/json' as 'application/json', - href: playlist.getSha256SegmentsUrl(video) - }) - - addVideoFilesInAPAcc({ acc: tag, video, files: playlist.VideoFiles || [] }) - - url.push({ - type: 'Link', - mediaType: 'application/x-mpegURL' as 'application/x-mpegURL', - href: playlist.getMasterPlaylistUrl(video), - tag - }) - } - - for (const trackerUrl of video.getTrackerUrls()) { - const rel2 = trackerUrl.startsWith('http') - ? 'http' - : 'websocket' - - url.push({ - type: 'Link', - name: `tracker-${rel2}`, - rel: [ 'tracker', rel2 ], - href: trackerUrl - }) - } - - const subtitleLanguage = [] - for (const caption of video.VideoCaptions) { - subtitleLanguage.push({ - identifier: caption.language, - name: VideoCaptionModel.getLanguageLabel(caption.language), - url: caption.getFileUrl(video) - }) - } - - const icons = [ video.getMiniature(), video.getPreview() ] - - return { - type: 'Video' as 'Video', - id: video.url, - name: video.name, - duration: getActivityStreamDuration(video.duration), - uuid: video.uuid, - tag, - category, - licence, - language, - views: video.views, - sensitive: video.nsfw, - waitTranscoding: video.waitTranscoding, - - state: video.state, - commentsEnabled: video.commentsEnabled, - downloadEnabled: video.downloadEnabled, - published: video.publishedAt.toISOString(), - - originallyPublishedAt: video.originallyPublishedAt - ? video.originallyPublishedAt.toISOString() - : null, - - updated: video.updatedAt.toISOString(), - - mediaType: 'text/markdown', - content: video.description, - support: video.support, - - subtitleLanguage, - - icon: icons.map(i => ({ - type: 'Image', - url: i.getOriginFileUrl(video), - mediaType: 'image/jpeg', - width: i.width, - height: i.height - })), - - preview: buildPreviewAPAttribute(video), - - url, - - likes: getLocalVideoLikesActivityPubUrl(video), - dislikes: getLocalVideoDislikesActivityPubUrl(video), - shares: getLocalVideoSharesActivityPubUrl(video), - comments: getLocalVideoCommentsActivityPubUrl(video), - - attributedTo: [ - { - type: 'Person', - id: video.VideoChannel.Account.Actor.url - }, - { - type: 'Group', - id: video.VideoChannel.Actor.url - } - ], - - ...buildLiveAPAttributes(video) - } -} - -function getCategoryLabel (id: number) { - return VIDEO_CATEGORIES[id] || 'Unknown' -} - -function getLicenceLabel (id: number) { - return VIDEO_LICENCES[id] || 'Unknown' -} - -function getLanguageLabel (id: string) { - return VIDEO_LANGUAGES[id] || 'Unknown' -} - -function getPrivacyLabel (id: number) { - return VIDEO_PRIVACIES[id] || 'Unknown' -} - -function getStateLabel (id: number) { - return VIDEO_STATES[id] || 'Unknown' -} - -export { - videoModelToFormattedJSON, - videoModelToFormattedDetailsJSON, - videoFilesModelToFormattedJSON, - videoModelToActivityPubObject, - - guessAdditionalAttributesFromQuery, - - getCategoryLabel, - getLicenceLabel, - getLanguageLabel, - getPrivacyLabel, - getStateLabel -} - -// --------------------------------------------------------------------------- - -function buildLiveAPAttributes (video: MVideoAP) { - if (!video.isLive) { - return { - isLiveBroadcast: false, - liveSaveReplay: null, - permanentLive: null, - latencyMode: null - } - } - - return { - isLiveBroadcast: true, - liveSaveReplay: video.VideoLive.saveReplay, - permanentLive: video.VideoLive.permanentLive, - latencyMode: video.VideoLive.latencyMode - } -} - -function buildPreviewAPAttribute (video: MVideoAP): ActivityPubStoryboard[] { - if (!video.Storyboard) return undefined - - const storyboard = video.Storyboard - - return [ - { - type: 'Image', - rel: [ 'storyboard' ], - url: [ - { - mediaType: 'image/jpeg', - - href: storyboard.getOriginFileUrl(video), - - width: storyboard.totalWidth, - height: storyboard.totalHeight, - - tileWidth: storyboard.spriteWidth, - tileHeight: storyboard.spriteHeight, - tileDuration: getActivityStreamDuration(storyboard.spriteDuration) - } - ] - } - ] -} diff --git a/server/models/video/video.ts b/server/models/video/video.ts index fd56d2423..06aec1308 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -114,13 +114,13 @@ import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, import { UserModel } from '../user/user' import { UserVideoHistoryModel } from '../user/user-video-history' import { VideoViewModel } from '../view/video-view' +import { videoModelToActivityPubObject } from './formatter/video-activity-pub-format' import { videoFilesModelToFormattedJSON, VideoFormattingJSONOptions, - videoModelToActivityPubObject, videoModelToFormattedDetailsJSON, videoModelToFormattedJSON -} from './formatter/video-format-utils' +} from './formatter/video-api-format' import { ScheduleVideoUpdateModel } from './schedule-video-update' import { BuildVideosListQueryOptions, diff --git a/shared/models/videos/video.model.ts b/shared/models/videos/video.model.ts index 06ffb327c..9004efb35 100644 --- a/shared/models/videos/video.model.ts +++ b/shared/models/videos/video.model.ts @@ -7,7 +7,7 @@ import { VideoScheduleUpdate } from './video-schedule-update.model' import { VideoState } from './video-state.enum' import { VideoStreamingPlaylist } from './video-streaming-playlist.model' -export interface Video { +export interface Video extends Partial { id: number uuid: string shortUUID: string @@ -57,20 +57,22 @@ export interface Video { } pluginData?: any +} - // Additional attributes dependending on the query - waitTranscoding?: boolean - state?: VideoConstant - scheduledUpdate?: VideoScheduleUpdate +// Not included by default, needs query params +export interface VideoAdditionalAttributes { + waitTranscoding: boolean + state: VideoConstant + scheduledUpdate: VideoScheduleUpdate - blacklisted?: boolean - blacklistedReason?: string + blacklisted: boolean + blacklistedReason: string - blockedOwner?: boolean - blockedServer?: boolean + blockedOwner: boolean + blockedServer: boolean - files?: VideoFile[] - streamingPlaylists?: VideoStreamingPlaylist[] + files: VideoFile[] + streamingPlaylists: VideoStreamingPlaylist[] } export interface VideoDetails extends Video {