Browse Source

Merge branch 'babylon' into pioneer-update-babylon

Leszek Wiesner 4 years ago
parent
commit
56ca307184
49 changed files with 2333 additions and 218 deletions
  1. 101 0
      cli/README.md
  2. 13 0
      cli/package.json
  3. 3 0
      cli/src/@types/@ffmpeg-installer/ffmpeg/index.d.ts
  4. 1 0
      cli/src/@types/ipfs-http-client/index.d.ts
  5. 1 0
      cli/src/@types/ipfs-only-hash/index.d.ts
  6. 50 7
      cli/src/Api.ts
  7. 2 0
      cli/src/ExitCodes.ts
  8. 30 3
      cli/src/base/AccountsCommandBase.ts
  9. 34 8
      cli/src/base/ApiCommandBase.ts
  10. 126 9
      cli/src/base/ContentDirectoryCommandBase.ts
  11. 2 0
      cli/src/base/StateAwareCommandBase.ts
  12. 22 10
      cli/src/commands/content-directory/addClassSchema.ts
  13. 2 1
      cli/src/commands/content-directory/class.ts
  14. 11 5
      cli/src/commands/content-directory/createClass.ts
  15. 13 9
      cli/src/commands/content-directory/curatorGroups.ts
  16. 22 17
      cli/src/commands/content-directory/entities.ts
  17. 8 1
      cli/src/commands/content-directory/entity.ts
  18. 46 0
      cli/src/commands/content-directory/removeCuratorFromGroup.ts
  19. 7 2
      cli/src/commands/content-directory/removeCuratorGroup.ts
  20. 53 0
      cli/src/commands/media/createChannel.ts
  21. 25 0
      cli/src/commands/media/myChannels.ts
  22. 33 0
      cli/src/commands/media/myVideos.ts
  23. 77 0
      cli/src/commands/media/updateChannel.ts
  24. 96 0
      cli/src/commands/media/updateVideo.ts
  25. 372 0
      cli/src/commands/media/uploadVideo.ts
  26. 10 4
      cli/src/helpers/InputOutput.ts
  27. 52 21
      cli/src/helpers/JsonSchemaPrompt.ts
  28. 128 1
      content-directory-schemas/README.md
  29. 52 0
      content-directory-schemas/examples/createChannel.ts
  30. 76 0
      content-directory-schemas/examples/createVideo.ts
  31. 47 0
      content-directory-schemas/examples/updateChannelTitle.ts
  32. 15 2
      content-directory-schemas/inputs/entityBatches/ContentCategoryBatch.json
  33. 36 1
      content-directory-schemas/inputs/entityBatches/LanguageBatch.json
  34. 4 4
      content-directory-schemas/inputs/entityBatches/VideoBatch.json
  35. 24 1
      content-directory-schemas/inputs/entityBatches/VideoMediaEncodingBatch.json
  36. 4 1
      content-directory-schemas/package.json
  37. 3 2
      content-directory-schemas/schemas/extrinsics/AddClassSchema.schema.json
  38. 6 2
      content-directory-schemas/schemas/extrinsics/CreateClass.schema.json
  39. 1 1
      content-directory-schemas/scripts/initializeContentDir.ts
  40. 187 63
      content-directory-schemas/src/helpers/InputParser.ts
  41. 4 4
      pioneer/packages/joy-forum/src/Context.tsx
  42. 2 2
      pioneer/packages/joy-forum/src/calls.tsx
  43. 2 2
      pioneer/packages/joy-proposals/src/stories/data/ProposalDetails.mock.ts
  44. 2 2
      pioneer/packages/joy-roles/src/classifiers.spec.ts
  45. 1 1
      storage-node/packages/cli/package.json
  46. 1 1
      types/src/JoyEnum.ts
  47. 12 5
      types/src/JoyStruct.ts
  48. 41 3
      types/src/index.ts
  49. 473 23
      yarn.lock

+ 101 - 0
cli/README.md

@@ -93,6 +93,12 @@ When using the CLI for the first time there are a few common steps you might wan
 * [`joystream-cli content-directory:updateClassPermissions [CLASSNAME]`](#joystream-cli-content-directoryupdateclasspermissions-classname)
 * [`joystream-cli council:info`](#joystream-cli-councilinfo)
 * [`joystream-cli help [COMMAND]`](#joystream-cli-help-command)
+* [`joystream-cli media:createChannel`](#joystream-cli-mediacreatechannel)
+* [`joystream-cli media:myChannels`](#joystream-cli-mediamychannels)
+* [`joystream-cli media:myVideos`](#joystream-cli-mediamyvideos)
+* [`joystream-cli media:updateChannel [ID]`](#joystream-cli-mediaupdatechannel-id)
+* [`joystream-cli media:updateVideo [ID]`](#joystream-cli-mediaupdatevideo-id)
+* [`joystream-cli media:uploadVideo FILEPATH`](#joystream-cli-mediauploadvideo-filepath)
 * [`joystream-cli working-groups:application WGAPPLICATIONID`](#joystream-cli-working-groupsapplication-wgapplicationid)
 * [`joystream-cli working-groups:createOpening`](#joystream-cli-working-groupscreateopening)
 * [`joystream-cli working-groups:decreaseWorkerStake WORKERID`](#joystream-cli-working-groupsdecreaseworkerstake-workerid)
@@ -440,6 +446,11 @@ ARGUMENTS
 
   PROPERTIES  Comma-separated properties to include in the results table (ie. code,name). By default all property values
               will be included.
+
+OPTIONS
+  --filters=filters  Comma-separated filters, ie. title="Some video",channelId=3.Currently only the = operator is
+                     supported.When multiple filters are provided, only the entities that match all of them together
+                     will be displayed.
 ```
 
 _See code: [src/commands/content-directory/entities.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/entities.ts)_
@@ -544,6 +555,96 @@ OPTIONS
 
 _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v2.2.3/src/commands/help.ts)_
 
+## `joystream-cli media:createChannel`
+
+Create a new channel on Joystream (requires a membership).
+
+```
+USAGE
+  $ joystream-cli media:createChannel
+
+OPTIONS
+  -i, --input=input    Path to JSON file to use as input (if not specified - the input can be provided interactively)
+  -o, --output=output  Path where the output JSON file should be placed (can be then reused as input)
+```
+
+_See code: [src/commands/media/createChannel.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/media/createChannel.ts)_
+
+## `joystream-cli media:myChannels`
+
+Show the list of channels associated with current account's membership.
+
+```
+USAGE
+  $ joystream-cli media:myChannels
+```
+
+_See code: [src/commands/media/myChannels.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/media/myChannels.ts)_
+
+## `joystream-cli media:myVideos`
+
+Show the list of videos associated with current account's membership.
+
+```
+USAGE
+  $ joystream-cli media:myVideos
+
+OPTIONS
+  -c, --channel=channel  Channel id to filter the videos by
+```
+
+_See code: [src/commands/media/myVideos.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/media/myVideos.ts)_
+
+## `joystream-cli media:updateChannel [ID]`
+
+Update one of the owned channels on Joystream (requires a membership).
+
+```
+USAGE
+  $ joystream-cli media:updateChannel [ID]
+
+ARGUMENTS
+  ID  ID of the channel to update
+
+OPTIONS
+  -i, --input=input    Path to JSON file to use as input (if not specified - the input can be provided interactively)
+  -o, --output=output  Path where the output JSON file should be placed (can be then reused as input)
+```
+
+_See code: [src/commands/media/updateChannel.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/media/updateChannel.ts)_
+
+## `joystream-cli media:updateVideo [ID]`
+
+Update existing video information (requires a membership).
+
+```
+USAGE
+  $ joystream-cli media:updateVideo [ID]
+
+ARGUMENTS
+  ID  ID of the Video to update
+```
+
+_See code: [src/commands/media/updateVideo.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/media/updateVideo.ts)_
+
+## `joystream-cli media:uploadVideo FILEPATH`
+
+Upload a new Video to a channel (requires a membership).
+
+```
+USAGE
+  $ joystream-cli media:uploadVideo FILEPATH
+
+ARGUMENTS
+  FILEPATH  Path to the media file to upload
+
+OPTIONS
+  -c, --channel=channel  ID of the channel to assign the video to (if omitted - one of the owned channels can be
+                         selected from the list)
+```
+
+_See code: [src/commands/media/uploadVideo.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/media/uploadVideo.ts)_
+
 ## `joystream-cli working-groups:application WGAPPLICATIONID`
 
 Shows an overview of given application by Working Group Application ID

+ 13 - 0
cli/package.json

@@ -9,6 +9,7 @@
   "bugs": "https://github.com/Joystream/joystream/issues",
   "dependencies": {
     "@apidevtools/json-schema-ref-parser": "^9.0.6",
+    "@ffmpeg-installer/ffmpeg": "^1.0.20",
     "@joystream/types": "^0.14.0",
     "@oclif/command": "^1.5.19",
     "@oclif/config": "^1.14.0",
@@ -17,12 +18,21 @@
     "@oclif/plugin-not-found": "^1.2.4",
     "@oclif/plugin-warn-if-update-available": "^1.7.0",
     "@polkadot/api": "1.26.1",
+    "@types/fluent-ffmpeg": "^2.1.16",
     "@types/inquirer": "^6.5.0",
     "@types/proper-lockfile": "^4.1.1",
     "@types/slug": "^0.9.1",
     "ajv": "^6.11.0",
     "cli-ux": "^5.4.5",
+    "fluent-ffmpeg": "^2.1.2",
     "inquirer": "^7.1.0",
+    "ipfs-http-client": "^47.0.1",
+    "ipfs-only-hash": "^1.0.2",
+    "it-all": "^1.0.4",
+    "it-drain": "^1.0.3",
+    "it-first": "^1.0.4",
+    "it-last": "^1.0.4",
+    "it-to-buffer": "^1.0.4",
     "moment": "^2.24.0",
     "proper-lockfile": "^4.1.1",
     "slug": "^2.1.1",
@@ -90,6 +100,9 @@
       },
       "content-directory": {
         "description": "Interactions with content directory module - managing classes, schemas, entities and permissions"
+      },
+      "media": {
+        "description": "Higher-level content directory interactions, ie. publishing and curating content"
       }
     }
   },

+ 3 - 0
cli/src/@types/@ffmpeg-installer/ffmpeg/index.d.ts

@@ -0,0 +1,3 @@
+declare module '@ffmpeg-installer/ffmpeg' {
+  export const path: string
+}

+ 1 - 0
cli/src/@types/ipfs-http-client/index.d.ts

@@ -0,0 +1 @@
+declare module 'ipfs-http-client'

+ 1 - 0
cli/src/@types/ipfs-only-hash/index.d.ts

@@ -0,0 +1 @@
+declare module 'ipfs-only-hash'

+ 50 - 7
cli/src/Api.ts

@@ -32,6 +32,7 @@ import {
   RoleStakeProfile,
   Opening as WGOpening,
   Application as WGApplication,
+  StorageProviderId,
 } from '@joystream/types/working-group'
 import {
   Opening,
@@ -48,6 +49,9 @@ import { Stake, StakeId } from '@joystream/types/stake'
 
 import { InputValidationLengthConstraint } from '@joystream/types/common'
 import { Class, ClassId, CuratorGroup, CuratorGroupId, Entity, EntityId } from '@joystream/types/content-directory'
+import { ContentId, DataObject } from '@joystream/types/media'
+import { ServiceProviderRecord, Url } from '@joystream/types/discovery'
+import _ from 'lodash'
 
 export const DEFAULT_API_URI = 'ws://localhost:9944/'
 const DEFAULT_DECIMALS = new BN(12)
@@ -61,6 +65,7 @@ export const apiModuleByGroup: { [key in WorkingGroups]: string } = {
 // Api wrapper for handling most common api calls and allowing easy API implementation switch in the future
 export default class Api {
   private _api: ApiPromise
+  private _cdClassesCache: [ClassId, Class][] | null = null
 
   private constructor(originalApi: ApiPromise) {
     this._api = originalApi
@@ -70,9 +75,12 @@ export default class Api {
     return this._api
   }
 
-  private static async initApi(apiUri: string = DEFAULT_API_URI): Promise<ApiPromise> {
+  private static async initApi(
+    apiUri: string = DEFAULT_API_URI,
+    metadataCache: Record<string, any>
+  ): Promise<ApiPromise> {
     const wsProvider: WsProvider = new WsProvider(apiUri)
-    const api = await ApiPromise.create({ provider: wsProvider, types })
+    const api = await ApiPromise.create({ provider: wsProvider, types, metadata: metadataCache })
 
     // Initializing some api params based on pioneer/packages/react-api/Api.tsx
     const [properties] = await Promise.all([api.rpc.system.properties()])
@@ -89,8 +97,8 @@ export default class Api {
     return api
   }
 
-  static async create(apiUri: string = DEFAULT_API_URI): Promise<Api> {
-    const originalApi: ApiPromise = await Api.initApi(apiUri)
+  static async create(apiUri: string = DEFAULT_API_URI, metadataCache: Record<string, any>): Promise<Api> {
+    const originalApi: ApiPromise = await Api.initApi(apiUri, metadataCache)
     return new Api(originalApi)
   }
 
@@ -111,6 +119,10 @@ export default class Api {
     })
   }
 
+  async bestNumber(): Promise<number> {
+    return (await this._api.derive.chain.bestNumber()).toNumber()
+  }
+
   async getAccountsBalancesInfo(accountAddresses: string[]): Promise<DeriveBalancesAll[]> {
     const accountsBalances: DeriveBalancesAll[] = await Promise.all(
       accountAddresses.map((addr) => this._api.derive.balances.all(addr))
@@ -481,8 +493,10 @@ export default class Api {
   }
 
   // Content directory
-  availableClasses(): Promise<[ClassId, Class][]> {
-    return this.entriesByIds<ClassId, Class>(this._api.query.contentDirectory.classById)
+  async availableClasses(useCache = true): Promise<[ClassId, Class][]> {
+    return useCache && this._cdClassesCache
+      ? this._cdClassesCache
+      : (this._cdClassesCache = await this.entriesByIds<ClassId, Class>(this._api.query.contentDirectory.classById))
   }
 
   availableCuratorGroups(): Promise<[CuratorGroupId, CuratorGroup][]> {
@@ -490,7 +504,7 @@ export default class Api {
   }
 
   async curatorGroupById(id: number): Promise<CuratorGroup | null> {
-    const exists = !!(await this._api.query.contentDirectory.curatorGroupById.size(id))
+    const exists = !!(await this._api.query.contentDirectory.curatorGroupById.size(id)).toNumber()
     return exists ? await this._api.query.contentDirectory.curatorGroupById<CuratorGroup>(id) : null
   }
 
@@ -512,4 +526,33 @@ export default class Api {
     const exists = !!(await this._api.query.contentDirectory.curatorGroupById.size(id))
     return exists ? await this._api.query.contentDirectory.entityById<Entity>(id) : null
   }
+
+  async dataObjectByContentId(contentId: ContentId): Promise<DataObject | null> {
+    const dataObject = await this._api.query.dataDirectory.dataObjectByContentId<Option<DataObject>>(contentId)
+    return dataObject.unwrapOr(null)
+  }
+
+  async ipnsIdentity(storageProviderId: number): Promise<string | null> {
+    const accountInfo = await this._api.query.discovery.accountInfoByStorageProviderId<ServiceProviderRecord>(
+      storageProviderId
+    )
+    return accountInfo.isEmpty || accountInfo.expires_at.toNumber() <= (await this.bestNumber())
+      ? null
+      : accountInfo.identity.toString()
+  }
+
+  async getRandomBootstrapEndpoint(): Promise<string | null> {
+    const endpoints = await this._api.query.discovery.bootstrapEndpoints<Vec<Url>>()
+    const randomEndpoint = _.sample(endpoints.toArray())
+    return randomEndpoint ? randomEndpoint.toString() : null
+  }
+
+  async isAnyProviderAvailable(): Promise<boolean> {
+    const accounInfoEntries = await this.entriesByIds<StorageProviderId, ServiceProviderRecord>(
+      this._api.query.discovery.accountInfoByStorageProviderId
+    )
+
+    const bestNumber = await this.bestNumber()
+    return !!accounInfoEntries.filter(([, info]) => info.expires_at.toNumber() > bestNumber).length
+  }
 }

+ 2 - 0
cli/src/ExitCodes.ts

@@ -11,5 +11,7 @@ enum ExitCodes {
   UnexpectedException = 500,
   FsOperationFailed = 501,
   ApiError = 502,
+  ExternalInfrastructureError = 503,
+  ActionCurrentlyUnavailable = 504,
 }
 export = ExitCodes

+ 30 - 3
cli/src/base/AccountsCommandBase.ts

@@ -216,14 +216,41 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
   }
 
   async requestAccountDecoding(account: NamedKeyringPair): Promise<void> {
-    const password: string = await this.promptForPassword()
+    // Skip if account already unlocked
+    if (!account.isLocked) {
+      return
+    }
+
+    // First - try decoding using empty string
     try {
-      account.decodePkcs8(password)
+      account.decodePkcs8('')
+      return
     } catch (e) {
-      this.error('Invalid password!', { exit: ExitCodes.InvalidInput })
+      // Continue...
+    }
+
+    let isPassValid = false
+    while (!isPassValid) {
+      try {
+        const password = await this.promptForPassword()
+        account.decodePkcs8(password)
+        isPassValid = true
+      } catch (e) {
+        this.warn('Invalid password... Try again.')
+      }
     }
   }
 
+  async getRequiredMemberId(): Promise<number> {
+    const account = await this.getRequiredSelectedAccount()
+    const memberIds = await this.getApi().getMemberIdsByControllerAccount(account.address)
+    if (!memberIds.length) {
+      this.error('Membership required to access this command!', { exit: ExitCodes.AccessDenied })
+    }
+
+    return memberIds[0].toNumber() // FIXME: Temporary solution (just using the first one)
+  }
+
   async init() {
     await super.init()
     try {

+ 34 - 8
cli/src/base/ApiCommandBase.ts

@@ -2,7 +2,7 @@ import ExitCodes from '../ExitCodes'
 import { CLIError } from '@oclif/errors'
 import StateAwareCommandBase from './StateAwareCommandBase'
 import Api from '../Api'
-import { getTypeDef, Option, Tuple, Bytes } from '@polkadot/types'
+import { getTypeDef, Option, Tuple, Bytes, TypeRegistry } from '@polkadot/types'
 import { Registry, Codec, CodecArg, TypeDef, TypeDefInfo, Constructor } from '@polkadot/types/types'
 
 import { Vec, Struct, Enum } from '@polkadot/types/codec'
@@ -16,6 +16,7 @@ import { createParamOptions } from '../helpers/promptOptions'
 import { SubmittableExtrinsic } from '@polkadot/api/types'
 import { DistinctQuestion } from 'inquirer'
 import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
+import { DispatchError } from '@polkadot/types/interfaces/system'
 
 class ExtrinsicFailedError extends Error {}
 
@@ -51,7 +52,17 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
       this.warn("You haven't provided a node/endpoint for the CLI to connect to yet!")
       apiUri = await this.promptForApiUri()
     }
-    this.api = await Api.create(apiUri)
+
+    const { metadataCache } = this.getPreservedState()
+    this.api = await Api.create(apiUri, metadataCache)
+
+    const { genesisHash, runtimeVersion } = this.getOriginalApi()
+    const metadataKey = `${genesisHash}-${runtimeVersion.specVersion}`
+    if (!metadataCache[metadataKey]) {
+      // Add new entry to metadata cache
+      metadataCache[metadataKey] = await this.getOriginalApi().runtimeMetadata.toJSON()
+      await this.setPreservedState({ metadataCache })
+    }
   }
 
   async promptForApiUri(): Promise<string> {
@@ -405,11 +416,26 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
         if (result.status.isInBlock) {
           unsubscribe()
           result.events
-            .filter(({ event: { section } }): boolean => section === 'system')
-            .forEach(({ event: { method } }): void => {
-              if (method === 'ExtrinsicFailed') {
-                reject(new ExtrinsicFailedError('Extrinsic execution error!'))
-              } else if (method === 'ExtrinsicSuccess') {
+            .filter(({ event }) => event.section === 'system')
+            .forEach(({ event }) => {
+              if (event.method === 'ExtrinsicFailed') {
+                const dispatchError = event.data[0] as DispatchError
+                let errorMsg = dispatchError.toString()
+                if (dispatchError.isModule) {
+                  try {
+                    // Need to assert that registry is of TypeRegistry type, since Registry intefrace
+                    // seems outdated and doesn't include DispatchErrorModule as possible argument for "findMetaError"
+                    const { name, documentation } = (this.getOriginalApi().registry as TypeRegistry).findMetaError(
+                      dispatchError.asModule
+                    )
+                    errorMsg = `${name} (${documentation})`
+                  } catch (e) {
+                    // This probably means we don't have this error in the metadata
+                    // In this case - continue (we'll just display dispatchError.toString())
+                  }
+                }
+                reject(new ExtrinsicFailedError(`Extrinsic execution error: ${errorMsg}`))
+              } else if (event.method === 'ExtrinsicSuccess') {
                 resolve()
               }
             })
@@ -427,7 +453,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
   async sendAndFollowTx(
     account: KeyringPair,
     tx: SubmittableExtrinsic<'promise'>,
-    warnOnly = true // If specified - only warning will be displayed in case of failure (instead of error beeing thrown)
+    warnOnly = false // If specified - only warning will be displayed in case of failure (instead of error beeing thrown)
   ): Promise<void> {
     try {
       await this.sendExtrinsic(account, tx)

+ 126 - 9
cli/src/base/ContentDirectoryCommandBase.ts

@@ -2,14 +2,17 @@ import ExitCodes from '../ExitCodes'
 import AccountsCommandBase from './AccountsCommandBase'
 import { WorkingGroups, NamedKeyringPair } from '../Types'
 import { ReferenceProperty } from 'cd-schemas/types/extrinsics/AddClassSchema'
+import { FlattenRelations } from 'cd-schemas/types/utility'
 import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
-import { Class, ClassId, CuratorGroup, CuratorGroupId, Entity } from '@joystream/types/content-directory'
+import { Class, ClassId, CuratorGroup, CuratorGroupId, Entity, EntityId } from '@joystream/types/content-directory'
 import { Worker } from '@joystream/types/working-group'
 import { CLIError } from '@oclif/errors'
 import { Codec } from '@polkadot/types/types'
+import _ from 'lodash'
+import chalk from 'chalk'
 
 /**
- * Abstract base class for commands related to working groups
+ * Abstract base class for commands related to content directory
  */
 export default abstract class ContentDirectoryCommandBase extends AccountsCommandBase {
   // Use when lead access is required in given command
@@ -72,6 +75,9 @@ export default abstract class ContentDirectoryCommandBase extends AccountsComman
 
   async promptForCuratorGroups(message = 'Select Curator Groups'): Promise<number[]> {
     const choices = await this.curatorGroupChoices()
+    if (!choices.length) {
+      return []
+    }
     const selectedIds = await this.simplePrompt({ message, type: 'checkbox', choices })
 
     return selectedIds
@@ -83,15 +89,24 @@ export default abstract class ContentDirectoryCommandBase extends AccountsComman
     return { className: selectedClass.name.toString(), sameOwner }
   }
 
-  async promptForCurator(message = 'Choose a Curator'): Promise<number> {
+  async promptForCurator(message = 'Choose a Curator', ids?: number[]): Promise<number> {
     const curators = await this.getApi().groupMembers(WorkingGroups.Curators)
+    const choices = curators
+      .filter((c) => (ids ? ids.includes(c.workerId.toNumber()) : true))
+      .map((c) => ({
+        name: `${c.profile.handle.toString()} (Worker ID: ${c.workerId})`,
+        value: c.workerId.toNumber(),
+      }))
+
+    if (!choices.length) {
+      this.warn('No Curators to choose from!')
+      this.exit(ExitCodes.InvalidInput)
+    }
+
     const selectedCuratorId = await this.simplePrompt({
       message,
       type: 'list',
-      choices: curators.map((c) => ({
-        name: `${c.profile.handle.toString()} (Worker ID: ${c.workerId})`,
-        value: c.workerId,
-      })),
+      choices,
     })
 
     return selectedCuratorId
@@ -129,7 +144,7 @@ export default abstract class ContentDirectoryCommandBase extends AccountsComman
     return group
   }
 
-  async getEntity(id: string | number): Promise<Entity> {
+  async getEntity(id: string | number, requiredClass?: string, ownerMemberId?: number): Promise<Entity> {
     if (typeof id === 'string') {
       id = parseInt(id)
     }
@@ -137,12 +152,87 @@ export default abstract class ContentDirectoryCommandBase extends AccountsComman
     const entity = await this.getApi().entityById(id)
 
     if (!entity) {
-      this.error('Invalid entity id!', { exit: ExitCodes.InvalidInput })
+      this.error(`Entity not found by id: ${id}`, { exit: ExitCodes.InvalidInput })
+    }
+
+    if (requiredClass) {
+      const [classId] = await this.classEntryByNameOrId(requiredClass)
+      if (entity.class_id.toNumber() !== classId.toNumber()) {
+        this.error(`Entity of id ${id} is not of class ${requiredClass}!`, { exit: ExitCodes.InvalidInput })
+      }
+    }
+
+    const { controller } = entity.entity_permissions
+    if (
+      ownerMemberId !== undefined &&
+      (!controller.isOfType('Member') || controller.asType('Member').toNumber() !== ownerMemberId)
+    ) {
+      this.error('Cannot execute this action for specified entity - invalid ownership.', {
+        exit: ExitCodes.AccessDenied,
+      })
     }
 
     return entity
   }
 
+  async getAndParseKnownEntity<T>(id: string | number): Promise<FlattenRelations<T>> {
+    const entity = await this.getEntity(id)
+    return this.parseToKnownEntityJson<T>(entity)
+  }
+
+  async entitiesByClassAndOwner(classNameOrId: number | string, ownerMemberId?: number): Promise<[EntityId, Entity][]> {
+    const classId =
+      typeof classNameOrId === 'number' ? classNameOrId : (await this.classEntryByNameOrId(classNameOrId))[0].toNumber()
+
+    return (await this.getApi().entitiesByClassId(classId)).filter(([, entity]) => {
+      const controller = entity.entity_permissions.controller
+      return ownerMemberId !== undefined
+        ? controller.isOfType('Member') && controller.asType('Member').toNumber() === ownerMemberId
+        : true
+    })
+  }
+
+  async promptForEntityEntry(
+    message: string,
+    className: string,
+    propName?: string,
+    ownerMemberId?: number,
+    defaultId?: number
+  ): Promise<[EntityId, Entity]> {
+    const [classId, entityClass] = await this.classEntryByNameOrId(className)
+    const entityEntries = await this.entitiesByClassAndOwner(classId.toNumber(), ownerMemberId)
+
+    if (!entityEntries.length) {
+      this.log(`${message}:`)
+      this.error(`No choices available! Exiting...`, { exit: ExitCodes.UnexpectedException })
+    }
+
+    const choosenEntityId = await this.simplePrompt({
+      message,
+      type: 'list',
+      choices: entityEntries.map(([id, entity]) => {
+        const parsedEntityPropertyValues = this.parseEntityPropertyValues(entity, entityClass)
+        return {
+          name: (propName && parsedEntityPropertyValues[propName]?.value.toString()) || `ID:${id.toString()}`,
+          value: id.toString(), // With numbers there are issues with "default"
+        }
+      }),
+      default: defaultId?.toString(),
+    })
+
+    return entityEntries.find(([id]) => choosenEntityId === id.toString())!
+  }
+
+  async promptForEntityId(
+    message: string,
+    className: string,
+    propName?: string,
+    ownerMemberId?: number,
+    defaultId?: number
+  ): Promise<number> {
+    return (await this.promptForEntityEntry(message, className, propName, ownerMemberId, defaultId))[0].toNumber()
+  }
+
   parseEntityPropertyValues(
     entity: Entity,
     entityClass: Class,
@@ -163,4 +253,31 @@ export default abstract class ContentDirectoryCommandBase extends AccountsComman
       return columns
     }, {} as Record<string, { value: Codec; type: string }>)
   }
+
+  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()
+    ) as unknown) as FlattenRelations<T>
+  }
+
+  async createEntityList(
+    className: string,
+    includedProps?: string[],
+    filters: [string, string][] = [],
+    ownerMemberId?: number
+  ): Promise<Record<string, string>[]> {
+    const [classId, entityClass] = await this.classEntryByNameOrId(className)
+    const entityEntries = await this.entitiesByClassAndOwner(classId.toNumber(), ownerMemberId)
+    const parsedEntities = (await Promise.all(
+      entityEntries.map(([id, entity]) => ({
+        'ID': id.toString(),
+        ..._.mapValues(this.parseEntityPropertyValues(entity, entityClass, includedProps), (v) =>
+          v.value.toJSON() === false && v.type !== 'Single<Bool>' ? chalk.grey('[not set]') : v.value.toString()
+        ),
+      }))
+    )) as Record<string, string>[]
+
+    return parsedEntities.filter((entity) => filters.every(([pName, pValue]) => entity[pName] === pValue))
+  }
 }

+ 2 - 0
cli/src/base/StateAwareCommandBase.ts

@@ -11,12 +11,14 @@ import _ from 'lodash'
 type StateObject = {
   selectedAccountFilename: string
   apiUri: string
+  metadataCache: Record<string, any>
 }
 
 // State object default values
 const DEFAULT_STATE: StateObject = {
   selectedAccountFilename: '',
   apiUri: '',
+  metadataCache: {},
 }
 
 // State file path (relative to getAppDataPath())

+ 22 - 10
cli/src/commands/content-directory/addClassSchema.ts

@@ -17,10 +17,11 @@ export default class AddClassSchemaCommand extends ContentDirectoryCommandBase {
   async run() {
     const account = await this.getRequiredSelectedAccount()
     await this.requireLead()
+    await this.requestAccountDecoding(account)
 
     const { input, output } = this.parse(AddClassSchemaCommand).flags
 
-    let inputJson = getInputJson<AddClassSchema>(input)
+    let inputJson = await getInputJson<AddClassSchema>(input)
     if (!inputJson) {
       let selectedClass: Class | undefined
       const customPrompts: JsonSchemaCustomPrompts = [
@@ -33,14 +34,27 @@ export default class AddClassSchemaCommand extends ContentDirectoryCommandBase {
         ],
         [
           'existingProperties',
-          async () =>
-            this.simplePrompt({
+          async () => {
+            const choices = selectedClass!.properties.map((p, i) => ({ name: `${i}: ${p.name.toString()}`, value: i }))
+            if (!choices.length) {
+              return []
+            }
+            return await this.simplePrompt({
               type: 'checkbox',
               message: 'Choose existing properties to keep',
-              choices: selectedClass!.properties.map((p, i) => ({ name: `${i}: ${p.name.toString()}`, value: i })),
-            }),
+              choices,
+            })
+          },
+        ],
+        [
+          /^newProperties\[\d+\]\.property_type\.(Single|Vector\.vec_type)\.Reference/,
+          async () => this.promptForClassReference(),
+        ],
+        [/^newProperties\[\d+\]\.property_type\.(Single|Vector\.vec_type)\.Text/, { message: 'Provide TextMaxLength' }],
+        [
+          /^newProperties\[\d+\]\.property_type\.(Single|Vector\.vec_type)\.Hash/,
+          { message: 'Provide HashedTextMaxLength' },
         ],
-        [/^newProperties\[\d+\]\.property_type\.Single\.Reference/, async () => this.promptForClassReference()],
       ]
 
       const prompter = new JsonSchemaPrompter<AddClassSchema>(
@@ -56,12 +70,10 @@ export default class AddClassSchemaCommand extends ContentDirectoryCommandBase {
     const confirmed = await this.simplePrompt({ type: 'confirm', message: 'Do you confirm the provided input?' })
 
     if (confirmed) {
-      await this.requestAccountDecoding(account)
+      saveOutputJson(output, `${inputJson.className}Schema.json`, inputJson)
       const inputParser = new InputParser(this.getOriginalApi())
       this.log('Sending the extrinsic...')
-      await this.sendAndFollowTx(account, await inputParser.parseAddClassSchemaExtrinsic(inputJson), true)
-
-      saveOutputJson(output, `${inputJson.className}Schema.json`, inputJson)
+      await this.sendAndFollowTx(account, await inputParser.parseAddClassSchemaExtrinsic(inputJson))
     }
   }
 }

+ 2 - 1
cli/src/commands/content-directory/class.ts

@@ -37,7 +37,8 @@ export default class ClassCommand extends ContentDirectoryCommandBase {
     displayHeader(`Properties`)
     if (aClass.properties.length) {
       displayTable(
-        aClass.properties.map((p) => ({
+        aClass.properties.map((p, i) => ({
+          'Index': i,
           'Name': p.name.toString(),
           'Type': JSON.stringify(p.property_type.toJSON()),
           'Required': p.required.toString(),

+ 11 - 5
cli/src/commands/content-directory/createClass.ts

@@ -15,12 +15,20 @@ export default class CreateClassCommand extends ContentDirectoryCommandBase {
   async run() {
     const account = await this.getRequiredSelectedAccount()
     await this.requireLead()
+    await this.requestAccountDecoding(account)
 
     const { input, output } = this.parse(CreateClassCommand).flags
+    const existingClassnames = (await this.getApi().availableClasses()).map(([, aClass]) => aClass.name.toString())
 
-    let inputJson = getInputJson<CreateClass>(input)
+    let inputJson = await getInputJson<CreateClass>(input, CreateClassSchema as JSONSchema)
     if (!inputJson) {
-      const customPrompts: JsonSchemaCustomPrompts = [
+      const customPrompts: JsonSchemaCustomPrompts<CreateClass> = [
+        [
+          'name',
+          {
+            validate: (className) => existingClassnames.includes(className) && 'A class with this name already exists!',
+          },
+        ],
         ['class_permissions.maintainers', () => this.promptForCuratorGroups('Select class maintainers')],
       ]
 
@@ -33,12 +41,10 @@ export default class CreateClassCommand extends ContentDirectoryCommandBase {
     const confirmed = await this.simplePrompt({ type: 'confirm', message: 'Do you confirm the provided input?' })
 
     if (confirmed) {
-      await this.requestAccountDecoding(account)
+      saveOutputJson(output, `${inputJson.name}Class.json`, inputJson)
       this.log('Sending the extrinsic...')
       const inputParser = new InputParser(this.getOriginalApi())
       await this.sendAndFollowTx(account, inputParser.parseCreateClassExtrinsic(inputJson))
-
-      saveOutputJson(output, `${inputJson.name}Class.json`, inputJson)
     }
   }
 }

+ 13 - 9
cli/src/commands/content-directory/curatorGroups.ts

@@ -8,14 +8,18 @@ export default class CuratorGroupsCommand extends ContentDirectoryCommandBase {
   async run() {
     const groups = await this.getApi().availableCuratorGroups()
 
-    displayTable(
-      groups.map(([id, group]) => ({
-        'ID': id.toString(),
-        'Status': group.active.valueOf() ? 'Active' : 'Inactive',
-        'Classes maintained': group.number_of_classes_maintained.toNumber(),
-        'Members': group.curators.toArray().length,
-      })),
-      5
-    )
+    if (groups.length) {
+      displayTable(
+        groups.map(([id, group]) => ({
+          'ID': id.toString(),
+          'Status': group.active.valueOf() ? 'Active' : 'Inactive',
+          'Classes maintained': group.number_of_classes_maintained.toNumber(),
+          'Members': group.curators.toArray().length,
+        })),
+        5
+      )
+    } else {
+      this.log('No Curator Groups available!')
+    }
   }
 }

+ 22 - 17
cli/src/commands/content-directory/entities.ts

@@ -1,8 +1,8 @@
 import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
 import { displayTable } from '../../helpers/display'
-import _ from 'lodash'
+import { flags } from '@oclif/command'
 
-export default class ClassCommand extends ContentDirectoryCommandBase {
+export default class EntitiesCommand extends ContentDirectoryCommandBase {
   static description = 'Show entities list by class id or name.'
   static args = [
     {
@@ -19,22 +19,27 @@ export default class ClassCommand extends ContentDirectoryCommandBase {
     },
   ]
 
+  static flags = {
+    filters: flags.string({
+      required: false,
+      description:
+        'Comma-separated filters, ie. title="Some video",channelId=3.' +
+        'Currently only the = operator is supported.' +
+        'When multiple filters are provided, only the entities that match all of them together will be displayed.',
+    }),
+  }
+
   async run() {
-    const { className, properties } = this.parse(ClassCommand).args
-    const [classId, entityClass] = await this.classEntryByNameOrId(className)
-    const entityEntries = await this.getApi().entitiesByClassId(classId.toNumber())
-    const propertiesToInclude = properties && (properties as string).split(',')
+    const { className, properties } = this.parse(EntitiesCommand).args
+    const { filters } = this.parse(EntitiesCommand).flags
+    const propsToInclude: string[] | undefined = (properties || undefined) && (properties as string).split(',')
+    const filtersArr: [string, string][] = filters
+      ? filters
+          .split(',')
+          .map((f) => f.split('='))
+          .map(([pName, pValue]) => [pName, pValue.replace(/^"(.+)"$/, '$1')])
+      : []
 
-    displayTable(
-      await Promise.all(
-        entityEntries.map(([id, entity]) => ({
-          'ID': id.toString(),
-          ..._.mapValues(this.parseEntityPropertyValues(entity, entityClass, propertiesToInclude), (v) =>
-            v.value.toString()
-          ),
-        }))
-      ),
-      3
-    )
+    displayTable(await this.createEntityList(className, propsToInclude, filtersArr), 3)
   }
 }

+ 8 - 1
cli/src/commands/content-directory/entity.ts

@@ -32,6 +32,13 @@ export default class EntityCommand extends ContentDirectoryCommandBase {
       'Total references': entity.reference_counter.total.toNumber(),
     })
     displayHeader('Property values')
-    displayCollapsedRow(_.mapValues(propertyValues, (v) => `${v.value.toString()} ${chalk.green(`${v.type}`)}`))
+    displayCollapsedRow(
+      _.mapValues(
+        propertyValues,
+        (v) =>
+          (v.value.toJSON() === false && v.type !== 'Single<Bool>' ? chalk.grey('[not set]') : v.value.toString()) +
+          ` ${chalk.green(`${v.type}`)}`
+      )
+    )
   }
 }

+ 46 - 0
cli/src/commands/content-directory/removeCuratorFromGroup.ts

@@ -0,0 +1,46 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+
+export default class RemoveCuratorFromGroupCommand extends ContentDirectoryCommandBase {
+  static description = 'Remove Curator from Curator Group.'
+  static args = [
+    {
+      name: 'groupId',
+      required: false,
+      description: 'ID of the Curator Group',
+    },
+    {
+      name: 'curatorId',
+      required: false,
+      description: 'ID of the curator',
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    await this.requireLead()
+
+    let { groupId, curatorId } = this.parse(RemoveCuratorFromGroupCommand).args
+
+    if (groupId === undefined) {
+      groupId = await this.promptForCuratorGroup()
+    }
+
+    const group = await this.getCuratorGroup(groupId)
+    const groupCuratorIds = group.curators.toArray().map((id) => id.toNumber())
+
+    if (curatorId === undefined) {
+      curatorId = await this.promptForCurator('Choose a Curator to remove', groupCuratorIds)
+    } else {
+      if (!groupCuratorIds.includes(parseInt(curatorId))) {
+        this.error(`Curator ${chalk.white(curatorId)} is not part of group ${chalk.white(groupId)}`)
+      }
+      await this.getCurator(curatorId)
+    }
+
+    await this.requestAccountDecoding(account)
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'removeCuratorFromGroup', [groupId, curatorId])
+
+    this.log(chalk.green(`Curator ${chalk.white(curatorId)} successfully removed from group ${chalk.white(groupId)}!`))
+  }
+}

+ 7 - 2
cli/src/commands/content-directory/removeCuratorGroup.ts

@@ -1,5 +1,6 @@
 import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
 import chalk from 'chalk'
+import ExitCodes from '../../ExitCodes'
 
 export default class AddCuratorGroupCommand extends ContentDirectoryCommandBase {
   static description = 'Remove existing Curator Group.'
@@ -18,8 +19,12 @@ export default class AddCuratorGroupCommand extends ContentDirectoryCommandBase
     let { id } = this.parse(AddCuratorGroupCommand).args
     if (id === undefined) {
       id = await this.promptForCuratorGroup('Select Curator Group to remove')
-    } else {
-      await this.getCuratorGroup(id)
+    }
+
+    const group = await this.getCuratorGroup(id)
+
+    if (group.number_of_classes_maintained.toNumber() > 0) {
+      this.error('Cannot remove a group which has some maintained classes!', { exit: ExitCodes.InvalidInput })
     }
 
     await this.requestAccountDecoding(account)

+ 53 - 0
cli/src/commands/media/createChannel.ts

@@ -0,0 +1,53 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import ChannelEntitySchema from 'cd-schemas/schemas/entities/ChannelEntity.schema.json'
+import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+import { InputParser } from 'cd-schemas'
+import { IOFlags, getInputJson, saveOutputJson } from '../../helpers/InputOutput'
+import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
+
+export default class CreateChannelCommand extends ContentDirectoryCommandBase {
+  static description = 'Create a new channel on Joystream (requires a membership).'
+  static flags = {
+    ...IOFlags,
+  }
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    const memberId = await this.getRequiredMemberId()
+    const actor = { Member: memberId }
+
+    await this.requestAccountDecoding(account)
+
+    const channelJsonSchema = (ChannelEntitySchema as unknown) as JSONSchema
+
+    const { input, output } = this.parse(CreateChannelCommand).flags
+
+    let inputJson = await getInputJson<ChannelEntity>(input, channelJsonSchema)
+    if (!inputJson) {
+      const customPrompts: JsonSchemaCustomPrompts = [
+        ['language', () => this.promptForEntityId('Choose channel language', 'Language', 'name')],
+        ['curationStatus', async () => undefined],
+      ]
+
+      const prompter = new JsonSchemaPrompter<ChannelEntity>(channelJsonSchema, undefined, customPrompts)
+
+      inputJson = await prompter.promptAll()
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(inputJson))
+    const confirmed = await this.simplePrompt({ type: 'confirm', message: 'Do you confirm the provided input?' })
+
+    if (confirmed) {
+      saveOutputJson(output, `${inputJson.title}Channel.json`, inputJson)
+      const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi(), [
+        {
+          className: 'Channel',
+          entries: [inputJson],
+        },
+      ])
+      const operations = await inputParser.getEntityBatchOperations()
+      await this.sendAndFollowNamedTx(account, 'contentDirectory', 'transaction', [actor, operations])
+    }
+  }
+}

+ 25 - 0
cli/src/commands/media/myChannels.ts

@@ -0,0 +1,25 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+import { displayTable } from '../../helpers/display'
+import chalk from 'chalk'
+
+export default class MyChannelsCommand extends ContentDirectoryCommandBase {
+  static description = "Show the list of channels associated with current account's membership."
+
+  async run() {
+    const memberId = await this.getRequiredMemberId()
+
+    const props: (keyof ChannelEntity)[] = ['title', 'isPublic']
+
+    const list = await this.createEntityList('Channel', props, [], memberId)
+
+    if (list.length) {
+      displayTable(list, 3)
+      this.log(
+        `\nTIP: Use ${chalk.bold('content-directory:entity ID')} command to see more details about given channel`
+      )
+    } else {
+      this.log(`No channels created yet! Create a channel with ${chalk.bold('media:createChannel')}`)
+    }
+  }
+}

+ 33 - 0
cli/src/commands/media/myVideos.ts

@@ -0,0 +1,33 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { VideoEntity } from 'cd-schemas/types/entities/VideoEntity'
+import { displayTable } from '../../helpers/display'
+import chalk from 'chalk'
+import { flags } from '@oclif/command'
+
+export default class MyVideosCommand extends ContentDirectoryCommandBase {
+  static description = "Show the list of videos associated with current account's membership."
+  static flags = {
+    channel: flags.integer({
+      char: 'c',
+      required: false,
+      description: 'Channel id to filter the videos by',
+    }),
+  }
+
+  async run() {
+    const memberId = await this.getRequiredMemberId()
+
+    const { channel } = this.parse(MyVideosCommand).flags
+    const props: (keyof VideoEntity)[] = ['title', 'isPublic', 'channel']
+    const filters: [string, string][] = channel !== undefined ? [['channel', channel.toString()]] : []
+
+    const list = await this.createEntityList('Video', props, filters, memberId)
+
+    if (list.length) {
+      displayTable(list, 3)
+      this.log(`\nTIP: Use ${chalk.bold('content-directory:entity ID')} command to see more details about given video`)
+    } else {
+      this.log(`No videos uploaded yet! Upload a video with ${chalk.bold('media:uploadVideo')}`)
+    }
+  }
+}

+ 77 - 0
cli/src/commands/media/updateChannel.ts

@@ -0,0 +1,77 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import ChannelEntitySchema from 'cd-schemas/schemas/entities/ChannelEntity.schema.json'
+import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+import { InputParser } from 'cd-schemas'
+import { IOFlags, getInputJson, saveOutputJson } from '../../helpers/InputOutput'
+import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
+import { Entity } from '@joystream/types/content-directory'
+
+export default class UpdateChannelCommand extends ContentDirectoryCommandBase {
+  static description = 'Update one of the owned channels on Joystream (requires a membership).'
+  static flags = {
+    ...IOFlags,
+  }
+
+  static args = [
+    {
+      name: 'id',
+      description: 'ID of the channel to update',
+      required: false,
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    const memberId = await this.getRequiredMemberId()
+    const actor = { Member: memberId }
+
+    await this.requestAccountDecoding(account)
+
+    const { id } = this.parse(UpdateChannelCommand).args
+
+    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 update', 'Channel', 'title', memberId)
+      channelId = id.toNumber()
+      channelEntity = channel
+    }
+
+    const currentValues = await this.parseToKnownEntityJson<ChannelEntity>(channelEntity)
+    this.jsonPrettyPrint(JSON.stringify(currentValues))
+
+    const channelJsonSchema = (ChannelEntitySchema as unknown) as JSONSchema
+
+    const { input, output } = this.parse(UpdateChannelCommand).flags
+
+    let inputJson = await getInputJson<ChannelEntity>(input, channelJsonSchema)
+    if (!inputJson) {
+      const customPrompts: JsonSchemaCustomPrompts = [
+        [
+          'language',
+          () =>
+            this.promptForEntityId('Choose channel language', 'Language', 'name', undefined, currentValues.language),
+        ],
+        ['curationStatus', async () => undefined],
+      ]
+
+      const prompter = new JsonSchemaPrompter<ChannelEntity>(channelJsonSchema, currentValues, customPrompts)
+
+      inputJson = await prompter.promptAll()
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(inputJson))
+    const confirmed = await this.simplePrompt({ type: 'confirm', message: 'Do you confirm the provided input?' })
+
+    if (confirmed) {
+      saveOutputJson(output, `${inputJson.title}Channel.json`, inputJson)
+      const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi())
+      const updateOperations = await inputParser.getEntityUpdateOperations(inputJson, 'Channel', channelId)
+      this.log('Sending the extrinsic...')
+      await this.sendAndFollowNamedTx(account, 'contentDirectory', 'transaction', [actor, updateOperations])
+    }
+  }
+}

+ 96 - 0
cli/src/commands/media/updateVideo.ts

@@ -0,0 +1,96 @@
+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 { Entity } from '@joystream/types/content-directory'
+
+export default class UpdateVideoCommand extends ContentDirectoryCommandBase {
+  static description = 'Update existing video information (requires a membership).'
+  static flags = {
+    // TODO: ...IOFlags, - providing input as json
+  }
+
+  static args = [
+    {
+      name: 'id',
+      description: 'ID of the Video to update',
+      required: false,
+    },
+  ]
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    const memberId = await this.getRequiredMemberId()
+    const actor = { Member: memberId }
+
+    await this.requestAccountDecoding(account)
+
+    const { id } = this.parse(UpdateVideoCommand).args
+
+    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 currentValues = await this.parseToKnownEntityJson<VideoEntity>(videoEntity)
+    const videoJsonSchema = (VideoEntitySchema as unknown) as JSONSchema
+
+    const { language: currLanguageId, category: currCategoryId, license: currLicenseId } = currentValues
+
+    const customizedPrompts: JsonSchemaCustomPrompts<VideoEntity> = [
+      [
+        'language',
+        () => this.promptForEntityId('Choose Video language', 'Language', 'name', undefined, currLanguageId),
+      ],
+      [
+        'category',
+        () => this.promptForEntityId('Choose Video category', 'ContentCategory', 'name', undefined, currCategoryId),
+      ],
+    ]
+    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',
+      'category',
+      'title',
+      'description',
+      'thumbnailURL',
+      'duration',
+      'isPublic',
+      'hasMarketing',
+    ])
+
+    this.jsonPrettyPrint(JSON.stringify(updatedProps))
+
+    // 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)
+  }
+}

+ 372 - 0
cli/src/commands/media/uploadVideo.ts

@@ -0,0 +1,372 @@
+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'
+import { VideoMediaEntity } from 'cd-schemas/types/entities/VideoMediaEntity'
+import { InputParser } from 'cd-schemas'
+import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+import { JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
+import { flags } from '@oclif/command'
+import fs from 'fs'
+import ExitCodes from '../../ExitCodes'
+import { ContentId } from '@joystream/types/media'
+import ipfsHash from 'ipfs-only-hash'
+import { cli } from 'cli-ux'
+import axios, { AxiosRequestConfig } from 'axios'
+import { URL } from 'url'
+import ipfsHttpClient from 'ipfs-http-client'
+import first from 'it-first'
+import last from 'it-last'
+import toBuffer from 'it-to-buffer'
+import ffmpegInstaller from '@ffmpeg-installer/ffmpeg'
+import ffmpeg from 'fluent-ffmpeg'
+
+ffmpeg.setFfmpegPath(ffmpegInstaller.path)
+
+const DATA_OBJECT_TYPE_ID = 1
+const MAX_FILE_SIZE = 500 * 1024 * 1024
+
+type VideoMetadata = {
+  width?: number
+  height?: number
+  codecName?: string
+  codecFullName?: string
+  duration?: number
+}
+
+export default class UploadVideoCommand extends ContentDirectoryCommandBase {
+  static description = 'Upload a new Video to a channel (requires a membership).'
+  static flags = {
+    // TODO: ...IOFlags, - providing input as json
+    channel: flags.integer({
+      char: 'c',
+      required: false,
+      description:
+        'ID of the channel to assign the video to (if omitted - one of the owned channels can be selected from the list)',
+    }),
+  }
+
+  static args = [
+    {
+      name: 'filePath',
+      required: true,
+      description: 'Path to the media file to upload',
+    },
+  ]
+
+  private createReadStreamWithProgressBar(filePath: string, barTitle: string, fileSize?: number) {
+    // Progress CLI UX:
+    // https://github.com/oclif/cli-ux#cliprogress
+    // https://www.npmjs.com/package/cli-progress
+    if (!fileSize) {
+      fileSize = fs.statSync(filePath).size
+    }
+    const progress = cli.progress({ format: `${barTitle} | {bar} | {value}/{total} KB processed` })
+    let processedKB = 0
+    const fileSizeKB = Math.ceil(fileSize / 1024)
+    progress.start(fileSizeKB, processedKB)
+    return {
+      fileStream: fs
+        .createReadStream(filePath)
+        .pause() // Explicitly pause to prevent switching to flowing mode (https://nodejs.org/api/stream.html#stream_event_data)
+        .on('error', () => {
+          progress.stop()
+          this.error(`Error while trying to read data from: ${filePath}!`, {
+            exit: ExitCodes.FsOperationFailed,
+          })
+        })
+        .on('data', (data) => {
+          processedKB += data.length / 1024
+          progress.update(processedKB)
+        })
+        .on('end', () => {
+          progress.update(fileSizeKB)
+          progress.stop()
+        }),
+      progressBar: progress,
+    }
+  }
+
+  private async calculateFileIpfsHash(filePath: string, fileSize: number): Promise<string> {
+    const { fileStream } = this.createReadStreamWithProgressBar(filePath, 'Calculating file hash', fileSize)
+    const hash: string = await ipfsHash.of(fileStream)
+
+    return hash
+  }
+
+  private async getDiscoveryDataViaLocalIpfsNode(ipnsIdentity: string): Promise<any> {
+    const ipfs = ipfsHttpClient({
+      // TODO: Allow customizing node url:
+      // host: 'localhost', port: '5001', protocol: 'http',
+      timeout: 10000,
+    })
+
+    const ipnsAddress = `/ipns/${ipnsIdentity}/`
+    const ipfsName = await last(
+      ipfs.name.resolve(ipnsAddress, {
+        recursive: false,
+        nocache: false,
+      })
+    )
+    const data: any = await first(ipfs.get(ipfsName))
+    const buffer = await toBuffer(data.content)
+
+    return JSON.parse(buffer.toString())
+  }
+
+  private async getDiscoveryDataViaBootstrapEndpoint(storageProviderId: number): Promise<any> {
+    const bootstrapEndpoint = await this.getApi().getRandomBootstrapEndpoint()
+    if (!bootstrapEndpoint) {
+      this.error('No bootstrap endpoints available', { exit: ExitCodes.ApiError })
+    }
+    this.log('Bootstrap endpoint:', bootstrapEndpoint)
+    const discoveryEndpoint = new URL(`/discover/v0/${storageProviderId}`, bootstrapEndpoint).toString()
+    try {
+      const data = (await axios.get(discoveryEndpoint)).data
+      return data
+    } catch (e) {
+      this.error(`Cannot retrieve data from bootstrap enpoint (${discoveryEndpoint})`, {
+        exit: ExitCodes.ExternalInfrastructureError,
+      })
+    }
+  }
+
+  private async getUploadUrlFromDiscoveryData(data: any, contentId: ContentId): Promise<string> {
+    if (typeof data === 'object' && data !== null && data.serialized) {
+      const unserialized = JSON.parse(data.serialized)
+      if (unserialized.asset && unserialized.asset.endpoint && typeof unserialized.asset.endpoint === 'string') {
+        return new URL(`/asset/v0/${contentId.encode()}`, unserialized.asset.endpoint).toString()
+      }
+    }
+    this.error(`Unexpected discovery data: ${JSON.stringify(data)}`)
+  }
+
+  private async getUploadUrl(ipnsIdentity: string, storageProviderId: number, contentId: ContentId): Promise<string> {
+    let data: any
+    try {
+      this.log('Trying to connect to local ipfs node...')
+      data = await this.getDiscoveryDataViaLocalIpfsNode(ipnsIdentity)
+    } catch (e) {
+      this.warn("Couldn't get data from local ipfs node, resolving to bootstrap endpoint...")
+      data = await this.getDiscoveryDataViaBootstrapEndpoint(storageProviderId)
+    }
+
+    const uploadUrl = await this.getUploadUrlFromDiscoveryData(data, contentId)
+
+    return uploadUrl
+  }
+
+  private async getVideoMetadata(filePath: string): Promise<VideoMetadata | null> {
+    let metadata: VideoMetadata | null = null
+    const metadataPromise = new Promise<VideoMetadata>((resolve, reject) => {
+      ffmpeg.ffprobe(filePath, (err, data) => {
+        if (err) {
+          reject(err)
+          return
+        }
+        const videoStream = data.streams.find((s) => s.codec_type === 'video')
+        if (videoStream) {
+          resolve({
+            width: videoStream.width,
+            height: videoStream.height,
+            codecName: videoStream.codec_name,
+            codecFullName: videoStream.codec_long_name,
+            duration: videoStream.duration !== undefined ? Math.ceil(Number(videoStream.duration)) || 0 : undefined,
+          })
+        } else {
+          reject(new Error('No video stream found in file'))
+        }
+      })
+    })
+
+    try {
+      metadata = await metadataPromise
+    } catch (e) {
+      const message = e.message || e
+      this.warn(`Failed to get video metadata via ffprobe (${message})`)
+    }
+
+    return metadata
+  }
+
+  private async uploadVideo(filePath: string, fileSize: number, uploadUrl: string) {
+    const { fileStream, progressBar } = this.createReadStreamWithProgressBar(filePath, 'Uploading', fileSize)
+    fileStream.on('end', () => {
+      cli.action.start('Waiting for the file to be processed...')
+    })
+
+    try {
+      const config: AxiosRequestConfig = {
+        headers: {
+          'Content-Type': '', // https://github.com/Joystream/storage-node-joystream/issues/16
+          'Content-Length': fileSize.toString(),
+        },
+        maxContentLength: MAX_FILE_SIZE,
+      }
+      await axios.put(uploadUrl, fileStream, config)
+      cli.action.stop()
+
+      this.log('File uploaded!')
+    } catch (e) {
+      progressBar.stop()
+      cli.action.stop()
+      const msg = (e.response && e.response.data && e.response.data.message) || e.message || e
+      this.error(`Unexpected error when trying to upload a file: ${msg}`, {
+        exit: ExitCodes.ExternalInfrastructureError,
+      })
+    }
+  }
+
+  async run() {
+    const account = await this.getRequiredSelectedAccount()
+    const memberId = await this.getRequiredMemberId()
+    const actor = { Member: memberId }
+
+    await this.requestAccountDecoding(account)
+
+    const {
+      args: { filePath },
+      flags: { channel: inputChannelId },
+    } = this.parse(UploadVideoCommand)
+
+    // Basic file validation
+    if (!fs.existsSync(filePath)) {
+      this.error('File does not exist under provided path!', { exit: ExitCodes.FileNotFound })
+    }
+
+    const { size: fileSize } = fs.statSync(filePath)
+    if (fileSize > MAX_FILE_SIZE) {
+      this.error(`File size too large! Max. file size is: ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(2)} MB`)
+    }
+
+    const videoMetadata = await this.getVideoMetadata(filePath)
+    this.log('Video media file parameters established:', { ...(videoMetadata || {}), size: fileSize })
+
+    // Check if any providers are available
+    if (!(await this.getApi().isAnyProviderAvailable())) {
+      this.error('No active storage providers available! Try again later...', {
+        exit: ExitCodes.ActionCurrentlyUnavailable,
+      })
+    }
+
+    // Start by prompting for a channel to make sure user has one available
+    let channelId: number
+    if (inputChannelId === undefined) {
+      channelId = await this.promptForEntityId(
+        'Select a channel to publish the video under',
+        'Channel',
+        'title',
+        memberId
+      )
+    } else {
+      await this.getEntity(inputChannelId, 'Channel', memberId) // Validates if exists and belongs to member
+      channelId = inputChannelId
+    }
+
+    // Calculate hash and create content id
+    const contentId = ContentId.generate(this.getTypesRegistry())
+    const ipfsCid = await this.calculateFileIpfsHash(filePath, fileSize)
+
+    this.log('Video identification established:', {
+      contentId: contentId.toString(),
+      encodedContentId: contentId.encode(),
+      ipfsHash: ipfsCid,
+    })
+
+    // Send dataDirectory.addContent extrinsic
+    await this.sendAndFollowNamedTx(account, 'dataDirectory', 'addContent', [
+      memberId,
+      contentId,
+      DATA_OBJECT_TYPE_ID,
+      fileSize,
+      ipfsCid,
+    ])
+
+    const dataObject = await this.getApi().dataObjectByContentId(contentId)
+    if (!dataObject) {
+      this.error('Data object could not be retrieved from chain', { exit: ExitCodes.ApiError })
+    }
+
+    this.log('Data object:', dataObject.toJSON())
+
+    // Get storage provider identity
+    const storageProviderId = dataObject.liaison.toNumber()
+    const ipnsIdentity = await this.getApi().ipnsIdentity(storageProviderId)
+
+    if (!ipnsIdentity) {
+      this.error('Storage provider IPNS identity could not be determined', { exit: ExitCodes.ApiError })
+    }
+
+    // Resolve upload url and upload the video
+    const uploadUrl = await this.getUploadUrl(ipnsIdentity, storageProviderId, contentId)
+    this.log('Resolved upload url:', uploadUrl)
+
+    await this.uploadVideo(filePath, fileSize, uploadUrl)
+
+    // Prompting for the data:
+
+    // Set the defaults
+    const videoMediaDefaults: Partial<VideoMediaEntity> = {
+      pixelWidth: videoMetadata?.width,
+      pixelHeight: videoMetadata?.height,
+    }
+    const videoDefaults: Partial<VideoEntity> = {
+      duration: videoMetadata?.duration,
+    }
+    // Create prompting helpers
+    const videoJsonSchema = (VideoEntitySchema as unknown) as JSONSchema
+    const videoMediaJsonSchema = (VideoMediaEntitySchema as unknown) as JSONSchema
+
+    const videoMediaPrompter = new JsonSchemaPrompter<VideoMediaEntity>(videoMediaJsonSchema, videoMediaDefaults)
+    const videoPrompter = new JsonSchemaPrompter<VideoEntity>(videoJsonSchema, videoDefaults)
+
+    // Prompt for the data
+    const encodingSuggestion =
+      videoMetadata && videoMetadata.codecFullName ? ` (suggested: ${videoMetadata.codecFullName})` : ''
+    const encoding = await this.promptForEntityId(
+      `Choose Video encoding${encodingSuggestion}`,
+      'VideoMediaEncoding',
+      'name'
+    )
+    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',
+      'thumbnailURL',
+      'duration',
+      'isPublic',
+      'isExplicit',
+      'hasMarketing',
+    ])
+
+    // Create final inputs
+    const videoMediaInput: VideoMediaEntity = {
+      encoding,
+      pixelWidth,
+      pixelHeight,
+      size: fileSize,
+      location: { new: { joystreamMediaLocation: { new: { dataObjectId: contentId.encode() } } } },
+    }
+    const videoInput: VideoEntity = {
+      ...videoProps,
+      channel: channelId,
+      language,
+      category,
+      license: { new: { knownLicense: license } },
+      media: { new: videoMediaInput },
+    }
+
+    // Parse inputs into operations and send final extrinsic
+    const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi(), [
+      {
+        className: 'Video',
+        entries: [videoInput],
+      },
+    ])
+    const operations = await inputParser.getEntityBatchOperations()
+    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'transaction', [actor, operations])
+  }
+}

+ 10 - 4
cli/src/helpers/InputOutput.ts

@@ -4,9 +4,13 @@ import ExitCodes from '../ExitCodes'
 import fs from 'fs'
 import path from 'path'
 import Ajv from 'ajv'
-import { JSONSchema7 } from 'json-schema'
+import $RefParser, { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+import { getSchemasLocation } from 'cd-schemas'
 import chalk from 'chalk'
 
+// Default schema path for resolving refs
+const DEFAULT_SCHEMA_PATH = getSchemasLocation('entities') + path.sep
+
 export const IOFlags = {
   input: flags.string({
     char: 'i',
@@ -16,11 +20,12 @@ export const IOFlags = {
   output: flags.string({
     char: 'o',
     required: false,
-    description: 'Path where the output JSON file should be placed (can be then reused as input)',
+    description:
+      'Path to the directory where the output JSON file should be placed (the output file can be then reused as input)',
   }),
 }
 
-export function getInputJson<T>(inputPath?: string, schema?: JSONSchema7): T | null {
+export async function getInputJson<T>(inputPath?: string, schema?: JSONSchema, schemaPath?: string): Promise<T | null> {
   if (inputPath) {
     let content, jsonObj
     try {
@@ -35,7 +40,8 @@ export function getInputJson<T>(inputPath?: string, schema?: JSONSchema7): T | n
     }
     if (schema) {
       const ajv = new Ajv()
-      const valid = ajv.validate(schema, jsonObj)
+      schema = await $RefParser.dereference(schemaPath || DEFAULT_SCHEMA_PATH, schema, {})
+      const valid = ajv.validate(schema, jsonObj) as boolean
       if (!valid) {
         throw new CLIError(`Input JSON file is not valid: ${ajv.errorsText()}`)
       }

+ 52 - 21
cli/src/helpers/JsonSchemaPrompt.ts

@@ -4,22 +4,38 @@ import _ from 'lodash'
 import RefParser, { JSONSchema } from '@apidevtools/json-schema-ref-parser'
 import chalk from 'chalk'
 import { BOOL_PROMPT_OPTIONS } from './prompting'
+import { getSchemasLocation } from 'cd-schemas'
+import path from 'path'
 
 type CustomPromptMethod = () => Promise<any>
 type CustomPrompt = DistinctQuestion | CustomPromptMethod | { $item: CustomPrompt }
 
-export type JsonSchemaCustomPrompts = [string | RegExp, CustomPrompt][]
+// For the explaination of "string & { x: never }", see: https://github.com/microsoft/TypeScript/issues/29729
+// eslint-disable-next-line @typescript-eslint/ban-types
+export type JsonSchemaCustomPrompts<T = Record<string, unknown>> = [keyof T | (string & {}) | RegExp, CustomPrompt][]
+
+// Default schema path for resolving refs
+// TODO: Would be nice to skip the filename part (but without it it doesn't work)
+const DEFAULT_SCHEMA_PATH = getSchemasLocation('entities') + path.sep
 
 export class JsonSchemaPrompter<JsonResult> {
   schema: JSONSchema
+  schemaPath: string
   customPropmpts?: JsonSchemaCustomPrompts
   ajv: Ajv.Ajv
   filledObject: Partial<JsonResult>
 
-  constructor(schema: JSONSchema, defaults?: Partial<JsonResult>, customPrompts?: JsonSchemaCustomPrompts) {
+  constructor(
+    schema: JSONSchema,
+    defaults?: Partial<JsonResult>,
+    customPrompts?: JsonSchemaCustomPrompts,
+    schemaPath: string = DEFAULT_SCHEMA_PATH
+  ) {
     this.customPropmpts = customPrompts
     this.schema = schema
-    this.ajv = new Ajv()
+    this.schemaPath = schemaPath
+    // allErrors prevents .validate from setting only one error when in fact there are multiple
+    this.ajv = new Ajv({ allErrors: true })
     this.filledObject = defaults || {}
   }
 
@@ -41,7 +57,7 @@ export class JsonSchemaPrompter<JsonResult> {
 
   private getCustomPrompt(propertyPath: string): CustomPrompt | undefined {
     const found = this.customPropmpts?.find(([pathToMatch]) =>
-      typeof pathToMatch === 'string' ? propertyPath === pathToMatch : pathToMatch.test(propertyPath)
+      pathToMatch instanceof RegExp ? pathToMatch.test(propertyPath) : propertyPath === pathToMatch
     )
 
     return found ? found[1] : undefined
@@ -51,8 +67,8 @@ export class JsonSchemaPrompter<JsonResult> {
     return chalk.green(propertyPath)
   }
 
-  private async prompt(schema: JSONSchema, propertyPath = ''): Promise<any> {
-    const customPrompt: CustomPrompt | undefined = this.getCustomPrompt(propertyPath)
+  private async prompt(schema: JSONSchema, propertyPath = '', custom?: CustomPrompt): Promise<any> {
+    const customPrompt: CustomPrompt | undefined = custom || this.getCustomPrompt(propertyPath)
     const propDisplayName = this.propertyDisplayName(propertyPath)
 
     // Custom prompt
@@ -83,9 +99,10 @@ export class JsonSchemaPrompter<JsonResult> {
     }
 
     // "primitive" values:
+    const currentValue = _.get(this.filledObject, propertyPath)
     const basicPromptOptions: DistinctQuestion = {
       message: propDisplayName,
-      default: _.get(this.filledObject, propertyPath) || schema.default,
+      default: currentValue !== undefined ? currentValue : schema.default,
     }
 
     let additionalPromptOptions: DistinctQuestion | undefined
@@ -100,17 +117,17 @@ export class JsonSchemaPrompter<JsonResult> {
 
     // Normalizers
     if (schema.type === 'integer') {
-      normalizer = (v) => parseInt(v)
+      normalizer = (v) => (parseInt(v).toString() === v ? parseInt(v) : v)
     }
 
     if (schema.type === 'number') {
-      normalizer = (v) => Number(v)
+      normalizer = (v) => (Number(v).toString() === v ? Number(v) : v)
     }
 
     const promptOptions = { ...basicPromptOptions, ...additionalPromptOptions, ...customPrompt }
     // Need to wrap in retry, because "validate" will not get called if "type" is "list" etc.
     return await this.promptWithRetry(
-      async () => normalizer(await this.promptSimple(promptOptions, propertyPath, schema, normalizer)),
+      async () => normalizer(await this.promptSimple(promptOptions, propertyPath, normalizer)),
       propertyPath
     )
   }
@@ -153,12 +170,7 @@ export class JsonSchemaPrompter<JsonResult> {
     return result
   }
 
-  private async promptSimple(
-    promptOptions: DistinctQuestion,
-    propertyPath: string,
-    schema: JSONSchema,
-    normalize?: (v: any) => any
-  ) {
+  private async promptSimple(promptOptions: DistinctQuestion, propertyPath: string, normalize?: (v: any) => any) {
     const { result } = await inquirer.prompt([
       {
         ...promptOptions,
@@ -185,7 +197,8 @@ export class JsonSchemaPrompter<JsonResult> {
       error = this.setValueAndGetError(propertyPath, value, nestedErrors)
       if (error) {
         console.log('\n')
-        console.warn(error)
+        console.log('Provided value:', value)
+        console.warn(`ERROR: ${error}`)
         console.warn(`Try providing the input for ${propertyPath} again...`)
       }
     } while (error)
@@ -193,14 +206,32 @@ export class JsonSchemaPrompter<JsonResult> {
     return value
   }
 
+  async getMainSchema() {
+    return await RefParser.dereference(this.schemaPath, this.schema, {})
+  }
+
   async promptAll() {
-    await this.prompt(await RefParser.dereference(this.schema))
+    await this.prompt(await this.getMainSchema())
     return this.filledObject as JsonResult
   }
 
-  async promptSingleProp<P extends keyof JsonResult & string>(p: P): Promise<Exclude<JsonResult[P], undefined>> {
-    const dereferenced = await RefParser.dereference(this.schema)
-    await this.prompt(dereferenced.properties![p] as JSONSchema, p)
+  async promptMultipleProps<P extends keyof JsonResult & string, PA extends readonly P[]>(
+    props: PA
+  ): Promise<{ [K in PA[number]]: Exclude<JsonResult[K], undefined> }> {
+    const result: Partial<{ [K in PA[number]]: Exclude<JsonResult[K], undefined> }> = {}
+    for (const prop of props) {
+      result[prop] = await this.promptSingleProp(prop)
+    }
+
+    return result as { [K in PA[number]]: Exclude<JsonResult[K], undefined> }
+  }
+
+  async promptSingleProp<P extends keyof JsonResult & string>(
+    p: P,
+    customPrompt?: CustomPrompt
+  ): Promise<Exclude<JsonResult[P], undefined>> {
+    const mainSchema = await this.getMainSchema()
+    await this.prompt(mainSchema.properties![p] as JSONSchema, p, customPrompt)
     return this.filledObject[p] as Exclude<JsonResult[P], undefined>
   }
 }

+ 128 - 1
content-directory-schemas/README.md

@@ -1,4 +1,4 @@
-# Content directory json schemas and inputs
+# Content directory tooling
 
 ## Definitions
 
@@ -143,6 +143,133 @@ Besides that, a Typescript code can be written to generate some inputs (ie. usin
 
 There are a lot of other potential use-cases, but for the purpose of this documentation it should be enough to mention there exists this very easy way of converting `.schema.json` files into Typescript interfaces.
 
+## Using as library
+
+The `content-directory-schemas` directory of the monorepo is constructed in such a way, that it should be possible to use it as library and import from it json schemas, types (mentioned in `Typescript support` section) and tools to, for example, convert entity input like this described in the `Entity batches` section into `CreateEntity`, `AddSchemaSupportToEntity` and/or `UpdateEntityPropertyValues` operations.
+
+### Examples
+
+The best way to ilustrate this would be by providing some examples:
+
+#### Creating a channel
+```
+  import { InputParser } from 'cd-schemas'
+  import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+  // Other imports...
+
+  async main() {
+    // Initialize the api, SENDER_KEYPAIR and SENDER_MEMBER_ID...
+
+    const channel: ChannelEntity = {
+      title: 'Example channel',
+      description: 'This is an example channel',
+      language: { existing: { code: 'EN' } },
+      coverPhotoUrl: '',
+      avatarPhotoURL: '',
+      isPublic: true,
+    }
+
+    const parser = InputParser.createWithKnownSchemas(api, [
+      {
+        className: 'Channel',
+        entries: [channel],
+      },
+    ])
+
+    const operations = await parser.getEntityBatchOperations()
+    await api.tx.contentDirectory
+      .transaction({ Member: SENDER_MEMBER_ID }, operations)
+      .signAndSend(SENDER_KEYPAIR)
+  }
+```
+_Full example with comments can be found in `content-directory-schemas/examples/createChannel.ts` and ran with `yarn workspace cd-schemas example:createChannel`_
+
+#### Creating a video
+```
+import { InputParser } from 'cd-schemas'
+import { VideoEntity } from 'cd-schemas/types/entities/VideoEntity'
+// ...
+
+async main() {
+  // ...
+
+  const video: VideoEntity = {
+    title: 'Example video',
+    description: 'This is an example video',
+    language: { existing: { code: 'EN' } },
+    category: { existing: { name: 'Education' } },
+    channel: { existing: { title: 'Example channel' } },
+    media: {
+      new: {
+        encoding: { existing: { name: 'H.263_MP4' } },
+        pixelHeight: 600,
+        pixelWidth: 800,
+        location: {
+          new: {
+            httpMediaLocation: {
+              new: { url: 'https://testnet.joystream.org/' },
+            },
+          },
+        },
+      },
+    },
+    license: {
+      new: {
+        knownLicense: {
+          existing: { code: 'CC_BY' },
+        },
+      },
+    },
+    duration: 3600,
+    thumbnailURL: '',
+    isExplicit: false,
+    isPublic: true,
+  }
+
+  const parser = InputParser.createWithKnownSchemas(api, [
+    {
+      className: 'Video',
+      entries: [video],
+    },
+  ])
+
+  const operations = await parser.getEntityBatchOperations()
+  await api.tx.contentDirectory
+    .transaction({ Member: SENDER_MEMBER_ID }, operations)
+    .signAndSend(SENDER_KEYPAIR)
+}
+```
+_Full example with comments can be found in `content-directory-schemas/examples/createVideo.ts` and ran with `yarn workspace cd-schemas example:createChannel`_
+
+#### Update channel title
+
+```
+import { InputParser } from 'cd-schemas'
+import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+// ...
+
+async function main() {
+  // ...
+
+  const channelUpdateInput: Partial<ChannelEntity> = {
+    title: 'Updated channel title',
+  }
+
+  const parser = InputParser.createWithKnownSchemas(api)
+
+  const CHANNEL_ID = await parser.findEntityIdByUniqueQuery({ title: 'Example channel' }, 'Channel')
+
+  const updateOperations = await parser.getEntityUpdateOperations(channelUpdateInput, 'Channel', CHANNEL_ID)
+
+  await api.tx.contentDirectory
+    .transaction({ Member: SENDER_MEMBER_ID }, [updateOperation])
+    .signAndSend(SENDER_KEYPAIR)
+}
+```
+_Full example with comments can be found in `content-directory-schemas/examples/updateChannelTitle.ts` and ran with `yarn workspace cd-schemas example:updateChannelTitle`_
+
+Note: Updates can also inlucde `new` and `existing` keywords. In case `new` is specified inside the update - `CreateEntity` and `AddSchemaSupportToEntity` operations will be included as part of the operations returned by `InputParser.getEntityUpdateOperations`.
+
 ## Current limitations
 
 Some limitations that should be dealt with in the nearest future:

+ 52 - 0
content-directory-schemas/examples/createChannel.ts

@@ -0,0 +1,52 @@
+import { ApiPromise, WsProvider } from '@polkadot/api'
+import { types as joyTypes } from '@joystream/types'
+import { Keyring } from '@polkadot/keyring'
+// Import input parser and channel entity from cd-schemas (we use it as library here)
+import { InputParser } from 'cd-schemas'
+import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+
+async function main() {
+  // Initialize the api
+  const provider = new WsProvider('ws://127.0.0.1:9944')
+  const api = await ApiPromise.create({ provider, types: joyTypes })
+
+  // Get Alice keypair
+  const keyring = new Keyring()
+  keyring.addFromUri('//Alice', undefined, 'sr25519')
+  const [ALICE] = keyring.getPairs()
+
+  const channel: ChannelEntity = {
+    title: 'Example channel',
+    description: 'This is an example channel',
+    // We can use "existing" syntax to reference either an on-chain entity or other entity that's part of the same batch.
+    // Here we reference language that we assume was added by initialization script (initialize:dev), as it is part of
+    // input/entityBatches/LanguageBatch.json
+    language: { existing: { code: 'EN' } },
+    coverPhotoUrl: '',
+    avatarPhotoURL: '',
+    isPublic: true,
+  }
+  // Create the parser with known entity schemas (the ones in content-directory-schemas/inputs)
+  const parser = InputParser.createWithKnownSchemas(
+    api,
+    // The second argument is an array of entity batches, following standard entity batch syntax ({ className, entries }):
+    [
+      {
+        className: 'Channel',
+        entries: [channel], // We could specify multiple entries here, but in this case we only need one
+      },
+    ]
+  )
+  // We parse the input into CreateEntity and AddSchemaSupportToEntity operations
+  const operations = await parser.getEntityBatchOperations()
+  await api.tx.contentDirectory
+    .transaction(
+      { Member: 0 }, // We use member with id 0 as actor (in this case we assume this is Alice)
+      operations // We provide parsed operations as second argument
+    )
+    .signAndSend(ALICE)
+}
+
+main()
+  .then(() => process.exit())
+  .catch(console.error)

+ 76 - 0
content-directory-schemas/examples/createVideo.ts

@@ -0,0 +1,76 @@
+import { ApiPromise, WsProvider } from '@polkadot/api'
+import { types as joyTypes } from '@joystream/types'
+import { Keyring } from '@polkadot/keyring'
+// Import input parser and video entity from cd-schemas (we use it as library here)
+import { InputParser } from 'cd-schemas'
+import { VideoEntity } from 'cd-schemas/types/entities/VideoEntity'
+
+async function main() {
+  // Initialize the api
+  const provider = new WsProvider('ws://127.0.0.1:9944')
+  const api = await ApiPromise.create({ provider, types: joyTypes })
+
+  // Get Alice keypair
+  const keyring = new Keyring()
+  keyring.addFromUri('//Alice', undefined, 'sr25519')
+  const [ALICE] = keyring.getPairs()
+
+  const video: VideoEntity = {
+    title: 'Example video',
+    description: 'This is an example video',
+    // We reference existing language and category by their unique properties with "existing" syntax
+    // (those referenced here are part of inputs/entityBatches)
+    language: { existing: { code: 'EN' } },
+    category: { existing: { name: 'Education' } },
+    // We use the same "existing" syntax to reference a channel by unique property (title)
+    // In this case it's a channel that we created in createChannel example
+    channel: { existing: { title: 'Example channel' } },
+    media: {
+      // We use "new" syntax to sygnalize we want to create a new VideoMedia entity that will be related to this Video entity
+      new: {
+        // We use "exisiting" enconding from inputs/entityBatches/VideoMediaEncodingBatch.json
+        encoding: { existing: { name: 'H.263_MP4' } },
+        pixelHeight: 600,
+        pixelWidth: 800,
+        // We create nested VideoMedia->MediaLocation->HttpMediaLocation relations using the "new" syntax
+        location: { new: { httpMediaLocation: { new: { url: 'https://testnet.joystream.org/' } } } },
+      },
+    },
+    // Here we use combined "new" and "existing" syntaxes to create Video->License->KnownLicense relations
+    license: {
+      new: {
+        knownLicense: {
+          // This license can be found in inputs/entityBatches/KnownLicenseBatch.json
+          existing: { code: 'CC_BY' },
+        },
+      },
+    },
+    duration: 3600,
+    thumbnailURL: '',
+    isExplicit: false,
+    isPublic: true,
+  }
+  // Create the parser with known entity schemas (the ones in content-directory-schemas/inputs)
+  const parser = InputParser.createWithKnownSchemas(
+    api,
+    // The second argument is an array of entity batches, following standard entity batch syntax ({ className, entries }):
+    [
+      {
+        className: 'Video',
+        entries: [video], // We could specify multiple entries here, but in this case we only need one
+      },
+    ]
+  )
+  // We parse the input into CreateEntity and AddSchemaSupportToEntity operations
+  const operations = await parser.getEntityBatchOperations()
+  await api.tx.contentDirectory
+    .transaction(
+      { Member: 0 }, // We use member with id 0 as actor (in this case we assume this is Alice)
+      operations // We provide parsed operations as second argument
+    )
+    .signAndSend(ALICE)
+}
+
+main()
+  .then(() => process.exit())
+  .catch(console.error)

+ 47 - 0
content-directory-schemas/examples/updateChannelTitle.ts

@@ -0,0 +1,47 @@
+import { ApiPromise, WsProvider } from '@polkadot/api'
+import { types as joyTypes } from '@joystream/types'
+import { Keyring } from '@polkadot/keyring'
+// Import input parser and channel entity from cd-schemas (we use it as library here)
+import { InputParser } from 'cd-schemas'
+import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+
+async function main() {
+  // Initialize the api
+  const provider = new WsProvider('ws://127.0.0.1:9944')
+  const api = await ApiPromise.create({ provider, types: joyTypes })
+
+  // Get Alice keypair
+  const keyring = new Keyring()
+  keyring.addFromUri('//Alice', undefined, 'sr25519')
+  const [ALICE] = keyring.getPairs()
+
+  // Create partial channel entity, only containing the fields we wish to update
+  const channelUpdateInput: Partial<ChannelEntity> = {
+    title: 'Updated channel title',
+  }
+
+  // Create the parser with known entity schemas (the ones in content-directory-schemas/inputs)
+  const parser = InputParser.createWithKnownSchemas(api)
+
+  // We can reuse InputParser's `findEntityIdByUniqueQuery` method to find entityId of the channel we
+  // created in ./createChannel.ts example (normally we would probably use some other way to do it, ie.: query node)
+  const CHANNEL_ID = await parser.findEntityIdByUniqueQuery({ title: 'Example channel' }, 'Channel')
+
+  // Use getEntityUpdateOperations to parse the update input
+  const updateOperations = await parser.getEntityUpdateOperations(
+    channelUpdateInput,
+    'Channel', // Class name
+    CHANNEL_ID // Id of the entity we want to update
+  )
+
+  await api.tx.contentDirectory
+    .transaction(
+      { Member: 0 }, // We use member with id 0 as actor (in this case we assume this is Alice)
+      updateOperations // In this case this will be just a single UpdateEntityPropertyValues operation
+    )
+    .signAndSend(ALICE)
+}
+
+main()
+  .then(() => process.exit())
+  .catch(console.error)

+ 15 - 2
content-directory-schemas/inputs/entityBatches/ContentCategoryBatch.json

@@ -1,7 +1,20 @@
 {
   "className": "ContentCategory",
   "entries": [
-    { "name": "Cartoon", "description": "Content which is a cartoon or related to cartoons" },
-    { "name": "Sports", "description": "Content related to sports" }
+    { "name": "Film & Animation" },
+    { "name": "Autos & Vehicles" },
+    { "name": "Music" },
+    { "name": "Pets & Animals" },
+    { "name": "Sports" },
+    { "name": "Travel & Events" },
+    { "name": "Gaming" },
+    { "name": "People & Blogs" },
+    { "name": "Comedy" },
+    { "name": "Entertainment" },
+    { "name": "News & Politics" },
+    { "name": "Howto & Style" },
+    { "name": "Education" },
+    { "name": "Science & Technology" },
+    { "name": "Nonprofits & Activism" }
   ]
 }

+ 36 - 1
content-directory-schemas/inputs/entityBatches/LanguageBatch.json

@@ -3,6 +3,41 @@
   "entries": [
     { "code": "EN", "name": "English" },
     { "code": "RU", "name": "Russian" },
-    { "code": "DE", "name": "German" }
+    { "code": "DE", "name": "German" },
+    { "code": "IT", "name": "Italian" },
+    { "code": "ES", "name": "Spanish" },
+    { "code": "UK", "name": "Ukrainian" },
+    { "code": "CZ", "name": "Czech" },
+    { "code": "PL", "name": "Polish" },
+    { "code": "RO", "name": "Romanian" },
+    { "code": "NO", "name": "Norwegian" },
+    { "code": "AR", "name": "Arabic" },
+    { "code": "BG", "name": "Bulgarian" },
+    { "code": "ZH", "name": "Chinese" },
+    { "code": "HR", "name": "Croatian" },
+    { "code": "DA", "name": "Danish" },
+    { "code": "NL", "name": "Dutch" },
+    { "code": "FI", "name": "Finnish" },
+    { "code": "FR", "name": "French" },
+    { "code": "EL", "name": "Greek" },
+    { "code": "HI", "name": "Hindi" },
+    { "code": "HU", "name": "Hungarian" },
+    { "code": "ID", "name": "Indonesian" },
+    { "code": "GA", "name": "Irish" },
+    { "code": "IS", "name": "Icelandic" },
+    { "code": "JA", "name": "Japanese" },
+    { "code": "KO", "name": "Korean" },
+    { "code": "LT", "name": "Lithuanian" },
+    { "code": "MK", "name": "Macedonian" },
+    { "code": "PT", "name": "Portuguese" },
+    { "code": "SR", "name": "Serbian" },
+    { "code": "SK", "name": "Slovak" },
+    { "code": "SL", "name": "Slovenian" },
+    { "code": "SV", "name": "Swedish" },
+    { "code": "TH", "name": "Thai" },
+    { "code": "BO", "name": "Tibetan" },
+    { "code": "TR", "name": "Turkish" },
+    { "code": "VI", "name": "Vietnamese" },
+    { "code": "CY", "name": "Welsh" }
   ]
 }

+ 4 - 4
content-directory-schemas/inputs/entityBatches/VideoBatch.json

@@ -5,14 +5,14 @@
       "title": "Caminades 2",
       "description": "Caminandes 2: Gran Dillama",
       "language": { "existing": { "code": "EN" } },
-      "category": { "existing": { "name": "Cartoon" } },
+      "category": { "existing": { "name": "Film & Animation" } },
       "channel": { "existing": { "title": "Joystream Cartoons" } },
       "duration": 146,
       "hasMarketing": false,
       "isPublic": true,
       "media": {
         "new": {
-          "encoding": { "existing": { "name": "MPEG4" } },
+          "encoding": { "existing": { "name": "H.264_MP4" } },
           "location": {
             "new": {
               "httpMediaLocation": {
@@ -34,14 +34,14 @@
       "title": "Caminades 3",
       "description": "Caminandes 3: Llamigos",
       "language": { "existing": { "code": "EN" } },
-      "category": { "existing": { "name": "Cartoon" } },
+      "category": { "existing": { "name": "Film & Animation" } },
       "channel": { "existing": { "title": "Joystream Cartoons" } },
       "duration": 150,
       "hasMarketing": false,
       "isPublic": true,
       "media": {
         "new": {
-          "encoding": { "existing": { "name": "MPEG4" } },
+          "encoding": { "existing": { "name": "H.264_MP4" } },
           "location": {
             "new": {
               "httpMediaLocation": {

+ 24 - 1
content-directory-schemas/inputs/entityBatches/VideoMediaEncodingBatch.json

@@ -1,4 +1,27 @@
 {
   "className": "VideoMediaEncoding",
-  "entries": [{ "name": "MPEG4" }]
+  "entries": [
+    { "name": "H.263_MP4" },
+    { "name": "H.263_3GP" },
+    { "name": "H.263_AVI" },
+    { "name": "H.264_MP4" },
+    { "name": "H.264_3GP" },
+    { "name": "H.264_AVI" },
+    { "name": "H.264_MKV" },
+    { "name": "H.265_MP4" },
+    { "name": "H.265_3GP" },
+    { "name": "H.265_AVI" },
+    { "name": "VP8_WEBM" },
+    { "name": "VP8_MP4" },
+    { "name": "VP8_AVI" },
+    { "name": "VP8_MKV" },
+    { "name": "VP9_WEBM" },
+    { "name": "VP9_MP4" },
+    { "name": "VP9_AVI" },
+    { "name": "VP9_MKV" },
+    { "name": "AV1_MP4" },
+    { "name": "MVC_MP4" },
+    { "name": "MVC_3GP" },
+    { "name": "MVC_MKV" }
+  ]
 }

+ 4 - 1
content-directory-schemas/package.json

@@ -16,7 +16,10 @@
     "generate:all": "yarn generate:entity-schemas && yarn generate:types",
     "initialize:alice-as-lead": "ts-node ./scripts/devInitAliceLead.ts",
     "initialize:content-dir": "ts-node ./scripts/initializeContentDir.ts",
-    "initialize:dev": "yarn initialize:alice-as-lead && yarn initialize:content-dir"
+    "initialize:dev": "yarn initialize:alice-as-lead && yarn initialize:content-dir",
+    "example:createChannel": "ts-node ./examples/createChannel.ts",
+    "example:createVideo": "ts-node ./examples/createVideo.ts",
+    "example:updateChannelTitle": "ts-node ./examples/updateChannelTitle.ts"
   },
   "dependencies": {
     "ajv": "6.12.5",

+ 3 - 2
content-directory-schemas/schemas/extrinsics/AddClassSchema.schema.json

@@ -50,11 +50,12 @@
     "PropertyName": {
       "type": "string",
       "minLength": 1,
-      "maxLength": 100
+      "maxLength": 49
     },
     "PropertyDescription": {
       "type": "string",
-      "minLength": 0,
+      "minLength": 1,
+      "maxLength": 500,
       "default": ""
     },
     "SinglePropertyType": {

+ 6 - 2
content-directory-schemas/schemas/extrinsics/CreateClass.schema.json

@@ -9,11 +9,15 @@
   "properties": {
     "name": {
       "type": "string",
-      "description": "Name of this class. Required property."
+      "description": "Name of this class. Required property.",
+      "minLength": 1,
+      "maxLength": 49
     },
     "description": {
       "type": "string",
-      "description": "Description of this class."
+      "description": "Description of this class.",
+      "minLength": 1,
+      "maxLength": 500
     },
     "class_permissions": {
       "type": "object",

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

@@ -54,7 +54,7 @@ async function main() {
       4
     )
   )
-  console.log('Sending Transaction extrinsic...')
+  console.log(`Sending Transaction extrinsic (${entityOperations.length} operations)...`)
   await txHelper.sendAndCheck(
     ALICE,
     [api.tx.contentDirectory.transaction({ Lead: null }, entityOperations)],

+ 187 - 63
content-directory-schemas/src/helpers/InputParser.ts

@@ -7,6 +7,9 @@ import {
   ParametrizedPropertyValue,
   PropertyId,
   PropertyType,
+  EntityId,
+  Entity,
+  ParametrizedClassPropertyValue,
 } from '@joystream/types/content-directory'
 import { isSingle, isReference } from './propertyType'
 import { ApiPromise } from '@polkadot/api'
@@ -22,12 +25,15 @@ export class InputParser {
   private batchInputs: EntityBatch[]
   private createEntityOperations: OperationType[] = []
   private addSchemaToEntityOprations: OperationType[] = []
+  private updateEntityPropertyValuesOperations: OperationType[] = []
   private entityIndexByUniqueQueryMap = new Map<string, number>()
+  private entityIdByUniqueQueryMap = new Map<string, number>()
   private entityByUniqueQueryCurrentIndex = 0
   private classIdByNameMap = new Map<string, number>()
   private classMapInitialized = false
+  private entityIdByUniqueQueryMapInitialized = false
 
-  static createWithKnownSchemas(api: ApiPromise, entityBatches?: EntityBatch[]) {
+  static createWithKnownSchemas(api: ApiPromise, entityBatches?: EntityBatch[]): InputParser {
     return new InputParser(
       api,
       [],
@@ -59,6 +65,56 @@ export class InputParser {
     this.classMapInitialized = true
   }
 
+  // Initialize entityIdByUniqueQueryMap with entities fetched from the chain
+  private async initializeEntityIdByUniqueQueryMap() {
+    if (this.entityIdByUniqueQueryMapInitialized) {
+      return
+    }
+
+    await this.initializeClassMap() // Initialize if not yet initialized
+
+    // Get entity entries
+    const entityEntries: [EntityId, Entity][] = (
+      await this.api.query.contentDirectory.entityById.entries()
+    ).map(([storageKey, entity]) => [storageKey.args[0] as EntityId, entity])
+
+    entityEntries.forEach(([entityId, entity]) => {
+      const classId = entity.class_id.toNumber()
+      const className = Array.from(this.classIdByNameMap.entries()).find(([, id]) => id === classId)?.[0]
+      if (!className) {
+        // Class not found - skip
+        return
+      }
+      let schema: AddClassSchema
+      try {
+        schema = this.schemaByClassName(className)
+      } catch (e) {
+        // Input schema not found - skip
+        return
+      }
+      const valuesEntries = Array.from(entity.getField('values').entries())
+      schema.newProperties.forEach(({ name, unique }, index) => {
+        if (!unique) {
+          return // Skip non-unique properties
+        }
+        const storedValue = valuesEntries.find(([propertyId]) => propertyId.toNumber() === index)?.[1]
+        if (
+          storedValue === undefined ||
+          // If unique value is Bool, it's almost definitely empty, so we skip it
+          (storedValue.isOfType('Single') && storedValue.asType('Single').isOfType('Bool'))
+        ) {
+          // Skip empty values (not all unique properties are required)
+          return
+        }
+        const simpleValue = storedValue.getValue().toJSON()
+        const hash = this.getUniqueQueryHash({ [name]: simpleValue }, schema.className)
+        this.entityIdByUniqueQueryMap.set(hash, entityId.toNumber())
+      })
+    })
+
+    this.entityIdByUniqueQueryMapInitialized = true
+  }
+
   private schemaByClassName(className: string) {
     const foundSchema = this.schemaInputs.find((data) => data.className === className)
     if (!foundSchema) {
@@ -84,6 +140,20 @@ export class InputParser {
     return foundIndex
   }
 
+  // Seatch for entity by { [uniquePropName]: [uniquePropVal] } on chain
+  async findEntityIdByUniqueQuery(uniquePropVal: Record<string, any>, className: string): Promise<number> {
+    await this.initializeEntityIdByUniqueQueryMap()
+    const hash = this.getUniqueQueryHash(uniquePropVal, className)
+    const foundId = this.entityIdByUniqueQueryMap.get(hash)
+    if (foundId === undefined) {
+      throw new Error(
+        `findEntityIdByUniqueQuery failed for class ${className} and query: ${JSON.stringify(uniquePropVal)}`
+      )
+    }
+
+    return foundId
+  }
+
   private getClassIdByName(className: string): number {
     const classId = this.classIdByNameMap.get(className)
     if (classId === undefined) {
@@ -129,73 +199,124 @@ export class InputParser {
     ++this.entityByUniqueQueryCurrentIndex
   }
 
-  private createParametrizedPropertyValues(
+  private async createParametrizedPropertyValues(
     entityInput: Record<string, any>,
     schema: AddClassSchema,
-    customHandler?: (property: Property, value: any) => ParametrizedPropertyValue | undefined
-  ) {
-    return Object.entries(entityInput)
-      .filter(([, pValue]) => pValue !== undefined)
-      .map(([propertyName, propertyValue]) => {
-        const schemaPropertyIndex = schema.newProperties.findIndex((p) => p.name === propertyName)
-        const schemaProperty = schema.newProperties[schemaPropertyIndex]
-
-        let value = customHandler && customHandler(schemaProperty, propertyValue)
-        if (value === undefined) {
-          value = createType('ParametrizedPropertyValue', {
-            InputPropertyValue: this.parsePropertyType(schemaProperty.property_type)
-              .toInputPropertyValue(propertyValue)
-              .toJSON(),
-          })
-        }
+    customHandler?: (property: Property, value: any) => Promise<ParametrizedPropertyValue | undefined>
+  ): Promise<ParametrizedClassPropertyValue[]> {
+    const filteredInput = Object.entries(entityInput).filter(([, pValue]) => pValue !== undefined)
+    const parametrizedClassPropValues: ParametrizedClassPropertyValue[] = []
+
+    for (const [propertyName, propertyValue] of filteredInput) {
+      const schemaPropertyIndex = schema.newProperties.findIndex((p) => p.name === propertyName)
+      const schemaProperty = schema.newProperties[schemaPropertyIndex]
 
-        return {
+      let value = customHandler && (await customHandler(schemaProperty, propertyValue))
+      if (value === undefined) {
+        value = createType('ParametrizedPropertyValue', {
+          InputPropertyValue: this.parsePropertyType(schemaProperty.property_type).toInputPropertyValue(propertyValue),
+        })
+      }
+
+      parametrizedClassPropValues.push(
+        createType('ParametrizedClassPropertyValue', {
           in_class_index: schemaPropertyIndex,
-          value: value.toJSON(),
-        }
+          value,
+        })
+      )
+    }
+
+    return parametrizedClassPropValues
+  }
+
+  private async existingEntityQueryToParametrizedPropertyValue(className: string, uniquePropVal: Record<string, any>) {
+    try {
+      // First - try to find in existing batches
+      const entityIndex = this.findEntityIndexByUniqueQuery(uniquePropVal, className)
+      return createType('ParametrizedPropertyValue', { InternalEntityJustAdded: entityIndex })
+    } catch (e) {
+      // If not found - fallback to chain search
+      const entityId = await this.findEntityIdByUniqueQuery(uniquePropVal, className)
+      return createType('ParametrizedPropertyValue', {
+        InputPropertyValue: { Single: { Reference: entityId } },
       })
+    }
   }
 
-  private parseEntityInput(entityInput: Record<string, any>, schema: AddClassSchema) {
-    const parametrizedPropertyValues = this.createParametrizedPropertyValues(entityInput, schema, (property, value) => {
-      // Custom handler for references
-      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')) {
-          const entityIndex = this.parseEntityInput(value.new, refEntitySchema)
-          return createType('ParametrizedPropertyValue', { InternalEntityJustAdded: entityIndex })
-        } else if (Object.keys(value).includes('existing')) {
-          const entityIndex = this.findEntityIndexByUniqueQuery(value.existing, refEntitySchema.className)
-          return createType('ParametrizedPropertyValue', { InternalEntityJustAdded: entityIndex })
+  // parseEntityInput Overloads
+  private parseEntityInput(entityInput: Record<string, any>, schema: AddClassSchema): Promise<number>
+  private parseEntityInput(
+    entityInput: Record<string, any>,
+    schema: AddClassSchema,
+    updatedEntityId: number
+  ): Promise<void>
+
+  // Parse entity input. Speficy "updatedEntityId" only if want to parse into update operation!
+  private async parseEntityInput(
+    entityInput: Record<string, any>,
+    schema: AddClassSchema,
+    updatedEntityId?: number
+  ): Promise<void | number> {
+    const parametrizedPropertyValues = await this.createParametrizedPropertyValues(
+      entityInput,
+      schema,
+      async (property, value) => {
+        // Custom handler for references
+        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')) {
+            const entityIndex = await this.parseEntityInput(value.new, refEntitySchema)
+            return createType('ParametrizedPropertyValue', { InternalEntityJustAdded: entityIndex })
+          } else if (Object.keys(value).includes('existing')) {
+            return this.existingEntityQueryToParametrizedPropertyValue(refEntitySchema.className, value.existing)
+          }
         }
+        return undefined
       }
-      return undefined
-    })
-
-    // Add operations
-    const createEntityOperationIndex = this.createEntityOperations.length
-    const classId = this.classIdByNameMap.get(schema.className)
-    this.createEntityOperations.push(createType('OperationType', { CreateEntity: { class_id: classId } }))
-    this.addSchemaToEntityOprations.push(
-      createType('OperationType', {
-        AddSchemaSupportToEntity: {
-          schema_id: 0,
-          entity_id: { InternalEntityJustAdded: createEntityOperationIndex },
-          parametrized_property_values: parametrizedPropertyValues,
-        },
-      })
     )
 
-    // Return CreateEntity operation index
-    return createEntityOperationIndex
+    if (updatedEntityId) {
+      // Update operation
+      this.updateEntityPropertyValuesOperations.push(
+        createType('OperationType', {
+          UpdatePropertyValues: {
+            entity_id: { ExistingEntity: updatedEntityId },
+            new_parametrized_property_values: parametrizedPropertyValues,
+          },
+        })
+      )
+    } else {
+      // Add operations (createEntity, AddSchemaSupportToEntity)
+      const createEntityOperationIndex = this.createEntityOperations.length
+      const classId = this.getClassIdByName(schema.className)
+      this.createEntityOperations.push(createType('OperationType', { CreateEntity: { class_id: classId } }))
+      this.addSchemaToEntityOprations.push(
+        createType('OperationType', {
+          AddSchemaSupportToEntity: {
+            schema_id: 0,
+            entity_id: { InternalEntityJustAdded: createEntityOperationIndex },
+            parametrized_property_values: parametrizedPropertyValues,
+          },
+        })
+      )
+
+      // Return CreateEntity operation index
+      return createEntityOperationIndex
+    }
   }
 
   private reset() {
     this.entityIndexByUniqueQueryMap = new Map<string, number>()
+    this.entityIdByUniqueQueryMapInitialized = false
+
     this.classIdByNameMap = new Map<string, number>()
+    this.classMapInitialized = false
+
     this.createEntityOperations = []
     this.addSchemaToEntityOprations = []
+    this.updateEntityPropertyValuesOperations = []
+
     this.entityByUniqueQueryCurrentIndex = 0
   }
 
@@ -207,10 +328,12 @@ export class InputParser {
       batch.entries.forEach((entityInput) => this.includeEntityInputInUniqueQueryMap(entityInput, entitySchema))
     })
     // Then - parse into actual operations
-    this.batchInputs.forEach((batch) => {
+    for (const batch of this.batchInputs) {
       const entitySchema = this.schemaByClassName(batch.className)
-      batch.entries.forEach((entityInput) => this.parseEntityInput(entityInput, entitySchema))
-    })
+      for (const entityInput of batch.entries) {
+        await this.parseEntityInput(entityInput, entitySchema)
+      }
+    }
 
     const operations = [...this.createEntityOperations, ...this.addSchemaToEntityOprations]
     this.reset()
@@ -218,21 +341,22 @@ export class InputParser {
     return operations
   }
 
-  public async createEntityUpdateOperation(
-    entityInput: Record<string, any>,
+  public async getEntityUpdateOperations(
+    input: Record<string, any>,
     className: string,
     entityId: number
-  ): Promise<OperationType> {
+  ): Promise<OperationType[]> {
     await this.initializeClassMap()
     const schema = this.schemaByClassName(className)
-    const parametrizedPropertyValues = this.createParametrizedPropertyValues(entityInput, schema)
+    await this.parseEntityInput(input, schema, entityId)
+    const operations = [
+      ...this.createEntityOperations,
+      ...this.addSchemaToEntityOprations,
+      ...this.updateEntityPropertyValuesOperations,
+    ]
+    this.reset()
 
-    return createType('OperationType', {
-      UpdatePropertyValues: {
-        entity_id: { ExistingEntity: entityId },
-        new_parametrized_property_values: parametrizedPropertyValues,
-      },
-    })
+    return operations
   }
 
   public async parseAddClassSchemaExtrinsic(inputData: AddClassSchema) {

+ 4 - 4
pioneer/packages/joy-forum/src/Context.tsx

@@ -232,10 +232,10 @@ function reducer (state: ForumState, action: ForumAction): ForumState {
         moderator_id: createType('AccountId', moderator),
         rationale: createType('Text', rationale)
       });
-      const threadUpd = createType('Thread', Object.assign(
-        thread.cloneValues(),
-        { moderation: createType('Option<ModerationAction>', moderation) }
-      ));
+      const threadUpd = createType('Thread', {
+        ...thread.cloneValues(),
+        moderation: createType('Option<ModerationAction>', moderation)
+      });
 
       threadById.set(id, threadUpd);
 

+ 2 - 2
pioneer/packages/joy-forum/src/calls.tsx

@@ -17,12 +17,12 @@ const storage: StorageType = 'substrate';
 type EntityMapName = 'categoryById' | 'threadById' | 'replyById';
 
 const getReactValue = (state: ForumState, endpoint: string, paramValue: any) => {
-  function getEntityById<T extends keyof InterfaceTypes>
+  function getEntityById<T extends 'Category' | 'Thread' | 'Reply'>
   (mapName: EntityMapName, type: T): InterfaceTypes[T] {
     const id = (paramValue as u64).toNumber();
     const entity = state[mapName].get(id);
 
-    return createType(type, entity);
+    return createType(type, entity as any);
   }
 
   switch (endpoint) {

+ 2 - 2
pioneer/packages/joy-proposals/src/stories/data/ProposalDetails.mock.ts

@@ -20,8 +20,8 @@ const mockedProposal: ParsedProposal = {
   proposerId: 303,
   status: createType('ProposalStatus', {
     Active: {
-      stakeId: 0,
-      sourceAccountId: '5C4hrfkRjSLwQSFVtCvtbV6wctV1WFnkiexUZWLAh4Bc7jib'
+      stake_id: 0,
+      source_account_id: '5C4hrfkRjSLwQSFVtCvtbV6wctV1WFnkiexUZWLAh4Bc7jib'
     }
   }),
   proposer: {

+ 2 - 2
pioneer/packages/joy-roles/src/classifiers.spec.ts

@@ -75,7 +75,7 @@ describe('hiring.Opening-> OpeningStageClassification', (): void => {
           stage: createType('OpeningStage', {
             Active: {
               stage: createType('ActiveOpeningStage', {
-                acceptingApplications: {
+                AcceptingApplications: {
                   started_accepting_applicants_at_block: 100
                 }
               })
@@ -101,7 +101,7 @@ describe('hiring.Opening-> OpeningStageClassification', (): void => {
           stage: createType('OpeningStage', {
             Active: {
               stage: createType('ActiveOpeningStage', {
-                reviewPeriod: {
+                ReviewPeriod: {
                   started_accepting_applicants_at_block: 100,
                   started_review_period_at_block: 100
                 }

+ 1 - 1
storage-node/packages/cli/package.json

@@ -44,7 +44,7 @@
     "@joystream/storage-runtime-api": "^0.1.0",
     "@joystream/service-discovery": "^0.1.0",
     "@joystream/storage-utils": "^0.1.0",
-    "@joystream/types": "^0.13.0",
+    "@joystream/types": "^0.14.0",
     "axios": "^0.19.2",
     "chalk": "^2.4.2",
     "lodash": "^4.17.11",

+ 1 - 1
types/src/JoyEnum.ts

@@ -9,7 +9,7 @@ export interface ExtendedEnum<Types extends Record<string, Constructor>> extends
   type: keyof Types & string // More typesafe type for the original Enum property
 }
 
-export interface ExtendedEnumConstructor<Types extends Record<string, Constructor>>
+export interface ExtendedEnumConstructor<Types extends Record<string, Constructor> = Record<string, Constructor>>
   extends EnumConstructor<ExtendedEnum<Types>> {
   create<TypeKey extends keyof Types>(
     registry: Registry,

+ 12 - 5
types/src/JoyStruct.ts

@@ -4,7 +4,8 @@ import { Codec, Constructor, Registry } from '@polkadot/types/types'
 export interface ExtendedStruct<FieldTypes extends Record<string, Constructor>> extends Struct<FieldTypes> {
   getField<FieldKey extends keyof FieldTypes>(key: FieldKey): InstanceType<FieldTypes[FieldKey]>
   getString<FieldKey extends keyof FieldTypes>(key: FieldKey): string
-  cloneValues(): { [k in keyof FieldTypes]: FieldTypes[k] }
+  cloneValues(): { [k in keyof FieldTypes]: InstanceType<FieldTypes[k]> }
+  typeDefs: FieldTypes
 }
 
 // Those getters are automatically added via Object.defineProperty when using Struct.with
@@ -27,12 +28,16 @@ export interface StructConstructor<
 export type ExtendedStructConstructor<FieldTypes extends Record<string, Constructor>> = StructConstructor<
   FieldTypes,
   ExtendedStruct<FieldTypes>
->
+> & {
+  typeDefs: FieldTypes
+}
 
 export type ExtendedStructDecoratedConstructor<FieldTypes extends Record<string, Constructor>> = StructConstructor<
   FieldTypes,
   ExtendedStructDecorated<FieldTypes>
->
+> & {
+  typeDefs: FieldTypes
+}
 
 // Helper for creating extended Struct type with TS-compatible interface
 // It's called JoyStructCustom, because eventually we'd want to migrate all structs to JoyStructDecorated,
@@ -42,6 +47,8 @@ export function JoyStructCustom<FieldTypes extends Record<string, Constructor>>(
   fields: FieldTypes
 ): ExtendedStructConstructor<FieldTypes> {
   return class JoyStructObject extends Struct.with(fields) {
+    static typeDefs = fields
+    typeDefs = JoyStructObject.typeDefs
     // eslint-disable-next-line no-useless-constructor
     constructor(registry: Registry, value?: { [k in keyof FieldTypes]: InstanceType<FieldTypes[k]> }) {
       super(registry, value)
@@ -56,14 +63,14 @@ export function JoyStructCustom<FieldTypes extends Record<string, Constructor>>(
     }
 
     // TODO: Check why would this ever be needed
-    cloneValues(): { [k in keyof FieldTypes]: FieldTypes[k] } {
+    cloneValues(): { [k in keyof FieldTypes]: InstanceType<FieldTypes[k]> } {
       const objectClone = {} as Partial<{ [k in keyof FieldTypes]: Codec }>
 
       super.forEach((v, k) => {
         objectClone[k] = v // shallow copy acceptable ?
       })
 
-      return (objectClone as unknown) as { [k in keyof FieldTypes]: FieldTypes[k] }
+      return objectClone as { [k in keyof FieldTypes]: InstanceType<FieldTypes[k]> }
     }
   }
 }

+ 41 - 3
types/src/index.ts

@@ -1,4 +1,4 @@
-import { RegistryTypes } from '@polkadot/types/types'
+import { Codec, RegistryTypes } from '@polkadot/types/types'
 import common from './common'
 import members from './members'
 import council from './council'
@@ -15,7 +15,10 @@ import media from './media'
 import proposals from './proposals'
 import contentDirectory from './content-directory'
 import { InterfaceTypes } from '@polkadot/types/types/registry'
-import { TypeRegistry } from '@polkadot/types'
+import { TypeRegistry, Text, UInt, Null, bool, Option, Vec, BTreeSet } from '@polkadot/types'
+import { ExtendedEnum } from './JoyEnum'
+import { ExtendedStruct } from './JoyStruct'
+import BN from 'bn.js'
 
 export {
   common,
@@ -57,9 +60,44 @@ export const types: RegistryTypes = {
 export const registry = new TypeRegistry()
 registry.register(types)
 
+// Tweaked version of https://stackoverflow.com/a/62163715 for handling enum variants
+// Based on type (T) like: { a: string; b: number; c: Null; }
+// will create a type like: { a: string } | { b: number } | { c: Null } | "c"
+type EnumVariant<T> = keyof T extends infer K
+  ? K extends keyof T
+    ? T[K] extends Null
+      ? K
+      : { [I in K]: T[I] }
+    : never
+  : never
+
+// Create simple interface for any Codec type (inlcuding JoyEnums and JoyStructs)
+// Cannot handle Option here, since that would cause circular reference error
+type CreateInterface_NoOption<T extends Codec> =
+  | T
+  | (T extends ExtendedEnum<infer S>
+      ? EnumVariant<{ [K in keyof S]: CreateInterface<InstanceType<T['typeDefinitions'][K]>> }>
+      : T extends ExtendedStruct<infer S>
+      ? { [K in keyof S]?: CreateInterface<InstanceType<T['typeDefs'][K]>> }
+      : T extends Text
+      ? string
+      : T extends UInt
+      ? number | BN
+      : T extends bool
+      ? boolean
+      : T extends Vec<infer S> | BTreeSet<infer S>
+      ? CreateInterface<S>[]
+      : any)
+
+// Wrapper for CreateInterface_NoOption that includes resolving an Option
+// (nested Options like Option<Option<Codec>> will resolve to Option<any>, but there are very edge case)
+type CreateInterface<T extends Codec> =
+  | T
+  | (T extends Option<infer S> ? undefined | null | S | CreateInterface_NoOption<S> : CreateInterface_NoOption<T>)
+
 export function createType<TypeName extends keyof InterfaceTypes>(
   type: TypeName,
-  value: any
+  value: InterfaceTypes[TypeName] extends Codec ? CreateInterface<InterfaceTypes[TypeName]> : any
 ): InterfaceTypes[TypeName] {
   return registry.createType(type, value)
 }

+ 473 - 23
yarn.lock

@@ -1500,6 +1500,54 @@
     unique-filename "^1.1.1"
     which "^1.3.1"
 
+"@ffmpeg-installer/darwin-x64@4.1.0":
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/@ffmpeg-installer/darwin-x64/-/darwin-x64-4.1.0.tgz#48e1706c690e628148482bfb64acb67472089aaa"
+  integrity sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==
+
+"@ffmpeg-installer/ffmpeg@^1.0.20":
+  version "1.0.20"
+  resolved "https://registry.yarnpkg.com/@ffmpeg-installer/ffmpeg/-/ffmpeg-1.0.20.tgz#d3c9c2bbcd76149468fb0886c2b3fe9e4795490b"
+  integrity sha512-wbgd//6OdwbFXYgV68ZyKrIcozEQpUKlvV66XHaqO2h3sFbX0jYLzx62Q0v8UcFWN21LoxT98NU2P+K0OWsKNA==
+  optionalDependencies:
+    "@ffmpeg-installer/darwin-x64" "4.1.0"
+    "@ffmpeg-installer/linux-arm" "4.1.3"
+    "@ffmpeg-installer/linux-arm64" "4.1.4"
+    "@ffmpeg-installer/linux-ia32" "4.1.0"
+    "@ffmpeg-installer/linux-x64" "4.1.0"
+    "@ffmpeg-installer/win32-ia32" "4.1.0"
+    "@ffmpeg-installer/win32-x64" "4.1.0"
+
+"@ffmpeg-installer/linux-arm64@4.1.4":
+  version "4.1.4"
+  resolved "https://registry.yarnpkg.com/@ffmpeg-installer/linux-arm64/-/linux-arm64-4.1.4.tgz#7219f3f901bb67f7926cb060b56b6974a6cad29f"
+  integrity sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==
+
+"@ffmpeg-installer/linux-arm@4.1.3":
+  version "4.1.3"
+  resolved "https://registry.yarnpkg.com/@ffmpeg-installer/linux-arm/-/linux-arm-4.1.3.tgz#c554f105ed5f10475ec25d7bec94926ce18db4c1"
+  integrity sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==
+
+"@ffmpeg-installer/linux-ia32@4.1.0":
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/@ffmpeg-installer/linux-ia32/-/linux-ia32-4.1.0.tgz#adad70b0d0d9d8d813983d6e683c5a338a75e442"
+  integrity sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==
+
+"@ffmpeg-installer/linux-x64@4.1.0":
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/@ffmpeg-installer/linux-x64/-/linux-x64-4.1.0.tgz#b4a5d89c4e12e6d9306dbcdc573df716ec1c4323"
+  integrity sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==
+
+"@ffmpeg-installer/win32-ia32@4.1.0":
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/@ffmpeg-installer/win32-ia32/-/win32-ia32-4.1.0.tgz#6eac4fb691b64c02e7a116c1e2d167f3e9b40638"
+  integrity sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==
+
+"@ffmpeg-installer/win32-x64@4.1.0":
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz#17e8699b5798d4c60e36e2d6326a8ebe5e95a2c5"
+  integrity sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==
+
 "@fortawesome/fontawesome-common-types@^0.2.30":
   version "0.2.30"
   resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.30.tgz#2f1cc5b46bd76723be41d0013a8450c9ba92b777"
@@ -1931,20 +1979,6 @@
     "@types/yargs" "^15.0.0"
     chalk "^4.0.0"
 
-"@joystream/types@^0.13.0":
-  version "0.13.1"
-  resolved "https://registry.yarnpkg.com/@joystream/types/-/types-0.13.1.tgz#d91773375a069663a3ec319f0929be8ddbb80210"
-  integrity sha512-Nz0zUzqS+gD0eaom+lzu595gm5XFG9uAK5L5ubwKceVDYgwVYC3rrZgfpKdPJJkGoGpVa8m0Gtl7YdOO1neMWw==
-  dependencies:
-    "@polkadot/api" "1.26.1"
-    "@polkadot/keyring" "^3.0.1"
-    "@polkadot/types" "1.26.1"
-    "@types/lodash" "^4.14.157"
-    "@types/vfile" "^4.0.0"
-    ajv "^6.11.0"
-    lodash "^4.17.15"
-    moment "^2.24.0"
-
 "@joystream/types@link:types":
   version "0.14.0"
   dependencies:
@@ -2718,6 +2752,11 @@
     call-me-maybe "^1.0.1"
     glob-to-regexp "^0.3.0"
 
+"@multiformats/base-x@^4.0.1":
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/@multiformats/base-x/-/base-x-4.0.1.tgz#95ff0fa58711789d53aefb2590a8b7a4e715d121"
+  integrity sha512-eMk0b9ReBbV23xXU693TAIrLyeO5iTgBZGSJfpqriG8UkYvr/hC9u9pyMlAakDNHWmbhMZCDs6KQO0jzKD8OTw==
+
 "@nodelib/fs.scandir@2.1.3":
   version "2.1.3"
   resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz#3a582bdb53804c6ba6d146579c46e52130cf4a3b"
@@ -4158,6 +4197,13 @@
   resolved "https://registry.yarnpkg.com/@types/filewriter/-/filewriter-0.0.28.tgz#c054e8af4d9dd75db4e63abc76f885168714d4b3"
   integrity sha1-wFTor02d11205jq8dviFFocU1LM=
 
+"@types/fluent-ffmpeg@^2.1.16":
+  version "2.1.16"
+  resolved "https://registry.yarnpkg.com/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.16.tgz#63949b0cb6bc88c9157a579cdd80858a269f3a3a"
+  integrity sha512-1FTstm6xY/2WsJVt6ARV7CiJjNCQDlR/nfw6xuYk5ITbVjk7sw89Biyqm2DGW4c3aZ3vBx+5irZvsql4eybpoQ==
+  dependencies:
+    "@types/node" "*"
+
 "@types/fs-extra@^9.0.1":
   version "9.0.1"
   resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-9.0.1.tgz#91c8fc4c51f6d5dbe44c2ca9ab09310bd00c7918"
@@ -5190,6 +5236,13 @@ abbrev@1:
   resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
   integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
 
+abort-controller@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"
+  integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==
+  dependencies:
+    event-target-shim "^5.0.0"
+
 abstract-leveldown@^6.2.1, abstract-leveldown@~6.2.1:
   version "6.2.2"
   resolved "https://registry.yarnpkg.com/abstract-leveldown/-/abstract-leveldown-6.2.2.tgz#677425beeb28204367c7639e264e93ea4b49971a"
@@ -5596,6 +5649,13 @@ any-promise@^1.0.0, any-promise@^1.1.0:
   resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
   integrity sha1-q8av7tzqUugJzcA3au0845Y10X8=
 
+any-signal@^1.1.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/any-signal/-/any-signal-1.2.0.tgz#d755f690896f3e75c4a07480f429a1ee7f8db3b4"
+  integrity sha512-Cl08k4xItix3jvu4cxO/dt2rQ6iUAjO66pTyRMub+WL1VXeAyZydCpD8GqWTPKfdL28U0R0UucmQVsUsBnvCmQ==
+  dependencies:
+    abort-controller "^3.0.0"
+
 anymatch@^1.3.0:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a"
@@ -5985,6 +6045,11 @@ async@0.9.x, async@^0.9.0:
   resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d"
   integrity sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=
 
+async@>=0.2.9:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720"
+  integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==
+
 async@^2.6.1, async@^2.6.2, async@^2.6.3:
   version "2.6.3"
   resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
@@ -6625,6 +6690,15 @@ bl@^3.0.0:
   dependencies:
     readable-stream "^3.0.1"
 
+bl@^4.0.0:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/bl/-/bl-4.0.3.tgz#12d6287adc29080e22a705e5764b2a9522cdc489"
+  integrity sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg==
+  dependencies:
+    buffer "^5.5.0"
+    inherits "^2.0.4"
+    readable-stream "^3.4.0"
+
 bl@^4.0.1:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/bl/-/bl-4.0.2.tgz#52b71e9088515d0606d9dd9cc7aa48dc1f98e73a"
@@ -6646,6 +6720,13 @@ blakejs@^1.1.0:
   resolved "https://registry.yarnpkg.com/blakejs/-/blakejs-1.1.0.tgz#69df92ef953aa88ca51a32df6ab1c54a155fc7a5"
   integrity sha1-ad+S75U6qIylGjLfarHFShVfx6U=
 
+blob-to-it@0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/blob-to-it/-/blob-to-it-0.0.2.tgz#851b4db0be4acebc86dd1c14c14b77fdc473e9b0"
+  integrity sha512-3/NRr0mUWQTkS71MYEC1teLbT5BTs7RZ6VMPXDV6qApjw3B4TAZspQuvDkYfHuD/XzL5p/RO91x5XRPeJvcCqg==
+  dependencies:
+    browser-readablestream-to-it "^0.0.2"
+
 block-stream@*:
   version "0.0.9"
   resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a"
@@ -6809,6 +6890,11 @@ browser-process-hrtime@^1.0.0:
   resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626"
   integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==
 
+browser-readablestream-to-it@0.0.2, browser-readablestream-to-it@^0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/browser-readablestream-to-it/-/browser-readablestream-to-it-0.0.2.tgz#4a5c2a20567623c106125ca6b640f68b081cea25"
+  integrity sha512-bbiTccngeAbPmpTUJcUyr6JhivADKV9xkNJVLdA91vjdzXyFBZ6fgrzElQsV3k1UNGQACRTl3p4y+cEGG9U48A==
+
 browser-stdout@1.3.1:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60"
@@ -7584,6 +7670,17 @@ cids@^0.5.7, cids@~0.5.2, cids@~0.5.4, cids@~0.5.5:
     multicodec "~0.5.0"
     multihashes "~0.4.14"
 
+cids@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/cids/-/cids-1.0.1.tgz#119d50a686be06b51e972831bbf4149d9617c7cf"
+  integrity sha512-A5DvnNMr7ABdP3IWFPEdbpued8Sw2cHu6n4+Hke9MpzEqhQH74Pm6Zk2TB59aNHIuue1Oh/jZi/t0wgISWgOow==
+  dependencies:
+    class-is "^1.1.0"
+    multibase "^3.0.1"
+    multicodec "^2.0.1"
+    multihashes "^3.0.1"
+    uint8arrays "^1.1.0"
+
 cids@~0.7.0:
   version "0.7.1"
   resolved "https://registry.yarnpkg.com/cids/-/cids-0.7.1.tgz#d8bba49a35a0e82110879b5001abf1039c62347f"
@@ -8020,7 +8117,7 @@ columnify@^1.5.4:
     strip-ansi "^3.0.0"
     wcwidth "^1.0.0"
 
-combined-stream@^1.0.6, combined-stream@~1.0.6:
+combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6:
   version "1.0.8"
   resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
   integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
@@ -10804,6 +10901,11 @@ event-emitter@^0.3.5:
     d "1"
     es5-ext "~0.10.14"
 
+event-target-shim@^5.0.0:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789"
+  integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==
+
 eventemitter3@^3.1.0:
   version "3.1.2"
   resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7"
@@ -11175,6 +11277,11 @@ fast-diff@^1.1.2:
   resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03"
   integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
 
+fast-fifo@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.0.0.tgz#9bc72e6860347bb045a876d1c5c0af11e9b984e7"
+  integrity sha512-4VEXmjxLj7sbs8J//cn2qhRap50dGzF5n8fjay8mau+Jn4hxSeR3xPFwxMaQq/pDaq7+KQk0PAbC2+nWDkJrmQ==
+
 fast-glob@^2.0.2, fast-glob@^2.2.6:
   version "2.2.7"
   resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.7.tgz#6953857c3afa475fff92ee6015d52da70a4cd39d"
@@ -11598,6 +11705,14 @@ flatten@^1.0.2:
   resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.3.tgz#c1283ac9f27b368abc1e36d1ff7b04501a30356b"
   integrity sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==
 
+fluent-ffmpeg@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/fluent-ffmpeg/-/fluent-ffmpeg-2.1.2.tgz#c952de2240f812ebda0aa8006d7776ee2acf7d74"
+  integrity sha1-yVLeIkD4EuvaCqgAbXd27irPfXQ=
+  dependencies:
+    async ">=0.2.9"
+    which "^1.1.1"
+
 flush-write-stream@^1.0.0, flush-write-stream@^1.0.2:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8"
@@ -11688,6 +11803,15 @@ form-data@^2.3.1, form-data@^2.3.3:
     combined-stream "^1.0.6"
     mime-types "^2.1.12"
 
+form-data@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682"
+  integrity sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==
+  dependencies:
+    asynckit "^0.4.0"
+    combined-stream "^1.0.8"
+    mime-types "^2.1.12"
+
 form-data@~2.3.2:
   version "2.3.3"
   resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
@@ -13697,6 +13821,21 @@ ipfs-block@~0.8.0, ipfs-block@~0.8.1:
     cids "~0.7.0"
     class-is "^1.1.0"
 
+ipfs-core-utils@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/ipfs-core-utils/-/ipfs-core-utils-0.4.0.tgz#f633b0e51be6374f8caa1b15d5107e056123137a"
+  integrity sha512-IBPFvYjWPfVFpCeYUL/0gCUOabdBhh7aO5i4tU//UlF2gVCXPH4PRYlbBH9WM83zE2+o4vDi+dBXsdAI6nLPAg==
+  dependencies:
+    blob-to-it "0.0.2"
+    browser-readablestream-to-it "0.0.2"
+    cids "^1.0.0"
+    err-code "^2.0.0"
+    ipfs-utils "^3.0.0"
+    it-all "^1.0.1"
+    it-map "^1.0.2"
+    it-peekable "0.0.1"
+    uint8arrays "^1.1.0"
+
 ipfs-http-client@^32.0.1:
   version "32.0.1"
   resolved "https://registry.yarnpkg.com/ipfs-http-client/-/ipfs-http-client-32.0.1.tgz#4f5845c56717c748751e70e5d579b7b18af9e824"
@@ -13747,6 +13886,41 @@ ipfs-http-client@^32.0.1:
     tar-stream "^2.0.1"
     through2 "^3.0.1"
 
+ipfs-http-client@^47.0.1:
+  version "47.0.1"
+  resolved "https://registry.yarnpkg.com/ipfs-http-client/-/ipfs-http-client-47.0.1.tgz#509c6c742ab405bc2a7e6e0fe373e19e9b85633b"
+  integrity sha512-IAQf+uTLvXw5QFOzbyhu/5lH3rn7jEwwwdCGaNKVhoPI7yfyOV0wRse3hVWejjP1Id0P9mKuMKG8rhcY7pVAdQ==
+  dependencies:
+    abort-controller "^3.0.0"
+    any-signal "^1.1.0"
+    bignumber.js "^9.0.0"
+    cids "^1.0.0"
+    debug "^4.1.0"
+    form-data "^3.0.0"
+    ipfs-core-utils "^0.4.0"
+    ipfs-utils "^3.0.0"
+    ipld-block "^0.10.0"
+    ipld-dag-cbor "^0.17.0"
+    ipld-dag-pb "^0.20.0"
+    ipld-raw "^6.0.0"
+    iso-url "^0.4.7"
+    it-last "^1.0.2"
+    it-map "^1.0.2"
+    it-tar "^1.2.2"
+    it-to-buffer "^1.0.0"
+    it-to-stream "^0.1.1"
+    merge-options "^2.0.0"
+    multiaddr "^8.0.0"
+    multiaddr-to-uri "^6.0.0"
+    multibase "^3.0.0"
+    multicodec "^2.0.0"
+    multihashes "^3.0.1"
+    nanoid "^3.0.2"
+    node-fetch "^2.6.0"
+    parse-duration "^0.4.4"
+    stream-to-it "^0.2.1"
+    uint8arrays "^1.1.0"
+
 ipfs-only-hash@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/ipfs-only-hash/-/ipfs-only-hash-1.0.2.tgz#ef11c604d11b2504bc624fa473488a870326d38a"
@@ -13817,6 +13991,24 @@ ipfs-unixfs@~0.1.16:
   dependencies:
     protons "^1.0.1"
 
+ipfs-utils@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/ipfs-utils/-/ipfs-utils-3.0.0.tgz#58f8345ff26c4ae6b4a8e3a2366bd25de3e1460e"
+  integrity sha512-qahDc+fghrM57sbySr2TeWjaVR/RH/YEB/hvdAjiTbjESeD87qZawrXwj+19Q2LtGmFGusKNLo5wExeuI5ZfDQ==
+  dependencies:
+    abort-controller "^3.0.0"
+    any-signal "^1.1.0"
+    buffer "^5.6.0"
+    err-code "^2.0.0"
+    fs-extra "^9.0.1"
+    is-electron "^2.2.0"
+    iso-url "^0.4.7"
+    it-glob "0.0.8"
+    merge-options "^2.0.0"
+    nanoid "^3.1.3"
+    node-fetch "^2.6.0"
+    stream-to-it "^0.2.0"
+
 ipfs-utils@~0.0.3:
   version "0.0.4"
   resolved "https://registry.yarnpkg.com/ipfs-utils/-/ipfs-utils-0.0.4.tgz#946114cfeb6afb4454b4ccb10d2327cd323b0cce"
@@ -13830,6 +14022,26 @@ ipfs-utils@~0.0.3:
     kind-of "^6.0.2"
     readable-stream "^3.4.0"
 
+ipld-block@^0.10.0:
+  version "0.10.1"
+  resolved "https://registry.yarnpkg.com/ipld-block/-/ipld-block-0.10.1.tgz#a9de6185257cf56903cc7f71de450672f4871b65"
+  integrity sha512-lPMfW9tA2hVZw9hdO/YSppTxFmA0+5zxcefBOlCTOn+12RLyy+pdepKMbQw8u0KESFu3pYVmabNRWuFGcgHLLw==
+  dependencies:
+    cids "^1.0.0"
+    class-is "^1.1.0"
+
+ipld-dag-cbor@^0.17.0:
+  version "0.17.0"
+  resolved "https://registry.yarnpkg.com/ipld-dag-cbor/-/ipld-dag-cbor-0.17.0.tgz#760d15515275261d0da6b9d60bc387fb2866f068"
+  integrity sha512-YprSTQClJQUyC+RhbWrVXhg7ysII5R/jrmZZ4en4n9Mav+MRbntAW699zd1PHRLB71lNCJbxABE2Uc9QU2Ka7g==
+  dependencies:
+    borc "^2.1.2"
+    cids "^1.0.0"
+    is-circular "^1.0.2"
+    multicodec "^2.0.0"
+    multihashing-async "^2.0.0"
+    uint8arrays "^1.0.0"
+
 ipld-dag-cbor@~0.13.0:
   version "0.13.1"
   resolved "https://registry.yarnpkg.com/ipld-dag-cbor/-/ipld-dag-cbor-0.13.1.tgz#2ffecba3a13b29d8b604ee3d9b5dad0d4542e26b"
@@ -13855,6 +14067,21 @@ ipld-dag-cbor@~0.15.0:
     multicodec "^1.0.0"
     multihashing-async "~0.8.0"
 
+ipld-dag-pb@^0.20.0:
+  version "0.20.0"
+  resolved "https://registry.yarnpkg.com/ipld-dag-pb/-/ipld-dag-pb-0.20.0.tgz#025c0343aafe6cb9db395dd1dc93c8c60a669360"
+  integrity sha512-zfM0EdaolqNjAxIrtpuGKvXxWk5YtH9jKinBuQGTcngOsWFQhyybGCTJHGNGGtRjHNJi2hz5Udy/8pzv4kcKyg==
+  dependencies:
+    cids "^1.0.0"
+    class-is "^1.1.0"
+    multicodec "^2.0.0"
+    multihashing-async "^2.0.0"
+    protons "^2.0.0"
+    reset "^0.1.0"
+    run "^1.4.0"
+    stable "^0.1.8"
+    uint8arrays "^1.0.0"
+
 ipld-dag-pb@~0.15.2:
   version "0.15.3"
   resolved "https://registry.yarnpkg.com/ipld-dag-pb/-/ipld-dag-pb-0.15.3.tgz#0f72b99e77e3265f722457de5dc63b0f700fbb7d"
@@ -13900,6 +14127,15 @@ ipld-raw@^2.0.1:
     cids "~0.5.2"
     multihashing-async "~0.5.1"
 
+ipld-raw@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/ipld-raw/-/ipld-raw-6.0.0.tgz#74d947fcd2ce4e0e1d5bb650c1b5754ed8ea6da0"
+  integrity sha512-UK7fjncAzs59iu/o2kwYtb8jgTtW6B+cNWIiNpAJkfRwqoMk1xD/6i25ktzwe4qO8gQgoR9RxA5ibC23nq8BLg==
+  dependencies:
+    cids "^1.0.0"
+    multicodec "^2.0.0"
+    multihashing-async "^2.0.0"
+
 ipld@^0.20.2:
   version "0.20.2"
   resolved "https://registry.yarnpkg.com/ipld/-/ipld-0.20.2.tgz#1b4c007c361dd0426207d86305805ef8df79b763"
@@ -14634,6 +14870,11 @@ isexe@^2.0.0:
   resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
   integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
 
+iso-constants@^0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/iso-constants/-/iso-constants-0.1.2.tgz#3d2456ed5aeaa55d18564f285ba02a47a0d885b4"
+  integrity sha512-OTCM5ZCQsHBCI4Wdu4tSxvDIkmDHd5EwJDps5mKqnQnWJSKlnwMs3EDZ4n3Fh1tmkWkDlyd2vCDbEYuPbyrUNQ==
+
 iso-random-stream@^1.1.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/iso-random-stream/-/iso-random-stream-1.1.1.tgz#83824bba77fbb3480dd6b35fbb06de7f9e93e80f"
@@ -14651,16 +14892,16 @@ iso-stream-http@~0.1.2:
     inherits "^2.0.1"
     readable-stream "^3.1.1"
 
+iso-url@^0.4.7, iso-url@~0.4.6, iso-url@~0.4.7:
+  version "0.4.7"
+  resolved "https://registry.yarnpkg.com/iso-url/-/iso-url-0.4.7.tgz#de7e48120dae46921079fe78f325ac9e9217a385"
+  integrity sha512-27fFRDnPAMnHGLq36bWTpKET+eiXct3ENlCcdcMdk+mjXrb2kw3mhBUg1B7ewAC0kVzlOPhADzQgz1SE6Tglog==
+
 iso-url@~0.4.4:
   version "0.4.6"
   resolved "https://registry.yarnpkg.com/iso-url/-/iso-url-0.4.6.tgz#45005c4af4984cad4f8753da411b41b74cf0a8a6"
   integrity sha512-YQO7+aIe6l1aSJUKOx+Vrv08DlhZeLFIVfehG2L29KLSEb9RszqPXilxJRVpp57px36BddKR5ZsebacO5qG0tg==
 
-iso-url@~0.4.6, iso-url@~0.4.7:
-  version "0.4.7"
-  resolved "https://registry.yarnpkg.com/iso-url/-/iso-url-0.4.7.tgz#de7e48120dae46921079fe78f325ac9e9217a385"
-  integrity sha512-27fFRDnPAMnHGLq36bWTpKET+eiXct3ENlCcdcMdk+mjXrb2kw3mhBUg1B7ewAC0kVzlOPhADzQgz1SE6Tglog==
-
 isobject@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89"
@@ -14784,6 +15025,89 @@ istanbul-reports@^3.0.2:
     html-escaper "^2.0.0"
     istanbul-lib-report "^3.0.0"
 
+it-all@^1.0.1, it-all@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/it-all/-/it-all-1.0.4.tgz#5a1aac996e2516c0d030911a631190b330afdb6d"
+  integrity sha512-7K+gjHHzZ7t+bCkrtulYiow35k3UgqH7miC+iUa9RGiyDRXJ6hVDeFsDrnWrlscjrkLFOJRKHxNOke4FNoQnhw==
+
+it-concat@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/it-concat/-/it-concat-1.0.1.tgz#4ac61c1b0a2185c97e1bd004d45f625902f7d728"
+  integrity sha512-ca7tnIqSpPycty9K+x08OwFj9kLSrsXgENn7ry2mNXlFlUkgEZe1/xvBjwnUlUEHvnITMj4Mq7ozPm1VaOm8FQ==
+  dependencies:
+    bl "^4.0.0"
+
+it-drain@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/it-drain/-/it-drain-1.0.3.tgz#2a3e6e667f65f5711faedb40ffb5358927609e93"
+  integrity sha512-KxwHBEpWW+0/EkGCOPR2MaHanvBW2A76tOC5CiitoJGLd8J56FxM6jJX3uow20v5qMidX5lnKgwH5oCIyYDszQ==
+
+it-first@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/it-first/-/it-first-1.0.4.tgz#359f2bf216686ec7498827991dc7fd503283b32b"
+  integrity sha512-L5ZB5k3Ol5ouAzLHo6fOCtByOy2lNjteNJpZLkE+VgmRt0MbC1ibmBW1AbOt6WzDx/QXFG5C8EEvY2nTXHg+Hw==
+
+it-glob@0.0.8:
+  version "0.0.8"
+  resolved "https://registry.yarnpkg.com/it-glob/-/it-glob-0.0.8.tgz#b63d24945c18b35de8bb593a8c872fd0257c0cac"
+  integrity sha512-PmIAgb64aJPM6wwT1UTlNDAJnNgdGrvr0vRr3AYCngcUuq1KaAovuz0dQAmUkaXudDG3EQzc7OttuLW9DaL3YQ==
+  dependencies:
+    fs-extra "^8.1.0"
+    minimatch "^3.0.4"
+
+it-last@^1.0.2, it-last@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/it-last/-/it-last-1.0.4.tgz#4009aac79ee76e3417443c6c1dfb64cd380e9e5b"
+  integrity sha512-h0aV43BaD+1nubAKwStWcda6vlbejPSTQKfOrQvyNrrceluWfoq8DrBXnL0PSz6RkyHSiVSHtAEaqUijYMPo8Q==
+
+it-map@^1.0.2:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/it-map/-/it-map-1.0.4.tgz#d413d2b0c3d8d9703df9e8a915ad96cb74a837ac"
+  integrity sha512-LZgYdb89XMo8cFUp6jF0cn5j3gF7wcZnKRVFS3qHHn0bPB2rpToh2vIkTBKduZLZxRRjWx1VW/udd98x+j2ulg==
+
+it-peekable@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/it-peekable/-/it-peekable-0.0.1.tgz#e3f91583d172444b9cd894ed2df6e26f0c176617"
+  integrity sha512-fd0JzbNldseeq+FFWthbqYB991UpKNyjPG6LqFhIOmJviCxSompMyoopKIXvLPLY+fBhhv2CT5PT31O/lEnTHw==
+
+it-reader@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/it-reader/-/it-reader-2.1.0.tgz#b1164be343f8538d8775e10fb0339f61ccf71b0f"
+  integrity sha512-hSysqWTO9Tlwc5EGjVf8JYZzw0D2FsxD/g+eNNWrez9zODxWt6QlN6JAMmycK72Mv4jHEKEXoyzUN4FYGmJaZw==
+  dependencies:
+    bl "^4.0.0"
+
+it-tar@^1.2.2:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/it-tar/-/it-tar-1.2.2.tgz#8d79863dad27726c781a4bcc491f53c20f2866cf"
+  integrity sha512-M8V4a9I+x/vwXTjqvixcEZbQZHjwDIb8iUQ+D4M2QbhAdNs3WKVSl+45u5/F2XFx6jYMFOGzMVlKNK/uONgNIA==
+  dependencies:
+    bl "^4.0.0"
+    buffer "^5.4.3"
+    iso-constants "^0.1.2"
+    it-concat "^1.0.0"
+    it-reader "^2.0.0"
+    p-defer "^3.0.0"
+
+it-to-buffer@^1.0.0, it-to-buffer@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/it-to-buffer/-/it-to-buffer-1.0.4.tgz#4fcbd34c9c503e607744c0fdbeaff30008429703"
+  integrity sha512-wycpGeAdQ8WH8eSBkMHN/HMNiQ0Y88XEXo6s6LGJbQZjf9K7ppVzUfCXn7OnxFfUPN0HTWZr+uhthwtrwMTTfw==
+  dependencies:
+    buffer "^5.5.0"
+
+it-to-stream@^0.1.1:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/it-to-stream/-/it-to-stream-0.1.2.tgz#7163151f75b60445e86b8ab1a968666acaacfe7b"
+  integrity sha512-DTB5TJRZG3untmZehcaFN0kGWl2bNv7tnJRgQHAO9QEt8jfvVRrebZtnD5NZd4SCj4WVPjl0LSrugNWE/UaZRQ==
+  dependencies:
+    buffer "^5.6.0"
+    fast-fifo "^1.0.0"
+    get-iterator "^1.0.2"
+    p-defer "^3.0.0"
+    p-fifo "^1.0.0"
+    readable-stream "^3.6.0"
+
 iterate-iterator@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/iterate-iterator/-/iterate-iterator-1.0.1.tgz#1693a768c1ddd79c969051459453f082fe82e9f6"
@@ -17083,6 +17407,13 @@ merge-options@^1.0.1:
   dependencies:
     is-plain-obj "^1.1"
 
+merge-options@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/merge-options/-/merge-options-2.0.0.tgz#36ca5038badfc3974dbde5e58ba89d3df80882c3"
+  integrity sha512-S7xYIeWHl2ZUKF7SDeBhGg6rfv5bKxVBdk95s/I7wVF8d+hjLSztJ/B271cnUiF6CAFduEQ5Zn3HYwAjT16DlQ==
+  dependencies:
+    is-plain-obj "^2.0.0"
+
 merge-source-map@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/merge-source-map/-/merge-source-map-1.1.0.tgz#2fdde7e6020939f70906a68f2d7ae685e4c8c646"
@@ -17278,7 +17609,7 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1:
   resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
   integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=
 
-"minimatch@2 || 3", minimatch@3.0.4, minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.2:
+minimatch@*, "minimatch@2 || 3", minimatch@3.0.4, minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.2:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
   integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
@@ -17539,6 +17870,13 @@ multer@^1.4.1:
     type-is "^1.6.4"
     xtend "^4.0.0"
 
+multiaddr-to-uri@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/multiaddr-to-uri/-/multiaddr-to-uri-6.0.0.tgz#8f08a75c6eeb2370d5d24b77b8413e3f0fa9bcc0"
+  integrity sha512-OjpkVHOXEmIKMO8WChzzQ7aZQcSQX8squxmvtDbRpy7/QNmJ3Z7jv6qyD74C28QtaeNie8O8ngW2AkeiMmKP7A==
+  dependencies:
+    multiaddr "^8.0.0"
+
 multiaddr@^6.0.3, multiaddr@^6.0.4, multiaddr@^6.0.6, multiaddr@^6.1.0:
   version "6.1.1"
   resolved "https://registry.yarnpkg.com/multiaddr/-/multiaddr-6.1.1.tgz#9aae57b3e399089b9896d9455afa8f6b117dff06"
@@ -17563,6 +17901,18 @@ multiaddr@^7.2.1, multiaddr@^7.3.0:
     multibase "^0.7.0"
     varint "^5.0.0"
 
+multiaddr@^8.0.0:
+  version "8.0.0"
+  resolved "https://registry.yarnpkg.com/multiaddr/-/multiaddr-8.0.0.tgz#22658c1811fd46fc26aaa66ed8c0de712e9a50ef"
+  integrity sha512-4OOyr0u0i4lvh9MY/mvuCNmH5eqoTamcnGeXz6umFGc0eaVQUGPDQNbp52YfFY92NlZ76pO6h4K2HkXsT5X43w==
+  dependencies:
+    cids "^1.0.0"
+    class-is "^1.1.0"
+    is-ip "^3.1.0"
+    multibase "^3.0.0"
+    uint8arrays "^1.1.0"
+    varint "^5.0.0"
+
 multibase@^0.7.0:
   version "0.7.0"
   resolved "https://registry.yarnpkg.com/multibase/-/multibase-0.7.0.tgz#1adfc1c50abe05eefeb5091ac0c2728d6b84581b"
@@ -17579,6 +17929,14 @@ multibase@^1.0.0, multibase@^1.0.1:
     base-x "^3.0.8"
     buffer "^5.5.0"
 
+multibase@^3.0.0, multibase@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/multibase/-/multibase-3.0.1.tgz#31e8b0de5b2fd5f7dc9f04dddf0a4fcc667284bf"
+  integrity sha512-MRU5WpnSg81/vYO977MweoeUAxBdXl7+F5Af2Es+X6Vcgfk/g/EjIqXTgm3kb+xO3m1Kzr+aIV14oRX7nv5Z9w==
+  dependencies:
+    "@multiformats/base-x" "^4.0.1"
+    web-encoding "^1.0.2"
+
 multibase@~0.6.0:
   version "0.6.0"
   resolved "https://registry.yarnpkg.com/multibase/-/multibase-0.6.0.tgz#0216e350614c7456da5e8e5b20d3fcd4c9104f56"
@@ -17607,6 +17965,14 @@ multicodec@^1.0.0, multicodec@^1.0.1:
     buffer "^5.6.0"
     varint "^5.0.0"
 
+multicodec@^2.0.0, multicodec@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/multicodec/-/multicodec-2.0.1.tgz#0971bbef83fcb354315c837c9a3f3e2e422af371"
+  integrity sha512-YDYeWn9iGa76hOHAyyZa0kbt3tr5FLg1ZXUHrZUJltjnxxdbTIbHnxWLd2zTcMOjdT3QyO+Xs4bQgJUcC2RWUA==
+  dependencies:
+    uint8arrays "1.0.0"
+    varint "^5.0.0"
+
 multicodec@~0.5.0, multicodec@~0.5.1:
   version "0.5.5"
   resolved "https://registry.yarnpkg.com/multicodec/-/multicodec-0.5.5.tgz#55c2535b44eca9ea40a13771420153fe075bb36d"
@@ -17614,6 +17980,15 @@ multicodec@~0.5.0, multicodec@~0.5.1:
   dependencies:
     varint "^5.0.0"
 
+multihashes@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/multihashes/-/multihashes-3.0.1.tgz#607c243d5e04ec022ac76c9c114e08416216f019"
+  integrity sha512-fFY67WOtb0359IjDZxaCU3gJILlkwkFbxbwrK9Bej5+NqNaYztzLOj8/NgMNMg/InxmhK+Uu8S/U4EcqsHzB7Q==
+  dependencies:
+    multibase "^3.0.0"
+    uint8arrays "^1.0.0"
+    varint "^5.0.0"
+
 multihashes@~0.4.13, multihashes@~0.4.14:
   version "0.4.15"
   resolved "https://registry.yarnpkg.com/multihashes/-/multihashes-0.4.15.tgz#6dbc55f7f312c6782f5367c03c9783681589d8a6"
@@ -17631,6 +18006,18 @@ multihashes@~0.4.15, multihashes@~0.4.19:
     multibase "^1.0.1"
     varint "^5.0.0"
 
+multihashing-async@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/multihashing-async/-/multihashing-async-2.0.1.tgz#cc50e05e88b02ed0a2d8a9518d8a6cf1fcf12aa1"
+  integrity sha512-LZcH8PqW4iEKymaJ3RpsgpSJhXF29kAvO02ccqbysiXkQhZpVce8rrg+vzRKWO89hhyIBnQHI2e/ZoRVxmiJ2Q==
+  dependencies:
+    blakejs "^1.1.0"
+    err-code "^2.0.0"
+    js-sha3 "^0.8.0"
+    multihashes "^3.0.1"
+    murmurhash3js-revisited "^3.0.0"
+    uint8arrays "^1.0.0"
+
 multihashing-async@~0.5.1:
   version "0.5.2"
   resolved "https://registry.yarnpkg.com/multihashing-async/-/multihashing-async-0.5.2.tgz#4af40e0dde2f1dbb12a7c6b265181437ac26b9de"
@@ -17731,7 +18118,7 @@ nan@^2.13.2:
   resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01"
   integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==
 
-nanoid@^3.1.3:
+nanoid@^3.0.2, nanoid@^3.1.3:
   version "3.1.12"
   resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.12.tgz#6f7736c62e8d39421601e4a0c77623a97ea69654"
   integrity sha512-1qstj9z5+x491jfiC4Nelk+f8XBad7LN20PmyWINJEMRSf3wcAjAWysw1qaA8z6NSKe2sjq1hRSDpBH5paCb6A==
@@ -18788,6 +19175,14 @@ p-each-series@^2.1.0:
   resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-2.1.0.tgz#961c8dd3f195ea96c747e636b262b800a6b1af48"
   integrity sha512-ZuRs1miPT4HrjFa+9fRfOFXxGJfORgelKV9f9nNOWw2gl6gVsRaVDOQP0+MI0G0wGKns1Yacsu0GjOFbTK0JFQ==
 
+p-fifo@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/p-fifo/-/p-fifo-1.0.0.tgz#e29d5cf17c239ba87f51dde98c1d26a9cfe20a63"
+  integrity sha512-IjoCxXW48tqdtDFz6fqo5q1UfFVjjVZe8TC1QRflvNUJtNfCUhxOUw6MOVZhDPjqhSzc26xKdugsO17gmzd5+A==
+  dependencies:
+    fast-fifo "^1.0.0"
+    p-defer "^3.0.0"
+
 p-finally@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
@@ -18992,6 +19387,11 @@ parse-asn1@^5.0.0:
     pbkdf2 "^3.0.3"
     safe-buffer "^5.1.1"
 
+parse-duration@^0.4.4:
+  version "0.4.4"
+  resolved "https://registry.yarnpkg.com/parse-duration/-/parse-duration-0.4.4.tgz#11c0f51a689e97d06c57bd772f7fda7dc013243c"
+  integrity sha512-KbAJuYGUhZkB9gotDiKLnZ7Z3VTacK3fgwmDdB6ZVDtJbMBT6MfLga0WJaYpPDu0mzqT0NgHtHDt5PY4l0nidg==
+
 parse-entities@^1.0.2, parse-entities@^1.1.0, parse-entities@^1.1.2:
   version "1.2.2"
   resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-1.2.2.tgz#c31bf0f653b6661354f8973559cb86dd1d5edf50"
@@ -20173,6 +20573,16 @@ protons@^1.0.1:
     signed-varint "^2.0.1"
     varint "^5.0.0"
 
+protons@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/protons/-/protons-2.0.0.tgz#a6910161f0ed0f3a9007c1503acda7a402023cd8"
+  integrity sha512-BTrE9D6/d1NGis+0D8TqAO1THdn4evHQhfjapA0NUaRH4+ecJJcbqaF7TE/DKv5czE9VB/TeOllBOmCyJhHnhg==
+  dependencies:
+    protocol-buffers-schema "^3.3.1"
+    signed-varint "^2.0.1"
+    uint8arrays "^1.0.0"
+    varint "^5.0.0"
+
 proxy-addr@~2.0.5:
   version "2.0.5"
   resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.5.tgz#34cbd64a2d81f4b1fd21e76f9f06c8a45299ee34"
@@ -21696,6 +22106,11 @@ reselect@^4.0.0:
   resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7"
   integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA==
 
+reset@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/reset/-/reset-0.1.0.tgz#9fc7314171995ae6cb0b7e58b06ce7522af4bafb"
+  integrity sha1-n8cxQXGZWubLC35YsGznUir0uvs=
+
 resize-observer-polyfill@^1.5.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
@@ -21927,6 +22342,13 @@ run-queue@^1.0.0, run-queue@^1.0.3:
   dependencies:
     aproba "^1.1.1"
 
+run@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/run/-/run-1.4.0.tgz#e17d9e9043ab2fe17776cb299e1237f38f0b4ffa"
+  integrity sha1-4X2ekEOrL+F3dsspnhI3848LT/o=
+  dependencies:
+    minimatch "*"
+
 rxjs-compat@^6.6.0:
   version "6.6.2"
   resolved "https://registry.yarnpkg.com/rxjs-compat/-/rxjs-compat-6.6.2.tgz#23592564243cf24641a5d2e2d2acfc8f6b127186"
@@ -23045,6 +23467,13 @@ stream-shift@^1.0.0:
   resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d"
   integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==
 
+stream-to-it@^0.2.0, stream-to-it@^0.2.1:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/stream-to-it/-/stream-to-it-0.2.2.tgz#fb3de7917424c354a987c7bc2aab2d0facbd7d94"
+  integrity sha512-waULBmQpVdr6TkDzci6t1P7dIaSZ0bHC1TaPXDUeJC5PpSK7U3T0H0Zeo/LWUnd6mnhXOmGGDKAkjUCHw5IOng==
+  dependencies:
+    get-iterator "^1.0.2"
+
 stream-to-pull-stream@^1.7.2:
   version "1.7.3"
   resolved "https://registry.yarnpkg.com/stream-to-pull-stream/-/stream-to-pull-stream-1.7.3.tgz#4161aa2d2eb9964de60bfa1af7feaf917e874ece"
@@ -24537,6 +24966,22 @@ uid-number@0.0.6:
   resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81"
   integrity sha1-DqEOgDXo61uOREnwbaHHMGY7qoE=
 
+uint8arrays@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/uint8arrays/-/uint8arrays-1.0.0.tgz#9cf979517f85c32d6ef54adf824e3499bb715331"
+  integrity sha512-14tqEVujDREW7YwonSZZwLvo7aFDfX7b6ubvM/U7XvZol+CC/LbhaX/550VlWmhddAL9Wou1sxp0Of3tGqXigg==
+  dependencies:
+    multibase "^3.0.0"
+    web-encoding "^1.0.2"
+
+uint8arrays@^1.0.0, uint8arrays@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/uint8arrays/-/uint8arrays-1.1.0.tgz#d034aa65399a9fd213a1579e323f0b29f67d0ed2"
+  integrity sha512-cLdlZ6jnFczsKf5IH1gPHTtcHtPGho5r4CvctohmQjw8K7Q3gFdfIGHxSTdTaCKrL4w09SsPRJTqRS0drYeszA==
+  dependencies:
+    multibase "^3.0.0"
+    web-encoding "^1.0.2"
+
 umask@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/umask/-/umask-1.1.0.tgz#f29cebf01df517912bb58ff9c4e50fde8e33320d"
@@ -25432,6 +25877,11 @@ wcwidth@^1.0.0, wcwidth@^1.0.1:
   dependencies:
     defaults "^1.0.3"
 
+web-encoding@^1.0.2:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/web-encoding/-/web-encoding-1.0.4.tgz#0398d39ce2cbef5ed2617080750ed874e6153aea"
+  integrity sha512-DcXs2lbVPzuJmn2kuDEwul2oZg7p4YMa5J2f0YzsOBHaAnBYGPNUB/rJ74DTjTKpw7F0+lSsVM8sFHE2UyBixg==
+
 webidl-conversions@^4.0.2:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
@@ -25740,7 +26190,7 @@ which-pm-runs@^1.0.0:
   resolved "https://registry.yarnpkg.com/which-pm-runs/-/which-pm-runs-1.0.0.tgz#670b3afbc552e0b55df6b7780ca74615f23ad1cb"
   integrity sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs=
 
-which@1, which@^1.2.14, which@^1.2.9, which@^1.3.0, which@^1.3.1:
+which@1, which@^1.1.1, which@^1.2.14, which@^1.2.9, which@^1.3.0, which@^1.3.1:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
   integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==