uploadVideo.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. import VideoEntitySchema from '@joystream/cd-schemas/schemas/entities/VideoEntity.schema.json'
  2. import VideoMediaEntitySchema from '@joystream/cd-schemas/schemas/entities/VideoMediaEntity.schema.json'
  3. import { VideoEntity } from '@joystream/cd-schemas/types/entities/VideoEntity'
  4. import { VideoMediaEntity } from '@joystream/cd-schemas/types/entities/VideoMediaEntity'
  5. import { InputParser } from '@joystream/cd-schemas'
  6. import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
  7. import { JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
  8. import { flags } from '@oclif/command'
  9. import fs from 'fs'
  10. import ExitCodes from '../../ExitCodes'
  11. import { ContentId } from '@joystream/types/media'
  12. import ipfsHash from 'ipfs-only-hash'
  13. import { cli } from 'cli-ux'
  14. import axios, { AxiosRequestConfig } from 'axios'
  15. import { URL } from 'url'
  16. import ipfsHttpClient from 'ipfs-http-client'
  17. import first from 'it-first'
  18. import last from 'it-last'
  19. import toBuffer from 'it-to-buffer'
  20. import ffprobeInstaller from '@ffprobe-installer/ffprobe'
  21. import ffmpeg from 'fluent-ffmpeg'
  22. import MediaCommandBase from '../../base/MediaCommandBase'
  23. import { getInputJson, validateInput, IOFlags } from '../../helpers/InputOutput'
  24. ffmpeg.setFfprobePath(ffprobeInstaller.path)
  25. const DATA_OBJECT_TYPE_ID = 1
  26. const MAX_FILE_SIZE = 2000 * 1024 * 1024
  27. type VideoMetadata = {
  28. width?: number
  29. height?: number
  30. codecName?: string
  31. codecFullName?: string
  32. duration?: number
  33. }
  34. export default class UploadVideoCommand extends MediaCommandBase {
  35. static description = 'Upload a new Video to a channel (requires a membership).'
  36. static flags = {
  37. input: IOFlags.input,
  38. channel: flags.integer({
  39. char: 'c',
  40. required: false,
  41. description:
  42. 'ID of the channel to assign the video to (if omitted - one of the owned channels can be selected from the list)',
  43. }),
  44. confirm: flags.boolean({ char: 'y', name: 'confirm', required: false, description: 'Confirm the provided input' }),
  45. }
  46. static args = [
  47. {
  48. name: 'filePath',
  49. required: true,
  50. description: 'Path to the media file to upload',
  51. },
  52. ]
  53. private createReadStreamWithProgressBar(filePath: string, barTitle: string, fileSize?: number) {
  54. // Progress CLI UX:
  55. // https://github.com/oclif/cli-ux#cliprogress
  56. // https://www.npmjs.com/package/cli-progress
  57. if (!fileSize) {
  58. fileSize = fs.statSync(filePath).size
  59. }
  60. const progress = cli.progress({ format: `${barTitle} | {bar} | {value}/{total} KB processed` })
  61. let processedKB = 0
  62. const fileSizeKB = Math.ceil(fileSize / 1024)
  63. progress.start(fileSizeKB, processedKB)
  64. return {
  65. fileStream: fs
  66. .createReadStream(filePath)
  67. .pause() // Explicitly pause to prevent switching to flowing mode (https://nodejs.org/api/stream.html#stream_event_data)
  68. .on('error', () => {
  69. progress.stop()
  70. this.error(`Error while trying to read data from: ${filePath}!`, {
  71. exit: ExitCodes.FsOperationFailed,
  72. })
  73. })
  74. .on('data', (data) => {
  75. processedKB += data.length / 1024
  76. progress.update(processedKB)
  77. })
  78. .on('end', () => {
  79. progress.update(fileSizeKB)
  80. progress.stop()
  81. }),
  82. progressBar: progress,
  83. }
  84. }
  85. private async calculateFileIpfsHash(filePath: string, fileSize: number): Promise<string> {
  86. const { fileStream } = this.createReadStreamWithProgressBar(filePath, 'Calculating file hash', fileSize)
  87. const hash: string = await ipfsHash.of(fileStream)
  88. return hash
  89. }
  90. private async getDiscoveryDataViaLocalIpfsNode(ipnsIdentity: string): Promise<any> {
  91. const ipfs = ipfsHttpClient({
  92. // TODO: Allow customizing node url:
  93. // host: 'localhost', port: '5001', protocol: 'http',
  94. timeout: 10000,
  95. })
  96. const ipnsAddress = `/ipns/${ipnsIdentity}/`
  97. const ipfsName = await last(
  98. ipfs.name.resolve(ipnsAddress, {
  99. recursive: false,
  100. nocache: false,
  101. })
  102. )
  103. const data: any = await first(ipfs.get(ipfsName))
  104. const buffer = await toBuffer(data.content)
  105. return JSON.parse(buffer.toString())
  106. }
  107. private async getDiscoveryDataViaBootstrapEndpoint(storageProviderId: number): Promise<any> {
  108. const bootstrapEndpoint = await this.getApi().getRandomBootstrapEndpoint()
  109. if (!bootstrapEndpoint) {
  110. this.error('No bootstrap endpoints available', { exit: ExitCodes.ApiError })
  111. }
  112. this.log('Bootstrap endpoint:', bootstrapEndpoint)
  113. const discoveryEndpoint = new URL(`discover/v0/${storageProviderId}`, bootstrapEndpoint).toString()
  114. try {
  115. const data = (await axios.get(discoveryEndpoint)).data
  116. return data
  117. } catch (e) {
  118. this.error(`Cannot retrieve data from bootstrap enpoint (${discoveryEndpoint})`, {
  119. exit: ExitCodes.ExternalInfrastructureError,
  120. })
  121. }
  122. }
  123. private async getUploadUrlFromDiscoveryData(data: any, contentId: ContentId): Promise<string> {
  124. if (typeof data === 'object' && data !== null && data.serialized) {
  125. const unserialized = JSON.parse(data.serialized)
  126. if (unserialized.asset && unserialized.asset.endpoint && typeof unserialized.asset.endpoint === 'string') {
  127. return new URL(`asset/v0/${contentId.encode()}`, unserialized.asset.endpoint).toString()
  128. }
  129. }
  130. this.error(`Unexpected discovery data: ${JSON.stringify(data)}`)
  131. }
  132. private async getUploadUrl(ipnsIdentity: string, storageProviderId: number, contentId: ContentId): Promise<string> {
  133. let data: any
  134. try {
  135. this.log('Trying to connect to local ipfs node...')
  136. data = await this.getDiscoveryDataViaLocalIpfsNode(ipnsIdentity)
  137. } catch (e) {
  138. this.warn("Couldn't get data from local ipfs node, resolving to bootstrap endpoint...")
  139. data = await this.getDiscoveryDataViaBootstrapEndpoint(storageProviderId)
  140. }
  141. const uploadUrl = await this.getUploadUrlFromDiscoveryData(data, contentId)
  142. return uploadUrl
  143. }
  144. private async getVideoMetadata(filePath: string): Promise<VideoMetadata | null> {
  145. let metadata: VideoMetadata | null = null
  146. const metadataPromise = new Promise<VideoMetadata>((resolve, reject) => {
  147. ffmpeg.ffprobe(filePath, (err, data) => {
  148. if (err) {
  149. reject(err)
  150. return
  151. }
  152. const videoStream = data.streams.find((s) => s.codec_type === 'video')
  153. if (videoStream) {
  154. resolve({
  155. width: videoStream.width,
  156. height: videoStream.height,
  157. codecName: videoStream.codec_name,
  158. codecFullName: videoStream.codec_long_name,
  159. duration: videoStream.duration !== undefined ? Math.ceil(Number(videoStream.duration)) || 0 : undefined,
  160. })
  161. } else {
  162. reject(new Error('No video stream found in file'))
  163. }
  164. })
  165. })
  166. try {
  167. metadata = await metadataPromise
  168. } catch (e) {
  169. const message = e.message || e
  170. this.warn(`Failed to get video metadata via ffprobe (${message})`)
  171. }
  172. return metadata
  173. }
  174. private async uploadVideo(filePath: string, fileSize: number, uploadUrl: string) {
  175. const { fileStream, progressBar } = this.createReadStreamWithProgressBar(filePath, 'Uploading', fileSize)
  176. fileStream.on('end', () => {
  177. cli.action.start('Waiting for the file to be processed...')
  178. })
  179. try {
  180. const config: AxiosRequestConfig = {
  181. headers: {
  182. 'Content-Type': '', // https://github.com/Joystream/storage-node-joystream/issues/16
  183. 'Content-Length': fileSize.toString(),
  184. },
  185. maxContentLength: MAX_FILE_SIZE,
  186. }
  187. await axios.put(uploadUrl, fileStream, config)
  188. cli.action.stop()
  189. this.log('File uploaded!')
  190. } catch (e) {
  191. progressBar.stop()
  192. cli.action.stop()
  193. const msg = (e.response && e.response.data && e.response.data.message) || e.message || e
  194. this.error(`Unexpected error when trying to upload a file: ${msg}`, {
  195. exit: ExitCodes.ExternalInfrastructureError,
  196. })
  197. }
  198. }
  199. private async promptForVideoInput(
  200. channelId: number,
  201. fileSize: number,
  202. contentId: ContentId,
  203. videoMetadata: VideoMetadata | null
  204. ) {
  205. // Set the defaults
  206. const videoMediaDefaults: Partial<VideoMediaEntity> = {
  207. pixelWidth: videoMetadata?.width,
  208. pixelHeight: videoMetadata?.height,
  209. }
  210. const videoDefaults: Partial<VideoEntity> = {
  211. duration: videoMetadata?.duration,
  212. skippableIntroDuration: 0,
  213. }
  214. // Prompt for data
  215. const videoJsonSchema = (VideoEntitySchema as unknown) as JSONSchema
  216. const videoMediaJsonSchema = (VideoMediaEntitySchema as unknown) as JSONSchema
  217. const videoMediaPrompter = new JsonSchemaPrompter<VideoMediaEntity>(videoMediaJsonSchema, videoMediaDefaults)
  218. const videoPrompter = new JsonSchemaPrompter<VideoEntity>(videoJsonSchema, videoDefaults)
  219. // Prompt for the data
  220. const encodingSuggestion =
  221. videoMetadata && videoMetadata.codecFullName ? ` (suggested: ${videoMetadata.codecFullName})` : ''
  222. const encoding = await this.promptForEntityId(
  223. `Choose Video encoding${encodingSuggestion}`,
  224. 'VideoMediaEncoding',
  225. 'name'
  226. )
  227. const { pixelWidth, pixelHeight } = await videoMediaPrompter.promptMultipleProps(['pixelWidth', 'pixelHeight'])
  228. const language = await this.promptForEntityId('Choose Video language', 'Language', 'name')
  229. const category = await this.promptForEntityId('Choose Video category', 'ContentCategory', 'name')
  230. const videoProps = await videoPrompter.promptMultipleProps([
  231. 'title',
  232. 'description',
  233. 'thumbnailUrl',
  234. 'duration',
  235. 'isPublic',
  236. 'isExplicit',
  237. 'hasMarketing',
  238. 'skippableIntroDuration',
  239. ])
  240. const license = await videoPrompter.promptSingleProp('license', () => this.promptForNewLicense())
  241. const publishedBeforeJoystream = await videoPrompter.promptSingleProp('publishedBeforeJoystream', () =>
  242. this.promptForPublishedBeforeJoystream()
  243. )
  244. // Create final inputs
  245. const videoMediaInput: VideoMediaEntity = {
  246. encoding,
  247. pixelWidth,
  248. pixelHeight,
  249. size: fileSize,
  250. location: { new: { joystreamMediaLocation: { new: { dataObjectId: contentId.encode() } } } },
  251. }
  252. return {
  253. ...videoProps,
  254. channel: channelId,
  255. language,
  256. category,
  257. license,
  258. media: { new: videoMediaInput },
  259. publishedBeforeJoystream,
  260. }
  261. }
  262. private async getVideoInputFromFile(
  263. filePath: string,
  264. channelId: number,
  265. fileSize: number,
  266. contentId: ContentId,
  267. videoMetadata: VideoMetadata | null
  268. ) {
  269. let videoInput = await getInputJson<any>(filePath)
  270. if (typeof videoInput !== 'object' || videoInput === null) {
  271. this.error('Invalid input json - expected an object', { exit: ExitCodes.InvalidInput })
  272. }
  273. const videoMediaDefaults: Partial<VideoMediaEntity> = {
  274. pixelWidth: videoMetadata?.width,
  275. pixelHeight: videoMetadata?.height,
  276. size: fileSize,
  277. }
  278. const videoDefaults: Partial<VideoEntity> = {
  279. channel: channelId,
  280. duration: videoMetadata?.duration,
  281. }
  282. const inputVideoMedia =
  283. videoInput.media && typeof videoInput.media === 'object' && (videoInput.media as any).new
  284. ? (videoInput.media as any).new
  285. : {}
  286. videoInput = {
  287. ...videoDefaults,
  288. ...videoInput,
  289. media: {
  290. new: {
  291. ...videoMediaDefaults,
  292. ...inputVideoMedia,
  293. location: { new: { joystreamMediaLocation: { new: { dataObjectId: contentId.encode() } } } },
  294. },
  295. },
  296. }
  297. const videoJsonSchema = (VideoEntitySchema as unknown) as JSONSchema
  298. await validateInput(videoInput, videoJsonSchema)
  299. return videoInput as VideoEntity
  300. }
  301. async run() {
  302. const account = await this.getRequiredSelectedAccount()
  303. const memberId = await this.getRequiredMemberId()
  304. const actor = { Member: memberId }
  305. await this.requestAccountDecoding(account)
  306. const {
  307. args: { filePath },
  308. flags: { channel: inputChannelId, input, confirm },
  309. } = this.parse(UploadVideoCommand)
  310. // Basic file validation
  311. if (!fs.existsSync(filePath)) {
  312. this.error('File does not exist under provided path!', { exit: ExitCodes.FileNotFound })
  313. }
  314. const { size: fileSize } = fs.statSync(filePath)
  315. if (fileSize > MAX_FILE_SIZE) {
  316. this.error(`File size too large! Max. file size is: ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(2)} MB`)
  317. }
  318. const videoMetadata = await this.getVideoMetadata(filePath)
  319. this.log('Video media file parameters established:', { ...(videoMetadata || {}), size: fileSize })
  320. // Check if any providers are available
  321. if (!(await this.getApi().isAnyProviderAvailable())) {
  322. this.error('No active storage providers available! Try again later...', {
  323. exit: ExitCodes.ActionCurrentlyUnavailable,
  324. })
  325. }
  326. // Start by prompting for a channel to make sure user has one available
  327. let channelId: number
  328. if (inputChannelId === undefined) {
  329. channelId = await this.promptForEntityId(
  330. 'Select a channel to publish the video under',
  331. 'Channel',
  332. 'handle',
  333. memberId
  334. )
  335. } else {
  336. await this.getEntity(inputChannelId, 'Channel', memberId) // Validates if exists and belongs to member
  337. channelId = inputChannelId
  338. }
  339. // Calculate hash and create content id
  340. const contentId = ContentId.generate(this.getTypesRegistry())
  341. const ipfsCid = await this.calculateFileIpfsHash(filePath, fileSize)
  342. this.log('Video identification established:', {
  343. contentId: contentId.toString(),
  344. encodedContentId: contentId.encode(),
  345. ipfsHash: ipfsCid,
  346. })
  347. // Send dataDirectory.addContent extrinsic
  348. await this.sendAndFollowNamedTx(account, 'dataDirectory', 'addContent', [
  349. memberId,
  350. contentId,
  351. DATA_OBJECT_TYPE_ID,
  352. fileSize,
  353. ipfsCid,
  354. ])
  355. const dataObject = await this.getApi().dataObjectByContentId(contentId)
  356. if (!dataObject) {
  357. this.error('Data object could not be retrieved from chain', { exit: ExitCodes.ApiError })
  358. }
  359. this.log('Data object:', dataObject.toJSON())
  360. // Get storage provider identity
  361. const storageProviderId = dataObject.liaison.toNumber()
  362. const ipnsIdentity = await this.getApi().ipnsIdentity(storageProviderId)
  363. if (!ipnsIdentity) {
  364. this.error('Storage provider IPNS identity could not be determined', { exit: ExitCodes.ApiError })
  365. }
  366. // Resolve upload url and upload the video
  367. const uploadUrl = await this.getUploadUrl(ipnsIdentity, storageProviderId, contentId)
  368. this.log('Resolved upload url:', uploadUrl)
  369. await this.uploadVideo(filePath, fileSize, uploadUrl)
  370. // No input, create prompting helpers
  371. const videoInput = input
  372. ? await this.getVideoInputFromFile(input, channelId, fileSize, contentId, videoMetadata)
  373. : await this.promptForVideoInput(channelId, fileSize, contentId, videoMetadata)
  374. this.jsonPrettyPrint(JSON.stringify(videoInput))
  375. if (!confirm) {
  376. await this.requireConfirmation('Do you confirm the provided input?', true)
  377. }
  378. // Parse inputs into operations and send final extrinsic
  379. const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi(), [
  380. {
  381. className: 'Video',
  382. entries: [videoInput],
  383. },
  384. ])
  385. const operations = await inputParser.getEntityBatchOperations()
  386. await this.sendAndFollowNamedTx(account, 'contentDirectory', 'transaction', [actor, operations])
  387. }
  388. }