Ver Fonte

Merge pull request #1614 from Lezek123/cli-babylon-remaining

CLI: Remaining content-directory/media functionality for Babylon
Mokhtar Naamani há 4 anos atrás
pai
commit
4666ac7f8c

+ 1 - 0
cli/package.json

@@ -26,6 +26,7 @@
     "cli-ux": "^5.4.5",
     "fluent-ffmpeg": "^2.1.2",
     "inquirer": "^7.1.0",
+    "inquirer-datepicker-prompt": "^0.4.2",
     "ipfs-http-client": "^47.0.1",
     "ipfs-only-hash": "^1.0.2",
     "it-all": "^1.0.4",

+ 1 - 0
cli/src/@types/inquirer-datepicker-prompt/index.d.ts

@@ -0,0 +1 @@
+declare module 'inquirer-datepicker-prompt'

+ 1 - 1
cli/src/Api.ts

@@ -523,7 +523,7 @@ export default class Api {
   }
 
   async entityById(id: number): Promise<Entity | null> {
-    const exists = !!(await this._api.query.contentDirectory.curatorGroupById.size(id))
+    const exists = !!(await this._api.query.contentDirectory.entityById.size(id)).toNumber()
     return exists ? await this._api.query.contentDirectory.entityById<Entity>(id) : null
   }
 

+ 23 - 4
cli/src/base/ContentDirectoryCommandBase.ts

@@ -19,6 +19,10 @@ import _ from 'lodash'
 import { RolesCommandBase } from './WorkingGroupsCommandBase'
 import { createType } from '@joystream/types'
 import chalk from 'chalk'
+import { flags } from '@oclif/command'
+
+const CONTEXTS = ['Member', 'Curator', 'Lead'] as const
+type Context = typeof CONTEXTS[number]
 
 /**
  * Abstract base class for commands related to content directory
@@ -26,6 +30,21 @@ import chalk from 'chalk'
 export default abstract class ContentDirectoryCommandBase extends RolesCommandBase {
   group = WorkingGroups.Curators // override group for RolesCommandBase
 
+  static contextFlag = flags.enum({
+    name: 'context',
+    required: false,
+    description: `Actor context to execute the command in (${CONTEXTS.join('/')})`,
+    options: [...CONTEXTS],
+  })
+
+  async promptForContext(message = 'Choose in which context you wish to execute the command'): Promise<Context> {
+    return this.simplePrompt({
+      message,
+      type: 'list',
+      choices: CONTEXTS.map((c) => ({ name: c, value: c })),
+    })
+  }
+
   // Use when lead access is required in given command
   async requireLead(): Promise<void> {
     await this.getRequiredLead()
@@ -243,7 +262,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     className: string,
     propName?: string,
     ownerMemberId?: number,
-    defaultId?: number
+    defaultId?: number | null
   ): Promise<[EntityId, Entity]> {
     const [classId, entityClass] = await this.classEntryByNameOrId(className)
     const entityEntries = await this.entitiesByClassAndOwner(classId.toNumber(), ownerMemberId)
@@ -263,7 +282,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
           value: id.toString(), // With numbers there are issues with "default"
         }
       }),
-      default: defaultId?.toString(),
+      default: typeof defaultId === 'number' ? defaultId.toString() : undefined,
     })
 
     return entityEntries.find(([id]) => choosenEntityId === id.toString())!
@@ -274,7 +293,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     className: string,
     propName?: string,
     ownerMemberId?: number,
-    defaultId?: number
+    defaultId?: number | null
   ): Promise<number> {
     return (await this.promptForEntityEntry(message, className, propName, ownerMemberId, defaultId))[0].toNumber()
   }
@@ -303,7 +322,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
   async parseToKnownEntityJson<T>(entity: Entity): Promise<FlattenRelations<T>> {
     const entityClass = (await this.classEntryByNameOrId(entity.class_id.toString()))[1]
     return (_.mapValues(this.parseEntityPropertyValues(entity, entityClass), (v) =>
-      v.value.toJSON()
+      v.type !== 'Single<Bool>' && v.value.toJSON() === false ? null : v.value.toJSON()
     ) as unknown) as FlattenRelations<T>
   }
 

+ 5 - 0
cli/src/base/DefaultCommandBase.ts

@@ -2,6 +2,7 @@ import ExitCodes from '../ExitCodes'
 import Command from '@oclif/command'
 import inquirer, { DistinctQuestion } from 'inquirer'
 import chalk from 'chalk'
+import inquirerDatepicker from 'inquirer-datepicker-prompt'
 
 /**
  * Abstract base class for pretty much all commands
@@ -103,4 +104,8 @@ export default abstract class DefaultCommandBase extends Command {
     if (!err) this.exit(ExitCodes.OK)
     super.finally(err)
   }
+
+  async init() {
+    inquirer.registerPrompt('datetime', inquirerDatepicker)
+  }
 }

+ 65 - 0
cli/src/base/MediaCommandBase.ts

@@ -0,0 +1,65 @@
+import ContentDirectoryCommandBase from './ContentDirectoryCommandBase'
+import { VideoEntity } from 'cd-schemas/types/entities'
+import fs from 'fs'
+import { DistinctQuestion } from 'inquirer'
+import path from 'path'
+import os from 'os'
+
+const MAX_USER_LICENSE_CONTENT_LENGTH = 4096
+
+/**
+ * Abstract base class for higher-level media commands
+ */
+export default abstract class MediaCommandBase extends ContentDirectoryCommandBase {
+  async promptForNewLicense(): Promise<VideoEntity['license']> {
+    let license: VideoEntity['license']
+    const licenseType: 'known' | 'custom' = await this.simplePrompt({
+      type: 'list',
+      message: 'Choose license type',
+      choices: [
+        { name: 'Creative Commons', value: 'known' },
+        { name: 'Custom (user-defined)', value: 'custom' },
+      ],
+    })
+    if (licenseType === 'known') {
+      license = { new: { knownLicense: await this.promptForEntityId('Choose License', 'KnownLicense', 'code') } }
+    } else {
+      let licenseContent: null | string = null
+      while (licenseContent === null) {
+        try {
+          let licensePath: string = await this.simplePrompt({ message: 'Path to license file:' })
+          licensePath = path.resolve(process.cwd(), licensePath.replace(/^~/, os.homedir()))
+          licenseContent = fs.readFileSync(licensePath).toString()
+        } catch (e) {
+          this.warn("The file was not found or couldn't be accessed, try again...")
+        }
+        if (licenseContent !== null && licenseContent.length > MAX_USER_LICENSE_CONTENT_LENGTH) {
+          this.warn(`The license content cannot be more than ${MAX_USER_LICENSE_CONTENT_LENGTH} characters long`)
+          licenseContent = null
+        }
+      }
+      license = { new: { userDefinedLicense: { new: { content: licenseContent } } } }
+    }
+
+    return license
+  }
+
+  async promptForPublishedBeforeJoystream(current?: number | null): Promise<number | null> {
+    const publishedBefore = await this.simplePrompt({
+      type: 'confirm',
+      message: `Do you want to set optional first publication date (publishedBeforeJoystream)?`,
+      default: typeof current === 'number',
+    })
+    if (publishedBefore) {
+      const options = ({
+        type: 'datetime',
+        message: 'Date of first publication',
+        format: ['yyyy', '-', 'mm', '-', 'dd', ' ', 'hh', ':', 'MM', ' ', 'TT'],
+        initial: current && new Date(current * 1000),
+      } as unknown) as DistinctQuestion // Need to assert, because we use datetime plugin which has no TS support
+      const date = await this.simplePrompt(options)
+      return Math.floor(new Date(date).getTime() / 1000)
+    }
+    return null
+  }
+}

+ 50 - 0
cli/src/commands/content-directory/initialize.ts

@@ -0,0 +1,50 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { CreateClass } from 'cd-schemas/types/extrinsics/CreateClass'
+import { getInputs, InputParser, ExtrinsicsHelper } from 'cd-schemas'
+import { AddClassSchema } from 'cd-schemas/types/extrinsics/AddClassSchema'
+import { EntityBatch } from 'cd-schemas/types/EntityBatch'
+
+export default class InitializeCommand extends ContentDirectoryCommandBase {
+  static description =
+    'Initialize content directory with input data from @joystream/content library. Requires lead access.'
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+    await this.requestAccountDecoding(account)
+
+    const classInputs = getInputs<CreateClass>('classes').map(({ data }) => data)
+    const schemaInputs = getInputs<AddClassSchema>('schemas').map(({ data }) => data)
+    const entityBatchInputs = getInputs<EntityBatch>('entityBatches').map(({ data }) => data)
+
+    const currentClasses = await this.getApi().availableClasses()
+
+    if (currentClasses.length) {
+      this.log('There are already some existing classes in the current content directory.')
+      await this.requireConfirmation('Do you wish to continue anyway?')
+    }
+
+    const txHelper = new ExtrinsicsHelper(this.getOriginalApi())
+    const parser = new InputParser(this.getOriginalApi(), classInputs, schemaInputs, entityBatchInputs)
+
+    this.log(`Initializing classes (${classInputs.length} input files found)...\n`)
+    const classExtrinsics = parser.getCreateClassExntrinsics()
+    await txHelper.sendAndCheck(account, classExtrinsics, 'Class initialization failed!')
+
+    this.log(`Initializing schemas (${schemaInputs.length} input files found)...\n`)
+    const schemaExtrinsics = await parser.getAddSchemaExtrinsics()
+    await txHelper.sendAndCheck(account, schemaExtrinsics, 'Schemas initialization failed!')
+
+    this.log(`Initializing entities (${entityBatchInputs.length} input files found)`)
+    const entityOperations = await parser.getEntityBatchOperations()
+
+    this.log(`Sending Transaction extrinsic (${entityOperations.length} operations)...\n`)
+    await txHelper.sendAndCheck(
+      account,
+      [this.getOriginalApi().tx.contentDirectory.transaction({ Lead: null }, entityOperations)],
+      'Entity initialization failed!'
+    )
+
+    this.log('DONE')
+  }
+}

+ 57 - 0
cli/src/commands/content-directory/removeEntity.ts

@@ -0,0 +1,57 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { Actor } from '@joystream/types/content-directory'
+import { createType } from '@joystream/types'
+import ExitCodes from '../../ExitCodes'
+
+export default class RemoveEntityCommand extends ContentDirectoryCommandBase {
+  static description = 'Removes a single entity by id (can be executed in Member, Curator or Lead context)'
+  static flags = {
+    context: ContentDirectoryCommandBase.contextFlag,
+  }
+
+  static args = [
+    {
+      name: 'id',
+      required: true,
+      description: 'ID of the entity to remove',
+    },
+  ]
+
+  async run() {
+    let {
+      args: { id },
+      flags: { context },
+    } = this.parse(RemoveEntityCommand)
+
+    const entity = await this.getEntity(id, undefined, undefined, false)
+    const [, entityClass] = await this.classEntryByNameOrId(entity.class_id.toString())
+
+    if (!context) {
+      context = await this.promptForContext()
+    }
+
+    const account = await this.getRequiredSelectedAccount()
+    let actor: Actor
+    if (context === 'Curator') {
+      actor = await this.getCuratorContext([entityClass.name.toString()])
+    } else if (context === 'Member') {
+      const memberId = await this.getRequiredMemberId()
+      if (
+        !entity.entity_permissions.controller.isOfType('Member') ||
+        entity.entity_permissions.controller.asType('Member').toNumber() !== memberId
+      ) {
+        this.error('You are not the entity controller!', { exit: ExitCodes.AccessDenied })
+      }
+      actor = createType('Actor', { Member: memberId })
+    } else {
+      actor = createType('Actor', { Lead: null })
+    }
+
+    await this.requireConfirmation(
+      `Are you sure you want to remove entity ${id} of class ${entityClass.name.toString()}?`
+    )
+    await this.requestAccountDecoding(account)
+
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'removeEntity', [actor, id])
+  }
+}

+ 44 - 0
cli/src/commands/media/removeChannel.ts

@@ -0,0 +1,44 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { Entity } from '@joystream/types/content-directory'
+import { createType } from '@joystream/types'
+import { ChannelEntity } from 'cd-schemas/types/entities'
+
+export default class RemoveChannelCommand extends ContentDirectoryCommandBase {
+  static description = 'Removes a channel (required controller access).'
+  static args = [
+    {
+      name: 'id',
+      required: false,
+      description: 'ID of the Channel entity',
+    },
+  ]
+
+  async run() {
+    const {
+      args: { id },
+    } = this.parse(RemoveChannelCommand)
+
+    const account = await this.getRequiredSelectedAccount()
+    const memberId = await this.getRequiredMemberId()
+    const actor = createType('Actor', { Member: memberId })
+
+    await this.requestAccountDecoding(account)
+
+    let channelEntity: Entity, channelId: number
+    if (id) {
+      channelId = parseInt(id)
+      channelEntity = await this.getEntity(channelId, 'Channel', memberId)
+    } else {
+      const [id, channel] = await this.promptForEntityEntry('Select a channel to remove', 'Channel', 'title', memberId)
+      channelId = id.toNumber()
+      channelEntity = channel
+    }
+    const channel = await this.parseToKnownEntityJson<ChannelEntity>(channelEntity)
+
+    await this.requireConfirmation(`Are you sure you want to remove "${channel.title}" channel?`)
+
+    const api = this.getOriginalApi()
+    this.log(`Removing Channel entity (ID: ${channelId})...`)
+    await this.sendAndFollowTx(account, api.tx.contentDirectory.removeEntity(actor, channelId))
+  }
+}

+ 49 - 0
cli/src/commands/media/removeVideo.ts

@@ -0,0 +1,49 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { Entity } from '@joystream/types/content-directory'
+import { VideoEntity } from 'cd-schemas/types/entities'
+import { createType } from '@joystream/types'
+
+export default class RemoveVideoCommand extends ContentDirectoryCommandBase {
+  static description = 'Remove given Video entity and associated entities (VideoMedia, License) from content directory.'
+  static args = [
+    {
+      name: 'id',
+      required: false,
+      description: 'ID of the Video entity',
+    },
+  ]
+
+  async run() {
+    const {
+      args: { id },
+    } = this.parse(RemoveVideoCommand)
+
+    const account = await this.getRequiredSelectedAccount()
+    const memberId = await this.getRequiredMemberId()
+    const actor = createType('Actor', { Member: memberId })
+
+    await this.requestAccountDecoding(account)
+
+    let videoEntity: Entity, videoId: number
+    if (id) {
+      videoId = parseInt(id)
+      videoEntity = await this.getEntity(videoId, 'Video', memberId)
+    } else {
+      const [id, video] = await this.promptForEntityEntry('Select a video to remove', 'Video', 'title', memberId)
+      videoId = id.toNumber()
+      videoEntity = video
+    }
+
+    const video = await this.parseToKnownEntityJson<VideoEntity>(videoEntity)
+
+    await this.requireConfirmation(`Are you sure you want to remove the "${video.title}" video?`)
+
+    const api = this.getOriginalApi()
+    this.log(`Removing the Video entity (ID: ${videoId})...`)
+    await this.sendAndFollowTx(account, api.tx.contentDirectory.removeEntity(actor, videoId))
+    this.log(`Removing the VideoMedia entity (ID: ${video.media})...`)
+    await this.sendAndFollowTx(account, api.tx.contentDirectory.removeEntity(actor, video.media))
+    this.log(`Removing the License entity (ID: ${video.license})...`)
+    await this.sendAndFollowTx(account, api.tx.contentDirectory.removeEntity(actor, video.license))
+  }
+}

+ 14 - 24
cli/src/commands/media/updateVideo.ts

@@ -1,16 +1,15 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
 import VideoEntitySchema from 'cd-schemas/schemas/entities/VideoEntity.schema.json'
 import { VideoEntity } from 'cd-schemas/types/entities/VideoEntity'
-import { LicenseEntity } from 'cd-schemas/types/entities/LicenseEntity'
 import { InputParser } from 'cd-schemas'
 import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
 import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
 import { Actor, Entity } from '@joystream/types/content-directory'
 import { createType } from '@joystream/types'
 import { flags } from '@oclif/command'
+import MediaCommandBase from '../../base/MediaCommandBase'
 
-export default class UpdateVideoCommand extends ContentDirectoryCommandBase {
-  static description = 'Update existing video information (requires a membership).'
+export default class UpdateVideoCommand extends MediaCommandBase {
+  static description = 'Update existing video information (requires controller/maintainer access).'
   static flags = {
     // TODO: ...IOFlags, - providing input as json
     asCurator: flags.boolean({
@@ -38,7 +37,7 @@ export default class UpdateVideoCommand extends ContentDirectoryCommandBase {
     let memberId: number | undefined, actor: Actor
 
     if (asCurator) {
-      actor = await this.getCuratorContext(['Video', 'License'])
+      actor = await this.getCuratorContext(['Video'])
     } else {
       memberId = await this.getRequiredMemberId()
       actor = createType('Actor', { Member: memberId })
@@ -59,7 +58,11 @@ export default class UpdateVideoCommand extends ContentDirectoryCommandBase {
     const currentValues = await this.parseToKnownEntityJson<VideoEntity>(videoEntity)
     const videoJsonSchema = (VideoEntitySchema as unknown) as JSONSchema
 
-    const { language: currLanguageId, category: currCategoryId, license: currLicenseId } = currentValues
+    const {
+      language: currLanguageId,
+      category: currCategoryId,
+      publishedBeforeJoystream: currPublishedBeforeJoystream,
+    } = currentValues
 
     const customizedPrompts: JsonSchemaCustomPrompts<VideoEntity> = [
       [
@@ -70,20 +73,10 @@ export default class UpdateVideoCommand extends ContentDirectoryCommandBase {
         'category',
         () => this.promptForEntityId('Choose Video category', 'ContentCategory', 'name', undefined, currCategoryId),
       ],
+      ['publishedBeforeJoystream', () => this.promptForPublishedBeforeJoystream(currPublishedBeforeJoystream)],
     ]
     const videoPrompter = new JsonSchemaPrompter<VideoEntity>(videoJsonSchema, currentValues, customizedPrompts)
 
-    // Updating a license is currently a bit more tricky since it's a nested relation
-    const currKnownLicenseId = (await this.getAndParseKnownEntity<LicenseEntity>(currLicenseId)).knownLicense
-    const knownLicenseId = await this.promptForEntityId(
-      'Choose a license',
-      'KnownLicense',
-      'code',
-      undefined,
-      currKnownLicenseId
-    )
-    const updatedLicense: LicenseEntity = { knownLicense: knownLicenseId }
-
     // Prompt for other video data
     const updatedProps: Partial<VideoEntity> = await videoPrompter.promptMultipleProps([
       'language',
@@ -93,7 +86,10 @@ export default class UpdateVideoCommand extends ContentDirectoryCommandBase {
       'thumbnailURL',
       'duration',
       'isPublic',
+      'isExplicit',
       'hasMarketing',
+      'publishedBeforeJoystream',
+      'skippableIntroDuration',
     ])
 
     if (asCurator) {
@@ -105,12 +101,6 @@ export default class UpdateVideoCommand extends ContentDirectoryCommandBase {
     // Parse inputs into operations and send final extrinsic
     const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi())
     const videoUpdateOperations = await inputParser.getEntityUpdateOperations(updatedProps, 'Video', videoId)
-    const licenseUpdateOperations = await inputParser.getEntityUpdateOperations(
-      updatedLicense,
-      'License',
-      currentValues.license
-    )
-    const operations = [...videoUpdateOperations, ...licenseUpdateOperations]
-    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'transaction', [actor, operations], true)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'transaction', [actor, videoUpdateOperations], true)
   }
 }

+ 59 - 0
cli/src/commands/media/updateVideoLicense.ts

@@ -0,0 +1,59 @@
+import MediaCommandBase from '../../base/MediaCommandBase'
+import { LicenseEntity, VideoEntity } from 'cd-schemas/types/entities'
+import { InputParser } from 'cd-schemas'
+import { Entity } from '@joystream/types/content-directory'
+import { createType } from '@joystream/types'
+
+export default class UpdateVideoLicenseCommand extends MediaCommandBase {
+  static description = 'Update existing video license (requires controller/maintainer access).'
+  // TODO: ...IOFlags, - providing input as json
+
+  static args = [
+    {
+      name: 'id',
+      description: 'ID of the Video',
+      required: false,
+    },
+  ]
+
+  async run() {
+    const {
+      args: { id },
+    } = this.parse(UpdateVideoLicenseCommand)
+
+    const account = await this.getRequiredSelectedAccount()
+    const memberId = await this.getRequiredMemberId()
+    const actor = createType('Actor', { Member: memberId })
+
+    await this.requestAccountDecoding(account)
+
+    let videoEntity: Entity, videoId: number
+    if (id) {
+      videoId = parseInt(id)
+      videoEntity = await this.getEntity(videoId, 'Video', memberId)
+    } else {
+      const [id, video] = await this.promptForEntityEntry('Select a video to update', 'Video', 'title', memberId)
+      videoId = id.toNumber()
+      videoEntity = video
+    }
+
+    const video = await this.parseToKnownEntityJson<VideoEntity>(videoEntity)
+    const currentLicense = await this.getAndParseKnownEntity<LicenseEntity>(video.license)
+
+    this.log('Current license:', currentLicense)
+
+    const updateInput: Partial<VideoEntity> = {
+      license: await this.promptForNewLicense(),
+    }
+
+    const api = this.getOriginalApi()
+    const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi())
+    const videoUpdateOperations = await inputParser.getEntityUpdateOperations(updateInput, 'Video', videoId)
+
+    this.log('Setting new license...')
+    await this.sendAndFollowTx(account, api.tx.contentDirectory.transaction(actor, videoUpdateOperations), true)
+
+    this.log(`Removing old License entity (ID: ${video.license})...`)
+    await this.sendAndFollowTx(account, api.tx.contentDirectory.removeEntity(actor, video.license))
+  }
+}

+ 14 - 4
cli/src/commands/media/uploadVideo.ts

@@ -1,4 +1,3 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
 import VideoEntitySchema from 'cd-schemas/schemas/entities/VideoEntity.schema.json'
 import VideoMediaEntitySchema from 'cd-schemas/schemas/entities/VideoMediaEntity.schema.json'
 import { VideoEntity } from 'cd-schemas/types/entities/VideoEntity'
@@ -20,6 +19,7 @@ import last from 'it-last'
 import toBuffer from 'it-to-buffer'
 import ffmpegInstaller from '@ffmpeg-installer/ffmpeg'
 import ffmpeg from 'fluent-ffmpeg'
+import MediaCommandBase from '../../base/MediaCommandBase'
 
 ffmpeg.setFfmpegPath(ffmpegInstaller.path)
 
@@ -34,7 +34,7 @@ type VideoMetadata = {
   duration?: number
 }
 
-export default class UploadVideoCommand extends ContentDirectoryCommandBase {
+export default class UploadVideoCommand extends MediaCommandBase {
   static description = 'Upload a new Video to a channel (requires a membership).'
   static flags = {
     // TODO: ...IOFlags, - providing input as json
@@ -312,6 +312,7 @@ export default class UploadVideoCommand extends ContentDirectoryCommandBase {
     }
     const videoDefaults: Partial<VideoEntity> = {
       duration: videoMetadata?.duration,
+      skippableIntroDuration: 0,
     }
     // Create prompting helpers
     const videoJsonSchema = (VideoEntitySchema as unknown) as JSONSchema
@@ -331,7 +332,6 @@ export default class UploadVideoCommand extends ContentDirectoryCommandBase {
     const { pixelWidth, pixelHeight } = await videoMediaPrompter.promptMultipleProps(['pixelWidth', 'pixelHeight'])
     const language = await this.promptForEntityId('Choose Video language', 'Language', 'name')
     const category = await this.promptForEntityId('Choose Video category', 'ContentCategory', 'name')
-    const license = await this.promptForEntityId('Choose License', 'KnownLicense', 'code')
     const videoProps = await videoPrompter.promptMultipleProps([
       'title',
       'description',
@@ -340,8 +340,14 @@ export default class UploadVideoCommand extends ContentDirectoryCommandBase {
       'isPublic',
       'isExplicit',
       'hasMarketing',
+      'skippableIntroDuration',
     ])
 
+    const license = await videoPrompter.promptSingleProp('license', () => this.promptForNewLicense())
+    const publishedBeforeJoystream = await videoPrompter.promptSingleProp('publishedBeforeJoystream', () =>
+      this.promptForPublishedBeforeJoystream()
+    )
+
     // Create final inputs
     const videoMediaInput: VideoMediaEntity = {
       encoding,
@@ -355,10 +361,14 @@ export default class UploadVideoCommand extends ContentDirectoryCommandBase {
       channel: channelId,
       language,
       category,
-      license: { new: { knownLicense: license } },
+      license,
       media: { new: videoMediaInput },
+      publishedBeforeJoystream,
     }
 
+    this.jsonPrettyPrint(JSON.stringify(videoInput))
+    await this.requireConfirmation('Do you confirm the provided input?')
+
     // Parse inputs into operations and send final extrinsic
     const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi(), [
       {

+ 11 - 8
cli/src/helpers/JsonSchemaPrompt.ts

@@ -85,13 +85,14 @@ export class JsonSchemaPrompter<JsonResult> {
     const customPrompt: CustomPrompt | undefined = custom || this.getCustomPrompt(propertyPath)
     const propDisplayName = this.propertyDisplayName(propertyPath)
     const currentValue = _.get(this.filledObject, propertyPath)
+    const type = Array.isArray(schema.type) ? schema.type[0] : schema.type
 
     if (customPrompt === 'skip') {
       return
     }
 
     // Automatically handle "null" values (useful for enum variants)
-    if (schema.type === 'null') {
+    if (type === 'null') {
       _.set(this.filledObject, propertyPath, null)
       return null
     }
@@ -113,7 +114,7 @@ export class JsonSchemaPrompter<JsonResult> {
     }
 
     // object
-    if (schema.type === 'object' && schema.properties) {
+    if (type === 'object' && schema.properties) {
       const value: Record<string, any> = {}
       for (const [pName, pSchema] of Object.entries(schema.properties)) {
         const objectPropertyPath = propertyPath ? `${propertyPath}.${pName}` : pName
@@ -133,7 +134,9 @@ export class JsonSchemaPrompter<JsonResult> {
                 message: `Do you want to provide optional ${chalk.greenBright(objectPropertyPath)}?`,
                 type: 'confirm',
                 name: 'confirmed',
-                default: _.get(this.filledObject, objectPropertyPath) !== undefined,
+                default:
+                  _.get(this.filledObject, objectPropertyPath) !== undefined &&
+                  _.get(this.filledObject, objectPropertyPath) !== null,
               },
             ])
           ).confirmed
@@ -141,14 +144,14 @@ export class JsonSchemaPrompter<JsonResult> {
         if (confirmed) {
           value[pName] = await this.prompt(pSchema, objectPropertyPath)
         } else {
-          _.set(this.filledObject, objectPropertyPath, undefined)
+          _.set(this.filledObject, objectPropertyPath, null)
         }
       }
       return value
     }
 
     // array
-    if (schema.type === 'array' && schema.items) {
+    if (type === 'array' && schema.items) {
       return await this.promptWithRetry(() => this.promptArray(schema, propertyPath), propertyPath, true)
     }
 
@@ -164,16 +167,16 @@ export class JsonSchemaPrompter<JsonResult> {
     // Prompt options
     if (schema.enum) {
       additionalPromptOptions = { type: 'list', choices: schema.enum as any[] }
-    } else if (schema.type === 'boolean') {
+    } else if (type === 'boolean') {
       additionalPromptOptions = BOOL_PROMPT_OPTIONS
     }
 
     // Normalizers
-    if (schema.type === 'integer') {
+    if (type === 'integer') {
       normalizer = (v) => (parseInt(v).toString() === v ? parseInt(v) : v)
     }
 
-    if (schema.type === 'number') {
+    if (type === 'number') {
       normalizer = (v) => (Number(v).toString() === v ? Number(v) : v)
     }
 

+ 1 - 2
content-directory-schemas/inputs/schemas/VideoSchema.json

@@ -79,8 +79,7 @@
       "name": "isExplicit",
       "description": "Whether the Video contains explicit material.",
       "required": true,
-      "property_type": { "Single": "Bool" },
-      "locking_policy": { "is_locked_from_controller": true }
+      "property_type": { "Single": "Bool" }
     },
     {
       "name": "license",

+ 1 - 1
content-directory-schemas/scripts/initializeContentDir.ts

@@ -26,7 +26,7 @@ async function main() {
   const ALICE = getAlicePair()
 
   // Emptiness check
-  if ((await api.query.contentDirectory.nextClassId()).toNumber() > 1) {
+  if ((await api.query.contentDirectory.classById.keys()).length > 0) {
     console.log('Content directory is not empty! Skipping...')
     process.exit()
   }

+ 20 - 7
content-directory-schemas/scripts/inputSchemasToEntitySchemas.ts

@@ -12,7 +12,7 @@ import {
 import PRIMITIVE_PROPERTY_DEFS from '../schemas/propertyValidationDefs.schema.json'
 import { getInputs } from '../src/helpers/inputs'
 import { getSchemasLocation, SCHEMA_TYPES } from '../src/helpers/schemas'
-import { JSONSchema7 } from 'json-schema'
+import { JSONSchema7, JSONSchema7TypeName } from 'json-schema'
 
 const schemaInputs = getInputs<AddClassSchema>('schemas')
 
@@ -68,12 +68,25 @@ const VecPropertyDef = ({ Vector: vec }: VecPropertyVariant): JSONSchema7 => ({
   'items': SinglePropertyDef({ Single: vec.vec_type }),
 })
 
-const PropertyDef = ({ property_type: propertyType, description }: Property): JSONSchema7 => ({
-  ...((propertyType as SinglePropertyVariant).Single
-    ? SinglePropertyDef(propertyType as SinglePropertyVariant)
-    : VecPropertyDef(propertyType as VecPropertyVariant)),
-  description,
-})
+const PropertyDef = ({ property_type: propertyType, description, required }: Property): JSONSchema7 => {
+  const def = {
+    ...((propertyType as SinglePropertyVariant).Single
+      ? SinglePropertyDef(propertyType as SinglePropertyVariant)
+      : VecPropertyDef(propertyType as VecPropertyVariant)),
+    description,
+  }
+  // Non-required fields:
+  // Simple fields:
+  if (!required && def.type) {
+    def.type = [def.type as JSONSchema7TypeName, 'null']
+  }
+  // Relationships:
+  else if (!required && def.oneOf) {
+    def.oneOf = [...def.oneOf, { type: 'null' }]
+  }
+
+  return def
+}
 
 // Mkdir entity schemas directories if they do not exist
 SCHEMA_TYPES.forEach((type) => {

+ 13 - 8
content-directory-schemas/src/helpers/InputParser.ts

@@ -20,7 +20,7 @@ import { CreateClass } from '../../types/extrinsics/CreateClass'
 import { EntityBatch } from '../../types/EntityBatch'
 import { getInputs } from './inputs'
 
-type SimpleEntityValue = string | boolean | number | string[] | boolean[] | number[] | undefined
+type SimpleEntityValue = string | boolean | number | string[] | boolean[] | number[] | undefined | null
 // Input without "new" or "extising" keywords
 type SimpleEntityInput = { [K: string]: SimpleEntityValue }
 
@@ -232,11 +232,16 @@ export class InputParser {
 
       let value = customHandler && (await customHandler(schemaProperty, propertyValue))
       if (value === undefined) {
-        value = createType('ParametrizedPropertyValue', {
-          InputPropertyValue: (await this.parsePropertyType(schemaProperty.property_type)).toInputPropertyValue(
-            propertyValue
-          ),
-        })
+        if (propertyValue === null) {
+          // Optional values: (can be cleared by setting them to Bool(false)):
+          value = createType('ParametrizedPropertyValue', { InputPropertyValue: { Single: { Bool: false } } })
+        } else {
+          value = createType('ParametrizedPropertyValue', {
+            InputPropertyValue: (await this.parsePropertyType(schemaProperty.property_type)).toInputPropertyValue(
+              propertyValue
+            ),
+          })
+        }
       }
 
       parametrizedClassPropValues.push(
@@ -286,10 +291,10 @@ export class InputParser {
         const { property_type: propertyType } = property
         if (isSingle(propertyType) && isReference(propertyType.Single)) {
           const refEntitySchema = this.schemaByClassName(propertyType.Single.Reference.className)
-          if (Object.keys(value).includes('new')) {
+          if (value !== null && Object.keys(value).includes('new')) {
             const entityIndex = await this.parseEntityInput(value.new, refEntitySchema)
             return createType('ParametrizedPropertyValue', { InternalEntityJustAdded: entityIndex })
-          } else if (Object.keys(value).includes('existing')) {
+          } else if (value !== null && Object.keys(value).includes('existing')) {
             return this.existingEntityQueryToParametrizedPropertyValue(refEntitySchema.className, value.existing)
           }
         }

+ 60 - 1
yarn.lock

@@ -8701,6 +8701,11 @@ character-reference-invalid@^1.0.0:
   resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.3.tgz#1647f4f726638d3ea4a750cf5d1975c1c7919a85"
   integrity sha512-VOq6PRzQBam/8Jm6XBGk2fNEnHXAdGd6go0rtd4weAGECBamHDwwCQSOT12TACIYUZegUXnV6xBXqUssijtxIg==
 
+chardet@^0.4.0:
+  version "0.4.2"
+  resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.4.2.tgz#b5473b33dc97c424e5d98dc87d55d4d8a29c8bf2"
+  integrity sha1-tUc7M9yXxCTl2Y3IfVXU2KKci/I=
+
 chardet@^0.7.0:
   version "0.7.0"
   resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
@@ -12658,6 +12663,15 @@ extend@^3.0.0, extend@~3.0.2:
   resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
   integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
 
+external-editor@^2.0.4:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-2.2.0.tgz#045511cfd8d133f3846673d1047c154e214ad3d5"
+  integrity sha512-bSn6gvGxKt+b7+6TKEv1ZycHleA7aHhRHyAqJyp5pbUFuYYNIzpZnQDk7AsYckyWdEnTeAnay0aCy2aV6iTk9A==
+  dependencies:
+    chardet "^0.4.0"
+    iconv-lite "^0.4.17"
+    tmp "^0.0.33"
+
 external-editor@^3.0.3:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495"
@@ -15120,7 +15134,7 @@ i18next@^19.6.3:
   dependencies:
     "@babel/runtime" "^7.10.1"
 
-iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@^0.4.5, iconv-lite@~0.4.13:
+iconv-lite@0.4.24, iconv-lite@^0.4.17, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@^0.4.5, iconv-lite@~0.4.13:
   version "0.4.24"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
   integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
@@ -15365,6 +15379,19 @@ init-package-json@^1.10.3:
     validate-npm-package-license "^3.0.1"
     validate-npm-package-name "^3.0.0"
 
+inquirer-datepicker-prompt@^0.4.2:
+  version "0.4.2"
+  resolved "https://registry.yarnpkg.com/inquirer-datepicker-prompt/-/inquirer-datepicker-prompt-0.4.2.tgz#d4419daccf7fdd099751ecb82210560a1437f67c"
+  integrity sha1-1EGdrM9/3QmXUey4IhBWChQ39nw=
+  dependencies:
+    chalk "^2.1.0"
+    cli-cursor "^2.1.0"
+    dateformat "^3.0.2"
+    datejs "^1.0.0-rc3"
+    inquirer "^3.3.0"
+    lodash "^4.17.4"
+    util "^0.10.3"
+
 inquirer@6.5.0:
   version "6.5.0"
   resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.5.0.tgz#2303317efc9a4ea7ec2e2df6f86569b734accf42"
@@ -15384,6 +15411,26 @@ inquirer@6.5.0:
     strip-ansi "^5.1.0"
     through "^2.3.6"
 
+inquirer@^3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-3.3.0.tgz#9dd2f2ad765dcab1ff0443b491442a20ba227dc9"
+  integrity sha512-h+xtnyk4EwKvFWHrUYsWErEVR+igKtLdchu+o0Z1RL7VU/jVMFbYir2bp6bAj8efFNxWqHX0dIss6fJQ+/+qeQ==
+  dependencies:
+    ansi-escapes "^3.0.0"
+    chalk "^2.0.0"
+    cli-cursor "^2.1.0"
+    cli-width "^2.0.0"
+    external-editor "^2.0.4"
+    figures "^2.0.0"
+    lodash "^4.3.0"
+    mute-stream "0.0.7"
+    run-async "^2.2.0"
+    rx-lite "^4.0.8"
+    rx-lite-aggregates "^4.0.8"
+    string-width "^2.1.0"
+    strip-ansi "^4.0.0"
+    through "^2.3.6"
+
 inquirer@^6.2.0:
   version "6.5.2"
   resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.5.2.tgz#ad50942375d036d327ff528c08bd5fab089928ca"
@@ -25167,6 +25214,18 @@ run@^1.4.0:
   dependencies:
     minimatch "*"
 
+rx-lite-aggregates@^4.0.8:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/rx-lite-aggregates/-/rx-lite-aggregates-4.0.8.tgz#753b87a89a11c95467c4ac1626c4efc4e05c67be"
+  integrity sha1-dTuHqJoRyVRnxKwWJsTvxOBcZ74=
+  dependencies:
+    rx-lite "*"
+
+rx-lite@*, rx-lite@^4.0.8:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444"
+  integrity sha1-Cx4Rr4vESDbwSmQH6S2kJGe3lEQ=
+
 rxjs-compat@^6.6.0:
   version "6.6.2"
   resolved "https://registry.yarnpkg.com/rxjs-compat/-/rxjs-compat-6.6.2.tgz#23592564243cf24641a5d2e2d2acfc8f6b127186"