Przeglądaj źródła

Merge branch 'babylon' into cli-working-groups-improvements

Leszek Wiesner 4 lat temu
rodzic
commit
e82e644d80
100 zmienionych plików z 2310 dodań i 2819 usunięć
  1. 58 13
      .github/workflows/run-network-tests.yml
  2. 101 0
      cli/README.md
  3. 13 0
      cli/package.json
  4. 3 0
      cli/src/@types/@ffmpeg-installer/ffmpeg/index.d.ts
  5. 1 0
      cli/src/@types/ipfs-http-client/index.d.ts
  6. 1 0
      cli/src/@types/ipfs-only-hash/index.d.ts
  7. 50 7
      cli/src/Api.ts
  8. 2 0
      cli/src/ExitCodes.ts
  9. 30 3
      cli/src/base/AccountsCommandBase.ts
  10. 34 8
      cli/src/base/ApiCommandBase.ts
  11. 190 16
      cli/src/base/ContentDirectoryCommandBase.ts
  12. 2 0
      cli/src/base/StateAwareCommandBase.ts
  13. 33 16
      cli/src/base/WorkingGroupsCommandBase.ts
  14. 22 10
      cli/src/commands/content-directory/addClassSchema.ts
  15. 2 1
      cli/src/commands/content-directory/class.ts
  16. 11 5
      cli/src/commands/content-directory/createClass.ts
  17. 13 9
      cli/src/commands/content-directory/curatorGroups.ts
  18. 22 17
      cli/src/commands/content-directory/entities.ts
  19. 9 2
      cli/src/commands/content-directory/entity.ts
  20. 46 0
      cli/src/commands/content-directory/removeCuratorFromGroup.ts
  21. 7 2
      cli/src/commands/content-directory/removeCuratorGroup.ts
  22. 53 0
      cli/src/commands/media/createChannel.ts
  23. 57 0
      cli/src/commands/media/curateContent.ts
  24. 25 0
      cli/src/commands/media/myChannels.ts
  25. 33 0
      cli/src/commands/media/myVideos.ts
  26. 97 0
      cli/src/commands/media/updateChannel.ts
  27. 116 0
      cli/src/commands/media/updateVideo.ts
  28. 372 0
      cli/src/commands/media/uploadVideo.ts
  29. 10 4
      cli/src/helpers/InputOutput.ts
  30. 52 21
      cli/src/helpers/JsonSchemaPrompt.ts
  31. 1 0
      content-directory-schemas/.npmignore
  32. 128 1
      content-directory-schemas/README.md
  33. 52 0
      content-directory-schemas/examples/createChannel.ts
  34. 68 0
      content-directory-schemas/examples/createChannelWithoutTransaction.ts
  35. 76 0
      content-directory-schemas/examples/createVideo.ts
  36. 47 0
      content-directory-schemas/examples/updateChannelTitle.ts
  37. 47 0
      content-directory-schemas/examples/updateChannelTitleWithoutTransaction.ts
  38. 0 7
      content-directory-schemas/inputs/classes/CurationStatusClass.json
  39. 15 2
      content-directory-schemas/inputs/entityBatches/ContentCategoryBatch.json
  40. 36 1
      content-directory-schemas/inputs/entityBatches/LanguageBatch.json
  41. 4 4
      content-directory-schemas/inputs/entityBatches/VideoBatch.json
  42. 24 1
      content-directory-schemas/inputs/entityBatches/VideoMediaEncodingBatch.json
  43. 3 3
      content-directory-schemas/inputs/schemas/ChannelSchema.json
  44. 0 27
      content-directory-schemas/inputs/schemas/CurationStatusSchema.json
  45. 3 3
      content-directory-schemas/inputs/schemas/VideoSchema.json
  46. 6 1
      content-directory-schemas/package.json
  47. 3 2
      content-directory-schemas/schemas/extrinsics/AddClassSchema.schema.json
  48. 6 2
      content-directory-schemas/schemas/extrinsics/CreateClass.schema.json
  49. 1 1
      content-directory-schemas/scripts/initializeContentDir.ts
  50. 12 1
      content-directory-schemas/scripts/schemasToTS.ts
  51. 249 85
      content-directory-schemas/src/helpers/InputParser.ts
  52. 32 8
      content-directory-schemas/src/helpers/extrinsics.ts
  53. 19 0
      content-directory-schemas/src/helpers/initialize.ts
  54. 1 0
      content-directory-schemas/src/index.ts
  55. 1 1
      content-directory-schemas/tsconfig.json
  56. 2 0
      node/src/chain_spec/mod.rs
  57. 1 1
      package.json
  58. 0 1
      pioneer/.eslintignore
  59. 1 1
      pioneer/packages/apps-routing/src/joy-roles.ts
  60. 0 1
      pioneer/packages/apps/public/locales/en/index.json
  61. 0 5
      pioneer/packages/apps/public/locales/en/joy-media.json
  62. 1 1
      pioneer/packages/apps/src/Content/NotFound.tsx
  63. 4 4
      pioneer/packages/joy-forum/src/Context.tsx
  64. 2 2
      pioneer/packages/joy-forum/src/calls.tsx
  65. 0 0
      pioneer/packages/joy-media/.skip-build
  66. 0 3
      pioneer/packages/joy-media/README.md
  67. 0 2
      pioneer/packages/joy-media/aplayer.d.ts
  68. 0 2
      pioneer/packages/joy-media/dplayer.d.ts
  69. 0 26
      pioneer/packages/joy-media/package.json
  70. 0 204
      pioneer/packages/joy-media/src/DiscoveryProvider.tsx
  71. 0 62
      pioneer/packages/joy-media/src/IterableFile.ts
  72. 0 98
      pioneer/packages/joy-media/src/MediaView.tsx
  73. 0 40
      pioneer/packages/joy-media/src/TransportContext.tsx
  74. 0 400
      pioneer/packages/joy-media/src/Upload.tsx
  75. 0 37
      pioneer/packages/joy-media/src/channels/ChannelAvatar.tsx
  76. 0 23
      pioneer/packages/joy-media/src/channels/ChannelAvatarAndName.tsx
  77. 0 20
      pioneer/packages/joy-media/src/channels/ChannelHeader.tsx
  78. 0 34
      pioneer/packages/joy-media/src/channels/ChannelHelpers.ts
  79. 0 19
      pioneer/packages/joy-media/src/channels/ChannelNameAsLink.tsx
  80. 0 114
      pioneer/packages/joy-media/src/channels/ChannelPreview.tsx
  81. 0 43
      pioneer/packages/joy-media/src/channels/ChannelPreviewStats.tsx
  82. 0 79
      pioneer/packages/joy-media/src/channels/ChannelsByOwner.tsx
  83. 0 34
      pioneer/packages/joy-media/src/channels/ChannelsByOwner.view.tsx
  84. 0 94
      pioneer/packages/joy-media/src/channels/CurationPanel.tsx
  85. 0 219
      pioneer/packages/joy-media/src/channels/EditChannel.tsx
  86. 0 38
      pioneer/packages/joy-media/src/channels/EditChannel.view.tsx
  87. 0 41
      pioneer/packages/joy-media/src/channels/ViewChannel.tsx
  88. 0 35
      pioneer/packages/joy-media/src/channels/ViewChannel.view.tsx
  89. 0 47
      pioneer/packages/joy-media/src/channels/ViewMusicChannel.tsx
  90. 0 34
      pioneer/packages/joy-media/src/channels/ViewVideoChannel.tsx
  91. 0 51
      pioneer/packages/joy-media/src/channels/YouHaveNoChannels.tsx
  92. 0 43
      pioneer/packages/joy-media/src/common/BgImg.tsx
  93. 0 59
      pioneer/packages/joy-media/src/common/FormTabs.tsx
  94. 0 44
      pioneer/packages/joy-media/src/common/MediaDropdownOptions.tsx
  95. 0 202
      pioneer/packages/joy-media/src/common/MediaForms.tsx
  96. 0 145
      pioneer/packages/joy-media/src/common/MediaPlayerView.tsx
  97. 0 159
      pioneer/packages/joy-media/src/common/MediaPlayerWithResolver.tsx
  98. 0 7
      pioneer/packages/joy-media/src/common/NoContentYet.tsx
  99. 0 44
      pioneer/packages/joy-media/src/common/TypeHelpers.ts
  100. 0 17
      pioneer/packages/joy-media/src/common/images.tsx

+ 58 - 13
.github/workflows/run-network-tests.yml

@@ -1,7 +1,7 @@
 name: run-network-tests
 on:
   pull_request:
-    types: [opened, labeled, synchronize]
+    types: [opened, synchronize]
 
   workflow_dispatch:
     # TODO: add an input so dispatcher can specify a list of tests to run,
@@ -78,9 +78,8 @@ jobs:
           name: ${{ steps.compute_shasum.outputs.shasum }}-joystream-node-docker-image.tar.gz
           path: joystream-node-docker-image.tar.gz
   
-  network_tests_1:
-    name: Network Integration Runtime Tests
-    if: contains(github.event.pull_request.labels.*.name, 'run-network-tests')
+  basic_runtime_with_upgrade:
+    name: Integration Tests (Runtime Upgrade)
     needs: build_images
     runs-on: ubuntu-latest
     steps:
@@ -101,11 +100,34 @@ jobs:
       - name: Ensure tests are runnable
         run: yarn workspace network-tests build
       - name: Execute network tests
-        run: tests/network-tests/run-tests.sh
+        run: RUNTIME=alexandria tests/network-tests/run-tests.sh full
 
-  network_tests_2:
+  basic_runtime:
+    name: Integration Tests (New Chain)
+    needs: build_images
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v1
+      - uses: actions/setup-node@v1
+        with:
+          node-version: '12.x'
+      - name: Get artifacts
+        uses: actions/download-artifact@v2
+        with:
+          name: ${{ needs.build_images.outputs.use_artifact }}
+      - name: Install artifacts
+        run: |
+          docker load --input joystream-node-docker-image.tar.gz
+          docker images
+      - name: Install packages and dependencies
+        run: yarn install --frozen-lockfile
+      - name: Ensure tests are runnable
+        run: yarn workspace network-tests build
+      - name: Execute network tests
+        run: tests/network-tests/run-tests.sh full
+
+  content_dir_init:
     name: Content Directory Initialization
-    if: contains(github.event.pull_request.labels.*.name, 'run-network-tests')
     needs: build_images
     runs-on: ubuntu-latest
     steps:
@@ -130,9 +152,8 @@ jobs:
       - name: Initialize the content directory
         run: yarn workspace cd-schemas initialize:dev
 
-  network_tests_3:
-    name: Storage Node Tests
-    if: contains(github.event.pull_request.labels.*.name, 'run-network-tests')
+  query_node:
+    name: Query Node Integration Tests
     needs: build_images
     runs-on: ubuntu-latest
     steps:
@@ -149,9 +170,33 @@ jobs:
           docker load --input joystream-node-docker-image.tar.gz
           docker images
       - name: Install packages and dependencies
+        run: yarn install --frozen-lockfile
+      - name: Ensure tests are runnable
+        run: yarn workspace network-tests build
+      # Bring up hydra query-node development instance, then run content directory
+      # integration tests
+      - name: Execute Tests
+        run: tests/query-node/run-tests.sh
+  
+  storage_node:
+    name: Storage Node Tests
+    needs: build_images
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v1
+      - uses: actions/setup-node@v1
+        with:
+          node-version: '12.x'
+      - name: Get artifacts
+        uses: actions/download-artifact@v2
+        with:
+          name: ${{ needs.build_images.outputs.use_artifact }}
+      - name: Install artifacts
         run: |
-          yarn install --frozen-lockfile
-          yarn workspace storage-node build
+          docker load --input joystream-node-docker-image.tar.gz
+          docker images
+      - name: Install packages and dependencies
+        run: yarn install --frozen-lockfile
       - name: Build storage node
         run: yarn workspace storage-node build
       - name: Start Services
@@ -162,4 +207,4 @@ jobs:
         run: sleep 90
         # Better would be poll `http://localhost:3001/discover/v0/1` until we get 200 Response
       - name: Upload a file
-        run: DEBUG=joystream:* yarn storage-cli upload ./pioneer/packages/apps/public/images/default-thumbnail.png 1 0
+        run: DEBUG=joystream:* yarn storage-cli upload ./pioneer/packages/apps/public/images/default-thumbnail.png 1 0

+ 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)

+ 190 - 16
cli/src/base/ContentDirectoryCommandBase.ts

@@ -1,25 +1,65 @@
 import ExitCodes from '../ExitCodes'
-import AccountsCommandBase from './AccountsCommandBase'
-import { WorkingGroups, NamedKeyringPair } from '../Types'
+import { WorkingGroups } 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,
+  Actor,
+} 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 { RolesCommandBase } from './WorkingGroupsCommandBase'
+import { createType } from '@joystream/types'
+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 {
+export default abstract class ContentDirectoryCommandBase extends RolesCommandBase {
+  group = WorkingGroups.Curators // override group for RolesCommandBase
+
   // Use when lead access is required in given command
   async requireLead(): Promise<void> {
-    const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
-    const lead = await this.getApi().groupLead(WorkingGroups.Curators)
+    await this.getRequiredLead()
+  }
 
-    if (!lead || lead.roleAccount.toString() !== selectedAccount.address) {
-      this.error('Content Working Group Lead access required for this command!', { exit: ExitCodes.AccessDenied })
+  async getCuratorContext(classNames: string[] = []): Promise<Actor> {
+    const curator = await this.getRequiredWorker()
+    const classes = await Promise.all(classNames.map(async (cName) => (await this.classEntryByNameOrId(cName))[1]))
+    const classMaintainers = classes.map(({ class_permissions: permissions }) => permissions.maintainers.toArray())
+
+    const groups = await this.getApi().availableCuratorGroups()
+    const availableGroupIds = groups
+      .filter(
+        ([groupId, group]) =>
+          group.active.valueOf() &&
+          classMaintainers.every((maintainers) => maintainers.some((m) => m.eq(groupId))) &&
+          group.curators.toArray().some((curatorId) => curatorId.eq(curator.workerId))
+      )
+      .map(([id]) => id)
+
+    let groupId: number
+    if (!availableGroupIds.length) {
+      this.error(
+        'You do not have the required maintainer access to at least one of the following classes: ' +
+          classNames.join(', '),
+        { exit: ExitCodes.AccessDenied }
+      )
+    } else if (availableGroupIds.length === 1) {
+      groupId = availableGroupIds[0].toNumber()
+    } else {
+      groupId = await this.promptForCuratorGroup('Select Curator Group context', availableGroupIds)
     }
+
+    return createType('Actor', { Curator: [groupId, curator.workerId.toNumber()] })
   }
 
   async promptForClass(message = 'Select a class'): Promise<Class> {
@@ -72,6 +112,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 +126,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 +181,12 @@ 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,
+    requireSchema = true
+  ): Promise<Entity> {
     if (typeof id === 'string') {
       id = parseInt(id)
     }
@@ -137,12 +194,91 @@ 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,
+      })
+    }
+
+    if (requireSchema && !entity.supported_schemas.toArray().length) {
+      this.error(`${requiredClass || ''}Entity of id ${id} has no schema support added!`)
     }
 
     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 +299,42 @@ 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)
+    // Create object of default "[not set]" values (prevents breaking the table if entity has no schema support)
+    const defaultValues = entityClass.properties
+      .map((p) => p.name.toString())
+      .reduce((d, propName) => {
+        if (includedProps?.includes(propName)) {
+          d[propName] = chalk.grey('[not set]')
+        }
+        return d
+      }, {} as Record<string, string>)
+
+    const entityEntries = await this.entitiesByClassAndOwner(classId.toNumber(), ownerMemberId)
+    const parsedEntities = (await Promise.all(
+      entityEntries.map(([id, entity]) => ({
+        'ID': id.toString(),
+        ...defaultValues,
+        ..._.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

@@ -13,6 +13,7 @@ type StateObject = {
   selectedAccountFilename: string
   apiUri: string
   defaultWorkingGroup: WorkingGroups
+  metadataCache: Record<string, any>
 }
 
 // State object default values
@@ -20,6 +21,7 @@ const DEFAULT_STATE: StateObject = {
   selectedAccountFilename: '',
   apiUri: '',
   defaultWorkingGroup: WorkingGroups.StorageProviders,
+  metadataCache: {},
 }
 
 // State file path (relative to getAppDataPath())

+ 33 - 16
cli/src/base/WorkingGroupsCommandBase.ts

@@ -24,34 +24,26 @@ import { IConfig } from '@oclif/config'
 const DRAFTS_FOLDER = 'opening-drafts'
 
 /**
- * Abstract base class for commands related to working groups
+ * Abstract base class for commands that need to use gates based on user's roles
  */
-export default abstract class WorkingGroupsCommandBase extends AccountsCommandBase {
+export abstract class RolesCommandBase extends AccountsCommandBase {
   group: WorkingGroups
 
   constructor(argv: string[], config: IConfig) {
     super(argv, config)
+    // Can be modified by child class constructor
     this.group = this.getPreservedState().defaultWorkingGroup
   }
 
-  static flags = {
-    group: flags.enum({
-      char: 'g',
-      description:
-        'The working group context in which the command should be executed\n' +
-        `Available values are: ${AvailableGroups.join(', ')}.`,
-      required: false,
-      options: [...AvailableGroups],
-    }),
-  }
-
   // Use when lead access is required in given command
   async getRequiredLead(): Promise<GroupMember> {
     const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
     const lead = await this.getApi().groupLead(this.group)
 
     if (!lead || lead.roleAccount.toString() !== selectedAccount.address) {
-      this.error('Lead access required for this command!', { exit: ExitCodes.AccessDenied })
+      this.error(`${_.startCase(this.group)} Group Lead access required for this command!`, {
+        exit: ExitCodes.AccessDenied,
+      })
     }
 
     return lead
@@ -64,7 +56,9 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
     const groupMembersByAccount = groupMembers.filter((m) => m.roleAccount.toString() === selectedAccount.address)
 
     if (!groupMembersByAccount.length) {
-      this.error('Worker access required for this command!', { exit: ExitCodes.AccessDenied })
+      this.error(`${_.startCase(this.group)} Group Worker access required for this command!`, {
+        exit: ExitCodes.AccessDenied,
+      })
     } else if (groupMembersByAccount.length === 1) {
       return groupMembersByAccount[0]
     } else {
@@ -93,7 +87,7 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
 
   async promptForWorker(groupMembers: GroupMember[]): Promise<GroupMember> {
     const chosenWorkerIndex = await this.simplePrompt({
-      message: 'Choose the intended worker context:',
+      message: `Choose the intended ${_.startCase(this.group)} Group Worker context:`,
       type: 'list',
       choices: groupMembers.map((groupMember, index) => ({
         name: `Worker ID ${groupMember.workerId.toString()}`,
@@ -103,6 +97,29 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
 
     return groupMembers[chosenWorkerIndex]
   }
+}
+
+/**
+ * Abstract base class for commands directly related to working groups
+ */
+export default abstract class WorkingGroupsCommandBase extends RolesCommandBase {
+  group: WorkingGroups
+
+  constructor(argv: string[], config: IConfig) {
+    super(argv, config)
+    this.group = this.getPreservedState().defaultWorkingGroup
+  }
+
+  static flags = {
+    group: flags.enum({
+      char: 'g',
+      description:
+        'The working group context in which the command should be executed\n' +
+        `Available values are: ${AvailableGroups.join(', ')}.`,
+      required: false,
+      options: [...AvailableGroups],
+    }),
+  }
 
   async promptForApplicationsToAccept(opening: GroupOpening): Promise<number[]> {
     const acceptableApplications = opening.applications.filter((a) => a.stage === ApplicationStageKeys.Active)

+ 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)
   }
 }

+ 9 - 2
cli/src/commands/content-directory/entity.ts

@@ -15,7 +15,7 @@ export default class EntityCommand extends ContentDirectoryCommandBase {
 
   async run() {
     const { id } = this.parse(EntityCommand).args
-    const entity = await this.getEntity(id)
+    const entity = await this.getEntity(id, undefined, undefined, false)
     const { controller, frozen, referenceable } = entity.entity_permissions
     const [classId, entityClass] = await this.classEntryByNameOrId(entity.class_id.toString())
     const propertyValues = this.parseEntityPropertyValues(entity, entityClass)
@@ -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')],
+        ['isCensored', 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])
+    }
+  }
+}

+ 57 - 0
cli/src/commands/media/curateContent.ts

@@ -0,0 +1,57 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { InputParser } from 'cd-schemas'
+import { flags } from '@oclif/command'
+import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+import { VideoEntity } from 'cd-schemas/types/entities/VideoEntity'
+
+const CLASSES = ['Channel', 'Video'] as const
+const STATUSES = ['Accepted', 'Censored'] as const
+
+export default class CurateContentCommand extends ContentDirectoryCommandBase {
+  static description = `Set the curation status of given entity (${CLASSES.join('/')}). Requires Curator access.`
+  static flags = {
+    className: flags.enum({
+      options: [...CLASSES],
+      description: `Name of the class of the entity to curate (${CLASSES.join('/')})`,
+      char: 'c',
+      required: true,
+    }),
+    status: flags.enum({
+      description: `Specifies the curation status (${STATUSES.join('/')})`,
+      char: 's',
+      options: [...STATUSES],
+      required: true,
+    }),
+    id: flags.integer({
+      description: 'ID of the entity to curate',
+      required: true,
+    }),
+  }
+
+  async run() {
+    const { className, status, id } = this.parse(CurateContentCommand).flags
+
+    const account = await this.getRequiredSelectedAccount()
+    // Get curator actor with required maintainer access to $className (Video/Channel) class
+    const actor = await this.getCuratorContext([className])
+
+    await this.requestAccountDecoding(account)
+
+    const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi())
+
+    await this.getEntity(id, className) // Check if entity exists and is of given class
+
+    const entityUpdateInput: Partial<ChannelEntity & VideoEntity> = {
+      isCensored: status === 'Censored',
+    }
+
+    this.log(`Updating the ${className} with:`)
+    this.jsonPrettyPrint(JSON.stringify(entityUpdateInput))
+    const confirmed = await this.simplePrompt({ type: 'confirm', message: 'Do you confirm the provided input?' })
+
+    if (confirmed) {
+      const operations = await inputParser.getEntityUpdateOperations(entityUpdateInput, className, id)
+      await this.sendAndFollowNamedTx(account, 'contentDirectory', 'transaction', [actor, operations], true)
+    }
+  }
+}

+ 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')}`)
+    }
+  }
+}

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

@@ -0,0 +1,97 @@
+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 { Actor, Entity } from '@joystream/types/content-directory'
+import { flags } from '@oclif/command'
+import { createType } from '@joystream/types'
+
+export default class UpdateChannelCommand extends ContentDirectoryCommandBase {
+  static description = 'Update one of the owned channels on Joystream (requires a membership).'
+  static flags = {
+    ...IOFlags,
+    asCurator: flags.boolean({
+      description: 'Provide this flag in order to use Curator context for the update',
+      required: false,
+    }),
+  }
+
+  static args = [
+    {
+      name: 'id',
+      description: 'ID of the channel to update',
+      required: false,
+    },
+  ]
+
+  async run() {
+    const {
+      args: { id },
+      flags: { asCurator },
+    } = this.parse(UpdateChannelCommand)
+
+    const account = await this.getRequiredSelectedAccount()
+
+    let memberId: number | undefined, actor: Actor
+
+    if (asCurator) {
+      actor = await this.getCuratorContext(['Channel'])
+    } else {
+      memberId = await this.getRequiredMemberId()
+      actor = createType('Actor', { Member: memberId })
+    }
+
+    await this.requestAccountDecoding(account)
+
+    let channelEntity: Entity, channelId: number
+    if (id) {
+      channelId = parseInt(id)
+      channelEntity = await this.getEntity(channelId, 'Channel', memberId)
+    } else {
+      const [id, channel] = await this.promptForEntityEntry('Select a channel to 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<ChannelEntity> = [
+        [
+          'language',
+          () =>
+            this.promptForEntityId('Choose channel language', 'Language', 'name', undefined, currentValues.language),
+        ],
+      ]
+
+      if (!asCurator) {
+        // Skip isCensored is it's not updated by the curator
+        customPrompts.push(['isCensored', 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])
+    }
+  }
+}

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

@@ -0,0 +1,116 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import VideoEntitySchema from 'cd-schemas/schemas/entities/VideoEntity.schema.json'
+import { VideoEntity } from 'cd-schemas/types/entities/VideoEntity'
+import { LicenseEntity } from 'cd-schemas/types/entities/LicenseEntity'
+import { InputParser } from 'cd-schemas'
+import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
+import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
+import { Actor, Entity } from '@joystream/types/content-directory'
+import { createType } from '@joystream/types'
+import { flags } from '@oclif/command'
+
+export default class UpdateVideoCommand extends ContentDirectoryCommandBase {
+  static description = 'Update existing video information (requires a membership).'
+  static flags = {
+    // TODO: ...IOFlags, - providing input as json
+    asCurator: flags.boolean({
+      description: 'Specify in order to update the video as curator',
+      required: false,
+    }),
+  }
+
+  static args = [
+    {
+      name: 'id',
+      description: 'ID of the Video to update',
+      required: false,
+    },
+  ]
+
+  async run() {
+    const {
+      args: { id },
+      flags: { asCurator },
+    } = this.parse(UpdateVideoCommand)
+
+    const account = await this.getRequiredSelectedAccount()
+
+    let memberId: number | undefined, actor: Actor
+
+    if (asCurator) {
+      actor = await this.getCuratorContext(['Video', 'License'])
+    } else {
+      memberId = await this.getRequiredMemberId()
+      actor = createType('Actor', { Member: memberId })
+    }
+
+    await this.requestAccountDecoding(account)
+
+    let videoEntity: Entity, videoId: number
+    if (id) {
+      videoId = parseInt(id)
+      videoEntity = await this.getEntity(videoId, 'Video', memberId)
+    } else {
+      const [id, video] = await this.promptForEntityEntry('Select a video to update', 'Video', 'title', memberId)
+      videoId = id.toNumber()
+      videoEntity = video
+    }
+
+    const 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',
+    ])
+
+    if (asCurator) {
+      updatedProps.isCensored = await videoPrompter.promptSingleProp('isCensored')
+    }
+
+    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>
   }
 }

+ 1 - 0
content-directory-schemas/.npmignore

@@ -0,0 +1 @@
+operations.json

+ 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'
+
+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)

+ 68 - 0
content-directory-schemas/examples/createChannelWithoutTransaction.ts

@@ -0,0 +1,68 @@
+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'
+import { FlattenRelations } from 'cd-schemas/types/utility'
+import { EntityId } from '@joystream/types/content-directory'
+
+// Alternative way of creating a channel using separate extrinsics (instead of contentDirectory.transaction)
+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 parser = InputParser.createWithKnownSchemas(api)
+
+  // In this case we need to fetch some data first (like classId and language entity id)
+  const classId = await parser.getClassIdByName('Channel')
+  const languageEntityId = await parser.findEntityIdByUniqueQuery({ code: 'EN' }, 'Language')
+
+  // We use FlattenRelations to exlude { new } and { existing } (which are not allowed if we want to parse only a single entity)
+  const channel: FlattenRelations<ChannelEntity> = {
+    title: 'Example channel 2',
+    description: 'This is an example channel',
+    language: languageEntityId,
+    coverPhotoUrl: '',
+    avatarPhotoURL: '',
+    isPublic: true,
+  }
+
+  // In this case we use some basic callback to retrieve entityId from the extrinsc event
+  const entityId = await new Promise<EntityId>((resolve, reject) => {
+    api.tx.contentDirectory.createEntity(classId, { Member: 0 }).signAndSend(ALICE, {}, (res) => {
+      if (res.isError) {
+        reject(new Error(res.status.type))
+      }
+      res.events.forEach(({ event: e }) => {
+        if (e.method === 'EntityCreated') {
+          resolve(e.data[1] as EntityId)
+        }
+        if (e.method === 'ExtrinsicFailed') {
+          reject(new Error('Extrinsic failed'))
+        }
+      })
+    })
+  })
+
+  const inputPropertyValuesMap = await parser.parseToInputEntityValuesMap({ ...channel }, 'Channel')
+  // Having entityId we can create and send addSchemaSupport tx
+  await api.tx.contentDirectory
+    .addSchemaSupportToEntity(
+      { Member: 0 }, // Context (in this case we assume it's Alice's member id)
+      entityId,
+      0, // Schema (currently we have one schema per class, so it can be just 0)
+      inputPropertyValuesMap
+    )
+    .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)

+ 47 - 0
content-directory-schemas/examples/updateChannelTitleWithoutTransaction.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'
+import { FlattenRelations } from 'cd-schemas/types/utility'
+
+// Alternative way of update a channel using updateEntityPropertyValues extrinsic
+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<FlattenRelations<ChannelEntity>> = {
+    title: 'Updated channel title 2',
+  }
+
+  // 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 ./createChannelWithoutTransaction.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 2' }, 'Channel')
+
+  // We use parser to create input property values map
+  const newPropertyValues = await parser.parseToInputEntityValuesMap(channelUpdateInput, 'Channel')
+
+  await api.tx.contentDirectory
+    .updateEntityPropertyValues(
+      { Member: 0 }, // We use member with id 0 as actor (in this case we assume this is Alice)
+      CHANNEL_ID,
+      newPropertyValues
+    )
+    .signAndSend(ALICE)
+}
+
+main()
+  .then(() => process.exit())
+  .catch(console.error)

+ 0 - 7
content-directory-schemas/inputs/classes/CurationStatusClass.json

@@ -1,7 +0,0 @@
-{
-  "name": "CurationStatus",
-  "description": "Curation status of a related entity (ie. Video or Channel)",
-  "maximum_entities_count": 400,
-  "default_entity_creation_voucher_upper_bound": 50,
-  "class_permissions": { "any_member": false }
-}

+ 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" }
+  ]
 }

+ 3 - 3
content-directory-schemas/inputs/schemas/ChannelSchema.json

@@ -33,11 +33,11 @@
       "property_type": { "Single": "Bool" }
     },
     {
-      "name": "curationStatus",
-      "description": "Channel curation status set by the Curator",
+      "name": "isCensored",
+      "description": "Channel censorship status set by the Curator.",
       "required": false,
       "unique": true,
-      "property_type": { "Single": { "Reference": { "className": "CurationStatus" } } },
+      "property_type": { "Single": "Bool" },
       "locking_policy": { "is_locked_from_controller": true }
     },
     {

+ 0 - 27
content-directory-schemas/inputs/schemas/CurationStatusSchema.json

@@ -1,27 +0,0 @@
-{
-  "className": "CurationStatus",
-  "newProperties": [
-    {
-      "name": "approved",
-      "description": "Indicates whether the content was approved by the Curator",
-      "required": false,
-      "property_type": { "Single": "Bool" },
-      "locking_policy": { "is_locked_from_controller": true }
-    },
-    {
-      "name": "comment",
-      "description": "Short, optional comment from the Curator",
-      "required": false,
-      "property_type": { "Single": { "Text": 256 } },
-      "locking_policy": { "is_locked_from_controller": true }
-    },
-    {
-      "name": "entityId",
-      "description": "ID of the curated entity. It's not a relation to prevent removal lock and allow different types of entities. Used to confirm the validity of Content => CurationStatus relation.",
-      "required": true,
-      "unique": true,
-      "property_type": { "Single": "Uint64" },
-      "locking_policy": { "is_locked_from_controller": true }
-    }
-  ]
-}

+ 3 - 3
content-directory-schemas/inputs/schemas/VideoSchema.json

@@ -90,11 +90,11 @@
       "property_type": { "Single": { "Reference": { "className": "License", "sameOwner": true } } }
     },
     {
-      "name": "curationStatus",
-      "description": "Video curation status set by the Curator",
+      "name": "isCensored",
+      "description": "Video censorship status set by the Curator.",
       "required": false,
       "unique": true,
-      "property_type": { "Single": { "Reference": { "className": "CurationStatus" } } },
+      "property_type": { "Single": "Bool" },
       "locking_policy": { "is_locked_from_controller": true }
     }
   ]

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

@@ -16,7 +16,12 @@
     "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",
+    "example:createChannelWithoutTransaction": "ts-node ./examples/createChannelWithoutTransaction.ts",
+    "example:updateChannelTitlelWithoutTransaction": "ts-node ./examples/updateChannelTitleWithoutTransaction.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)],

+ 12 - 1
content-directory-schemas/scripts/schemasToTS.ts

@@ -10,15 +10,19 @@ const OUTPUT_TYPES_LOCATION = path.join(__dirname, '../types')
 const SUBDIRS_INCLUDED = ['extrinsics', 'entities'] as const
 
 async function main() {
+  // Create typescript files
   for (const subdirName of fs.readdirSync(SCHEMAS_LOCATION)) {
     if (!SUBDIRS_INCLUDED.includes(subdirName as any)) {
       console.log(`Subdir/filename not included: ${subdirName} - skipping...`)
       continue
     }
     const schemaSubdir = subdirName as typeof SUBDIRS_INCLUDED[number]
+    const indexExportedTypes: string[] = []
     for (const schemaFilename of fs.readdirSync(path.join(SCHEMAS_LOCATION, schemaSubdir))) {
       const schemaFilePath = path.join(SCHEMAS_LOCATION, schemaSubdir, schemaFilename)
-      const outputFilename = schemaFilename.replace('.schema.json', '.d.ts')
+      const mainTypeName = schemaFilename.replace('.schema.json', '')
+      const outputFilename = mainTypeName + '.d.ts'
+      indexExportedTypes.push(mainTypeName)
       const outputDir = path.join(OUTPUT_TYPES_LOCATION, schemaSubdir)
       if (!fs.existsSync(outputDir)) {
         fs.mkdirSync(outputDir)
@@ -37,6 +41,13 @@ async function main() {
         console.error(e)
       }
     }
+    // Generate main index.d.ts export file for entities
+    const indexFilePath = path.join(OUTPUT_TYPES_LOCATION, schemaSubdir, 'index.d.ts')
+    fs.writeFileSync(
+      indexFilePath,
+      indexExportedTypes.reduce((content, typeName) => (content += `export { ${typeName} } from './${typeName}'\n`), '')
+    )
+    console.log(`${indexFilePath} succesfully generated!`)
   }
 }
 

+ 249 - 85
content-directory-schemas/src/helpers/InputParser.ts

@@ -1,13 +1,18 @@
 import { AddClassSchema, Property } from '../../types/extrinsics/AddClassSchema'
 import { createType } from '@joystream/types'
-import { blake2AsHex } from '@polkadot/util-crypto'
 import {
+  InputEntityValuesMap,
   ClassId,
   OperationType,
   ParametrizedPropertyValue,
   PropertyId,
   PropertyType,
+  EntityId,
+  Entity,
+  ParametrizedClassPropertyValue,
+  InputPropertyValue,
 } from '@joystream/types/content-directory'
+import { blake2AsHex } from '@polkadot/util-crypto'
 import { isSingle, isReference } from './propertyType'
 import { ApiPromise } from '@polkadot/api'
 import { JoyBTreeSet } from '@joystream/types/common'
@@ -15,6 +20,10 @@ import { CreateClass } from '../../types/extrinsics/CreateClass'
 import { EntityBatch } from '../../types/EntityBatch'
 import { getInputs } from './inputs'
 
+type SimpleEntityValue = string | boolean | number | string[] | boolean[] | number[] | undefined
+// Input without "new" or "extising" keywords
+type SimpleEntityInput = { [K: string]: SimpleEntityValue }
+
 export class InputParser {
   private api: ApiPromise
   private classInputs: CreateClass[]
@@ -22,16 +31,26 @@ 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
 
-  static createWithKnownSchemas(api: ApiPromise, entityBatches?: EntityBatch[]) {
+  static createWithInitialInputs(api: ApiPromise): InputParser {
+    return new InputParser(
+      api,
+      getInputs<CreateClass>('classes').map(({ data }) => data),
+      getInputs<AddClassSchema>('schemas').map(({ data }) => data),
+      getInputs<EntityBatch>('entityBatches').map(({ data }) => data)
+    )
+  }
+
+  static createWithKnownSchemas(api: ApiPromise, entityBatches?: EntityBatch[]): InputParser {
     return new InputParser(
       api,
       [],
-      getInputs('schemas').map(({ data }) => data),
+      getInputs<AddClassSchema>('schemas').map(({ data }) => data),
       entityBatches
     )
   }
@@ -48,15 +67,61 @@ export class InputParser {
     this.batchInputs = batchInputs || []
   }
 
-  private async initializeClassMap() {
-    if (this.classMapInitialized) {
-      return
-    }
+  private async loadClassMap() {
+    this.classIdByNameMap = new Map<string, number>()
+
     const classEntries = await this.api.query.contentDirectory.classById.entries()
     classEntries.forEach(([key, aClass]) => {
       this.classIdByNameMap.set(aClass.name.toString(), (key.args[0] as ClassId).toNumber())
     })
-    this.classMapInitialized = true
+  }
+
+  private async loadEntityIdByUniqueQueryMap() {
+    this.entityIdByUniqueQueryMap = new Map<string, number>()
+
+    // Get entity entries
+    const entityEntries: [EntityId, Entity][] = (
+      await this.api.query.contentDirectory.entityById.entries()
+    ).map(([storageKey, entity]) => [storageKey.args[0] as EntityId, entity])
+
+    // Since we use classMap directly we need to make sure it's loaded first
+    if (!this.classIdByNameMap.size) {
+      await this.loadClassMap()
+    }
+
+    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())
+      })
+    })
   }
 
   private schemaByClassName(className: string) {
@@ -84,18 +149,42 @@ export class InputParser {
     return foundIndex
   }
 
-  private getClassIdByName(className: string): number {
-    const classId = this.classIdByNameMap.get(className)
+  // Seatch for entity by { [uniquePropName]: [uniquePropVal] } on chain
+  async findEntityIdByUniqueQuery(uniquePropVal: Record<string, any>, className: string): Promise<number> {
+    const hash = this.getUniqueQueryHash(uniquePropVal, className)
+    let foundId = this.entityIdByUniqueQueryMap.get(hash)
+    if (foundId === undefined) {
+      // Try to re-load the map and find again
+      await this.loadEntityIdByUniqueQueryMap()
+      foundId = this.entityIdByUniqueQueryMap.get(hash)
+      if (foundId === undefined) {
+        // If still not found - throw
+        throw new Error(
+          `findEntityIdByUniqueQuery failed for class ${className} and query: ${JSON.stringify(uniquePropVal)}`
+        )
+      }
+    }
+    return foundId
+  }
+
+  async getClassIdByName(className: string): Promise<number> {
+    let classId = this.classIdByNameMap.get(className)
     if (classId === undefined) {
-      throw new Error(`Could not find class id by name: "${className}"!`)
+      // Try to re-load the map
+      await this.loadClassMap()
+      classId = this.classIdByNameMap.get(className)
+      if (classId === undefined) {
+        // If still not found - throw
+        throw new Error(`Could not find class id by name: "${className}"!`)
+      }
     }
     return classId
   }
 
-  private parsePropertyType(propertyType: Property['property_type']): PropertyType {
+  private async parsePropertyType(propertyType: Property['property_type']): Promise<PropertyType> {
     if (isSingle(propertyType) && isReference(propertyType.Single)) {
       const { className, sameOwner } = propertyType.Single.Reference
-      const classId = this.getClassIdByName(className)
+      const classId = await this.getClassIdByName(className)
       return createType('PropertyType', { Single: { Reference: [classId, sameOwner] } })
     }
     // Types other than reference are fully compatible
@@ -129,88 +218,139 @@ 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[] = []
 
-        return {
+    for (const [propertyName, propertyValue] of filteredInput) {
+      const schemaPropertyIndex = schema.newProperties.findIndex((p) => p.name === propertyName)
+      const schemaProperty = schema.newProperties[schemaPropertyIndex]
+
+      let value = customHandler && (await customHandler(schemaProperty, propertyValue))
+      if (value === undefined) {
+        value = createType('ParametrizedPropertyValue', {
+          InputPropertyValue: (await 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 = await 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.classIdByNameMap = new Map<string, number>()
+
     this.createEntityOperations = []
     this.addSchemaToEntityOprations = []
+    this.updateEntityPropertyValuesOperations = []
+
     this.entityByUniqueQueryCurrentIndex = 0
   }
 
   public async getEntityBatchOperations() {
-    await this.initializeClassMap()
     // First - create entityUniqueQueryMap to allow referencing any entity at any point
     this.batchInputs.forEach((batch) => {
       const entitySchema = this.schemaByClassName(batch.className)
       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,31 +358,32 @@ 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> {
-    await this.initializeClassMap()
+  ): Promise<OperationType[]> {
     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) {
-    await this.initializeClassMap() // Initialize if not yet initialized
-    const classId = this.getClassIdByName(inputData.className)
-    const newProperties = inputData.newProperties.map((p) => ({
-      ...p,
-      // Parse different format for Reference (and potentially other propTypes in the future)
-      property_type: this.parsePropertyType(p.property_type).toJSON(),
-    }))
+    const classId = await this.getClassIdByName(inputData.className)
+    const newProperties = await Promise.all(
+      inputData.newProperties.map(async (p) => ({
+        ...p,
+        // Parse different format for Reference (and potentially other propTypes in the future)
+        property_type: (await this.parsePropertyType(p.property_type)).toJSON(),
+      }))
+    )
     return this.api.tx.contentDirectory.addClassSchema(
       classId,
       new (JoyBTreeSet(PropertyId))(this.api.registry, inputData.existingProperties),
@@ -267,4 +408,27 @@ export class InputParser {
   public getCreateClassExntrinsics() {
     return this.classInputs.map((data) => this.parseCreateClassExtrinsic(data))
   }
+
+  // Helper parser for "standalone" extrinsics like addSchemaSupportToEntity / updateEntityPropertyValues
+  public async parseToInputEntityValuesMap(
+    inputData: SimpleEntityInput,
+    className: string
+  ): Promise<InputEntityValuesMap> {
+    await this.parseEntityInput(inputData, this.schemaByClassName(className))
+    const inputPropValMap = new Map<PropertyId, InputPropertyValue>()
+
+    const [operation] = this.addSchemaToEntityOprations
+    operation
+      .asType('AddSchemaSupportToEntity')
+      .parametrized_property_values /* First we need to sort by propertyId, since otherwise there will be issues
+      when encoding the BTreeMap (similar to BTreeSet) */
+      .sort((a, b) => a.in_class_index.toNumber() - b.in_class_index.toNumber())
+      .map((pcpv) => {
+        inputPropValMap.set(pcpv.in_class_index, pcpv.value.asType('InputPropertyValue'))
+      })
+
+    this.reset()
+
+    return createType('InputEntityValuesMap', inputPropValMap)
+  }
 }

+ 32 - 8
content-directory-schemas/src/helpers/extrinsics.ts

@@ -2,6 +2,10 @@ import { Keyring } from '@polkadot/keyring'
 import { KeyringPair } from '@polkadot/keyring/types'
 import { ApiPromise } from '@polkadot/api'
 import { SubmittableExtrinsic } from '@polkadot/api/types'
+import { DispatchError } from '@polkadot/types/interfaces/system'
+import { TypeRegistry } from '@polkadot/types'
+
+// TODO: Move to @joystream/js soon
 
 export function getAlicePair() {
   const keyring = new Keyring({ type: 'sr25519' })
@@ -34,17 +38,37 @@ export class ExtrinsicsHelper {
       promises.push(
         new Promise((resolve, reject) => {
           tx.signAndSend(sender, { nonce }, (result) => {
+            let txError: string | null = null
             if (result.isError) {
-              reject(new Error(errorMessage))
+              txError = `Transaction failed with status: ${result.status.type}`
+              reject(new Error(`${errorMessage} - ${txError}`))
             }
+
             if (result.status.isInBlock) {
-              if (
-                result.events.some(({ event }) => event.section === 'system' && event.method === 'ExtrinsicSuccess')
-              ) {
-                resolve()
-              } else {
-                reject(new Error(errorMessage))
-              }
+              result.events
+                .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.api.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 Error(`${errorMessage} - Extrinsic execution error: ${errorMsg}`))
+                  } else if (event.method === 'ExtrinsicSuccess') {
+                    resolve()
+                  }
+                })
             }
           })
         })

+ 19 - 0
content-directory-schemas/src/helpers/initialize.ts

@@ -0,0 +1,19 @@
+import { ApiPromise } from '@polkadot/api'
+import { InputParser } from './InputParser'
+import { ExtrinsicsHelper } from './extrinsics'
+import { KeyringPair } from '@polkadot/keyring/types'
+
+export default async function initializeContentDir(api: ApiPromise, leadKey: KeyringPair): Promise<void> {
+  const txHelper = new ExtrinsicsHelper(api)
+  const parser = InputParser.createWithInitialInputs(api)
+
+  // Initialize classes first in order to later be able to get classIdByNameMap
+  const createClassTxs = await parser.getCreateClassExntrinsics()
+  await txHelper.sendAndCheck(leadKey, createClassTxs, 'Classes initialization failed!')
+
+  // Initialize schemas and entities
+  const addSchemaTxs = await parser.getAddSchemaExtrinsics()
+  const entitiesTx = api.tx.contentDirectory.transaction({ Lead: null }, await parser.getEntityBatchOperations())
+  await txHelper.sendAndCheck(leadKey, addSchemaTxs, 'Schemas initialization failed!')
+  await txHelper.sendAndCheck(leadKey, [entitiesTx], 'Entities initialization failed!')
+}

+ 1 - 0
content-directory-schemas/src/index.ts

@@ -3,3 +3,4 @@ export { InputParser } from './helpers/InputParser'
 export { getInputs, getInputsLocation } from './helpers/inputs'
 export { isReference, isSingle } from './helpers/propertyType'
 export { getSchemasLocation } from './helpers/schemas'
+export { default as initializeContentDir } from './helpers/initialize'

+ 1 - 1
content-directory-schemas/tsconfig.json

@@ -23,5 +23,5 @@
       "@polkadot/api/augment": ["../types/augment-codec/augment-api.ts"]
     }
   },
-  "include": [ "src/**/*", "scripts/**/*", "typings/**/*" ]
+  "include": [ "src/**/*", "scripts/**/*", "typings/**/*", "examples/**/*" ]
 }

+ 2 - 0
node/src/chain_spec/mod.rs

@@ -330,6 +330,8 @@ pub fn testnet_genesis(
         }),
         content_directory: Some({
             ContentDirectoryConfig {
+                class_by_id: vec![],
+                entity_by_id: vec![],
                 curator_group_by_id: vec![],
                 next_class_id: 1,
                 next_entity_id: 1,

+ 1 - 1
package.json

@@ -18,7 +18,7 @@
     "devops/prettier-config",
     "pioneer",
     "pioneer/packages/*",
-    "utils/api-examples",
+    "utils/api-scripts",
     "content-directory-schemas"
   ],
   "resolutions": {

+ 0 - 1
pioneer/.eslintignore

@@ -1,6 +1,5 @@
 **/build/*
 **/coverage/*
 **/node_modules/*
-packages/joy-media/**
 .eslintrc.js
 i18next-scanner.config.js

+ 1 - 1
pioneer/packages/apps-routing/src/joy-roles.ts

@@ -7,7 +7,7 @@ export default function create (t: <T = string> (key: string, text: string, opti
     Component: Roles,
     display: {
       needsApi: [
-        'query.contentWorkingGroup.mint',
+        'query.contentDirectoryWorkingGroup.mint',
         'query.storageWorkingGroup.mint'
       ]
     },

+ 0 - 1
pioneer/packages/apps/public/locales/en/index.json

@@ -22,7 +22,6 @@
   "apps-routing.json",
   "apps.json",
   "joy-election.json",
-  "joy-media.json",
   "joy-members.json",
   "joy-proposals.json",
   "joy-roles.json",

+ 0 - 5
pioneer/packages/apps/public/locales/en/joy-media.json

@@ -1,5 +0,0 @@
-{
-  "Explore": "Explore",
-  "My channels": "My channels",
-  "My videos": "My videos"
-}

+ 1 - 1
pioneer/packages/apps/src/Content/NotFound.tsx

@@ -7,7 +7,7 @@ import { Redirect } from 'react-router';
 
 function NotFound (): React.ReactElement {
   return (
-    <Redirect to='/media' />
+    <Redirect to='/tokenomics' />
   );
 }
 

+ 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) {

+ 0 - 0
pioneer/packages/joy-media/.skip-build


+ 0 - 3
pioneer/packages/joy-media/README.md

@@ -1,3 +0,0 @@
-# Media content module for Joystream node
-
-This module works with multi-media content such as audio and video.

+ 0 - 2
pioneer/packages/joy-media/aplayer.d.ts

@@ -1,2 +0,0 @@
-// No offical definitions available
-declare module 'react-aplayer';

+ 0 - 2
pioneer/packages/joy-media/dplayer.d.ts

@@ -1,2 +0,0 @@
-// No offical definitions available
-declare module 'react-dplayer';

+ 0 - 26
pioneer/packages/joy-media/package.json

@@ -1,26 +0,0 @@
-{
-  "name": "@polkadot/joy-media",
-  "version": "0.1.1",
-  "description": "Media content module for Joystream node",
-  "main": "index.js",
-  "scripts": {},
-  "author": "Joystream contributors",
-  "maintainers": [],
-  "dependencies": {
-    "@babel/runtime": "^7.10.5",
-    "@polkadot/joy-utils": "^0.1.1",
-    "@polkadot/react-components": "0.51.1",
-    "@polkadot/react-query": "0.51.1",
-    "@types/mime-types": "^2.1.0",
-    "@types/react-beautiful-dnd": "^11.0.3",
-    "aplayer": "^1.10.1",
-    "dplayer": "1.25.0",
-    "ipfs-only-hash": "^1.0.2",
-    "iso-639-1": "^2.1.0",
-    "lodash": "^4.17.11",
-    "mime-types": "^2.1.22",
-    "react-aplayer": "^1.0.0",
-    "react-beautiful-dnd": "^12.0.0",
-    "react-dplayer": "^0.2.3"
-  }
-}

+ 0 - 204
pioneer/packages/joy-media/src/DiscoveryProvider.tsx

@@ -1,204 +0,0 @@
-import React, { useState, useEffect, useContext, createContext } from 'react';
-import { Message } from 'semantic-ui-react';
-import axios, { CancelToken } from 'axios';
-
-import { StorageProviderId } from '@joystream/types/working-group';
-import { Vec } from '@polkadot/types';
-import { Url } from '@joystream/types/discovery';
-import ApiContext from '@polkadot/react-api/ApiContext';
-import { ApiProps } from '@polkadot/react-api/types';
-import { JoyInfo } from '@polkadot/joy-utils/react/components';
-import { componentName } from '@polkadot/joy-utils/react/helpers';
-import { isObjectWithProperties } from '@polkadot/joy-utils/functions/misc';
-
-export type BootstrapNodes = {
-  bootstrapNodes?: Url[];
-};
-
-export type DiscoveryProvider = {
-  resolveAssetEndpoint: (provider: StorageProviderId, contentId?: string, cancelToken?: CancelToken) => Promise<string>;
-  reportUnreachable: (provider: StorageProviderId) => void;
-};
-
-export type DiscoveryProviderProps = {
-  discoveryProvider: DiscoveryProvider;
-};
-
-// return string Url with last `/` removed
-function normalizeUrl (url: string | Url): string {
-  const st: string = url.toString();
-
-  if (st.endsWith('/')) {
-    return st.substring(0, st.length - 1);
-  }
-
-  return st.toString();
-}
-
-type ProviderStats = {
-  assetApiEndpoint: string;
-  unreachableReports: number;
-  resolvedAt: number;
-}
-
-function newDiscoveryProvider ({ bootstrapNodes }: BootstrapNodes): DiscoveryProvider {
-  const stats = new Map<string, ProviderStats>();
-
-  const resolveAssetEndpoint = async (
-    storageProvider: StorageProviderId,
-    contentId?: string,
-    cancelToken?: CancelToken
-  ) => {
-    const providerKey = storageProvider.toString();
-
-    let stat = stats.get(providerKey);
-
-    if (
-      (!stat || (stat && (Date.now() > (stat.resolvedAt + (10 * 60 * 1000))))) &&
-      bootstrapNodes
-    ) {
-      for (let n = 0; n < bootstrapNodes.length; n++) {
-        const discoveryUrl = normalizeUrl(bootstrapNodes[n]);
-
-        try {
-          // eslint-disable-next-line no-new
-          new URL(discoveryUrl);
-        } catch (err) {
-          continue;
-        }
-
-        const serviceInfoQuery = `${discoveryUrl}/discover/v0/${storageProvider.toString()}`;
-
-        try {
-          console.log(`Resolving ${providerKey} using ${discoveryUrl}`);
-
-          const serviceInfo = await axios.get<unknown>(serviceInfoQuery, { cancelToken });
-
-          if (!serviceInfo) {
-            continue;
-          }
-
-          const { data } = serviceInfo;
-
-          if (!isObjectWithProperties(data, 'serialized') || typeof data.serialized !== 'string') {
-            continue;
-          }
-
-          const dataParsed = JSON.parse(data.serialized) as unknown;
-
-          if (
-            !isObjectWithProperties(dataParsed, 'asset') ||
-            !isObjectWithProperties(dataParsed.asset, 'endpoint') ||
-            typeof dataParsed.asset.endpoint !== 'string'
-          ) {
-            continue;
-          }
-
-          stats.set(providerKey, {
-            assetApiEndpoint: normalizeUrl(dataParsed.asset.endpoint),
-            unreachableReports: 0,
-            resolvedAt: Date.now()
-          });
-          break;
-        } catch (err) {
-          console.log(err);
-
-          if (axios.isCancel(err)) {
-            throw err;
-          }
-
-          continue;
-        }
-      }
-    }
-
-    stat = stats.get(providerKey);
-
-    console.log(stat);
-
-    if (stat) {
-      return `${stat.assetApiEndpoint}/asset/v0/${contentId || ''}`;
-    }
-
-    throw new Error('Resolving failed.');
-  };
-
-  const reportUnreachable = (provider: StorageProviderId) => {
-    const key = provider.toString();
-    const stat = stats.get(key);
-
-    if (stat) {
-      stat.unreachableReports = stat.unreachableReports + 1;
-    }
-  };
-
-  return { resolveAssetEndpoint, reportUnreachable };
-}
-
-const DiscoveryProviderContext = createContext<DiscoveryProvider>(undefined as unknown as DiscoveryProvider);
-
-export const DiscoveryProviderProvider = (props: React.PropsWithChildren<Record<any, unknown>>) => {
-  const api: ApiProps = useContext(ApiContext);
-  const [provider, setProvider] = useState<DiscoveryProvider | undefined>();
-  const [loaded, setLoaded] = useState<boolean | undefined>();
-
-  useEffect(() => {
-    const load = async () => {
-      if (loaded || !api) return;
-
-      console.log('Discovery Provider: Loading bootstrap node from Substrate...');
-      const bootstrapNodes = await api.api.query.discovery.bootstrapEndpoints() as Vec<Url>;
-
-      setProvider(newDiscoveryProvider({ bootstrapNodes }));
-      setLoaded(true);
-      console.log('Discovery Provider: Initialized');
-    };
-
-    void load();
-  }, [loaded]);
-
-  if (!api || !api.isApiReady) {
-    // Substrate API is not ready yet.
-    return null;
-  }
-
-  if (!provider) {
-    return (
-      <Message info className='JoyMainStatus'>
-        <Message.Header>Initializing Content Discovery Provider</Message.Header>
-        <div style={{ marginTop: '1rem' }}>
-          Loading bootstrap nodes... Please wait.
-        </div>
-      </Message>
-    );
-  }
-
-  return (
-    <DiscoveryProviderContext.Provider value={provider}>
-      {props.children}
-    </DiscoveryProviderContext.Provider>
-  );
-};
-
-export const useDiscoveryProvider = () =>
-  useContext(DiscoveryProviderContext);
-
-export function withDiscoveryProvider (Component: React.ComponentType<DiscoveryProviderProps>) {
-  const ResultComponent: React.FunctionComponent<Record<any, unknown>> = (props: React.PropsWithChildren<Record<any, unknown>>) => {
-    const discoveryProvider = useDiscoveryProvider();
-
-    if (!discoveryProvider) {
-      return <JoyInfo title={'Please wait...'}>Loading discovery provider.</JoyInfo>;
-    }
-
-    return (
-      <Component {...props} discoveryProvider={discoveryProvider}>
-        {props.children}
-      </Component>
-    );
-  };
-
-  ResultComponent.displayName = `withDiscoveryProvider(${componentName(Component)})`;
-
-  return ResultComponent;
-}

+ 0 - 62
pioneer/packages/joy-media/src/IterableFile.ts

@@ -1,62 +0,0 @@
-// Based on
-// https://gist.github.com/grishgrigoryan/bf6222d16d72cb28620399d27e83eb22
-
-interface IConfig{
-  chunkSize: number;
-}
-
-const DEFAULT_CHUNK_SIZE: number = 64 * 1024; // 64K
-
-export class IterableFile implements AsyncIterable<Buffer> {
-  private reader: FileReader;
-  private file: File
-  private config: IConfig = { chunkSize: DEFAULT_CHUNK_SIZE }
-
-  constructor (file: File, config: Partial<IConfig> = {}) {
-    this.file = file;
-    this.reader = new FileReader();
-    Object.assign(this.config, config);
-  }
-
-  [Symbol.asyncIterator] () {
-    return this.readFile();
-  }
-
-  get chunkSize () {
-    return this.config.chunkSize;
-  }
-
-  get fileSize () {
-    return this.file.size;
-  }
-
-  readBlobAsBuffer (blob: Blob): Promise<Buffer> {
-    return new Promise((resolve, reject) => {
-      this.reader.onload = (e) => {
-        e.target?.result && resolve(typeof e.target.result === 'string' ? Buffer.from(e.target.result) : Buffer.from(e.target.result));
-        e.target?.error && reject(e.target.error);
-      };
-
-      this.reader.readAsArrayBuffer(blob);
-    });
-  }
-
-  async * readFile () {
-    let offset = 0;
-    let blob;
-    let result;
-
-    while (offset < this.fileSize) {
-      blob = this.file.slice(offset, this.chunkSize + offset);
-      result = await this.readBlobAsBuffer(blob);
-      offset += result.length;
-      yield result;
-    }
-  }
-}
-
-// Usage:
-//  let iterableFile = new IterableFile(file)
-//  for await (const chunk: Buffer of iterableFile) {
-//      doSomethingWithBuffer(chunk)
-//  }

+ 0 - 98
pioneer/packages/joy-media/src/MediaView.tsx

@@ -1,98 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { MediaTransport } from './transport';
-import { MemberId } from '@joystream/types/members';
-import { useMyMembership } from '@polkadot/joy-utils/react/hooks';
-import { useTransportContext } from './TransportContext';
-import { withMembershipRequired } from '@polkadot/joy-utils/react/hocs/guards';
-import { useApi } from '@polkadot/react-hooks';
-import { ApiPromise } from '@polkadot/api';
-import { isObjectWithProperties } from '@polkadot/joy-utils/functions/misc';
-
-type InitialPropsWithMembership<A> = A & {
-  myAddress?: string;
-  myMemberId?: MemberId;
-}
-
-type ResolverProps<A> = InitialPropsWithMembership<A> & {
-  transport: MediaTransport;
-  api: ApiPromise;
-}
-
-type BaseProps<A, B> = {
-  component: React.ComponentType<A & B>;
-  unresolvedView?: React.ReactElement;
-  resolveProps?: (props: ResolverProps<A>) => Promise<B>;
-
-  /**
-   * Array of property names that can trigger re-render of the view,
-   * if values of such properties changed.
-   */
-  triggers?: (keyof A)[];
-
-  /** Set `true` if only members should have access to this component. `false` by default. */
-  membersOnly?: boolean;
-}
-
-function serializeTrigger (val: unknown): number | boolean | string | undefined {
-  if (['number', 'boolean', 'string'].includes(typeof val)) {
-    return val as number | boolean | string;
-  } else if (isObjectWithProperties(val, 'toString') && typeof val.toString === 'function') {
-    return val.toString() as string;
-  } else {
-    return undefined;
-  }
-}
-
-export function MediaView<A extends Record<string, unknown> = Record<string, unknown>, B extends Record<string, unknown> = Record<string, unknown>> (baseProps: BaseProps<A, B>) {
-  function InnerView (initialProps: A & B) {
-    const { component: Component, resolveProps, triggers = [], unresolvedView = null } = baseProps;
-
-    const transport = useTransportContext();
-    const { myAddress, myMemberId } = useMyMembership();
-    const { api } = useApi();
-    const resolverProps = { ...initialProps, transport, api, myAddress, myMemberId };
-
-    const [resolvedProps, setResolvedProps] = useState({} as B);
-    const [propsResolved, setPropsResolved] = useState(false);
-
-    const initialDeps = triggers.map((propName) => serializeTrigger(initialProps[propName]));
-    const rerenderDeps = [...initialDeps, myAddress];
-
-    useEffect(() => {
-      async function doResolveProps () {
-        if (typeof resolveProps !== 'function') return;
-
-        console.log('Resolving props of media view');
-
-        // Transport session allows us to cache loaded channels, entites and classes
-        // during the render of this view:
-        transport.openSession();
-        setResolvedProps(await resolveProps(resolverProps));
-        transport.closeSession();
-        setPropsResolved(true);
-      }
-
-      if (!transport) {
-        console.error('Transport is not defined');
-      } else {
-        void doResolveProps();
-      }
-    }, rerenderDeps);
-
-    console.log('Rerender deps of Media View:', rerenderDeps);
-
-    const renderResolving = () => {
-      return unresolvedView || <div className='ui active centered inline loader' />;
-    };
-
-    return propsResolved
-      ? <Component {...initialProps} {...resolvedProps} />
-      : renderResolving();
-  }
-
-  const { membersOnly = false } = baseProps;
-
-  return membersOnly
-    ? withMembershipRequired(InnerView)
-    : InnerView;
-}

+ 0 - 40
pioneer/packages/joy-media/src/TransportContext.tsx

@@ -1,40 +0,0 @@
-import React, { useEffect, useState, useContext, createContext } from 'react';
-import { MediaTransport } from './transport';
-import { MockTransport } from './transport.mock';
-import { SubstrateTransport } from './transport.substrate';
-import ApiContext from '@polkadot/react-api/ApiContext';
-import { ApiProps } from '@polkadot/react-api/types';
-
-export const TransportContext = createContext<MediaTransport>(undefined as unknown as MediaTransport);
-
-export const useTransportContext = () =>
-  useContext(TransportContext);
-
-export const MockTransportProvider = (props: React.PropsWithChildren<Record<any, unknown>>) =>
-  <TransportContext.Provider value={new MockTransport()}>
-    {props.children}
-  </TransportContext.Provider>;
-
-export const SubstrateTransportProvider = (props: React.PropsWithChildren<Record<any, unknown>>) => {
-  const api: ApiProps = useContext(ApiContext);
-  const [transport, setTransport] = useState<SubstrateTransport>();
-  const [loaded, setLoaded] = useState<boolean>();
-
-  useEffect(() => {
-    if (!loaded && api && api.isApiReady) {
-      setTransport(new SubstrateTransport(api));
-      setLoaded(true);
-    }
-  }, [loaded]);
-
-  if (!transport) {
-    // Substrate API is not ready yet.
-    return null;
-  }
-
-  return (
-    <TransportContext.Provider value={transport}>
-      {props.children}
-    </TransportContext.Provider>
-  );
-};

+ 0 - 400
pioneer/packages/joy-media/src/Upload.tsx

@@ -1,400 +0,0 @@
-import React from 'react';
-import BN from 'bn.js';
-import axios, { CancelTokenSource, AxiosError, AxiosRequestConfig } from 'axios';
-import { History } from 'history';
-import { Progress, Message } from 'semantic-ui-react';
-
-import { registry } from '@joystream/types';
-import { InputFileAsync, TxButton, JoyInfo, Loading } from '@polkadot/joy-utils/react/components';
-import { ApiProps } from '@polkadot/react-api/types';
-import { I18nProps } from '@polkadot/react-components/types';
-import { SubmittableResult } from '@polkadot/api';
-import { Option } from '@polkadot/types/codec';
-import { withMulti, withApi } from '@polkadot/react-api';
-import { formatNumber } from '@polkadot/util';
-
-import translate from './translate';
-import { fileNameWoExt } from './utils';
-import { ContentId, DataObject } from '@joystream/types/media';
-import { MyAccountProps } from '@polkadot/joy-utils/react/hocs/accounts';
-import { withOnlyMembers } from '@polkadot/joy-utils/react/hocs/guards';
-import { DiscoveryProviderProps, withDiscoveryProvider } from './DiscoveryProvider';
-
-import IpfsHash from 'ipfs-only-hash';
-import { ChannelId } from '@joystream/types/content-working-group';
-import { EditVideoView } from './upload/EditVideo.view';
-
-import { IterableFile } from './IterableFile';
-import { StorageProviderId } from '@joystream/types/working-group';
-import { normalizeError, isObjectWithProperties } from '@polkadot/joy-utils/functions/misc';
-
-const MAX_FILE_SIZE_MB = 500;
-const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
-
-type Props = ApiProps & I18nProps & DiscoveryProviderProps & MyAccountProps & {
-  channelId: ChannelId;
-  history?: History;
-  match: {
-    params: {
-      channelId: string;
-    };
-  };
-};
-
-type State = {
-  error?: string;
-  file?: File;
-  computingHash: boolean;
-  ipfs_cid?: string;
-  newContentId: ContentId;
-  discovering: boolean;
-  uploading: boolean;
-  sendingTx: boolean;
-  progress: number;
-  cancelSource: CancelTokenSource;
-};
-
-const defaultState = (): State => ({
-  error: undefined,
-  file: undefined,
-  computingHash: false,
-  ipfs_cid: undefined,
-  newContentId: ContentId.generate(registry),
-  discovering: false,
-  uploading: false,
-  sendingTx: false,
-  progress: 0,
-  cancelSource: axios.CancelToken.source()
-});
-
-class Upload extends React.PureComponent<Props, State> {
-  state = defaultState();
-
-  componentWillUnmount () {
-    this.setState({
-      discovering: false,
-      uploading: false
-    });
-
-    const { cancelSource } = this.state;
-
-    cancelSource.cancel('unmounting');
-  }
-
-  render () {
-    return (
-      <div className='UploadBox'>
-        {this.renderContent()}
-      </div>
-    );
-  }
-
-  private renderContent () {
-    const { error, uploading, discovering, computingHash, sendingTx } = this.state;
-
-    if (error) return this.renderError(error);
-    else if (discovering) return this.renderDiscovering();
-    else if (uploading) return this.renderUploading();
-    else if (computingHash) return this.renderComputingHash();
-    else if (sendingTx) return this.renderSendingTx();
-    else return this.renderFileInput();
-  }
-
-  private renderError (error: string) {
-    return (
-      <Message error className='JoyMainStatus'>
-        <Message.Header>Failed to upload your file</Message.Header>
-        <p>{error}</p>
-        <button className='ui button' onClick={this.resetForm}>Start over</button>
-      </Message>
-    );
-  }
-
-  private resetForm = () => {
-    const { cancelSource } = this.state;
-
-    this.setState({
-      ...defaultState(),
-      cancelSource
-    });
-  }
-
-  private renderUploading () {
-    const { file, newContentId, progress, error } = this.state;
-
-    if (!file || !file.name) return <JoyInfo title='Loading...' />;
-
-    const success = !error && progress >= 100;
-    const { history, match: { params: { channelId } }, api } = this.props;
-
-    return <div style={{ width: '100%' }}>
-      {this.renderProgress()}
-      {success &&
-        <EditVideoView
-          channelId={api.createType('ChannelId', channelId)}
-          contentId={newContentId}
-          fileName={fileNameWoExt(file.name)}
-          history={history}
-        />
-      }
-    </div>;
-  }
-
-  private renderSendingTx () {
-    return <JoyInfo title='Please wait...'><Loading text='Waiting for the transaction confirmation...' /></JoyInfo>;
-  }
-
-  private renderDiscovering () {
-    return <JoyInfo title={'Please wait...'}>Contacting storage provider.</JoyInfo>;
-  }
-
-  private renderProgress () {
-    const { progress, error } = this.state;
-    const active = !error && progress < 100;
-    const success = !error && progress >= 100;
-
-    let label = '';
-
-    if (active) {
-      label = 'Your file is uploading. Please keep this page open until it\'s done.';
-    } else if (success) {
-      label = 'Uploaded! Click "Publish" button to make your file live.';
-    }
-
-    return <Progress
-      className='UploadProgress'
-      progress={success}
-      percent={progress}
-      active={active}
-      success={success}
-      label={label}
-    />;
-  }
-
-  private renderFileInput () {
-    const { file } = this.state;
-    const file_size = file ? file.size : 0;
-    const file_name = file ? file.name : '';
-
-    return <div className='UploadSelectForm'>
-      <InputFileAsync
-        label=''
-        withLabel={false}
-        className={`UploadInputFile ${file_name ? 'FileSelected' : ''}`}
-        placeholder={
-          <div>
-            <div><i className='cloud upload icon'></i></div>
-            <div>{file_name
-              ? `${file_name} (${formatNumber(file_size)} bytes)`
-              : <>
-                <div>Drag and drop either video or audio file here.</div>
-                <div>Your file should not be more than {MAX_FILE_SIZE_MB} MB.</div>
-              </>
-            }</div>
-          </div>
-        }
-        onChange={this.onFileSelected}
-      />
-      {file_name && <div className='UploadButtonBox'>
-        <TxButton
-          label={'Upload'}
-          isDisabled={!file_name}
-          tx={'dataDirectory.addContent'}
-          params={this.buildTxParams()}
-          onClick={(sendTx) => {
-            this.setState({ sendingTx: true });
-            sendTx();
-          }}
-          txSuccessCb={ this.onDataObjectCreated }
-          txFailedCb={() => { this.setState({ sendingTx: false }); }}
-        />
-      </div>}
-    </div>;
-  }
-
-  private onFileSelected = (file: File) => {
-    if (!file.size) {
-      this.setState({ error: 'You cannot upload an empty file.' });
-    } else if (file.size > MAX_FILE_SIZE_BYTES) {
-      this.setState({
-        error:
-        `You can't upload files larger than ${MAX_FILE_SIZE_MB} MBytes in size.`
-      });
-    } else {
-      this.setState({ file, computingHash: true });
-      void this.startComputingHash();
-    }
-  }
-
-  private async startComputingHash () {
-    const { file } = this.state;
-
-    if (!file) {
-      return this.hashComputationComplete(undefined, 'No file passed to hasher');
-    }
-
-    try {
-      const iterableFile = new IterableFile(file, { chunkSize: 65535 });
-      // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-call
-      const ipfs_cid = (await IpfsHash.of(iterableFile)) as string;
-
-      this.hashComputationComplete(ipfs_cid);
-    } catch (err) {
-      return this.hashComputationComplete(undefined, err);
-    }
-  }
-
-  private hashComputationComplete (ipfs_cid: string | undefined, error?: string) {
-    if (!error) {
-      console.log('Computed IPFS hash:', ipfs_cid);
-    }
-
-    this.setState({
-      computingHash: false,
-      ipfs_cid,
-      error
-    });
-  }
-
-  private renderComputingHash () {
-    return <JoyInfo title='Processing your file. Please wait...' />;
-  }
-
-  private buildTxParams = () => {
-    const { file, newContentId, ipfs_cid } = this.state;
-
-    if (!file || !ipfs_cid) return [];
-
-    // TODO get corresponding data type id based on file content
-    const dataObjectTypeId = new BN(1);
-    const { myMemberId } = this.props;
-
-    return [myMemberId, newContentId, dataObjectTypeId, new BN(file.size), ipfs_cid];
-  }
-
-  private onDataObjectCreated = async (_txResult: SubmittableResult) => {
-    this.setState({ sendingTx: false, discovering: true });
-
-    const { api } = this.props;
-    const { newContentId } = this.state;
-    let dataObject: Option<DataObject>;
-
-    try {
-      dataObject = await api.query.dataDirectory.dataObjectByContentId(newContentId) as Option<DataObject>;
-    } catch (err) {
-      this.setState({
-        error: normalizeError(err),
-        discovering: false
-      });
-
-      return;
-    }
-
-    const { discovering } = this.state;
-
-    if (!discovering) {
-      return;
-    }
-
-    if (dataObject.isSome) {
-      const storageProvider = dataObject.unwrap().liaison;
-
-      void this.uploadFileTo(storageProvider);
-    } else {
-      this.setState({
-        error: 'No Storage Provider assigned to process upload',
-        discovering: false
-      });
-    }
-  }
-
-  private uploadFileTo = async (storageProvider: StorageProviderId) => {
-    const { file, newContentId, cancelSource } = this.state;
-
-    if (!file || !file.size) {
-      this.setState({
-        error: 'No file to upload!',
-        discovering: false
-      });
-
-      return;
-    }
-
-    const contentId = newContentId.encode();
-    const config: AxiosRequestConfig = {
-      headers: {
-        // TODO uncomment this once the issue fixed:
-        // https://github.com/Joystream/storage-node-joystream/issues/16
-        // 'Content-Type': file.type
-        'Content-Type': '' // <-- this is a temporary hack
-      },
-      cancelToken: cancelSource.token,
-      onUploadProgress: (progressEvent: unknown) => {
-        if (
-          !isObjectWithProperties(progressEvent, 'loaded', 'total') ||
-          typeof progressEvent.loaded !== 'number' ||
-          typeof progressEvent.total !== 'number'
-        ) {
-          return;
-        }
-
-        const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
-
-        this.setState({
-          progress: percentCompleted
-        });
-      }
-    };
-
-    const { discoveryProvider } = this.props;
-    let url: string;
-
-    try {
-      url = await discoveryProvider.resolveAssetEndpoint(storageProvider, contentId, cancelSource.token);
-    } catch (err) {
-      return this.setState({
-        error: `Failed to contact storage provider: ${normalizeError(err)}`,
-        discovering: false
-      });
-    }
-
-    const { discovering } = this.state;
-
-    if (!discovering) {
-      return;
-    }
-
-    // TODO: validate url .. must start with http
-
-    this.setState({ discovering: false, uploading: true, progress: 0 });
-
-    try {
-      await axios.put<{ message: string }>(url, file, config);
-    } catch (e) {
-      const err = e as unknown;
-
-      this.setState({ progress: 0, error: normalizeError(err), uploading: false });
-
-      if (axios.isCancel(err)) {
-        return;
-      }
-
-      const response = isObjectWithProperties(err, 'response')
-        ? (err as AxiosError).response
-        : undefined;
-
-      if (!response || (response.status >= 500 && response.status <= 504)) {
-        // network connection error
-        discoveryProvider.reportUnreachable(storageProvider);
-      }
-    }
-  }
-}
-
-export const UploadWithRouter = withMulti(
-  Upload,
-  translate,
-  withApi,
-  withOnlyMembers,
-  withDiscoveryProvider
-);

+ 0 - 37
pioneer/packages/joy-media/src/channels/ChannelAvatar.tsx

@@ -1,37 +0,0 @@
-import React from 'react';
-import { Link } from 'react-router-dom';
-import { ChannelEntity } from '../entities/ChannelEntity';
-import { BgImg } from '../common/BgImg';
-import { DEFAULT_THUMBNAIL_URL } from '../common/images';
-
-const defaultSizePx = 75;
-
-export type ChannelAvatarSize = 'big' | 'default' | 'small';
-
-type Props = {
-  channel: ChannelEntity;
-  size?: ChannelAvatarSize;
-}
-
-function sizeToPx (size: ChannelAvatarSize): number {
-  switch (size) {
-    case 'big': return 100;
-    case 'small': return 35;
-    case 'default': return defaultSizePx;
-    default: return defaultSizePx;
-  }
-}
-
-export function ChannelAvatar (props: Props) {
-  const { channel, size = 'default' } = props;
-
-  return (
-    <Link to={`/media/channels/${channel.id}`}>
-      <BgImg
-        className={'ChannelAvatar ' + size}
-        url={channel.avatar || DEFAULT_THUMBNAIL_URL}
-        size={sizeToPx(size)}
-      />
-    </Link>
-  );
-}

+ 0 - 23
pioneer/packages/joy-media/src/channels/ChannelAvatarAndName.tsx

@@ -1,23 +0,0 @@
-import React from 'react';
-import { ChannelEntity } from '../entities/ChannelEntity';
-import { ChannelAvatar } from './ChannelAvatar';
-import { ChannelNameAsLink } from './ChannelNameAsLink';
-
-type Props = {
-  channel: ChannelEntity;
-}
-
-export const ChannelAvatarAndName = (props: Props) => {
-  const { channel } = props;
-
-  return (
-    <div className={'ChannelPreview small'}>
-      <ChannelAvatar channel={channel} size='small' />
-      <div className='ChannelDetails'>
-        <h3 className='ChannelTitle' style={{ display: 'block' }}>
-          <ChannelNameAsLink channel={channel} />
-        </h3>
-      </div>
-    </div>
-  );
-};

+ 0 - 20
pioneer/packages/joy-media/src/channels/ChannelHeader.tsx

@@ -1,20 +0,0 @@
-import React from 'react';
-import { BgImg } from '../common/BgImg';
-import { ChannelEntity } from '../entities/ChannelEntity';
-import { ChannelPreview } from './ChannelPreview';
-
-type Props = {
-  channel: ChannelEntity;
-}
-
-export function ChannelHeader (props: Props) {
-  const { channel } = props;
-  const { banner } = channel;
-
-  return (
-    <div className='ChannelHeader'>
-      {banner && <BgImg className='ChannelCover' url={banner} />}
-      <ChannelPreview channel={channel} size='big' withDescription />
-    </div>
-  );
-}

+ 0 - 34
pioneer/packages/joy-media/src/channels/ChannelHelpers.ts

@@ -1,34 +0,0 @@
-import { AccountId } from '@polkadot/types/interfaces';
-import { ChannelType } from '../schemas/channel/Channel';
-import { ChannelPublicationStatusAllValues } from '@joystream/types/content-working-group';
-
-export const ChannelPublicationStatusDropdownOptions =
-  ChannelPublicationStatusAllValues
-    .map((x) => ({ key: x, value: x, text: x }));
-
-export const isVideoChannel = (channel: ChannelType) => {
-  return channel.content === 'Video';
-};
-
-export const isMusicChannel = (channel: ChannelType) => {
-  return channel.content === 'Music';
-};
-
-export const isAccountAChannelOwner = (channel?: ChannelType, account?: AccountId | string): boolean => {
-  return (channel && account) ? channel.roleAccount.eq(account) : false;
-};
-
-export function isPublicChannel (channel: ChannelType): boolean {
-  return (
-    channel.publicationStatus === 'Public' &&
-    channel.curationStatus !== 'Censored'
-  );
-}
-
-export function isCensoredChannel (channel: ChannelType): boolean {
-  return channel.curationStatus === 'Censored';
-}
-
-export function isVerifiedChannel (channel: ChannelType): boolean {
-  return channel.verified;
-}

+ 0 - 19
pioneer/packages/joy-media/src/channels/ChannelNameAsLink.tsx

@@ -1,19 +0,0 @@
-import React from 'react';
-import { Link } from 'react-router-dom';
-import { ChannelEntity } from '../entities/ChannelEntity';
-
-type Props = {
-  channel: ChannelEntity;
-  className?: string;
-  style?: React.CSSProperties;
-}
-
-export const ChannelNameAsLink = (props: Props) => {
-  const { channel, className, style } = props;
-
-  return (
-    <Link to={`/media/channels/${channel.id}`} className={className} style={style}>
-      {channel.title || channel.handle}
-    </Link>
-  );
-};

+ 0 - 114
pioneer/packages/joy-media/src/channels/ChannelPreview.tsx

@@ -1,114 +0,0 @@
-import React from 'react';
-import { Link } from 'react-router-dom';
-import ReactMarkdown from 'react-markdown';
-import { Icon, Label, SemanticICONS, SemanticCOLORS } from 'semantic-ui-react';
-import { ChannelEntity } from '../entities/ChannelEntity';
-import { ChannelAvatar, ChannelAvatarSize } from './ChannelAvatar';
-import { isPublicChannel, isMusicChannel, isVideoChannel, isAccountAChannelOwner, isVerifiedChannel } from './ChannelHelpers';
-
-import { useMyMembership } from '@polkadot/joy-utils/react/hooks';
-import { nonEmptyStr } from '@polkadot/joy-utils/functions/misc';
-import { CurationPanel } from './CurationPanel';
-import { ChannelNameAsLink } from './ChannelNameAsLink';
-
-type ChannelPreviewProps = {
-  channel: ChannelEntity;
-  size?: ChannelAvatarSize;
-  withSubtitle?: boolean;
-  withDescription?: boolean;
-};
-
-export const ChannelPreview = (props: ChannelPreviewProps) => {
-  const { myAccountId } = useMyMembership();
-  const { channel, size, withSubtitle = true, withDescription } = props;
-
-  let subtitle: string | undefined;
-  let icon: 'music' | 'film' | undefined;
-
-  if (isMusicChannel(channel)) {
-    subtitle = 'Music channel';
-    icon = 'music';
-  } else if (isVideoChannel(channel)) {
-    subtitle = 'Video channel';
-    icon = 'film';
-  }
-
-  let visibilityIcon: SemanticICONS = 'eye';
-  let visibilityColor: SemanticCOLORS = 'green';
-  let visibilityText = 'Public';
-
-  if (!isPublicChannel(channel)) {
-    visibilityIcon = 'eye slash';
-    visibilityColor = 'orange';
-    visibilityText = 'Unlisted';
-  }
-
-  return <>
-    <div className={`ChannelPreview ${size || ''}`}>
-
-      <ChannelAvatar channel={channel} size={size} />
-
-      <div className='ChannelDetails'>
-        <h3 className='ChannelTitle' style={{ display: 'block' }}>
-          <ChannelNameAsLink channel={channel} style={{ marginRight: '1rem' }} />
-
-          {isAccountAChannelOwner(channel, myAccountId) &&
-            <div style={{ float: 'right' }}>
-
-              <Link to={`/media/channels/${channel.id}/edit`} className='ui button basic' style={{ marginRight: '1rem' }}>
-                <i className='icon pencil' />
-                Edit
-              </Link>
-
-              <Link to={`/media/channels/${channel.id}/upload`} className='ui button basic primary'>
-                <i className='icon upload' />
-                Upload {channel.content}
-              </Link>
-
-            </div>
-          }
-        </h3>
-
-        <div className='ChannelSubtitle'>
-
-          {withSubtitle && subtitle &&
-            <span style={{ marginRight: '1rem' }}>
-              {icon && <i className={`icon ${icon}`} />}
-              {subtitle}
-            </span>
-          }
-
-          <Label basic color={visibilityColor} style={{ marginRight: '1rem' }}>
-            <Icon name={visibilityIcon} />
-            {visibilityText}
-          </Label>
-
-          {channel.curationStatus !== 'Normal' &&
-            <Label basic color='red'>
-              <Icon name='dont' />
-              Channel {channel.curationStatus}
-              {' '}<Icon name='question circle outline' size='small' />
-            </Label>
-          }
-
-          {isVerifiedChannel(channel) &&
-            <Label basic color='blue'>
-              <i className='icon checkmark'/>
-              Verified
-            </Label>
-          }
-        </div>
-
-        <CurationPanel channel={channel} />
-
-        {withDescription && nonEmptyStr(channel.description) &&
-          <ReactMarkdown className='JoyMemo--full ChannelDesc' source={channel.description} linkTarget='_blank' />
-        }
-      </div>
-
-      {/* // TODO uncomment when we calculate reward and count of videos in channel: */}
-      {/* <ChannelStats channel={channel} /> */}
-
-    </div>
-  </>;
-};

+ 0 - 43
pioneer/packages/joy-media/src/channels/ChannelPreviewStats.tsx

@@ -1,43 +0,0 @@
-import React from 'react';
-import { Statistic } from 'semantic-ui-react';
-
-import { ChannelEntity } from '../entities/ChannelEntity';
-import { formatNumber } from '@polkadot/util';
-
-type Props = {
-  channel: ChannelEntity;
-};
-
-export const ChannelPreviewStats = (props: Props) => {
-  const { channel } = props;
-  const statSize = 'tiny';
-
-  let itemsPublishedLabel = '';
-
-  if (channel.content === 'Video') {
-    itemsPublishedLabel = 'Videos';
-  } else if (channel.content === 'Music') {
-    itemsPublishedLabel = 'Music tracks';
-  }
-
-  return (
-    <div className='ChannelStats'>
-      <div>
-        <Statistic size={statSize}>
-          <Statistic.Label>Reward earned</Statistic.Label>
-          <Statistic.Value>
-            {formatNumber(channel.rewardEarned)}
-            &nbsp;<span style={{ fontSize: '1.5rem' }}>JOY</span>
-          </Statistic.Value>
-        </Statistic>
-      </div>
-
-      <div style={{ marginTop: '1rem' }}>
-        <Statistic size={statSize}>
-          <Statistic.Label>{itemsPublishedLabel}</Statistic.Label>
-          <Statistic.Value>{formatNumber(channel.contentItemsCount)}</Statistic.Value>
-        </Statistic>
-      </div>
-    </div>
-  );
-};

+ 0 - 79
pioneer/packages/joy-media/src/channels/ChannelsByOwner.tsx

@@ -1,79 +0,0 @@
-import React, { useState } from 'react';
-import { Link } from 'react-router-dom';
-import { Segment, Tab } from 'semantic-ui-react';
-import { AccountId } from '@polkadot/types/interfaces';
-import { ChannelEntity } from '../entities/ChannelEntity';
-import { YouHaveNoChannels } from './YouHaveNoChannels';
-import { ChannelContentTypeValue } from '@joystream/types/content-working-group';
-import { ChannelPreview } from './ChannelPreview';
-
-export type ChannelsByOwnerProps = {
-  accountId: AccountId;
-  suspended?: boolean;
-  channels?: ChannelEntity[];
-};
-
-const TabsAndChannels = (props: ChannelsByOwnerProps) => {
-  const { channels: allChannels = [] } = props;
-  const [channels, setChannels] = useState(allChannels);
-
-  let videoChannelsCount = 0;
-  let musicChannelsCount = 0;
-
-  allChannels.forEach((x) => {
-    if (x.content === 'Video') {
-      videoChannelsCount++;
-    } else if (x.content === 'Music') {
-      musicChannelsCount++;
-    }
-  });
-
-  const panes = [
-    { menuItem: `All channels (${allChannels.length})` },
-    { menuItem: `Video channels (${videoChannelsCount})` },
-    { menuItem: `Music channels (${musicChannelsCount})` }
-  ];
-
-  const contentTypeByTabIndex: Array<ChannelContentTypeValue | undefined> =
-    [undefined, 'Video', 'Music'];
-
-  const switchTab = (activeIndex: number) => {
-    const activeContentType = contentTypeByTabIndex[activeIndex];
-
-    if (activeContentType === undefined) {
-      setChannels(allChannels);
-    } else {
-      setChannels(allChannels.filter(
-        (x) => x.content === activeContentType)
-      );
-    }
-  };
-
-  return <>
-    <Tab
-      panes={panes}
-      menu={{ secondary: true }}
-      style={{ display: 'inline-flex', margin: '0 2rem 1rem 0' }}
-      onTabChange={(_e, data) => switchTab(data.activeIndex as number)}
-    />
-    <Link to={'/media/channels/new'} className='ui button'>
-      <i className='icon plus' />
-      Create Channel
-    </Link>
-    {channels.map((channel) =>
-      <Segment key={channel.id} padded style={{ backgroundColor: '#fff' }}>
-        <ChannelPreview channel={channel} withDescription />
-      </Segment>
-    )}
-  </>;
-};
-
-export function ChannelsByOwner (props: ChannelsByOwnerProps) {
-  const { suspended = false, channels = [] } = props;
-
-  return <div className='JoyChannels'>
-    {!channels.length
-      ? <YouHaveNoChannels suspended={suspended} />
-      : <TabsAndChannels {...props} />
-    }</div>;
-}

+ 0 - 34
pioneer/packages/joy-media/src/channels/ChannelsByOwner.view.tsx

@@ -1,34 +0,0 @@
-import React from 'react';
-import { RouteComponentProps } from 'react-router';
-
-import { MediaView } from '../MediaView';
-import { ChannelsByOwnerProps, ChannelsByOwner } from './ChannelsByOwner';
-import { JoyError } from '@polkadot/joy-utils/react/components';
-import { useApi } from '@polkadot/react-hooks';
-
-type Props = ChannelsByOwnerProps;
-
-export const ChannelsByOwnerView = MediaView<Props>({
-  component: ChannelsByOwner,
-  resolveProps: async (props) => {
-    const { transport, accountId } = props;
-    const channels = await transport.channelsByAccount(accountId);
-
-    return { channels };
-  }
-});
-
-export const ChannelsByOwnerWithRouter = (props: Props & RouteComponentProps<Record<string, string | undefined>>) => {
-  const { match: { params: { account } } } = props;
-  const { api } = useApi();
-
-  if (account) {
-    try {
-      return <ChannelsByOwnerView {...props} accountId={api.createType('AccountId', account)} />;
-    } catch (err) {
-      console.log('ChannelsByOwnerWithRouter failed:', err);
-    }
-  }
-
-  return <JoyError title={'Invalid account address in URL'}>{account}</JoyError>;
-};

+ 0 - 94
pioneer/packages/joy-media/src/channels/CurationPanel.tsx

@@ -1,94 +0,0 @@
-import React from 'react';
-import { ChannelEntity } from '../entities/ChannelEntity';
-import { isVerifiedChannel, isCensoredChannel } from './ChannelHelpers';
-import { useMyMembership } from '@polkadot/joy-utils/react/hooks';
-import { SemanticTxButton } from '@polkadot/joy-utils/react/components/TxButton';
-import { AccountId } from '@polkadot/types/interfaces';
-import { useApi } from '@polkadot/react-hooks';
-import { Icon } from 'semantic-ui-react';
-
-type ChannelCurationPanelProps = {
-  channel: ChannelEntity;
-};
-
-export const CurationPanel = (props: ChannelCurationPanelProps) => {
-  const { api } = useApi();
-  const { curationActor, allAccounts } = useMyMembership();
-  const { channel } = props;
-
-  const canUseAccount = (account: AccountId) => {
-    if (!allAccounts || !Object.keys(allAccounts).length) {
-      return false;
-    }
-
-    const ix = Object.keys(allAccounts).findIndex((key) => {
-      return account.eq(allAccounts[key].json.address);
-    });
-
-    return ix !== -1;
-  };
-
-  const renderToggleCensorshipButton = () => {
-    if (!curationActor) { return null; }
-
-    const [curation_actor, role_account] = curationActor;
-    const accountAvailable = canUseAccount(role_account);
-
-    const isCensored = isCensoredChannel(channel);
-
-    const new_curation_status = api.createType('ChannelCurationStatus',
-      isCensored ? 'Normal' : 'Censored'
-    );
-
-    return <SemanticTxButton
-      accountId={role_account.toString()}
-      type='submit'
-      size='small'
-      color={isCensored ? undefined : 'red'}
-      disabled={!accountAvailable}
-      params={[
-        curation_actor,
-        channel.id,
-        null, // not changing verified status
-        new_curation_status // toggled curation status
-      ]}
-      tx={'contentWorkingGroup.updateChannelAsCurationActor'}
-    >
-      <Icon name={isCensored ? 'x' : 'warning'}/>
-      { isCensored ? 'Un-Censor' : 'Censor' }
-    </SemanticTxButton>;
-  };
-
-  const renderToggleVerifiedButton = () => {
-    if (!curationActor) { return null; }
-
-    const [curation_actor, role_account] = curationActor;
-    const accountAvailable = canUseAccount(role_account);
-    const isVerified = isVerifiedChannel(channel);
-
-    return <SemanticTxButton
-      accountId={role_account.toString()}
-      type='submit'
-      size='small'
-      color={isVerified ? undefined : 'green'}
-      disabled={!accountAvailable}
-      params={[
-        curation_actor,
-        channel.id,
-        !isVerified, // toggle verified
-        null // not changing curation status
-      ]}
-      tx={'contentWorkingGroup.updateChannelAsCurationActor'}
-    >
-      <Icon name={isVerified ? 'x' : 'checkmark'}/>
-      { isVerified ? 'Remove Verification' : 'Verify' }
-    </SemanticTxButton>;
-  };
-
-  return <>
-    <div style={{ display: 'flex', float: 'right', margin: '0.5em', marginRight: 0 }}>
-      {renderToggleCensorshipButton()}
-      {renderToggleVerifiedButton()}
-    </div>
-  </>;
-};

+ 0 - 219
pioneer/packages/joy-media/src/channels/EditChannel.tsx

@@ -1,219 +0,0 @@
-import React from 'react';
-import { Button } from 'semantic-ui-react';
-import { Form, withFormik } from 'formik';
-import { History } from 'history';
-
-import { Option } from '@polkadot/types';
-import { TxButton, JoyError, Section } from '@polkadot/joy-utils/react/components';
-import { onImageError } from '../common/images';
-import { withMediaForm, MediaFormProps } from '../common/MediaForms';
-import { ChannelType, ChannelClass as Fields, buildChannelValidationSchema, ChannelFormValues, ChannelToFormValues, ChannelGenericProp } from '../schemas/channel/Channel';
-import { MediaDropdownOptions } from '../common/MediaDropdownOptions';
-import { ChannelId, OptionalText } from '@joystream/types/content-working-group';
-import { findFirstParamOfSubstrateEvent } from '@polkadot/joy-utils/functions/misc';
-import { useMyMembership } from '@polkadot/joy-utils/react/hooks';
-import { ChannelPublicationStatusDropdownOptions, isAccountAChannelOwner } from './ChannelHelpers';
-import { TxCallback } from '@polkadot/react-components/Status/types';
-import { SubmittableResult } from '@polkadot/api';
-import { ChannelValidationConstraints } from '../transport';
-
-import { useApi } from '@polkadot/react-hooks';
-
-export type OuterProps = {
-  history?: History;
-  id?: ChannelId;
-  entity?: ChannelType;
-  constraints?: ChannelValidationConstraints;
-  opts?: MediaDropdownOptions;
-};
-
-type FormValues = ChannelFormValues;
-
-const InnerForm = (props: MediaFormProps<OuterProps, FormValues>) => {
-  const {
-    // React components for form fields:
-    MediaText,
-    MediaDropdown,
-    LabelledField,
-
-    // Callbacks:
-    onSubmit,
-    // onTxSuccess,
-    onTxFailed,
-
-    history,
-    id: existingId,
-    entity,
-    isFieldChanged,
-
-    // Formik stuff:
-    values,
-    dirty,
-    isValid,
-    isSubmitting,
-    setSubmitting,
-    resetForm
-  } = props;
-
-  const { myAccountId, myMemberId } = useMyMembership();
-  const { api } = useApi();
-
-  if (entity && !isAccountAChannelOwner(entity, myAccountId)) {
-    return <JoyError title={'Only owner can edit channel'} />;
-  }
-
-  const { avatar } = values;
-  const isNew = !entity;
-
-  // if user is not the channel owner don't render the edit form
-  // return null
-
-  const onTxSuccess: TxCallback = (txResult: SubmittableResult) => {
-    setSubmitting(false);
-    if (!history) return;
-
-    const id = existingId || findFirstParamOfSubstrateEvent<ChannelId>(txResult, 'ChannelCreated');
-
-    console.log('Channel id:', id?.toString());
-
-    if (id) {
-      history.push('/media/channels/' + id.toString());
-    }
-  };
-
-  const buildTxParams = () => {
-    if (!isValid) return [];
-
-    if (!entity) {
-      // Create a new channel
-
-      const channelOwner = myMemberId;
-      const roleAccount = myAccountId;
-      const contentType = api.createType('ChannelContentType', values.content);
-
-      return [
-        channelOwner,
-        roleAccount,
-        contentType,
-        values.handle,
-        values.title || null,
-        values.description || null,
-        values.avatar || null,
-        values.banner || null,
-        values.publicationStatus
-      ];
-    } else {
-      // Update an existing channel
-
-      const updOptText = (field: ChannelGenericProp): Option<OptionalText> => {
-        return api.createType('Option<OptionalText>',
-          isFieldChanged(field)
-            ? api.createType('Option<Text>', values[field.id])
-            : null
-        );
-      };
-
-      const updHandle = api.createType('Option<Text>',
-        isFieldChanged(Fields.handle)
-          ? values[Fields.handle.id]
-          : null
-      );
-
-      const updPublicationStatus = api.createType('Option<ChannelPublicationStatus>',
-        isFieldChanged(Fields.publicationStatus)
-          ? api.createType('ChannelPublicationStatus', values[Fields.publicationStatus.id])
-          : null
-      );
-
-      return [
-        entity.id,
-        updHandle,
-        updOptText(Fields.title),
-        updOptText(Fields.description),
-        updOptText(Fields.avatar),
-        updOptText(Fields.banner),
-        updPublicationStatus
-      ];
-    }
-  };
-
-  const formFields = () => <>
-    <MediaText field={Fields.handle} {...props} />
-    <MediaText field={Fields.title} {...props} />
-    <MediaText field={Fields.avatar} {...props} />
-    <MediaText field={Fields.banner} {...props} />
-    <MediaText field={Fields.description} textarea {...props} />
-
-    <MediaDropdown
-      {...props}
-      field={Fields.publicationStatus}
-      options={ChannelPublicationStatusDropdownOptions}
-    />
-  </>;
-
-  const renderMainButton = () =>
-    <TxButton
-      type='submit'
-      isDisabled={!dirty || isSubmitting}
-      label={isNew
-        ? 'Create channel'
-        : 'Update channel'
-      }
-      params={buildTxParams()}
-      tx={isNew
-        ? 'contentWorkingGroup.createChannel'
-        : 'contentWorkingGroup.updateChannelAsOwner'
-      }
-      onClick={onSubmit}
-      txFailedCb={onTxFailed}
-      txSuccessCb={onTxSuccess}
-    />;
-
-  return <div className='EditMetaBox'>
-    <div className='EditMetaThumb'>
-      {avatar && <img src={avatar} onError={onImageError} />}
-    </div>
-
-    <Section title={isNew ? 'Create a channel' : 'Edit a channel'}>
-      <Form className='ui form JoyForm EditMetaForm'>
-
-        {formFields()}
-
-        <LabelledField style={{ marginTop: '1rem' }} {...props} flex>
-          {renderMainButton()}
-          <Button
-            type='button'
-            size='large'
-            disabled={!dirty || isSubmitting}
-            onClick={() => resetForm()}
-            content='Reset form'
-          />
-        </LabelledField>
-      </Form>
-    </Section>
-  </div>;
-};
-
-export const EditForm = withFormik<OuterProps, FormValues>({
-
-  // Transform outer props into form values
-  mapPropsToValues: (props): FormValues => {
-    const { entity } = props;
-
-    return ChannelToFormValues(entity);
-  },
-
-  validationSchema: (props: OuterProps): any => {
-    const { constraints } = props;
-
-    if (!constraints) return null;
-
-    return buildChannelValidationSchema(constraints);
-  },
-
-  handleSubmit: () => {
-    // do submitting things
-  }
-})(withMediaForm(InnerForm) as any);
-
-export default EditForm;

+ 0 - 38
pioneer/packages/joy-media/src/channels/EditChannel.view.tsx

@@ -1,38 +0,0 @@
-import React from 'react';
-import { RouteComponentProps } from 'react-router';
-import { MediaView } from '../MediaView';
-import { OuterProps, EditForm } from './EditChannel';
-import { JoyError } from '@polkadot/joy-utils/react/components';
-import { useApi } from '@polkadot/react-hooks';
-
-type Props = OuterProps;
-
-export const EditChannelView = MediaView<Props>({
-  component: EditForm,
-  membersOnly: true,
-  triggers: ['id'],
-  resolveProps: async (props) => {
-    const { transport, id } = props;
-    const entity = id && await transport.channelById(id);
-    const constraints = await transport.channelValidationConstraints();
-
-    return { entity, constraints };
-  }
-});
-
-type WithRouterProps = Props & RouteComponentProps<Record<string, string | undefined>>
-
-export const EditChannelWithRouter = (props: WithRouterProps) => {
-  const { match: { params: { id } } } = props;
-  const { api } = useApi();
-
-  if (id) {
-    try {
-      return <EditChannelView {...props} id={api.createType('ChannelId', id)} />;
-    } catch (err) {
-      console.log('EditChannelWithRouter failed:', err);
-    }
-  }
-
-  return <JoyError title={'Invalid channel id in URL'}>{id}</JoyError>;
-};

+ 0 - 41
pioneer/packages/joy-media/src/channels/ViewChannel.tsx

@@ -1,41 +0,0 @@
-import React from 'react';
-import { ChannelEntity } from '../entities/ChannelEntity';
-import { ChannelId } from '@joystream/types/content-working-group';
-import { VideoType } from '../schemas/video/Video';
-import { MusicAlbumPreviewProps } from '../music/MusicAlbumPreview';
-import { MusicTrackReaderPreviewProps } from '../music/MusicTrackReaderPreview';
-import { ViewVideoChannel } from './ViewVideoChannel';
-import { ViewMusicChannel } from './ViewMusicChannel';
-import { toVideoPreviews } from '../video/VideoPreview';
-import { isVideoChannel, isMusicChannel } from './ChannelHelpers';
-import { JoyError } from '@polkadot/joy-utils/react/components';
-
-export type ViewChannelProps = {
-  id: ChannelId;
-  channel?: ChannelEntity;
-
-  // Video channel specific:
-  videos?: VideoType[];
-
-  // Music channel specific:
-  albums?: MusicAlbumPreviewProps[];
-  tracks?: MusicTrackReaderPreviewProps[];
-}
-
-export function ViewChannel (props: ViewChannelProps) {
-  const { channel, videos = [], albums = [], tracks = [] } = props;
-
-  if (!channel) {
-    return <JoyError title={'Channel was not found'} />;
-  }
-
-  if (isVideoChannel(channel)) {
-    const previews = toVideoPreviews(videos);
-
-    return <ViewVideoChannel channel={channel} videos={previews} />;
-  } else if (isMusicChannel(channel)) {
-    return <ViewMusicChannel channel={channel} albums={albums} tracks={tracks} />;
-  } else {
-    return <JoyError title={'Unsupported channel type'}>{channel.content}</JoyError>;
-  }
-}

+ 0 - 35
pioneer/packages/joy-media/src/channels/ViewChannel.view.tsx

@@ -1,35 +0,0 @@
-import React from 'react';
-import { RouteComponentProps } from 'react-router';
-import { MediaView } from '../MediaView';
-import { ViewChannelProps, ViewChannel } from './ViewChannel';
-import { JoyError } from '@polkadot/joy-utils/react/components';
-import { useApi } from '@polkadot/react-hooks';
-
-type Props = ViewChannelProps;
-
-export const ViewChannelView = MediaView<Props>({
-  component: ViewChannel,
-  triggers: ['id'],
-  resolveProps: async (props) => {
-    const { transport, id } = props;
-    const channel = await transport.channelById(id);
-    const videos = await transport.videosByChannelId(id);
-
-    return { channel, videos };
-  }
-});
-
-export const ViewChannelWithRouter = (props: Props & RouteComponentProps<Record<string, string | undefined>>) => {
-  const { match: { params: { id } } } = props;
-  const { api } = useApi();
-
-  if (id) {
-    try {
-      return <ViewChannelView {...props} id={api.createType('ChannelId', id)} />;
-    } catch (err) {
-      console.log('ViewChannelWithRouter failed:', err);
-    }
-  }
-
-  return <JoyError title={'Invalid channel id in URL'}>{id}</JoyError>;
-};

+ 0 - 47
pioneer/packages/joy-media/src/channels/ViewMusicChannel.tsx

@@ -1,47 +0,0 @@
-import React from 'react';
-import { ChannelEntity } from '../entities/ChannelEntity';
-import { Section } from '@polkadot/joy-utils/react/components';
-import { ChannelHeader } from './ChannelHeader';
-import { MusicAlbumPreviewProps, MusicAlbumPreview } from '../music/MusicAlbumPreview';
-import { MusicTrackReaderPreview, MusicTrackReaderPreviewProps } from '../music/MusicTrackReaderPreview';
-import NoContentYet from '../common/NoContentYet';
-
-type Props = {
-  channel: ChannelEntity;
-  albums?: MusicAlbumPreviewProps[];
-  tracks?: MusicTrackReaderPreviewProps[];
-};
-
-function NoAlbums () {
-  return <NoContentYet>Channel has no music albums yet.</NoContentYet>;
-}
-
-function NoTracks () {
-  return <NoContentYet>Channel has no music tracks yet.</NoContentYet>;
-}
-
-export function ViewMusicChannel (props: Props) {
-  const { channel, albums = [], tracks = [] } = props;
-
-  const renderAlbumsSection = () => (
-    !albums.length
-      ? <NoAlbums />
-      : <Section title={'Music albums'}>
-        {albums.map((x) => <MusicAlbumPreview key={x.id} {...x} />)}
-      </Section>
-  );
-
-  const renderTracksSection = () => (
-    !tracks.length
-      ? <NoTracks />
-      : <Section title={'Music tracks'}>
-        {tracks.map((x) => <MusicTrackReaderPreview key={x.id} {...x} />)}
-      </Section>
-  );
-
-  return <div className='JoyViewChannel'>
-    <ChannelHeader channel={channel} />
-    {renderAlbumsSection()}
-    {renderTracksSection()}
-  </div>;
-}

+ 0 - 34
pioneer/packages/joy-media/src/channels/ViewVideoChannel.tsx

@@ -1,34 +0,0 @@
-import React from 'react';
-import { Section } from '@polkadot/joy-utils/react/components';
-import { ChannelEntity } from '../entities/ChannelEntity';
-import { ChannelHeader } from './ChannelHeader';
-import { VideoPreview, VideoPreviewProps } from '../video/VideoPreview';
-import NoContentYet from '../common/NoContentYet';
-
-type Props = {
-  channel: ChannelEntity;
-  videos?: VideoPreviewProps[];
-};
-
-function NoVideosYet () {
-  return <NoContentYet>Channel has no videos yet.</NoContentYet>;
-}
-
-export function ViewVideoChannel (props: Props) {
-  const { channel, videos = [] } = props;
-
-  const renderVideosSection = () => (
-    !videos.length
-      ? <NoVideosYet />
-      : <Section title={'Videos'}>
-        {videos.map((x) =>
-          <VideoPreview key={x.id.toString()} {...x} channel={channel} />
-        )}
-      </Section>
-  );
-
-  return <div className='JoyViewChannel'>
-    <ChannelHeader channel={channel} />
-    {renderVideosSection()}
-  </div>;
-}

+ 0 - 51
pioneer/packages/joy-media/src/channels/YouHaveNoChannels.tsx

@@ -1,51 +0,0 @@
-import React from 'react';
-import { Link } from 'react-router-dom';
-import { Message } from 'semantic-ui-react';
-
-type Props = {
-  suspended?: boolean;
-};
-
-export function YouHaveNoChannels (props: Props) {
-  const { suspended = false } = props;
-
-  const renderSuspendedAlert = () => (
-    <Message
-      compact
-      error
-      icon='warning sign'
-      header='Channel Creation Suspended'
-      content='Please try again later'
-      className='JoyInlineMsg'
-    />
-  );
-
-  const renderCreateButton = () => (
-    <Link to={'/media/channels/new'}>
-      <Message
-        compact
-        success
-        icon='plus circle'
-        header='Create Channel'
-        content='and start publishing'
-        className='JoyInlineMsg CreateBtn'
-      />
-    </Link>
-  );
-
-  return <>
-    <h2 style={{ marginTop: '2rem', marginBottom: '.5rem' }}>
-      Build your following on Joystream
-    </h2>
-
-    <p style={{ marginBottom: '2rem' }}>
-      A channel is a way to organize related content for the benefit
-      of both the publisher and the audience.
-    </p>
-
-    {suspended
-      ? renderSuspendedAlert()
-      : renderCreateButton()
-    }
-  </>;
-}

+ 0 - 43
pioneer/packages/joy-media/src/common/BgImg.tsx

@@ -1,43 +0,0 @@
-import React, { CSSProperties } from 'react';
-
-type Props = {
-  url: string;
-  size?: number;
-  width?: number;
-  height?: number;
-  circle?: boolean;
-  className?: string;
-  style?: CSSProperties;
-};
-
-export function BgImg (props: Props) {
-  let { url, width, height, size, circle, className, style } = props;
-
-  const fullClass = `JoyBgImg ${className || ''}`;
-
-  let fullStyle: CSSProperties = {
-    backgroundImage: `url(${url})`
-  };
-
-  if (!width || !height) {
-    width = size;
-    height = size;
-  }
-
-  fullStyle = Object.assign(fullStyle, {
-    width,
-    height,
-    minWidth: width,
-    minHeight: height
-  });
-
-  if (circle) {
-    fullStyle = Object.assign(fullStyle, {
-      borderRadius: '50%'
-    });
-  }
-
-  fullStyle = Object.assign(fullStyle, style);
-
-  return <div className={fullClass} style={fullStyle} />;
-}

+ 0 - 59
pioneer/packages/joy-media/src/common/FormTabs.tsx

@@ -1,59 +0,0 @@
-import React from 'react';
-import { Menu, Label, Tab } from 'semantic-ui-react';
-import { FormikErrors } from 'formik';
-import { GenericMediaProp } from './MediaForms';
-
-type FormTab<FormValues> = {
-  id: string;
-  fields?: GenericMediaProp<FormValues>[];
-  renderTitle?: () => React.ReactNode;
-  render?: () => React.ReactNode;
-}
-
-type FormTabsProps<FormValues> = {
-  errors: FormikErrors<FormValues>;
-  panes: FormTab<FormValues>[];
-}
-
-export function FormTabs <FormValues> (props: FormTabsProps<FormValues>) {
-  const { panes, errors } = props;
-
-  return <Tab
-    menu={{ secondary: true, pointing: true, color: 'blue' }}
-    panes={panes.map((tab) => {
-      const {
-        id,
-        fields = [],
-        renderTitle = () => id,
-        render = () => null
-      } = tab;
-
-      const tabErrors: any[] = [];
-
-      fields.forEach((f) => {
-        const err = errors[f.id];
-
-        if (err) {
-          tabErrors.push(err);
-        }
-      });
-
-      // Currently we don't show error counter because it's markup is broken:
-      // a red circle with a number is shifted quite far from the right border of its tab.
-      const showErrorCounter = false;
-
-      const errCount = tabErrors.length;
-      const errTooltip = 'Number of errors on this tab';
-
-      const menuItem =
-        <Menu.Item key={id}>
-          {renderTitle()}
-          {showErrorCounter && errCount > 0 &&
-            <Label color='red' circular floating title={errTooltip}>{errCount}</Label>
-          }
-        </Menu.Item>;
-
-      return { menuItem, render };
-    })}
-  />;
-}

+ 0 - 44
pioneer/packages/joy-media/src/common/MediaDropdownOptions.tsx

@@ -1,44 +0,0 @@
-import ISO6391 from 'iso-639-1';
-import { DropdownItemProps } from 'semantic-ui-react';
-import { LanguageType } from '../schemas/general/Language';
-import { TextValueEntity } from '@joystream/types/versioned-store/EntityCodec';
-import { InternalEntities } from '../transport';
-
-const buildOptions = (entities: TextValueEntity[]): DropdownItemProps[] =>
-  entities.map((x) => ({ key: x.id, value: x.id, text: x.value }));
-
-const buildLanguageOptions = (entities: LanguageType[]): DropdownItemProps[] =>
-  entities.map((x) => ({ key: x.id, value: x.id, text: ISO6391.getName(x.value) }));
-
-export class MediaDropdownOptions {
-  public languageOptions: DropdownItemProps[]
-  public contentLicenseOptions: DropdownItemProps[]
-  public curationStatusOptions: DropdownItemProps[]
-  public musicGenreOptions: DropdownItemProps[]
-  public musicMoodOptions: DropdownItemProps[]
-  public musicThemeOptions: DropdownItemProps[]
-  public publicationStatusOptions: DropdownItemProps[]
-  public videoCategoryOptions: DropdownItemProps[]
-
-  constructor (props: InternalEntities) {
-    this.languageOptions = buildLanguageOptions(props.languages);
-    this.contentLicenseOptions = buildOptions(props.contentLicenses);
-    this.curationStatusOptions = buildOptions(props.curationStatuses);
-    this.musicGenreOptions = buildOptions(props.musicGenres);
-    this.musicMoodOptions = buildOptions(props.musicMoods);
-    this.musicThemeOptions = buildOptions(props.musicThemes);
-    this.publicationStatusOptions = buildOptions(props.publicationStatuses);
-    this.videoCategoryOptions = buildOptions(props.videoCategories);
-  }
-
-  static Empty = new MediaDropdownOptions({
-    languages: [],
-    contentLicenses: [],
-    curationStatuses: [],
-    musicGenres: [],
-    musicMoods: [],
-    musicThemes: [],
-    publicationStatuses: [],
-    videoCategories: []
-  });
-}

+ 0 - 202
pioneer/packages/joy-media/src/common/MediaForms.tsx

@@ -1,202 +0,0 @@
-import React from 'react';
-import { Dropdown, DropdownItemProps, DropdownProps } from 'semantic-ui-react';
-import { FormikProps, Field } from 'formik';
-import * as JoyForms from '@polkadot/joy-utils/react/components/forms';
-import { SubmittableResult } from '@polkadot/api';
-import { TxFailedCallback, TxCallback } from '@polkadot/react-components/Status/types';
-import { MediaDropdownOptions } from './MediaDropdownOptions';
-import { OnTxButtonClick } from '@polkadot/joy-utils/react/components/TxButton';
-import isEqual from 'lodash/isEqual';
-import { componentName } from '@polkadot/joy-utils/react/helpers';
-
-export const datePlaceholder = 'Date in format yyyy-mm-dd';
-
-export const boolOptions: DropdownItemProps[] = [
-  { value: 'true', text: 'Yes' },
-  { value: 'false', text: 'No' }
-];
-
-export type FormCallbacks = {
-  onSubmit: OnTxButtonClick;
-  onTxSuccess: TxCallback;
-  onTxFailed: TxFailedCallback;
-};
-
-export type GenericMediaProp<FormValues> = {
-  id: keyof FormValues;
-  type: string;
-  name: string;
-  description?: string;
-  required?: boolean;
-  minItems?: number;
-  maxItems?: number;
-  minTextLength?: number;
-  maxTextLength?: number;
-  classId?: any;
-};
-
-type BaseFieldProps<OuterProps, FormValues> = OuterProps & FormikProps<FormValues> & {
-  field: GenericMediaProp<FormValues>;
-};
-
-type MediaTextProps<OuterProps, FormValues> =
-  BaseFieldProps<OuterProps, FormValues> & JoyForms.LabelledProps<FormValues>;
-
-type MediaFieldProps<OuterProps, FormValues> =
-  BaseFieldProps<OuterProps, FormValues> &
-  JoyForms.LabelledProps<FormValues> & {
-    fieldProps: Record<string, unknown>;
-  }
-
-type MediaDropdownProps<OuterProps, FormValues> =
-  BaseFieldProps<OuterProps, FormValues> &
-  {
-    options: DropdownItemProps[];
-  };
-
-type FormFields<OuterProps, FormValues> = {
-  LabelledText: React.FunctionComponent<JoyForms.LabelledProps<FormValues>>;
-  LabelledField: React.FunctionComponent<JoyForms.LabelledProps<FormValues>>;
-  MediaText: React.FunctionComponent<MediaTextProps<OuterProps, FormValues>>;
-  MediaField: React.FunctionComponent<MediaFieldProps<OuterProps, FormValues>>;
-  MediaDropdown: React.FunctionComponent<MediaDropdownProps<OuterProps, FormValues>>;
-};
-
-export type MediaFormProps<OuterProps, FormValues> =
-  OuterProps &
-  FormikProps<FormValues> &
-  FormFields<OuterProps, FormValues> &
-  FormCallbacks & {
-    opts: MediaDropdownOptions;
-    isFieldChanged: (field: keyof FormValues | GenericMediaProp<FormValues>) => boolean;
-  };
-
-export function withMediaForm<OuterProps, FormValues>
-(Component: React.ComponentType<MediaFormProps<OuterProps, FormValues>>) {
-  type FieldName = keyof FormValues
-
-  type FieldObject = GenericMediaProp<FormValues>
-
-  const LabelledText = JoyForms.LabelledText<FormValues>();
-
-  const LabelledField = JoyForms.LabelledField<FormValues>();
-
-  function MediaText (props: MediaTextProps<OuterProps, FormValues>) {
-    const { field: f } = props;
-
-    return !f ? null : <LabelledText name={f.id} label={f.name} tooltip={f.description} required={f.required} {...props} />;
-  }
-
-  const MediaField = (props: MediaFieldProps<OuterProps, FormValues>) => {
-    const { field: f, fieldProps = {}, placeholder, className, style, ...otherProps } = props;
-
-    const { id } = f;
-
-    const allFieldProps = {
-      name: id,
-      id,
-      placeholder,
-      className,
-      style,
-      disabled: otherProps.isSubmitting,
-      ...fieldProps
-    };
-
-    return !f ? null : (
-      <LabelledField name={id} label={f.name} tooltip={f.description} required={f.required} {...props}>
-        <Field {...allFieldProps} />
-      </LabelledField>
-    );
-  };
-
-  const MediaDropdown = (props: MediaDropdownProps<OuterProps, FormValues>) => {
-    const { field: f, options = [] } = props;
-    const id = f.id;
-    const value = props.values[id] || '';
-
-    return <MediaField {...props} fieldProps={{
-      component: Dropdown,
-      selection: true,
-      search: true,
-      options,
-      value,
-      onBlur: (_event: any, _data: DropdownProps) => {
-        props.setFieldTouched(id as string, true);
-      },
-      onChange: (_event: any, data: DropdownProps) => {
-        props.setFieldValue(id as string, data.value);
-      }
-    }} />;
-  };
-
-  const ResultComponent: React.FunctionComponent<MediaFormProps<OuterProps, FormValues>> =
-    (props: MediaFormProps<OuterProps, FormValues>) => {
-      const {
-        initialValues,
-        values,
-        dirty,
-        touched,
-        errors,
-        isValid,
-        setSubmitting,
-        opts = MediaDropdownOptions.Empty
-      } = props;
-
-      const isFieldChanged = (field: FieldName | FieldObject): boolean => {
-        const fieldName = typeof field === 'string' ? field : (field as FieldObject).id;
-
-        return (
-          dirty &&
-          touched[fieldName] === true &&
-          !isEqual(values[fieldName], initialValues[fieldName])
-        );
-      };
-
-      const onSubmit = (sendTx: () => void) => {
-        if (isValid) {
-          sendTx();
-        } else {
-          console.log('Form is invalid. Errors:', errors);
-        }
-      };
-
-      const onTxSuccess: TxCallback = (_txResult: SubmittableResult) => {
-        setSubmitting(false);
-      };
-
-      const onTxFailed: TxFailedCallback = (txResult: SubmittableResult | null) => {
-        setSubmitting(false);
-
-        if (txResult === null) {
-          // Tx cancelled
-
-        }
-      };
-
-      const allProps = {
-        ...props,
-
-        // Callbacks:
-        onSubmit,
-        onTxSuccess,
-        onTxFailed,
-
-        // Components:
-        LabelledText,
-        LabelledField,
-        MediaText,
-        MediaField,
-        MediaDropdown,
-
-        // Other
-        opts,
-        isFieldChanged
-      };
-
-      return <Component {...allProps} />;
-    };
-
-  ResultComponent.displayName = `withMediaForm(${componentName(Component)})`;
-
-  return ResultComponent;
-}

+ 0 - 145
pioneer/packages/joy-media/src/common/MediaPlayerView.tsx

@@ -1,145 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { Link } from 'react-router-dom';
-import DPlayer from 'react-dplayer';
-import APlayer from 'react-aplayer';
-
-import { ApiProps } from '@polkadot/react-api/types';
-import { I18nProps } from '@polkadot/react-components/types';
-import { withCalls, withMulti } from '@polkadot/react-api/hoc';
-import { Option } from '@polkadot/types/codec';
-
-import translate from '../translate';
-import { DiscoveryProviderProps } from '../DiscoveryProvider';
-import { DataObject, ContentId } from '@joystream/types/media';
-import { VideoType } from '../schemas/video/Video';
-import { isAccountAChannelOwner } from '../channels/ChannelHelpers';
-import { ChannelEntity } from '../entities/ChannelEntity';
-import { useMyMembership } from '@polkadot/joy-utils/react/hooks';
-import { JoyError } from '@polkadot/joy-utils/react/components';
-
-const PLAYER_COMMON_PARAMS = {
-  lang: 'en',
-  autoplay: true,
-  theme: '#2185d0'
-};
-
-// This is just a part of Player's methods that are used in this component.
-// To see all the methods available on APlayer and DPlayer visit the next URLs:
-// http://aplayer.js.org/#/home?id=api
-// http://dplayer.js.org/#/home?id=api
-interface PartOfPlayer {
-  pause: () => void;
-  destroy: () => void;
-}
-
-export type RequiredMediaPlayerProps = {
-  channel: ChannelEntity;
-  video: VideoType;
-  contentId: ContentId;
-}
-
-type ContentProps = {
-  contentType?: string;
-  dataObjectOpt?: Option<DataObject>;
-  resolvedAssetUrl: string;
-}
-
-type MediaPlayerViewProps = ApiProps & I18nProps &
-DiscoveryProviderProps & RequiredMediaPlayerProps & ContentProps
-
-type PlayerProps = RequiredMediaPlayerProps & ContentProps
-
-function Player (props: PlayerProps) {
-  const { video, resolvedAssetUrl: url, contentType = 'video/video' } = props;
-  const { thumbnail: cover } = video;
-  const prefix = contentType.substring(0, contentType.indexOf('/'));
-
-  const [player, setPlayer] = useState<PartOfPlayer>();
-
-  const onPlayerCreated = (newPlayer: PartOfPlayer) => {
-    console.log('onPlayerCreated:', newPlayer);
-    setPlayer(newPlayer);
-  };
-
-  const destroyPlayer = () => {
-    if (!player) return;
-
-    console.log('Destroy the current player');
-    player.pause();
-    player.destroy();
-    setPlayer(undefined);
-  };
-
-  useEffect(() => {
-    return () => {
-      destroyPlayer();
-    };
-  }, [url]);
-
-  if (prefix === 'video') {
-    const video = { url, name, pic: cover };
-
-    return <DPlayer
-      video={video}
-      {...PLAYER_COMMON_PARAMS}
-      loop={false}
-      onLoad={onPlayerCreated} // Note that DPlayer has onLoad, but APlayer - onInit.
-    />;
-  } else if (prefix === 'audio') {
-    const audio = { url, name, cover };
-
-    return <APlayer
-      audio={audio}
-      {...PLAYER_COMMON_PARAMS}
-      loop='none'
-      onInit={onPlayerCreated} // Note that APlayer has onInit, but DPlayer - onLoad.
-    />;
-  }
-
-  return <JoyError title={'Unsupported type of content'}>{contentType}</JoyError>;
-}
-
-function InnerComponent (props: MediaPlayerViewProps) {
-  const { video, resolvedAssetUrl: url } = props;
-
-  const { dataObjectOpt, channel } = props;
-  const { myAccountId } = useMyMembership();
-
-  if (!dataObjectOpt || dataObjectOpt.isNone) {
-    return null;
-  }
-
-  // TODO extract and show the next info from dataObject:
-  // {"owner":"5GSMNn8Sy8k64mGUWPDafjMZu9bQNX26GujbBQ1LeJpNbrfg","added_at":{"block":2781,"time":1582750854000},"type_id":1,"size":3664485,"liaison":"5HN528fspu4Jg3KXWm7Pu7aUK64RSBz2ZSbwo1XKR9iz3hdY","liaison_judgement":1,"ipfs_content_id":"QmNk4QczoJyPTAKdfoQna6KhAz3FwfjpKyRBXAZHG5djYZ"}
-  const iAmOwner = isAccountAChannelOwner(channel, myAccountId);
-
-  return (
-    <div className='PlayBox'>
-
-      {/* Note that here we use a 'key' prop to force Player component to rerender */}
-      <Player {...props} key={url} />
-
-      <div className='ContentHeader'>
-        <a className='ui button outline DownloadBtn' href={`${url}?download`}><i className='cloud download icon'></i> Download</a>
-
-        {iAmOwner &&
-          <Link to={`/media/videos/${video.id}/edit`} className='ui button' style={{ float: 'right' }}>
-            <i className='pencil alternate icon'></i>
-            Edit
-          </Link>
-        }
-
-        <h1>{video.title}</h1>
-      </div>
-    </div>
-  );
-}
-
-export const MediaPlayerView = withMulti(
-  InnerComponent,
-  translate,
-  withCalls<MediaPlayerViewProps>(
-    ['query.dataDirectory.dataObjectByContentId',
-      { paramName: 'contentId', propName: 'dataObjectOpt' }]
-  )
-);

+ 0 - 159
pioneer/packages/joy-media/src/common/MediaPlayerWithResolver.tsx

@@ -1,159 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import axios, { CancelTokenSource, AxiosError } from 'axios';
-import _ from 'lodash';
-
-import { ApiProps } from '@polkadot/react-api/types';
-import { I18nProps } from '@polkadot/react-components/types';
-import { withMulti } from '@polkadot/react-api/hoc';
-import { Option, Vec } from '@polkadot/types/codec';
-
-import translate from '../translate';
-import { DiscoveryProviderProps, withDiscoveryProvider } from '../DiscoveryProvider';
-import { DataObjectStorageRelationshipId, DataObjectStorageRelationship } from '@joystream/types/media';
-import { Message } from 'semantic-ui-react';
-import { MediaPlayerView, RequiredMediaPlayerProps } from './MediaPlayerView';
-import { JoyInfo } from '@polkadot/joy-utils/react/components';
-import { useTransport } from '@polkadot/joy-utils/react/hooks';
-import { isObjectWithProperties } from '@polkadot/joy-utils/functions/misc';
-
-type Props = ApiProps & I18nProps & DiscoveryProviderProps & RequiredMediaPlayerProps;
-
-function newCancelSource (): CancelTokenSource {
-  return axios.CancelToken.source();
-}
-
-function InnerComponent (props: Props) {
-  const { contentId, api, discoveryProvider } = props;
-  const transport = useTransport();
-
-  const [error, setError] = useState<Error>();
-  const [resolvedAssetUrl, setResolvedAssetUrl] = useState<string>();
-  const [contentType, setContentType] = useState<string>();
-  const [cancelSource, setCancelSource] = useState<CancelTokenSource>(newCancelSource());
-
-  const resolveAsset = async () => {
-    setError(undefined);
-    setCancelSource(newCancelSource());
-
-    const rids = await api.query.dataObjectStorageRegistry.relationshipsByContentId<Vec<DataObjectStorageRelationshipId>>(contentId);
-
-    const allRelationships = await Promise.all(
-      rids.map((id) =>
-        api.query.dataObjectStorageRegistry.relationships<Option<DataObjectStorageRelationship>>(id)
-      )
-    );
-
-    // Providers that have signalled onchain that they have the asset
-    let readyProviders = allRelationships.filter((r) => r.isSome).map((r) => r.unwrap())
-      .filter((r) => r.ready)
-      .map((r) => r.storage_provider);
-
-    // runtime doesn't currently guarantee unique set
-    readyProviders = _.uniqBy(readyProviders, (provider) => provider.toString());
-
-    if (!readyProviders.length) {
-      setError(new Error('No Storage Providers found storing this content'));
-
-      return;
-    }
-
-    const activeProviders = (await transport.workingGroups.allWorkers('Storage')).map(([id]) => id);
-
-    // filter out providers no longer active - relationships of providers that have left
-    // are not pruned onchain.
-    readyProviders = _.intersectionBy(activeProviders, readyProviders, (provider) => provider.toString());
-
-    console.log(`Found ${readyProviders.length} providers ready to serve content: ${readyProviders.join(', ')}`);
-
-    // shuffle to spread the load
-    readyProviders = _.shuffle(readyProviders);
-
-    // TODO: prioritize already resolved providers, least reported unreachable, closest
-    // by geography etc..
-
-    // loop over providers until we find one that responds
-    while (readyProviders.length) {
-      const provider = readyProviders.shift();
-
-      if (!provider) continue;
-
-      let assetUrl: string | undefined;
-
-      try {
-        assetUrl = await discoveryProvider.resolveAssetEndpoint(provider, contentId.encode(), cancelSource.token);
-      } catch (err) {
-        if (axios.isCancel(err)) {
-          return;
-        } else {
-          continue;
-        }
-      }
-
-      try {
-        console.log('Check URL of resolved asset:', assetUrl);
-        const response = await axios.head(assetUrl, { cancelToken: cancelSource.token });
-        const headers = response.headers as Record<string, string | undefined>;
-
-        setContentType(headers['content-type'] || 'video/video');
-        setResolvedAssetUrl(assetUrl);
-
-        return;
-      } catch (e) {
-        const err = e as unknown;
-
-        if (axios.isCancel(err)) {
-          return;
-        } else {
-          const response = isObjectWithProperties(err, 'response')
-            ? (err as AxiosError).response
-            : undefined;
-
-          if (!response || (response.status >= 500 && response.status <= 504)) {
-            // network connection error
-            discoveryProvider.reportUnreachable(provider);
-          }
-
-          // try next provider
-          continue;
-        }
-      }
-    }
-
-    setError(new Error('Unable to reach any provider serving this content'));
-  };
-
-  useEffect(() => {
-    void resolveAsset();
-
-    return () => {
-      cancelSource.cancel();
-    };
-  }, [contentId.encode()]);
-
-  console.log('Content id:', contentId.encode());
-  console.log('Resolved asset URL:', resolvedAssetUrl);
-
-  if (error) {
-    return (
-      <Message error className='JoyMainStatus'>
-        <Message.Header>Error loading media content</Message.Header>
-        <p>{error.toString()}</p>
-        <button className='ui button' onClick={resolveAsset}>Try again</button>
-      </Message>
-    );
-  }
-
-  if (!resolvedAssetUrl) {
-    return <JoyInfo title={'Please wait...'}>Resolving media content.</JoyInfo>;
-  }
-
-  const playerProps = { ...props, contentType, resolvedAssetUrl };
-
-  return <MediaPlayerView {...playerProps} />;
-}
-
-export const MediaPlayerWithResolver = withMulti(
-  InnerComponent,
-  translate,
-  withDiscoveryProvider
-);

+ 0 - 7
pioneer/packages/joy-media/src/common/NoContentYet.tsx

@@ -1,7 +0,0 @@
-import React from 'react';
-
-const NoContentYet: React.FunctionComponent = (props) => {
-  return <div className='NoContentYet'>{props.children}</div>;
-};
-
-export default NoContentYet;

+ 0 - 44
pioneer/packages/joy-media/src/common/TypeHelpers.ts

@@ -1,44 +0,0 @@
-import BN from 'bn.js';
-import { createType } from '@joystream/types';
-import { ChannelId } from '@joystream/types/content-working-group';
-import { EntityId, ClassId } from '@joystream/types/versioned-store';
-
-export type AnyChannelId = ChannelId | BN | number | string
-
-export type AnyEntityId = EntityId | BN | number | string
-
-export type AnyClassId = ClassId | BN | number | string
-
-function canBeId (id: BN | number | string): boolean {
-  return id instanceof BN || typeof id === 'number' || typeof id === 'string';
-}
-
-export function asChannelId (id: AnyChannelId): ChannelId {
-  if (id instanceof ChannelId) {
-    return id;
-  } else if (canBeId(id)) {
-    return createType('ChannelId', id);
-  } else {
-    throw new Error(`Not supported format for Channel id: ${typeof id === 'object' ? id.constructor.name : id}`);
-  }
-}
-
-export function asEntityId (id: AnyEntityId): EntityId {
-  if (id instanceof EntityId) {
-    return id;
-  } else if (canBeId(id)) {
-    return createType('EntityId', id);
-  } else {
-    throw new Error(`Not supported format for Entity id: ${typeof id === 'object' ? id.constructor.name : id}`);
-  }
-}
-
-export function asClassId (id: AnyClassId): ClassId {
-  if (id instanceof ClassId) {
-    return id;
-  } else if (canBeId(id)) {
-    return createType('ClassId', id);
-  } else {
-    throw new Error(`Not supported format for Class id: ${typeof id === 'object' ? id.constructor.name : id}`);
-  }
-}

+ 0 - 17
pioneer/packages/joy-media/src/common/images.tsx

@@ -1,17 +0,0 @@
-import React from 'react';
-
-export const DEFAULT_THUMBNAIL_URL = 'images/default-thumbnail.png';
-
-// This is a hack to just satisfy TypeScript compiler.
-type ImageOnErrorEvent = EventTarget & {
-  src: string;
-  onerror?: (e: any) => void;
-};
-
-export function onImageError (event: React.SyntheticEvent<HTMLImageElement, Event>) {
-  const target = event.target as ImageOnErrorEvent;
-
-  // Set onerror callback to undefined to prevent infinite callbacks when image src path fails:
-  target.onerror = undefined;
-  target.src = DEFAULT_THUMBNAIL_URL;
-}

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików