]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - server/models/video/video-format-utils.ts
Merge branch 'release/3.2.0' into develop
[github/Chocobozzz/PeerTube.git] / server / models / video / video-format-utils.ts
index 284539deff6551125d3c15e9df5df6f027ee1f90..551cb2842aa6883ae69f02506c802c843b63e9ec 100644 (file)
@@ -1,34 +1,40 @@
-import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
-import { VideoModel } from './video'
-import { VideoFileModel } from './video-file'
-import {
-  ActivityPlaylistInfohashesObject,
-  ActivityPlaylistSegmentHashesObject,
-  ActivityUrlObject,
-  VideoTorrentObject
-} from '../../../shared/models/activitypub/objects'
+import { generateMagnetUri } from '@server/helpers/webtorrent'
+import { getLocalVideoFileMetadataUrl } from '@server/lib/video-paths'
+import { VideoFile } from '@shared/models/videos/video-file.model'
+import { ActivityTagObject, ActivityUrlObject, VideoObject } from '../../../shared/models/activitypub/objects'
+import { Video, VideoDetails } from '../../../shared/models/videos'
+import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model'
+import { isArray } from '../../helpers/custom-validators/misc'
 import { MIMETYPES, WEBSERVER } from '../../initializers/constants'
-import { VideoCaptionModel } from './video-caption'
 import {
-  getVideoCommentsActivityPubUrl,
-  getVideoDislikesActivityPubUrl,
-  getVideoLikesActivityPubUrl,
-  getVideoSharesActivityPubUrl
-} from '../../lib/activitypub'
-import { isArray } from '../../helpers/custom-validators/misc'
-import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model'
-import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
+  getLocalVideoCommentsActivityPubUrl,
+  getLocalVideoDislikesActivityPubUrl,
+  getLocalVideoLikesActivityPubUrl,
+  getLocalVideoSharesActivityPubUrl
+} from '../../lib/activitypub/url'
+import {
+  MStreamingPlaylistRedundanciesOpt,
+  MVideo,
+  MVideoAP,
+  MVideoFile,
+  MVideoFormattable,
+  MVideoFormattableDetails
+} from '../../types/models'
+import { MVideoFileRedundanciesOpt } from '../../types/models/video/video-file'
+import { VideoModel } from './video'
+import { VideoCaptionModel } from './video-caption'
 
 export type VideoFormattingJSONOptions = {
   completeDescription?: boolean
   additionalAttributes: {
-    state?: boolean,
-    waitTranscoding?: boolean,
-    scheduledUpdate?: boolean,
+    state?: boolean
+    waitTranscoding?: boolean
+    scheduledUpdate?: boolean
     blacklistInfo?: boolean
   }
 }
-function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video {
+
+function videoModelToFormattedJSON (video: MVideoFormattable, options?: VideoFormattingJSONOptions): Video {
   const userHistory = isArray(video.UserVideoHistories) ? video.UserVideoHistories[0] : undefined
 
   const videoObject: Video = {
@@ -52,7 +58,11 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
       label: VideoModel.getPrivacyLabel(video.privacy)
     },
     nsfw: video.nsfw,
-    description: options && options.completeDescription === true ? video.description : video.getTruncatedDescription(),
+
+    description: options && options.completeDescription === true
+      ? video.description
+      : video.getTruncatedDescription(),
+
     isLocal: video.isOwned(),
     duration: video.duration,
     views: video.views,
@@ -66,12 +76,17 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
     publishedAt: video.publishedAt,
     originallyPublishedAt: video.originallyPublishedAt,
 
+    isLive: video.isLive,
+
     account: video.VideoChannel.Account.toFormattedSummaryJSON(),
     channel: video.VideoChannel.toFormattedSummaryJSON(),
 
-    userHistory: userHistory ? {
-      currentTime: userHistory.currentTime
-    } : undefined
+    userHistory: userHistory
+      ? { currentTime: userHistory.currentTime }
+      : undefined,
+
+    // Can be added by external plugins
+    pluginData: (video as any).pluginData
   }
 
   if (options) {
@@ -102,7 +117,7 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
   return videoObject
 }
 
-function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
+function videoModelToFormattedDetailsJSON (video: MVideoFormattableDetails): VideoDetails {
   const formattedJson = video.toFormattedJSON({
     additionalAttributes: {
       scheduledUpdate: true,
@@ -110,8 +125,6 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
     }
   })
 
-  const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
-
   const tags = video.Tags ? video.Tags.map(t => t.name) : []
 
   const streamingPlaylists = streamingPlaylistsModelToFormattedJSON(video, video.VideoStreamingPlaylists)
@@ -130,7 +143,7 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
       label: VideoModel.getStateLabel(video.state)
     },
 
-    trackerUrls: video.getTrackerUrls(baseUrlHttp, baseUrlWs),
+    trackerUrls: video.getTrackerUrls(),
 
     files: [],
     streamingPlaylists
@@ -142,7 +155,10 @@ function videoModelToFormattedDetailsJSON (video: VideoModel): VideoDetails {
   return Object.assign(formattedJson, detailsJson)
 }
 
-function streamingPlaylistsModelToFormattedJSON (video: VideoModel, playlists: VideoStreamingPlaylistModel[]): VideoStreamingPlaylist[] {
+function streamingPlaylistsModelToFormattedJSON (
+  video: MVideoFormattableDetails,
+  playlists: MStreamingPlaylistRedundanciesOpt[]
+): VideoStreamingPlaylist[] {
   if (isArray(playlists) === false) return []
 
   return playlists
@@ -151,46 +167,111 @@ function streamingPlaylistsModelToFormattedJSON (video: VideoModel, playlists: V
         ? playlist.RedundancyVideos.map(r => ({ baseUrl: r.fileUrl }))
         : []
 
+      const files = videoFilesModelToFormattedJSON(video, playlist.VideoFiles)
+
       return {
         id: playlist.id,
         type: playlist.type,
         playlistUrl: playlist.playlistUrl,
         segmentsSha256Url: playlist.segmentsSha256Url,
-        redundancies
-      } as VideoStreamingPlaylist
+        redundancies,
+        files
+      }
     })
 }
 
-function videoFilesModelToFormattedJSON (video: VideoModel, videoFiles: VideoFileModel[]): VideoFile[] {
-  const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
+function sortByResolutionDesc (fileA: MVideoFile, fileB: MVideoFile) {
+  if (fileA.resolution < fileB.resolution) return 1
+  if (fileA.resolution === fileB.resolution) return 0
+  return -1
+}
 
-  return videoFiles
+function videoFilesModelToFormattedJSON (
+  video: MVideoFormattableDetails,
+  videoFiles: MVideoFileRedundanciesOpt[],
+  includeMagnet = true
+): VideoFile[] {
+  const trackerUrls = includeMagnet
+    ? video.getTrackerUrls()
+    : []
+
+  return [ ...videoFiles ]
+    .filter(f => !f.isLive())
+    .sort(sortByResolutionDesc)
     .map(videoFile => {
-      let resolutionLabel = videoFile.resolution + 'p'
-
       return {
         resolution: {
           id: videoFile.resolution,
-          label: resolutionLabel
+          label: videoFile.resolution + 'p'
         },
-        magnetUri: video.generateMagnetUri(videoFile, baseUrlHttp, baseUrlWs),
+
+        magnetUri: includeMagnet && videoFile.hasTorrent()
+          ? generateMagnetUri(video, videoFile, trackerUrls)
+          : undefined,
+
         size: videoFile.size,
         fps: videoFile.fps,
-        torrentUrl: video.getTorrentUrl(videoFile, baseUrlHttp),
-        torrentDownloadUrl: video.getTorrentDownloadUrl(videoFile, baseUrlHttp),
-        fileUrl: video.getVideoFileUrl(videoFile, baseUrlHttp),
-        fileDownloadUrl: video.getVideoFileDownloadUrl(videoFile, baseUrlHttp)
+
+        torrentUrl: videoFile.getTorrentUrl(),
+        torrentDownloadUrl: videoFile.getTorrentDownloadUrl(),
+
+        fileUrl: videoFile.getFileUrl(video),
+        fileDownloadUrl: videoFile.getFileDownloadUrl(video),
+
+        metadataUrl: videoFile.metadataUrl ?? getLocalVideoFileMetadataUrl(video, videoFile)
       } as VideoFile
     })
-    .sort((a, b) => {
-      if (a.resolution.id < b.resolution.id) return 1
-      if (a.resolution.id === b.resolution.id) return 0
-      return -1
+}
+
+function addVideoFilesInAPAcc (
+  acc: ActivityUrlObject[] | ActivityTagObject[],
+  video: MVideo,
+  files: MVideoFile[]
+) {
+  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: VideoModel): VideoTorrentObject {
-  const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
+function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
   if (!video.Tags) video.Tags = []
 
   const tag = video.Tags.map(t => ({
@@ -222,74 +303,60 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
     }
   }
 
-  const url: ActivityUrlObject[] = []
-  for (const file of video.VideoFiles) {
-    url.push({
+  const url: ActivityUrlObject[] = [
+    // HTML url should be the first element in the array so Mastodon correctly displays the embed
+    {
       type: 'Link',
-      mimeType: MIMETYPES.VIDEO.EXT_MIMETYPE[ file.extname ] as any,
-      mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[ file.extname ] as any,
-      href: video.getVideoFileUrl(file, baseUrlHttp),
-      height: file.resolution,
-      size: file.size,
-      fps: file.fps
-    })
+      mediaType: 'text/html',
+      href: WEBSERVER.URL + '/videos/watch/' + video.uuid
+    }
+  ]
 
-    url.push({
-      type: 'Link',
-      mimeType: 'application/x-bittorrent' as 'application/x-bittorrent',
-      mediaType: 'application/x-bittorrent' as 'application/x-bittorrent',
-      href: video.getTorrentUrl(file, baseUrlHttp),
-      height: file.resolution
-    })
-
-    url.push({
-      type: 'Link',
-      mimeType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
-      mediaType: 'application/x-bittorrent;x-scheme-handler/magnet' as 'application/x-bittorrent;x-scheme-handler/magnet',
-      href: video.generateMagnetUri(file, baseUrlHttp, baseUrlWs),
-      height: file.resolution
-    })
-  }
+  addVideoFilesInAPAcc(url, video, video.VideoFiles || [])
 
   for (const playlist of (video.VideoStreamingPlaylists || [])) {
-    let tag: (ActivityPlaylistSegmentHashesObject | ActivityPlaylistInfohashesObject)[]
-
-    tag = playlist.p2pMediaLoaderInfohashes
-                  .map(i => ({ type: 'Infohash' as 'Infohash', name: i }))
+    const tag = playlist.p2pMediaLoaderInfohashes
+                  .map(i => ({ type: 'Infohash' as 'Infohash', name: i })) as ActivityTagObject[]
     tag.push({
       type: 'Link',
       name: 'sha256',
-      mimeType: 'application/json' as 'application/json',
       mediaType: 'application/json' as 'application/json',
       href: playlist.segmentsSha256Url
     })
 
+    addVideoFilesInAPAcc(tag, video, playlist.VideoFiles || [])
+
     url.push({
       type: 'Link',
-      mimeType: 'application/x-mpegURL' as 'application/x-mpegURL',
       mediaType: 'application/x-mpegURL' as 'application/x-mpegURL',
       href: playlist.playlistUrl,
       tag
     })
   }
 
-  // Add video url too
-  url.push({
-    type: 'Link',
-    mimeType: 'text/html',
-    mediaType: 'text/html',
-    href: WEBSERVER.URL + '/videos/watch/' + video.uuid
-  })
+  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)
+      name: VideoCaptionModel.getLanguageLabel(caption.language),
+      url: caption.getFileUrl(video)
     })
   }
 
-  const miniature = video.getMiniature()
+  const icons = [ video.getMiniature(), video.getPreview() ]
 
   return {
     type: 'Video' as 'Video',
@@ -304,28 +371,42 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
     views: video.views,
     sensitive: video.nsfw,
     waitTranscoding: video.waitTranscoding,
+    isLiveBroadcast: video.isLive,
+
+    liveSaveReplay: video.isLive
+      ? video.VideoLive.saveReplay
+      : null,
+
+    permanentLive: video.isLive
+      ? video.VideoLive.permanentLive
+      : null,
+
     state: video.state,
     commentsEnabled: video.commentsEnabled,
     downloadEnabled: video.downloadEnabled,
     published: video.publishedAt.toISOString(),
-    originallyPublishedAt: video.originallyPublishedAt ? video.originallyPublishedAt.toISOString() : null,
+
+    originallyPublishedAt: video.originallyPublishedAt
+      ? video.originallyPublishedAt.toISOString()
+      : null,
+
     updated: video.updatedAt.toISOString(),
     mediaType: 'text/markdown',
-    content: video.getTruncatedDescription(),
+    content: video.description,
     support: video.support,
     subtitleLanguage,
-    icon: {
+    icon: icons.map(i => ({
       type: 'Image',
-      url: miniature.getFileUrl(),
+      url: i.getFileUrl(video),
       mediaType: 'image/jpeg',
-      width: miniature.width,
-      height: miniature.height
-    },
+      width: i.width,
+      height: i.height
+    })),
     url,
-    likes: getVideoLikesActivityPubUrl(video),
-    dislikes: getVideoDislikesActivityPubUrl(video),
-    shares: getVideoSharesActivityPubUrl(video),
-    comments: getVideoCommentsActivityPubUrl(video),
+    likes: getLocalVideoLikesActivityPubUrl(video),
+    dislikes: getLocalVideoDislikesActivityPubUrl(video),
+    shares: getLocalVideoSharesActivityPubUrl(video),
+    comments: getLocalVideoCommentsActivityPubUrl(video),
     attributedTo: [
       {
         type: 'Person',