Begin live tests
This commit is contained in:
		
							parent
							
								
									77e9f859c6
								
							
						
					
					
						commit
						af4ae64f6f
					
				
					 21 changed files with 472 additions and 31 deletions
				
			
		| 
						 | 
				
			
			@ -58,8 +58,9 @@ elif [ "$1" = "api-2" ]; then
 | 
			
		|||
 | 
			
		||||
    serverFiles=$(findTestFiles server/tests/api/server)
 | 
			
		||||
    usersFiles=$(findTestFiles server/tests/api/users)
 | 
			
		||||
    liveFiles=$(findTestFiles server/tests/api/live)
 | 
			
		||||
 | 
			
		||||
    MOCHA_PARALLEL=true runTest 2 $serverFiles $usersFiles
 | 
			
		||||
    MOCHA_PARALLEL=true runTest 2 $serverFiles $usersFiles liveFiles
 | 
			
		||||
elif [ "$1" = "api-3" ]; then
 | 
			
		||||
    npm run build:server
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 7.8 KiB  | 
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 40 KiB  | 
| 
						 | 
				
			
			@ -223,7 +223,7 @@ function getAccountVideoRateFactory (rateType: VideoRateType) {
 | 
			
		|||
 | 
			
		||||
async function videoController (req: express.Request, res: express.Response) {
 | 
			
		||||
  // We need more attributes
 | 
			
		||||
  const video = await VideoModel.loadForGetAPI({ id: res.locals.onlyVideoWithRights.id }) as MVideoAPWithoutCaption
 | 
			
		||||
  const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(res.locals.onlyVideoWithRights.id)
 | 
			
		||||
 | 
			
		||||
  if (video.url.startsWith(WEBSERVER.URL) === false) return res.redirect(video.url)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -189,7 +189,7 @@ async function addVideo (req: express.Request, res: express.Response) {
 | 
			
		|||
  videoData.state = CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED
 | 
			
		||||
  videoData.duration = videoPhysicalFile['duration'] // duration was added by a previous middleware
 | 
			
		||||
 | 
			
		||||
  const video = new VideoModel(videoData) as MVideoDetails
 | 
			
		||||
  const video = new VideoModel(videoData) as MVideoFullLight
 | 
			
		||||
  video.url = getVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
 | 
			
		||||
 | 
			
		||||
  const videoFile = new VideoFileModel({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,6 +4,7 @@ import { createReqFiles } from '@server/helpers/express-utils'
 | 
			
		|||
import { CONFIG } from '@server/initializers/config'
 | 
			
		||||
import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants'
 | 
			
		||||
import { getVideoActivityPubUrl } from '@server/lib/activitypub/url'
 | 
			
		||||
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
 | 
			
		||||
import { buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
 | 
			
		||||
import { videoLiveAddValidator, videoLiveGetValidator, videoLiveUpdateValidator } from '@server/middlewares/validators/videos/video-live'
 | 
			
		||||
import { VideoLiveModel } from '@server/models/video/video-live'
 | 
			
		||||
| 
						 | 
				
			
			@ -63,10 +64,13 @@ async function getLiveVideo (req: express.Request, res: express.Response) {
 | 
			
		|||
async function updateLiveVideo (req: express.Request, res: express.Response) {
 | 
			
		||||
  const body: LiveVideoUpdate = req.body
 | 
			
		||||
 | 
			
		||||
  const video = res.locals.videoAll
 | 
			
		||||
  const videoLive = res.locals.videoLive
 | 
			
		||||
  videoLive.saveReplay = body.saveReplay || false
 | 
			
		||||
 | 
			
		||||
  await videoLive.save()
 | 
			
		||||
  video.VideoLive = await videoLive.save()
 | 
			
		||||
 | 
			
		||||
  await federateVideoIfNeeded(video, false)
 | 
			
		||||
 | 
			
		||||
  return res.sendStatus(204)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -113,10 +117,12 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
 | 
			
		|||
    videoCreated.VideoChannel = res.locals.videoChannel
 | 
			
		||||
 | 
			
		||||
    videoLive.videoId = videoCreated.id
 | 
			
		||||
    await videoLive.save(sequelizeOptions)
 | 
			
		||||
    videoCreated.VideoLive = await videoLive.save(sequelizeOptions)
 | 
			
		||||
 | 
			
		||||
    await setVideoTags({ video, tags: videoInfo.tags, transaction: t })
 | 
			
		||||
 | 
			
		||||
    await federateVideoIfNeeded(videoCreated, true, t)
 | 
			
		||||
 | 
			
		||||
    logger.info('Video live %s with uuid %s created.', videoInfo.name, videoCreated.uuid)
 | 
			
		||||
 | 
			
		||||
    return { videoCreated }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -63,6 +63,7 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
 | 
			
		|||
  if (!isBooleanValid(video.downloadEnabled)) video.downloadEnabled = true
 | 
			
		||||
  if (!isBooleanValid(video.commentsEnabled)) video.commentsEnabled = false
 | 
			
		||||
  if (!isBooleanValid(video.isLiveBroadcast)) video.isLiveBroadcast = false
 | 
			
		||||
  if (!isBooleanValid(video.liveSaveReplay)) video.liveSaveReplay = false
 | 
			
		||||
 | 
			
		||||
  return isActivityPubUrlValid(video.id) &&
 | 
			
		||||
    isVideoNameValid(video.name) &&
 | 
			
		||||
| 
						 | 
				
			
			@ -79,7 +80,6 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
 | 
			
		|||
    isDateValid(video.updated) &&
 | 
			
		||||
    (!video.originallyPublishedAt || isDateValid(video.originallyPublishedAt)) &&
 | 
			
		||||
    (!video.content || isRemoteVideoContentValid(video.mediaType, video.content)) &&
 | 
			
		||||
    video.url.length !== 0 &&
 | 
			
		||||
    video.attributedTo.length !== 0
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -92,9 +92,9 @@ async function doesVideoChannelOfAccountExist (channelId: number, user: MUserAcc
 | 
			
		|||
  return true
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right: UserRight, res: Response) {
 | 
			
		||||
function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right: UserRight, res: Response, onlyOwned = true) {
 | 
			
		||||
  // Retrieve the user who did the request
 | 
			
		||||
  if (video.isOwned() === false) {
 | 
			
		||||
  if (onlyOwned && video.isOwned() === false) {
 | 
			
		||||
    res.status(403)
 | 
			
		||||
       .json({ error: 'Cannot manage a video of another server.' })
 | 
			
		||||
       .end()
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,3 +1,4 @@
 | 
			
		|||
import { VideoLiveModel } from '@server/models/video/video-live'
 | 
			
		||||
import * as Bluebird from 'bluebird'
 | 
			
		||||
import { maxBy, minBy } from 'lodash'
 | 
			
		||||
import * as magnetUtil from 'magnet-uri'
 | 
			
		||||
| 
						 | 
				
			
			@ -84,7 +85,7 @@ async function federateVideoIfNeeded (videoArg: MVideoAPWithoutCaption, isNewVid
 | 
			
		|||
    // Check this is not a blacklisted video, or unfederated blacklisted video
 | 
			
		||||
    (video.isBlacklisted() === false || (isNewVideo === false && video.VideoBlacklist.unfederated === false)) &&
 | 
			
		||||
    // Check the video is public/unlisted and published
 | 
			
		||||
    video.hasPrivacyForFederation() && video.state === VideoState.PUBLISHED
 | 
			
		||||
    video.hasPrivacyForFederation() && (video.state === VideoState.PUBLISHED || video.state === VideoState.WAITING_FOR_LIVE)
 | 
			
		||||
  ) {
 | 
			
		||||
    // Fetch more attributes that we will need to serialize in AP object
 | 
			
		||||
    if (isArray(video.VideoCaptions) === false) {
 | 
			
		||||
| 
						 | 
				
			
			@ -424,6 +425,27 @@ async function updateVideoFromAP (options: {
 | 
			
		|||
        await Promise.all(videoCaptionsPromises)
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      {
 | 
			
		||||
        // Create or update existing live
 | 
			
		||||
        if (video.isLive) {
 | 
			
		||||
          const [ videoLive ] = await VideoLiveModel.upsert({
 | 
			
		||||
            saveReplay: videoObject.liveSaveReplay,
 | 
			
		||||
            videoId: video.id
 | 
			
		||||
          }, { transaction: t, returning: true })
 | 
			
		||||
 | 
			
		||||
          videoUpdated.VideoLive = videoLive
 | 
			
		||||
        } else { // Delete existing live if it exists
 | 
			
		||||
          await VideoLiveModel.destroy({
 | 
			
		||||
            where: {
 | 
			
		||||
              videoId: video.id
 | 
			
		||||
            },
 | 
			
		||||
            transaction: t
 | 
			
		||||
          })
 | 
			
		||||
 | 
			
		||||
          videoUpdated.VideoLive = null
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return videoUpdated
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -436,7 +458,7 @@ async function updateVideoFromAP (options: {
 | 
			
		|||
    })
 | 
			
		||||
 | 
			
		||||
    if (wasPrivateVideo || wasUnlistedVideo) Notifier.Instance.notifyOnNewVideoIfNeeded(videoUpdated) // Notify our users?
 | 
			
		||||
    if (videoUpdated.isLive) PeerTubeSocket.Instance.sendVideoLiveNewState(video)
 | 
			
		||||
    if (videoUpdated.isLive) PeerTubeSocket.Instance.sendVideoLiveNewState(videoUpdated)
 | 
			
		||||
 | 
			
		||||
    logger.info('Remote video with uuid %s updated', videoObject.uuid)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -606,6 +628,16 @@ async function createVideo (videoObject: VideoObject, channel: MChannelAccountLi
 | 
			
		|||
 | 
			
		||||
    videoCreated.VideoFiles = videoFiles
 | 
			
		||||
 | 
			
		||||
    if (videoCreated.isLive) {
 | 
			
		||||
      const videoLive = new VideoLiveModel({
 | 
			
		||||
        streamKey: null,
 | 
			
		||||
        saveReplay: videoObject.liveSaveReplay,
 | 
			
		||||
        videoId: videoCreated.id
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      videoCreated.VideoLive = await videoLive.save({ transaction: t })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const autoBlacklisted = await autoBlacklistVideoIfNeeded({
 | 
			
		||||
      video: videoCreated,
 | 
			
		||||
      user: undefined,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,14 +16,14 @@ const videoLiveGetValidator = [
 | 
			
		|||
  param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
 | 
			
		||||
 | 
			
		||||
  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
 | 
			
		||||
    logger.debug('Checking videoLiveGetValidator parameters', { parameters: req.body })
 | 
			
		||||
    logger.debug('Checking videoLiveGetValidator parameters', { parameters: req.params, user: res.locals.oauth.token.User.username })
 | 
			
		||||
 | 
			
		||||
    if (areValidationErrors(req, res)) return
 | 
			
		||||
    if (!await doesVideoExist(req.params.videoId, res, 'all')) return
 | 
			
		||||
 | 
			
		||||
    // Check if the user who did the request is able to update the video
 | 
			
		||||
    // Check if the user who did the request is able to get the live info
 | 
			
		||||
    const user = res.locals.oauth.token.User
 | 
			
		||||
    if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return
 | 
			
		||||
    if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.GET_ANY_LIVE, res, false)) return
 | 
			
		||||
 | 
			
		||||
    const videoLive = await VideoLiveModel.loadByVideoId(res.locals.videoAll.id)
 | 
			
		||||
    if (!videoLive) return res.sendStatus(404)
 | 
			
		||||
| 
						 | 
				
			
			@ -122,6 +122,10 @@ const videoLiveUpdateValidator = [
 | 
			
		|||
        .json({ error: 'Cannot update a live that has already started' })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Check the user can manage the live
 | 
			
		||||
    const user = res.locals.oauth.token.User
 | 
			
		||||
    if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.GET_ANY_LIVE, res)) return
 | 
			
		||||
 | 
			
		||||
    return next()
 | 
			
		||||
  }
 | 
			
		||||
]
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -352,11 +352,20 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
 | 
			
		|||
    sensitive: video.nsfw,
 | 
			
		||||
    waitTranscoding: video.waitTranscoding,
 | 
			
		||||
    isLiveBroadcast: video.isLive,
 | 
			
		||||
 | 
			
		||||
    liveSaveReplay: video.isLive
 | 
			
		||||
      ? video.VideoLive.saveReplay
 | 
			
		||||
      : 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.description,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -93,7 +93,11 @@ export class VideoLiveModel extends Model<VideoLiveModel> {
 | 
			
		|||
 | 
			
		||||
  toFormattedJSON (): LiveVideo {
 | 
			
		||||
    return {
 | 
			
		||||
      rtmpUrl: WEBSERVER.RTMP_URL,
 | 
			
		||||
      // If we don't have a stream key, it means this is a remote live so we don't specify the rtmp URL
 | 
			
		||||
      rtmpUrl: this.streamKey
 | 
			
		||||
        ? WEBSERVER.RTMP_URL
 | 
			
		||||
        : null,
 | 
			
		||||
 | 
			
		||||
      streamKey: this.streamKey,
 | 
			
		||||
      saveReplay: this.saveReplay
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -26,6 +26,7 @@ import {
 | 
			
		|||
} from 'sequelize-typescript'
 | 
			
		||||
import { buildNSFWFilter } from '@server/helpers/express-utils'
 | 
			
		||||
import { getPrivaciesForFederation, isPrivacyForFederation } from '@server/helpers/video'
 | 
			
		||||
import { LiveManager } from '@server/lib/live-manager'
 | 
			
		||||
import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
 | 
			
		||||
import { getServerActor } from '@server/models/application/application'
 | 
			
		||||
import { ModelCache } from '@server/models/model-cache'
 | 
			
		||||
| 
						 | 
				
			
			@ -121,14 +122,13 @@ import {
 | 
			
		|||
  videoModelToFormattedJSON
 | 
			
		||||
} from './video-format-utils'
 | 
			
		||||
import { VideoImportModel } from './video-import'
 | 
			
		||||
import { VideoLiveModel } from './video-live'
 | 
			
		||||
import { VideoPlaylistElementModel } from './video-playlist-element'
 | 
			
		||||
import { buildListQuery, BuildVideosQueryOptions, wrapForAPIResults } from './video-query-builder'
 | 
			
		||||
import { VideoShareModel } from './video-share'
 | 
			
		||||
import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
 | 
			
		||||
import { VideoTagModel } from './video-tag'
 | 
			
		||||
import { VideoViewModel } from './video-view'
 | 
			
		||||
import { LiveManager } from '@server/lib/live-manager'
 | 
			
		||||
import { VideoLiveModel } from './video-live'
 | 
			
		||||
 | 
			
		||||
export enum ScopeNames {
 | 
			
		||||
  AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS',
 | 
			
		||||
| 
						 | 
				
			
			@ -142,7 +142,8 @@ export enum ScopeNames {
 | 
			
		|||
  WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
 | 
			
		||||
  WITH_USER_ID = 'WITH_USER_ID',
 | 
			
		||||
  WITH_IMMUTABLE_ATTRIBUTES = 'WITH_IMMUTABLE_ATTRIBUTES',
 | 
			
		||||
  WITH_THUMBNAILS = 'WITH_THUMBNAILS'
 | 
			
		||||
  WITH_THUMBNAILS = 'WITH_THUMBNAILS',
 | 
			
		||||
  WITH_LIVE = 'WITH_LIVE'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type ForAPIOptions = {
 | 
			
		||||
| 
						 | 
				
			
			@ -245,6 +246,14 @@ export type AvailableForListIDsOptions = {
 | 
			
		|||
      }
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  [ScopeNames.WITH_LIVE]: {
 | 
			
		||||
    include: [
 | 
			
		||||
      {
 | 
			
		||||
        model: VideoLiveModel,
 | 
			
		||||
        required: false
 | 
			
		||||
      }
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  [ScopeNames.WITH_USER_ID]: {
 | 
			
		||||
    include: [
 | 
			
		||||
      {
 | 
			
		||||
| 
						 | 
				
			
			@ -943,6 +952,17 @@ export class VideoModel extends Model<VideoModel> {
 | 
			
		|||
            }
 | 
			
		||||
          ]
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: VideoStreamingPlaylistModel.unscoped(),
 | 
			
		||||
          required: false,
 | 
			
		||||
          include: [
 | 
			
		||||
            {
 | 
			
		||||
              model: VideoFileModel,
 | 
			
		||||
              required: false
 | 
			
		||||
            }
 | 
			
		||||
          ]
 | 
			
		||||
        },
 | 
			
		||||
        VideoLiveModel,
 | 
			
		||||
        VideoFileModel,
 | 
			
		||||
        TagModel
 | 
			
		||||
      ]
 | 
			
		||||
| 
						 | 
				
			
			@ -1330,7 +1350,8 @@ export class VideoModel extends Model<VideoModel> {
 | 
			
		|||
      ScopeNames.WITH_SCHEDULED_UPDATE,
 | 
			
		||||
      ScopeNames.WITH_WEBTORRENT_FILES,
 | 
			
		||||
      ScopeNames.WITH_STREAMING_PLAYLISTS,
 | 
			
		||||
      ScopeNames.WITH_THUMBNAILS
 | 
			
		||||
      ScopeNames.WITH_THUMBNAILS,
 | 
			
		||||
      ScopeNames.WITH_LIVE
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    if (userId) {
 | 
			
		||||
| 
						 | 
				
			
			@ -1362,6 +1383,7 @@ export class VideoModel extends Model<VideoModel> {
 | 
			
		|||
      ScopeNames.WITH_ACCOUNT_DETAILS,
 | 
			
		||||
      ScopeNames.WITH_SCHEDULED_UPDATE,
 | 
			
		||||
      ScopeNames.WITH_THUMBNAILS,
 | 
			
		||||
      ScopeNames.WITH_LIVE,
 | 
			
		||||
      { method: [ ScopeNames.WITH_WEBTORRENT_FILES, true ] },
 | 
			
		||||
      { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] }
 | 
			
		||||
    ]
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										1
									
								
								server/tests/api/live/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								server/tests/api/live/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './live'
 | 
			
		||||
							
								
								
									
										351
									
								
								server/tests/api/live/live.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										351
									
								
								server/tests/api/live/live.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,351 @@
 | 
			
		|||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
 | 
			
		||||
 | 
			
		||||
import 'mocha'
 | 
			
		||||
import * as chai from 'chai'
 | 
			
		||||
import { LiveVideo, LiveVideoCreate, VideoDetails, VideoPrivacy } from '@shared/models'
 | 
			
		||||
import {
 | 
			
		||||
  acceptChangeOwnership,
 | 
			
		||||
  cleanupTests,
 | 
			
		||||
  createLive,
 | 
			
		||||
  doubleFollow,
 | 
			
		||||
  flushAndRunMultipleServers,
 | 
			
		||||
  getLive,
 | 
			
		||||
  getVideo,
 | 
			
		||||
  getVideosList,
 | 
			
		||||
  makeRawRequest,
 | 
			
		||||
  removeVideo,
 | 
			
		||||
  ServerInfo,
 | 
			
		||||
  setAccessTokensToServers,
 | 
			
		||||
  setDefaultVideoChannel,
 | 
			
		||||
  testImage,
 | 
			
		||||
  updateCustomSubConfig,
 | 
			
		||||
  updateLive,
 | 
			
		||||
  waitJobs
 | 
			
		||||
} from '../../../../shared/extra-utils'
 | 
			
		||||
 | 
			
		||||
const expect = chai.expect
 | 
			
		||||
 | 
			
		||||
describe('Test live', function () {
 | 
			
		||||
  let servers: ServerInfo[] = []
 | 
			
		||||
  let liveVideoUUID: string
 | 
			
		||||
 | 
			
		||||
  before(async function () {
 | 
			
		||||
    this.timeout(120000)
 | 
			
		||||
 | 
			
		||||
    servers = await flushAndRunMultipleServers(2)
 | 
			
		||||
 | 
			
		||||
    // Get the access tokens
 | 
			
		||||
    await setAccessTokensToServers(servers)
 | 
			
		||||
    await setDefaultVideoChannel(servers)
 | 
			
		||||
 | 
			
		||||
    await updateCustomSubConfig(servers[0].url, servers[0].accessToken, {
 | 
			
		||||
      live: {
 | 
			
		||||
        enabled: true,
 | 
			
		||||
        allowReplay: true
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    // Server 1 and server 2 follow each other
 | 
			
		||||
    await doubleFollow(servers[0], servers[1])
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  describe('Live creation, update and delete', function () {
 | 
			
		||||
 | 
			
		||||
    it('Should create a live with the appropriate parameters', async function () {
 | 
			
		||||
      this.timeout(20000)
 | 
			
		||||
 | 
			
		||||
      const attributes: LiveVideoCreate = {
 | 
			
		||||
        category: 1,
 | 
			
		||||
        licence: 2,
 | 
			
		||||
        language: 'fr',
 | 
			
		||||
        description: 'super live description',
 | 
			
		||||
        support: 'support field',
 | 
			
		||||
        channelId: servers[0].videoChannel.id,
 | 
			
		||||
        nsfw: false,
 | 
			
		||||
        waitTranscoding: false,
 | 
			
		||||
        name: 'my super live',
 | 
			
		||||
        tags: [ 'tag1', 'tag2' ],
 | 
			
		||||
        commentsEnabled: false,
 | 
			
		||||
        downloadEnabled: false,
 | 
			
		||||
        saveReplay: true,
 | 
			
		||||
        privacy: VideoPrivacy.PUBLIC,
 | 
			
		||||
        previewfile: 'video_short1-preview.webm.jpg',
 | 
			
		||||
        thumbnailfile: 'video_short1.webm.jpg'
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const res = await createLive(servers[0].url, servers[0].accessToken, attributes)
 | 
			
		||||
      liveVideoUUID = res.body.video.uuid
 | 
			
		||||
 | 
			
		||||
      await waitJobs(servers)
 | 
			
		||||
 | 
			
		||||
      for (const server of servers) {
 | 
			
		||||
        const resVideo = await getVideo(server.url, liveVideoUUID)
 | 
			
		||||
        const video: VideoDetails = resVideo.body
 | 
			
		||||
 | 
			
		||||
        expect(video.category.id).to.equal(1)
 | 
			
		||||
        expect(video.licence.id).to.equal(2)
 | 
			
		||||
        expect(video.language.id).to.equal('fr')
 | 
			
		||||
        expect(video.description).to.equal('super live description')
 | 
			
		||||
        expect(video.support).to.equal('support field')
 | 
			
		||||
 | 
			
		||||
        expect(video.channel.name).to.equal(servers[0].videoChannel.name)
 | 
			
		||||
        expect(video.channel.host).to.equal(servers[0].videoChannel.host)
 | 
			
		||||
 | 
			
		||||
        expect(video.nsfw).to.be.false
 | 
			
		||||
        expect(video.waitTranscoding).to.be.false
 | 
			
		||||
        expect(video.name).to.equal('my super live')
 | 
			
		||||
        expect(video.tags).to.deep.equal([ 'tag1', 'tag2' ])
 | 
			
		||||
        expect(video.commentsEnabled).to.be.false
 | 
			
		||||
        expect(video.downloadEnabled).to.be.false
 | 
			
		||||
        expect(video.privacy.id).to.equal(VideoPrivacy.PUBLIC)
 | 
			
		||||
 | 
			
		||||
        await testImage(server.url, 'video_short1-preview.webm', video.previewPath)
 | 
			
		||||
        await testImage(server.url, 'video_short1.webm', video.thumbnailPath)
 | 
			
		||||
 | 
			
		||||
        const resLive = await getLive(server.url, server.accessToken, liveVideoUUID)
 | 
			
		||||
        const live: LiveVideo = resLive.body
 | 
			
		||||
 | 
			
		||||
        if (server.url === servers[0].url) {
 | 
			
		||||
          expect(live.rtmpUrl).to.equal('rtmp://' + server.hostname + ':1936/live')
 | 
			
		||||
          expect(live.streamKey).to.not.be.empty
 | 
			
		||||
        } else {
 | 
			
		||||
          expect(live.rtmpUrl).to.be.null
 | 
			
		||||
          expect(live.streamKey).to.be.null
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        expect(live.saveReplay).to.be.true
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should have a default preview and thumbnail', async function () {
 | 
			
		||||
      this.timeout(20000)
 | 
			
		||||
 | 
			
		||||
      const attributes: LiveVideoCreate = {
 | 
			
		||||
        name: 'default live thumbnail',
 | 
			
		||||
        channelId: servers[0].videoChannel.id,
 | 
			
		||||
        privacy: VideoPrivacy.UNLISTED,
 | 
			
		||||
        nsfw: true
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const res = await createLive(servers[0].url, servers[0].accessToken, attributes)
 | 
			
		||||
      const videoId = res.body.video.uuid
 | 
			
		||||
 | 
			
		||||
      await waitJobs(servers)
 | 
			
		||||
 | 
			
		||||
      for (const server of servers) {
 | 
			
		||||
        const resVideo = await getVideo(server.url, videoId)
 | 
			
		||||
        const video: VideoDetails = resVideo.body
 | 
			
		||||
 | 
			
		||||
        expect(video.privacy.id).to.equal(VideoPrivacy.UNLISTED)
 | 
			
		||||
        expect(video.nsfw).to.be.true
 | 
			
		||||
 | 
			
		||||
        await makeRawRequest(server.url + video.thumbnailPath, 200)
 | 
			
		||||
        await makeRawRequest(server.url + video.previewPath, 200)
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should not have the live listed since nobody streams into', async function () {
 | 
			
		||||
      for (const server of servers) {
 | 
			
		||||
        const res = await getVideosList(server.url)
 | 
			
		||||
 | 
			
		||||
        expect(res.body.total).to.equal(0)
 | 
			
		||||
        expect(res.body.data).to.have.lengthOf(0)
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should not be able to update a live of another server', async function () {
 | 
			
		||||
      await updateLive(servers[1].url, servers[1].accessToken, liveVideoUUID, { saveReplay: false }, 403)
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should update the live', async function () {
 | 
			
		||||
      this.timeout(10000)
 | 
			
		||||
 | 
			
		||||
      await updateLive(servers[0].url, servers[0].accessToken, liveVideoUUID, { saveReplay: false })
 | 
			
		||||
      await waitJobs(servers)
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Have the live updated', async function () {
 | 
			
		||||
      for (const server of servers) {
 | 
			
		||||
        const res = await getLive(server.url, server.accessToken, liveVideoUUID)
 | 
			
		||||
        const live: LiveVideo = res.body
 | 
			
		||||
 | 
			
		||||
        if (server.url === servers[0].url) {
 | 
			
		||||
          expect(live.rtmpUrl).to.equal('rtmp://' + server.hostname + ':1936/live')
 | 
			
		||||
          expect(live.streamKey).to.not.be.empty
 | 
			
		||||
        } else {
 | 
			
		||||
          expect(live.rtmpUrl).to.be.null
 | 
			
		||||
          expect(live.streamKey).to.be.null
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        expect(live.saveReplay).to.be.false
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Delete the live', async function () {
 | 
			
		||||
      this.timeout(10000)
 | 
			
		||||
 | 
			
		||||
      await removeVideo(servers[0].url, servers[0].accessToken, liveVideoUUID)
 | 
			
		||||
      await waitJobs(servers)
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should have the live deleted', async function () {
 | 
			
		||||
      for (const server of servers) {
 | 
			
		||||
        await getVideo(server.url, liveVideoUUID, 404)
 | 
			
		||||
        await getLive(server.url, server.accessToken, liveVideoUUID, 404)
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  describe('Test live constraints', function () {
 | 
			
		||||
 | 
			
		||||
    it('Should not have size limit if save replay is disabled', async function () {
 | 
			
		||||
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should have size limit if save replay is enabled', async function () {
 | 
			
		||||
      // daily quota + total quota
 | 
			
		||||
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should have max duration limit', async function () {
 | 
			
		||||
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  describe('With save replay disabled', function () {
 | 
			
		||||
 | 
			
		||||
    it('Should correctly create and federate the "waiting for stream" live', async function () {
 | 
			
		||||
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should correctly have updated the live and federated it when streaming in the live', async function () {
 | 
			
		||||
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should correctly delete the video and the live after the stream ended', async function () {
 | 
			
		||||
      // Wait 10 seconds
 | 
			
		||||
      // get video 404
 | 
			
		||||
      // get video federation 404
 | 
			
		||||
 | 
			
		||||
      // check cleanup
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should correctly terminate the stream on blacklist and delete the live', async function () {
 | 
			
		||||
      // Wait 10 seconds
 | 
			
		||||
      // get video 404
 | 
			
		||||
      // get video federation 404
 | 
			
		||||
 | 
			
		||||
      // check cleanup
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should correctly terminate the stream on delete and delete the video', async function () {
 | 
			
		||||
      // Wait 10 seconds
 | 
			
		||||
      // get video 404
 | 
			
		||||
      // get video federation 404
 | 
			
		||||
 | 
			
		||||
      // check cleanup
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  describe('With save replay enabled', function () {
 | 
			
		||||
 | 
			
		||||
    it('Should correctly create and federate the "waiting for stream" live', async function () {
 | 
			
		||||
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should correctly have updated the live and federated it when streaming in the live', async function () {
 | 
			
		||||
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should correctly have saved the live and federated it after the streaming', async function () {
 | 
			
		||||
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should update the saved live and correctly federate the updated attributes', async function () {
 | 
			
		||||
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should have cleaned up the live files', async function () {
 | 
			
		||||
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should correctly terminate the stream on blacklist and blacklist the saved replay video', async function () {
 | 
			
		||||
      // Wait 10 seconds
 | 
			
		||||
      // get video -> blacklisted
 | 
			
		||||
      // get video federation -> blacklisted
 | 
			
		||||
 | 
			
		||||
      // check cleanup live files quand meme
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should correctly terminate the stream on delete and delete the video', async function () {
 | 
			
		||||
      // Wait 10 seconds
 | 
			
		||||
      // get video 404
 | 
			
		||||
      // get video federation 404
 | 
			
		||||
 | 
			
		||||
      // check cleanup
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  describe('Stream checks', function () {
 | 
			
		||||
 | 
			
		||||
    it('Should not allow a stream without the appropriate path', async function () {
 | 
			
		||||
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should not allow a stream without the appropriate stream key', async function () {
 | 
			
		||||
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should not allow a stream on a live that was blacklisted', async function () {
 | 
			
		||||
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should not allow a stream on a live that was deleted', async function () {
 | 
			
		||||
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  describe('Live transcoding', function () {
 | 
			
		||||
 | 
			
		||||
    it('Should enable transcoding without additional resolutions', async function () {
 | 
			
		||||
      // enable
 | 
			
		||||
      // stream
 | 
			
		||||
      // wait federation + test
 | 
			
		||||
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should enable transcoding with some resolutions', async function () {
 | 
			
		||||
      // enable
 | 
			
		||||
      // stream
 | 
			
		||||
      // wait federation + test
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should enable transcoding with some resolutions and correctly save them', async function () {
 | 
			
		||||
      // enable
 | 
			
		||||
      // stream
 | 
			
		||||
      // end stream
 | 
			
		||||
      // wait federation + test
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should correctly have cleaned up the live files', async function () {
 | 
			
		||||
      // check files
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  describe('Live socket messages', function () {
 | 
			
		||||
 | 
			
		||||
    it('Should correctly send a message when the live starts', async function () {
 | 
			
		||||
      // local
 | 
			
		||||
      // federation
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('Should correctly send a message when the live ends', async function () {
 | 
			
		||||
      // local
 | 
			
		||||
      // federation
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  after(async function () {
 | 
			
		||||
    await cleanupTests(servers)
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
| 
						 | 
				
			
			@ -21,6 +21,7 @@ import { MThumbnail } from './thumbnail'
 | 
			
		|||
import { MVideoBlacklist, MVideoBlacklistLight, MVideoBlacklistUnfederated } from './video-blacklist'
 | 
			
		||||
import { MScheduleVideoUpdate } from './schedule-video-update'
 | 
			
		||||
import { MUserVideoHistoryTime } from '../user/user-video-history'
 | 
			
		||||
import { MVideoLive } from './video-live'
 | 
			
		||||
 | 
			
		||||
type Use<K extends keyof VideoModel, M> = PickWith<VideoModel, K, M>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -29,7 +30,7 @@ type Use<K extends keyof VideoModel, M> = PickWith<VideoModel, K, M>
 | 
			
		|||
export type MVideo =
 | 
			
		||||
  Omit<VideoModel, 'VideoChannel' | 'Tags' | 'Thumbnails' | 'VideoPlaylistElements' | 'VideoAbuses' |
 | 
			
		||||
  'VideoFiles' | 'VideoStreamingPlaylists' | 'VideoShares' | 'AccountVideoRates' | 'VideoComments' | 'VideoViews' | 'UserVideoHistories' |
 | 
			
		||||
  'ScheduleVideoUpdate' | 'VideoBlacklist' | 'VideoImport' | 'VideoCaptions'>
 | 
			
		||||
  'ScheduleVideoUpdate' | 'VideoBlacklist' | 'VideoImport' | 'VideoCaptions' | 'VideoLive'>
 | 
			
		||||
 | 
			
		||||
// ############################################################################
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -151,7 +152,8 @@ export type MVideoFullLight =
 | 
			
		|||
  Use<'UserVideoHistories', MUserVideoHistoryTime[]> &
 | 
			
		||||
  Use<'VideoFiles', MVideoFile[]> &
 | 
			
		||||
  Use<'ScheduleVideoUpdate', MScheduleVideoUpdate> &
 | 
			
		||||
  Use<'VideoStreamingPlaylists', MStreamingPlaylistFiles[]>
 | 
			
		||||
  Use<'VideoStreamingPlaylists', MStreamingPlaylistFiles[]> &
 | 
			
		||||
  Use<'VideoLive', MVideoLive>
 | 
			
		||||
 | 
			
		||||
// ############################################################################
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -165,7 +167,8 @@ export type MVideoAP =
 | 
			
		|||
  Use<'VideoCaptions', MVideoCaptionLanguageUrl[]> &
 | 
			
		||||
  Use<'VideoBlacklist', MVideoBlacklistUnfederated> &
 | 
			
		||||
  Use<'VideoFiles', MVideoFileRedundanciesOpt[]> &
 | 
			
		||||
  Use<'Thumbnails', MThumbnail[]>
 | 
			
		||||
  Use<'Thumbnails', MThumbnail[]> &
 | 
			
		||||
  Use<'VideoLive', MVideoLive>
 | 
			
		||||
 | 
			
		||||
export type MVideoAPWithoutCaption = Omit<MVideoAP, 'VideoCaptions'>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,10 +10,12 @@ import { randomInt } from '../../core-utils/miscs/miscs'
 | 
			
		|||
 | 
			
		||||
interface ServerInfo {
 | 
			
		||||
  app: ChildProcess
 | 
			
		||||
 | 
			
		||||
  url: string
 | 
			
		||||
  host: string
 | 
			
		||||
 | 
			
		||||
  hostname: string
 | 
			
		||||
  port: number
 | 
			
		||||
 | 
			
		||||
  parallel: boolean
 | 
			
		||||
  internalServerNumber: number
 | 
			
		||||
  serverNumber: number
 | 
			
		||||
| 
						 | 
				
			
			@ -109,6 +111,7 @@ async function flushAndRunServer (serverNumber: number, configOverride?: Object,
 | 
			
		|||
    serverNumber,
 | 
			
		||||
    url: `http://localhost:${port}`,
 | 
			
		||||
    host: `localhost:${port}`,
 | 
			
		||||
    hostname: 'localhost',
 | 
			
		||||
    client: {
 | 
			
		||||
      id: null,
 | 
			
		||||
      secret: null
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,8 +2,8 @@ import * as ffmpeg from 'fluent-ffmpeg'
 | 
			
		|||
import { LiveVideoCreate, LiveVideoUpdate, VideoDetails, VideoState } from '@shared/models'
 | 
			
		||||
import { buildAbsoluteFixturePath, wait } from '../miscs/miscs'
 | 
			
		||||
import { makeGetRequest, makePutBodyRequest, makeUploadRequest } from '../requests/requests'
 | 
			
		||||
import { ServerInfo } from '../server/servers'
 | 
			
		||||
import { getVideo, getVideoWithToken } from './videos'
 | 
			
		||||
import { getVideoWithToken } from './videos'
 | 
			
		||||
import { omit } from 'lodash'
 | 
			
		||||
 | 
			
		||||
function getLive (url: string, token: string, videoId: number | string, statusCodeExpected = 200) {
 | 
			
		||||
  const path = '/api/v1/videos/live'
 | 
			
		||||
| 
						 | 
				
			
			@ -31,16 +31,18 @@ function updateLive (url: string, token: string, videoId: number | string, field
 | 
			
		|||
function createLive (url: string, token: string, fields: LiveVideoCreate, statusCodeExpected = 200) {
 | 
			
		||||
  const path = '/api/v1/videos/live'
 | 
			
		||||
 | 
			
		||||
  let attaches: any = {}
 | 
			
		||||
  if (fields.thumbnailfile) attaches = { thumbnailfile: fields.thumbnailfile }
 | 
			
		||||
  if (fields.previewfile) attaches = { previewfile: fields.previewfile }
 | 
			
		||||
  const attaches: any = {}
 | 
			
		||||
  if (fields.thumbnailfile) attaches.thumbnailfile = fields.thumbnailfile
 | 
			
		||||
  if (fields.previewfile) attaches.previewfile = fields.previewfile
 | 
			
		||||
 | 
			
		||||
  const updatedFields = omit(fields, 'thumbnailfile', 'previewfile')
 | 
			
		||||
 | 
			
		||||
  return makeUploadRequest({
 | 
			
		||||
    url,
 | 
			
		||||
    path,
 | 
			
		||||
    token,
 | 
			
		||||
    attaches,
 | 
			
		||||
    fields,
 | 
			
		||||
    fields: updatedFields,
 | 
			
		||||
    statusCodeExpected
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,7 +21,9 @@ export interface VideoObject {
 | 
			
		|||
  views: number
 | 
			
		||||
 | 
			
		||||
  sensitive: boolean
 | 
			
		||||
 | 
			
		||||
  isLiveBroadcast: boolean
 | 
			
		||||
  liveSaveReplay: boolean
 | 
			
		||||
 | 
			
		||||
  commentsEnabled: boolean
 | 
			
		||||
  downloadEnabled: boolean
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -30,6 +30,7 @@ export const enum UserRight {
 | 
			
		|||
  UPDATE_ANY_VIDEO,
 | 
			
		||||
  UPDATE_ANY_VIDEO_PLAYLIST,
 | 
			
		||||
 | 
			
		||||
  GET_ANY_LIVE,
 | 
			
		||||
  SEE_ALL_VIDEOS,
 | 
			
		||||
  CHANGE_VIDEO_OWNERSHIP,
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -18,6 +18,6 @@ export interface VideoCreate {
 | 
			
		|||
  scheduleUpdate?: VideoScheduleUpdate
 | 
			
		||||
  originallyPublishedAt?: Date | string
 | 
			
		||||
 | 
			
		||||
  thumbnailfile?: Blob
 | 
			
		||||
  previewfile?: Blob
 | 
			
		||||
  thumbnailfile?: Blob | string
 | 
			
		||||
  previewfile?: Blob | string
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue