瀏覽代碼

Merge pull request #2514 from shamil-gadelshin/storage_node_v2

Storage node v2
Shamil Gadelshin 3 年之前
父節點
當前提交
e6fa74ba0a
共有 100 個文件被更改,包括 2389 次插入2904 次删除
  1. 2 2
      .dockerignore
  2. 11 0
      .env
  3. 0 23
      .github/workflows/content-directory-schemas.yml
  4. 28 0
      .github/workflows/content-metadata.yml
  5. 59 0
      .github/workflows/create-ami.yml
  6. 152 0
      .github/workflows/create-release.yml
  7. 4 6
      .github/workflows/joystream-cli.yml
  8. 1 1
      .github/workflows/joystream-node-checks.yml
  9. 155 48
      .github/workflows/joystream-node-docker.yml
  10. 2 2
      .github/workflows/joystream-types.yml
  11. 2 6
      .github/workflows/network-tests.yml
  12. 4 4
      .github/workflows/pioneer.yml
  13. 43 0
      .github/workflows/query-node.yml
  14. 24 55
      .github/workflows/run-network-tests.yml
  15. 2 2
      .github/workflows/storage-node.yml
  16. 6 65
      Cargo.lock
  17. 2 5
      Cargo.toml
  18. 2 1
      apps.Dockerfile
  19. 1 2
      build-npm-packages.sh
  20. 1 0
      cli/.gitignore
  21. 1 0
      cli/.prettierignore
  22. 237 352
      cli/README.md
  23. 3 0
      cli/examples/content/CreateCategory.json
  24. 10 0
      cli/examples/content/CreateChannel.json
  25. 20 0
      cli/examples/content/CreateVideo.json
  26. 3 0
      cli/examples/content/UpdateCategory.json
  27. 5 0
      cli/examples/content/UpdateChannel.json
  28. 7 0
      cli/examples/content/UpdateVideo.json
  29. 二進制
      cli/examples/content/avatar-photo-1.png
  30. 二進制
      cli/examples/content/avatar-photo-2.png
  31. 二進制
      cli/examples/content/cover-photo-1.png
  32. 二進制
      cli/examples/content/cover-photo-2.png
  33. 二進制
      cli/examples/content/video.mp4
  34. 16 15
      cli/package.json
  35. 81 38
      cli/src/Api.ts
  36. 85 0
      cli/src/Types.ts
  37. 6 1
      cli/src/base/AccountsCommandBase.ts
  38. 18 8
      cli/src/base/ApiCommandBase.ts
  39. 96 352
      cli/src/base/ContentDirectoryCommandBase.ts
  40. 1 1
      cli/src/base/DefaultCommandBase.ts
  41. 0 70
      cli/src/base/MediaCommandBase.ts
  42. 283 0
      cli/src/base/UploadCommandBase.ts
  43. 1 1
      cli/src/base/WorkingGroupsCommandBase.ts
  44. 2 2
      cli/src/commands/account/choose.ts
  45. 3 3
      cli/src/commands/account/create.ts
  46. 4 2
      cli/src/commands/account/export.ts
  47. 3 3
      cli/src/commands/account/import.ts
  48. 5 5
      cli/src/commands/account/transferTokens.ts
  49. 6 6
      cli/src/commands/api/inspect.ts
  50. 1 1
      cli/src/commands/api/setUri.ts
  51. 0 79
      cli/src/commands/content-directory/addClassSchema.ts
  52. 0 44
      cli/src/commands/content-directory/addMaintainerToClass.ts
  53. 0 55
      cli/src/commands/content-directory/class.ts
  54. 0 24
      cli/src/commands/content-directory/classes.ts
  55. 0 50
      cli/src/commands/content-directory/createClass.ts
  56. 0 58
      cli/src/commands/content-directory/createEntity.ts
  57. 0 45
      cli/src/commands/content-directory/entities.ts
  58. 0 44
      cli/src/commands/content-directory/entity.ts
  59. 0 57
      cli/src/commands/content-directory/initialize.ts
  60. 0 35
      cli/src/commands/content-directory/removeCuratorGroup.ts
  61. 0 45
      cli/src/commands/content-directory/removeEntity.ts
  62. 0 44
      cli/src/commands/content-directory/removeMaintainerFromClass.ts
  63. 0 55
      cli/src/commands/content-directory/updateClassPermissions.ts
  64. 0 61
      cli/src/commands/content-directory/updateEntityPropertyValues.ts
  65. 6 2
      cli/src/commands/content/addCuratorToGroup.ts
  66. 44 0
      cli/src/commands/content/channel.ts
  67. 25 0
      cli/src/commands/content/channels.ts
  68. 70 0
      cli/src/commands/content/createChannel.ts
  69. 54 0
      cli/src/commands/content/createChannelCategory.ts
  70. 4 4
      cli/src/commands/content/createCuratorGroup.ts
  71. 95 0
      cli/src/commands/content/createVideo.ts
  72. 54 0
      cli/src/commands/content/createVideoCategory.ts
  73. 1 6
      cli/src/commands/content/curatorGroup.ts
  74. 0 1
      cli/src/commands/content/curatorGroups.ts
  75. 35 0
      cli/src/commands/content/deleteChannelCategory.ts
  76. 35 0
      cli/src/commands/content/deleteVideoCategory.ts
  77. 7 3
      cli/src/commands/content/removeCuratorFromGroup.ts
  78. 36 0
      cli/src/commands/content/reuploadAssets.ts
  79. 2 2
      cli/src/commands/content/setCuratorGroupStatus.ts
  80. 27 0
      cli/src/commands/content/setFeaturedVideos.ts
  81. 87 0
      cli/src/commands/content/updateChannel.ts
  82. 56 0
      cli/src/commands/content/updateChannelCategory.ts
  83. 79 0
      cli/src/commands/content/updateChannelCensorshipStatus.ts
  84. 69 0
      cli/src/commands/content/updateVideo.ts
  85. 57 0
      cli/src/commands/content/updateVideoCategory.ts
  86. 80 0
      cli/src/commands/content/updateVideoCensorshipStatus.ts
  87. 28 0
      cli/src/commands/content/video.ts
  88. 40 0
      cli/src/commands/content/videos.ts
  89. 0 81
      cli/src/commands/media/createChannel.ts
  90. 0 57
      cli/src/commands/media/curateContent.ts
  91. 0 36
      cli/src/commands/media/featuredVideos.ts
  92. 0 25
      cli/src/commands/media/myChannels.ts
  93. 0 33
      cli/src/commands/media/myVideos.ts
  94. 0 44
      cli/src/commands/media/removeChannel.ts
  95. 0 49
      cli/src/commands/media/removeVideo.ts
  96. 0 79
      cli/src/commands/media/setFeaturedVideos.ts
  97. 0 98
      cli/src/commands/media/updateChannel.ts
  98. 0 106
      cli/src/commands/media/updateVideo.ts
  99. 0 59
      cli/src/commands/media/updateVideoLicense.ts
  100. 0 441
      cli/src/commands/media/uploadVideo.ts

+ 2 - 2
.dockerignore

@@ -2,8 +2,8 @@ target/
 **node_modules*
 .tmp/
 .vscode/
-query-node/generated
 query-node/**/dist
 query-node/lib
 cli/
-tests/
+tests/
+devops/

+ 11 - 0
.env

@@ -2,12 +2,17 @@ COMPOSE_PROJECT_NAME=joystream
 PROJECT_NAME=query_node
 
 # We will use a single postgres service with multiple databases
+# The env variables below are by default used by all services and should be 
+# overriden in local env files
+# DB config
 INDEXER_DB_NAME=query_node_indexer
 DB_NAME=query_node_processor
 DB_USER=postgres
 DB_PASS=postgres
 DB_HOST=localhost
 DB_PORT=5432
+DEBUG=index-builder:*
+TYPEORM_LOGGING=error
 
 DEBUG=index-builder:*
 TYPEORM_LOGGING=error
@@ -28,3 +33,9 @@ GRAPHQL_SERVER_PORT=4002
 GRAPHQL_SERVER_HOST=localhost
 WARTHOG_APP_PORT=4002
 WARTHOG_APP_HOST=localhost
+
+# Default configuration is to use the docker container
+WS_PROVIDER_ENDPOINT_URI=ws://joystream-node:9944/
+
+# If running joystream-node on host machine you can use following address to reach it instead
+# WS_PROVIDER_ENDPOINT_URI=ws://host.docker.internal:9944/

+ 0 - 23
.github/workflows/content-directory-schemas.yml

@@ -1,23 +0,0 @@
-name: content-directory-schemas
-on: [pull_request, push]
-
-jobs:
-  schemas_checks:
-    name: Checks
-    runs-on: ubuntu-latest
-    strategy:
-      matrix:
-        node-version: [12.x]
-    steps:
-    - uses: actions/checkout@v1
-    - name: Use Node.js ${{ matrix.node-version }}
-      uses: actions/setup-node@v1
-      with:
-        node-version: ${{ matrix.node-version }}
-    - name: validate
-      run: |
-        yarn install --frozen-lockfile
-        yarn workspace @joystream/types build
-        yarn workspace @joystream/cd-schemas generate:all
-        yarn workspace @joystream/cd-schemas build
-        yarn workspace @joystream/cd-schemas checks --quiet

+ 28 - 0
.github/workflows/content-metadata.yml

@@ -0,0 +1,28 @@
+name: content-metadata
+on: [pull_request, push]
+
+jobs:
+  schemas_checks:
+    name: Checks
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        node-version: [14.x]
+    steps:
+    - uses: actions/checkout@v1
+    - name: Use Node.js ${{ matrix.node-version }}
+      uses: actions/setup-node@v1
+      with:
+        node-version: ${{ matrix.node-version }}
+    - name: test protobuf
+      run: |
+        # # Install protoc compiler
+        # sudo apt-get install -y protobuf-compiler
+        # protoc --version
+        # # Install documentation plugin
+        # sudo apt-get install -y golang-go
+        # go get -u github.com/pseudomuto/protoc-gen-doc/cmd/protoc-gen-doc
+        yarn install --frozen-lockfile
+        yarn workspace @joystream/content-metadata-protobuf build:ts
+        yarn workspace @joystream/content-metadata-protobuf checks --quiet
+        yarn workspace @joystream/content-metadata-protobuf test

+ 59 - 0
.github/workflows/create-ami.yml

@@ -0,0 +1,59 @@
+name: Create AWS AMI
+
+on:
+  workflow_dispatch:
+
+jobs:
+  build:
+    name: Build the code and run setup
+    runs-on: ubuntu-latest
+    env:
+      STACK_NAME: joystream-github-action-${{ github.run_number }}
+      KEY_NAME: joystream-github-action-key
+    steps:
+    - name: Extract branch name
+      shell: bash
+      run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})"
+      id: extract_branch
+
+    - name: Set AMI Name environment variable
+      shell: bash
+      run: echo "ami_name=joystream-${{ steps.extract_branch.outputs.branch }}-${{ github.run_number }}" >> $GITHUB_ENV
+      id: ami_name
+
+    - name: Checkout
+      uses: actions/checkout@v2
+
+    - name: Configure AWS credentials
+      uses: aws-actions/configure-aws-credentials@v1
+      with:
+        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+        aws-region: us-east-1
+
+    - name: Deploy to AWS CloudFormation
+      uses: aws-actions/aws-cloudformation-github-deploy@v1
+      id: deploy_stack
+      with:
+        name: ${{ env.STACK_NAME }}
+        template: devops/infrastructure/single-instance.yml
+        no-fail-on-empty-changeset: "1"
+        parameter-overrides: "KeyName=${{ env.KEY_NAME }}"
+
+    - name: Install Ansible dependencies
+      run: pipx inject ansible-core boto3 botocore
+
+    - name: Run playbook
+      uses: dawidd6/action-ansible-playbook@v2
+      with:
+        playbook: github-action-playbook.yml
+        directory: devops/infrastructure
+        requirements: requirements.yml
+        key: ${{ secrets.SSH_PRIVATE_KEY }}
+        inventory: |
+          [all]
+          ${{ steps.deploy_stack.outputs.PublicIp }}
+        options: |
+          --extra-vars "git_repo=https://github.com/${{ github.repository }} \
+                        branch_name=${{ steps.extract_branch.outputs.branch }} instance_id=${{ steps.deploy_stack.outputs.InstanceId }}
+                        stack_name=${{ env.STACK_NAME }} ami_name=${{ env.ami_name }}"

+ 152 - 0
.github/workflows/create-release.yml

@@ -0,0 +1,152 @@
+name: Create release with node binaries
+
+on:
+  workflow_dispatch:
+    inputs:
+      name:
+        description: 'Release name (v9.3.0 - Antioch)'
+        required: true
+      tag:
+        description: 'Tag (v9.3.0)'
+        required: true
+
+env:
+  REPOSITORY: joystream/node
+
+jobs:
+  build-mac-binary:
+    runs-on: macos-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+
+      - id: compute_shasum
+        name: Compute runtime code shasum
+        run: |
+          export RUNTIME_CODE_SHASUM=`scripts/runtime-code-shasum.sh`
+          echo "::set-output name=shasum::${RUNTIME_CODE_SHASUM}"
+
+      - name: Run Setup
+        run: |
+          ./setup.sh
+
+      - name: Build binaries
+        run: |
+          yarn cargo-build
+
+      - name: Tar the binary
+        run: |
+          tar czvf joystream-node-macos.tar.gz -C ./target/release joystream-node
+
+      - name: Temporarily save node binary
+        uses: actions/upload-artifact@v2
+        with:
+          name: joystream-node-macos-${{ steps.compute_shasum.outputs.shasum }}
+          path: joystream-node-macos.tar.gz
+          retention-days: 1
+
+  build-rpi-binary:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+
+      - id: compute_shasum
+        name: Compute runtime code shasum
+        run: |
+          export RUNTIME_CODE_SHASUM=`scripts/runtime-code-shasum.sh`
+          echo "::set-output name=shasum::${RUNTIME_CODE_SHASUM}"
+
+      - name: Run Setup
+        run: |
+          ./setup.sh
+
+      - name: Build binaries
+        run: |
+          export WORKSPACE_ROOT=`cargo metadata --offline --no-deps --format-version 1 | jq .workspace_root -r`
+          sudo chmod a+w $WORKSPACE_ROOT
+          ./scripts/raspberry-cross-build.sh
+
+      - name: Tar the binary
+        run: |
+          tar czvf joystream-node-rpi.tar.gz -C ./target/arm-unknown-linux-gnueabihf/release joystream-node
+
+      - name: Temporarily save node binary
+        uses: actions/upload-artifact@v2
+        with:
+          name: joystream-node-rpi-${{ steps.compute_shasum.outputs.shasum }}
+          path: joystream-node-rpi.tar.gz
+          retention-days: 1
+
+  create-release:
+    runs-on: ubuntu-latest
+    needs: [build-mac-binary, build-rpi-binary]
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+
+      - id: compute_shasum
+        name: Compute runtime code shasum
+        run: |
+          export RUNTIME_CODE_SHASUM=`scripts/runtime-code-shasum.sh`
+          echo "::set-output name=shasum::${RUNTIME_CODE_SHASUM}"
+
+      - id: extract_binaries
+        name: Copy binaries & wasm file from docker images
+        run: |
+          IMAGE=${{ env.REPOSITORY }}:${{ steps.compute_shasum.outputs.shasum }}
+
+          docker run -d --entrypoint tail --name temp-container-joystream-node $IMAGE-amd64 -f /dev/null
+
+          RESULT=$(docker exec temp-container-joystream-node b2sum -l 256 runtime.compact.wasm | awk '{print $1}')
+          VERSION_AND_COMMIT=$(docker exec temp-container-joystream-node /joystream/node --version | awk '{print $2}' | cut -d- -f -2)
+          echo "::set-output name=blob_hash::${RESULT}"
+          echo "::set-output name=version_and_commit::${VERSION_AND_COMMIT}"
+
+          docker cp temp-container-joystream-node:/joystream/runtime.compact.wasm ./joystream_runtime_${{ github.event.inputs.tag }}.wasm
+          docker cp temp-container-joystream-node:/joystream/node ./joystream-node
+          tar -czvf joystream-node-$VERSION_AND_COMMIT-x86_64-linux-gnu.tar.gz joystream-node
+
+          docker rm --force temp-container-joystream-node
+
+          docker cp $(docker create --rm $IMAGE-arm64):/joystream/node ./joystream-node
+          tar -czvf joystream-node-$VERSION_AND_COMMIT-arm64-linux-gnu.tar.gz joystream-node
+
+          docker cp $(docker create --rm $IMAGE-arm):/joystream/node ./joystream-node
+          tar -czvf joystream-node-$VERSION_AND_COMMIT-armv7-linux-gnu.tar.gz joystream-node
+
+      - name: Retrieve saved MacOS binary
+        uses: actions/download-artifact@v2
+        with:
+          name: joystream-node-macos-${{ steps.compute_shasum.outputs.shasum }}
+
+      - name: Retrieve saved RPi binary
+        uses: actions/download-artifact@v2
+        with:
+          name: joystream-node-rpi-${{ steps.compute_shasum.outputs.shasum }}
+
+      - name: Rename MacOS and RPi tar
+        run: |
+          mv joystream-node-macos.tar.gz joystream-node-${{ steps.extract_binaries.outputs.version_and_commit }}-x86_64-macos.tar.gz
+          mv joystream-node-rpi.tar.gz joystream-node-${{ steps.extract_binaries.outputs.version_and_commit }}-rpi.tar.gz
+
+      - name: Release
+        uses: softprops/action-gh-release@v1
+        with:
+          files: |
+            *.tar.gz
+            *.wasm
+          tag_name: ${{ github.event.inputs.tag }}
+          name: ${{ github.event.inputs.name }}
+          draft: true
+          body: 'Verify wasm hash:
+            ```
+            $ b2sum -l 256 joystream_runtime_${{ github.event.inputs.tag }}.wasm
+            ```
+
+            This should be the output
+
+            ```
+            ${{ steps.extract_binaries.outputs.blob_hash }}
+            ```
+            '

+ 4 - 6
.github/workflows/joystream-cli.yml

@@ -7,7 +7,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}
@@ -18,8 +18,7 @@ jobs:
       run: |
         yarn install --frozen-lockfile
         yarn workspace @joystream/types build
-        yarn workspace @joystream/cd-schemas generate:all
-        yarn workspace @joystream/cd-schemas build
+        yarn workspace @joystream/content-metadata-protobuf build:ts
         yarn workspace @joystream/cli checks --quiet
     - name: yarn pack test
       run: |
@@ -32,7 +31,7 @@ jobs:
     runs-on: macos-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}
@@ -43,8 +42,7 @@ jobs:
       run: |
         yarn install --frozen-lockfile --network-timeout 120000
         yarn workspace @joystream/types build
-        yarn workspace @joystream/cd-schemas generate:all
-        yarn workspace @joystream/cd-schemas build        
+        yarn workspace @joystream/content-metadata-protobuf build:ts
         yarn workspace @joystream/cli checks --quiet
     - name: yarn pack test
       run: |

+ 1 - 1
.github/workflows/joystream-node-checks.yml

@@ -10,7 +10,7 @@ jobs:
       - uses: actions/checkout@v1
       - uses: actions/setup-node@v1
         with:
-          node-version: '12.x'
+          node-version: '14.x'
       - uses: technote-space/get-diff-action@v3
         with:
           PREFIX_FILTER: |

+ 155 - 48
.github/workflows/joystream-node-docker.yml

@@ -1,16 +1,25 @@
 name: joystream-node-docker
+
 on: push
 
+env:
+  REPOSITORY: joystream/node
+  KEY_NAME: joystream-github-action-key
+
 jobs:
-  build:
-    name: Build joystream/node Docker image
-    if: github.repository == 'Joystream/joystream'
+  push-amd64:
+    name: Build joystream/node Docker image for amd64
     runs-on: ubuntu-latest
+    outputs:
+      tag_shasum: ${{ steps.compute_shasum.outputs.shasum }}
+      image_exists: ${{ steps.compute_main_image_exists.outputs.image_exists }}
     steps:
-      - uses: actions/checkout@v1
+      - name: Checkout
+        uses: actions/checkout@v2
+
       - uses: actions/setup-node@v1
         with:
-          node-version: '12.x'
+          node-version: '14.x'
 
       - id: compute_shasum
         name: Compute runtime code shasum
@@ -18,62 +27,160 @@ jobs:
           export RUNTIME_CODE_SHASUM=`scripts/runtime-code-shasum.sh`
           echo "::set-output name=shasum::${RUNTIME_CODE_SHASUM}"
 
-      - name: Setup cache directory
-        run: mkdir ~/docker-images
-
-      - name: Cache docker images
-        uses: actions/cache@v2
-        env:
-          cache-name: joystream-node-docker
+      - name: Login to DockerHub
+        uses: docker/login-action@v1
         with:
-          path: ~/docker-images
-          key: ${{ env.cache-name }}-${{ steps.compute_shasum.outputs.shasum }}
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_PASSWORD }}
 
-      - name: Check if we have cached image
-        continue-on-error: true
+      - name: Check if we have already have the manifest on Dockerhub
+        id: compute_main_image_exists
+        # Will output 0 if image exists and 1 if does not exists
         run: |
-          if [ -f ~/docker-images/joystream-node-docker-image.tar.gz ]; then
-            docker load --input ~/docker-images/joystream-node-docker-image.tar.gz
-            cp ~/docker-images/joystream-node-docker-image.tar.gz .
-          fi
+          export IMAGE_EXISTS=$(docker manifest inspect ${{ env.REPOSITORY }}:${{ steps.compute_shasum.outputs.shasum }} > /dev/null ; echo $?)
+          echo "::set-output name=image_exists::${IMAGE_EXISTS}"
 
       - name: Check if we have pre-built image on Dockerhub
-        continue-on-error: true
+        id: compute_image_exists
+        # Will output 0 if image exists and 1 if does not exists
         run: |
-          if ! [ -f joystream-node-docker-image.tar.gz ]; then
-            docker pull joystream/node:${{ steps.compute_shasum.outputs.shasum }}
-            docker image tag joystream/node:${{ steps.compute_shasum.outputs.shasum }} joystream/node:latest
-            docker save --output joystream-node-docker-image.tar joystream/node:latest
-            gzip joystream-node-docker-image.tar
-            cp joystream-node-docker-image.tar.gz ~/docker-images/
-          fi
-
-      - name: Build new joystream/node image
+          export IMAGE_EXISTS=$(docker manifest inspect ${{ env.REPOSITORY }}:${{ steps.compute_shasum.outputs.shasum }}-amd64 > /dev/null ; echo $?)
+          echo "::set-output name=image_exists::${IMAGE_EXISTS}"
+
+      - name: Build and push
+        uses: docker/build-push-action@v2
+        with:
+          context: .
+          file: joystream-node.Dockerfile
+          platforms: linux/amd64
+          push: true
+          tags: ${{ env.REPOSITORY }}:${{ steps.compute_shasum.outputs.shasum }}-amd64
+        if: ${{ steps.compute_image_exists.outputs.image_exists == 1 }}
+
+  push-arm:
+    name: Build joystream/node Docker image for arm
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        platform: ['linux/arm64', 'linux/arm/v7']
+        include:
+          - platform: 'linux/arm64'
+            platform_tag: 'arm64'
+            file: 'joystream-node.Dockerfile'
+          - platform: 'linux/arm/v7'
+            platform_tag: 'arm'
+            file: 'joystream-node-armv7.Dockerfile'
+    env:
+      STACK_NAME: joystream-ga-docker-${{ github.run_number }}-${{ matrix.platform_tag }}
+    steps:
+      - name: Extract branch name
+        shell: bash
+        run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})"
+        id: extract_branch
+
+      - name: Checkout
+        uses: actions/checkout@v2
+
+      - uses: actions/setup-node@v1
+        with:
+          node-version: '14.x'
+
+      - name: Install Ansible dependencies
+        run: pipx inject ansible-core boto3 botocore
+
+      - id: compute_shasum
+        name: Compute runtime code shasum
         run: |
-          if ! [ -f joystream-node-docker-image.tar.gz ]; then
-            docker build . --file joystream-node.Dockerfile --tag joystream/node
-            docker save --output joystream-node-docker-image.tar joystream/node
-            gzip joystream-node-docker-image.tar
-            cp joystream-node-docker-image.tar.gz ~/docker-images/
-            echo "NEW_BUILD=true" >> $GITHUB_ENV
-          fi
-
-      - name: Save joystream/node image to Artifacts
-        uses: actions/upload-artifact@v2
+          export RUNTIME_CODE_SHASUM=`scripts/runtime-code-shasum.sh`
+          echo "::set-output name=shasum::${RUNTIME_CODE_SHASUM}"
+
+      - name: Login to DockerHub
+        uses: docker/login-action@v1
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_PASSWORD }}
+
+      - name: Check if we have pre-built image on Dockerhub
+        id: compute_image_exists
+        # Will output 0 if image exists and 1 if does not exists
+        run: |
+          export IMAGE_EXISTS=$(docker manifest inspect ${{ env.REPOSITORY }}:${{ steps.compute_shasum.outputs.shasum }}-${{ matrix.platform_tag }} > /dev/null ; echo $?)
+          echo "::set-output name=image_exists::${IMAGE_EXISTS}"
+
+      - name: Configure AWS credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          aws-region: us-east-1
+        if: ${{ steps.compute_image_exists.outputs.image_exists == 1 }}
+
+      - name: Deploy to AWS CloudFormation
+        uses: aws-actions/aws-cloudformation-github-deploy@v1
+        id: deploy_stack
+        with:
+          name: ${{ env.STACK_NAME }}
+          template: devops/infrastructure/single-instance-docker.yml
+          no-fail-on-empty-changeset: '1'
+          parameter-overrides: 'KeyName=${{ env.KEY_NAME }},EC2AMI=ami-00d1ab6b335f217cf,EC2InstanceType=t4g.xlarge'
+        if: ${{ steps.compute_image_exists.outputs.image_exists == 1 }}
+
+      - name: Run playbook
+        uses: dawidd6/action-ansible-playbook@v2
         with:
-          name: ${{ steps.compute_shasum.outputs.shasum }}-joystream-node-docker-image.tar.gz
-          path: joystream-node-docker-image.tar.gz
+          playbook: build-arm64-playbook.yml
+          directory: devops/infrastructure
+          requirements: requirements.yml
+          key: ${{ secrets.SSH_PRIVATE_KEY }}
+          inventory: |
+            [all]
+            ${{ steps.deploy_stack.outputs.PublicIp }}
+          options: |
+            --extra-vars "git_repo=https://github.com/${{ github.repository }} \
+                          branch_name=${{ steps.extract_branch.outputs.branch }} \
+                          docker_username=${{ secrets.DOCKERHUB_USERNAME }} \
+                          docker_password=${{ secrets.DOCKERHUB_PASSWORD }} \
+                          tag_name=${{ steps.compute_shasum.outputs.shasum }}-${{ matrix.platform_tag }} \
+                          repository=${{ env.REPOSITORY }} dockerfile=${{ matrix.file }} \
+                          stack_name=${{ env.STACK_NAME }} platform=${{ matrix.platform }}"
+        if: ${{ steps.compute_image_exists.outputs.image_exists == 1 }}
 
+  push-manifest:
+    name: Create manifest using both the arch images
+    needs: [push-amd64, push-arm]
+    # Only run this job if the image does not exist with tag equal to the shasum
+    if: needs.push-amd64.outputs.image_exists == 1
+    runs-on: ubuntu-latest
+    env:
+      TAG_SHASUM: ${{ needs.push-amd64.outputs.tag_shasum }}
+    steps:
       - name: Login to DockerHub
         uses: docker/login-action@v1
         with:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_PASSWORD }}
-        if: env.NEW_BUILD
 
-      - name: Publish new image to DockerHub
+      - name: Create manifest for multi-arch images
+        run: |
+          # get artifacts from previous steps
+          IMAGE=${{ env.REPOSITORY }}:${{ env.TAG_SHASUM }}
+          echo $IMAGE
+          docker pull $IMAGE-amd64
+          docker pull $IMAGE-arm64
+          docker pull $IMAGE-arm
+          docker manifest create $IMAGE $IMAGE-amd64 $IMAGE-arm64 $IMAGE-arm
+          docker manifest annotate $IMAGE $IMAGE-amd64 --arch amd64
+          docker manifest annotate $IMAGE $IMAGE-arm64 --arch arm64
+          docker manifest annotate $IMAGE $IMAGE-arm --arch arm
+          docker manifest push $IMAGE
+
+      - name: Create manifest with latest tag for master
+        if: github.ref == 'refs/heads/master'
         run: |
-          docker image tag joystream/node joystream/node:${{ steps.compute_shasum.outputs.shasum }}
-          docker push joystream/node:${{ steps.compute_shasum.outputs.shasum }}
-        if: env.NEW_BUILD
-  
+          IMAGE=${{ env.REPOSITORY }}:${{ env.TAG_SHASUM }}
+          LATEST_TAG=${{ env.REPOSITORY }}:latest
+          docker manifest create $LATEST_TAG $IMAGE-amd64 $IMAGE-arm64 $IMAGE-arm
+          docker manifest annotate $LATEST_TAG $IMAGE-amd64 --arch amd64
+          docker manifest annotate $LATEST_TAG $IMAGE-arm64 --arch arm64
+          docker manifest annotate $LATEST_TAG $IMAGE-arm --arch arm
+          docker manifest push $LATEST_TAG

+ 2 - 2
.github/workflows/joystream-types.yml

@@ -7,7 +7,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}
@@ -31,7 +31,7 @@ jobs:
     runs-on: macos-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}

+ 2 - 6
.github/workflows/network-tests.yml

@@ -7,7 +7,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}
@@ -18,8 +18,6 @@ jobs:
       run: |
         yarn install --frozen-lockfile
         yarn workspace @joystream/types build
-        yarn workspace @joystream/cd-schemas generate:all
-        yarn workspace @joystream/cd-schemas build
         yarn workspace network-tests checks --quiet
 
   network_build_osx:
@@ -27,7 +25,7 @@ jobs:
     runs-on: macos-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}
@@ -38,6 +36,4 @@ jobs:
       run: |
         yarn install --frozen-lockfile --network-timeout 120000
         yarn workspace @joystream/types build
-        yarn workspace @joystream/cd-schemas generate:all
-        yarn workspace @joystream/cd-schemas build
         yarn workspace network-tests checks --quiet

+ 4 - 4
.github/workflows/pioneer.yml

@@ -7,7 +7,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}
@@ -25,7 +25,7 @@ jobs:
     runs-on: macos-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}
@@ -43,7 +43,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}
@@ -61,7 +61,7 @@ jobs:
     runs-on: macos-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}

+ 43 - 0
.github/workflows/query-node.yml

@@ -0,0 +1,43 @@
+name: query-node
+on: [pull_request, push]
+
+jobs:
+  query_node_build_ubuntu:
+    name: Ubuntu Checks
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        node-version: [14.x]
+    steps:
+    - uses: actions/checkout@v1
+    - name: Use Node.js ${{ matrix.node-version }}
+      uses: actions/setup-node@v1
+      with:
+        node-version: ${{ matrix.node-version }}
+    - name: checks
+      run: |
+        yarn install --frozen-lockfile
+        yarn workspace @joystream/types build
+        yarn workspace @joystream/content-metadata-protobuf build:ts
+        ./query-node/build.sh
+        yarn workspace query-node-mappings checks --quiet
+
+  query_node_build_osx:
+    name: MacOS Checks
+    runs-on: macos-latest
+    strategy:
+      matrix:
+        node-version: [14.x]
+    steps:
+    - uses: actions/checkout@v1
+    - name: Use Node.js ${{ matrix.node-version }}
+      uses: actions/setup-node@v1
+      with:
+        node-version: ${{ matrix.node-version }}
+    - name: checks
+      run: |
+        yarn install --frozen-lockfile --network-timeout 120000
+        yarn workspace @joystream/types build
+        yarn workspace @joystream/content-metadata-protobuf build:ts
+        ./query-node/build.sh
+        yarn workspace query-node-mappings checks --quiet

+ 24 - 55
.github/workflows/run-network-tests.yml

@@ -25,7 +25,7 @@ jobs:
       - uses: actions/checkout@v1
       - uses: actions/setup-node@v1
         with:
-          node-version: '12.x'
+          node-version: '14.x'
 
       - id: compute_shasum
         name: Compute runtime code shasum
@@ -79,7 +79,7 @@ jobs:
           path: joystream-node-docker-image.tar.gz
 
   basic_runtime_with_upgrade:
-    if: ${{ false }}  # Antioch will be a new chain
+    # if: ${{ false }}
     name: Integration Tests (Runtime Upgrade)
     needs: build_images
     runs-on: ubuntu-latest
@@ -87,7 +87,7 @@ jobs:
       - uses: actions/checkout@v1
       - uses: actions/setup-node@v1
         with:
-          node-version: '12.x'
+          node-version: '14.x'
       - name: Get artifacts
         uses: actions/download-artifact@v2
         with:
@@ -99,11 +99,11 @@ jobs:
       - name: Install packages and dependencies
         run: |
           yarn install --frozen-lockfile
-          yarn build:packages
+          yarn workspace @joystream/types build
       - name: Ensure tests are runnable
         run: yarn workspace network-tests build
       - name: Execute network tests
-        run: RUNTIME=antioch tests/network-tests/run-tests.sh full
+        run: RUNTIME=sumer tests/network-tests/run-tests.sh full
 
   basic_runtime:
     name: Integration Tests (New Chain)
@@ -113,7 +113,7 @@ jobs:
       - uses: actions/checkout@v1
       - uses: actions/setup-node@v1
         with:
-          node-version: '12.x'
+          node-version: '14.x'
       - name: Get artifacts
         uses: actions/download-artifact@v2
         with:
@@ -125,42 +125,12 @@ jobs:
       - name: Install packages and dependencies
         run: |
           yarn install --frozen-lockfile
-          yarn build:packages
+          yarn workspace @joystream/types build
       - 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
-    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
-          yarn workspace @joystream/types build
-          yarn workspace @joystream/cd-schemas generate:all
-          yarn workspace @joystream/cd-schemas build
-      - name: Ensure tests are runnable
-        run: yarn workspace @joystream/cd-schemas checks --quiet
-      - name: Start chain
-        run: docker-compose up -d joystream-node
-      - name: Initialize the content directory
-        run: yarn workspace @joystream/cd-schemas initialize:dev
-
   query_node:
     name: Query Node Integration Tests
     needs: build_images
@@ -169,7 +139,7 @@ jobs:
       - uses: actions/checkout@v1
       - uses: actions/setup-node@v1
         with:
-          node-version: '12.x'
+          node-version: '14.x'
       - name: Get artifacts
         uses: actions/download-artifact@v2
         with:
@@ -182,15 +152,17 @@ jobs:
         run: |
           yarn install --frozen-lockfile
           yarn workspace @joystream/types build
-          yarn workspace @joystream/cd-schemas generate:all
-          yarn workspace @joystream/cd-schemas build
-          yarn workspace query-node-root build
+          yarn workspace @joystream/content-metadata-protobuf build:ts
+      - name: Ensure query-node builds
+        run: yarn workspace query-node-root build
       - 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: query-node/run-tests.sh
+        run: |
+          docker-compose up -d joystream-node
+          query-node/run-tests.sh
 
   storage_node:
     name: Storage Node Tests
@@ -200,7 +172,7 @@ jobs:
       - uses: actions/checkout@v1
       - uses: actions/setup-node@v1
         with:
-          node-version: '12.x'
+          node-version: '14.x'
       - name: Get artifacts
         uses: actions/download-artifact@v2
         with:
@@ -221,19 +193,16 @@ jobs:
           docker-compose up -d joystream-node
       - name: Configure and start development storage node
         run: |
-          DEBUG=* yarn storage-cli dev-init
+          DEBUG=joystream:* yarn storage-cli dev-init
           docker-compose up -d colossus
       - name: Test uploading
         run: |
-          WAIT_TIME=90
+          sleep 6
           export DEBUG=joystream:*
-          for i in {1..4}; do
-            [ "$i" == "4" ] && exit -1
-            echo "Waiting for ipfs name registration"
-            sleep ${WAIT_TIME}
-            if yarn storage-cli upload ./pioneer/packages/apps/public/images/default-thumbnail.png 1 0; then
-              break
-            else
-              echo "Upload test failed, will retry"
-            fi
-          done
+          yarn storage-cli upload ./tests/network-tests/assets/joystream.MOV 1 0
+          # Wait for storage-node to set status Accepted on uploaded content
+          sleep 6
+          cd utils/api-scripts/
+          # Assume only one accepted data object was created
+          CONTENT_ID=`yarn --silent script get-first-content-id | tail -n2 | head -n1`
+          yarn storage-cli download ${CONTENT_ID} ./joystream.mov

+ 2 - 2
.github/workflows/storage-node.yml

@@ -7,7 +7,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}
@@ -26,7 +26,7 @@ jobs:
     runs-on: macos-latest
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
     steps:
     - uses: actions/checkout@v1
     - name: Use Node.js ${{ matrix.node-version }}

+ 6 - 65
Cargo.lock

@@ -731,7 +731,7 @@ dependencies = [
 
 [[package]]
 name = "chain-spec-builder"
-version = "3.1.0"
+version = "3.1.1"
 dependencies = [
  "ansi_term 0.12.1",
  "enum-utils",
@@ -2332,7 +2332,7 @@ dependencies = [
 
 [[package]]
 name = "joystream-node"
-version = "5.1.0"
+version = "5.7.0"
 dependencies = [
  "frame-benchmarking",
  "frame-benchmarking-cli",
@@ -2393,7 +2393,7 @@ dependencies = [
 
 [[package]]
 name = "joystream-node-runtime"
-version = "9.3.0"
+version = "9.9.0"
 dependencies = [
  "frame-benchmarking",
  "frame-executive",
@@ -2408,8 +2408,7 @@ dependencies = [
  "pallet-balances",
  "pallet-collective",
  "pallet-common",
- "pallet-content-directory",
- "pallet-content-working-group",
+ "pallet-content",
  "pallet-finality-tracker",
  "pallet-forum",
  "pallet-governance",
@@ -2437,8 +2436,6 @@ dependencies = [
  "pallet-transaction-payment",
  "pallet-transaction-payment-rpc-runtime-api",
  "pallet-utility",
- "pallet-versioned-store",
- "pallet-versioned-store-permissions",
  "pallet-working-group",
  "parity-scale-codec",
  "serde",
@@ -3809,7 +3806,7 @@ dependencies = [
 
 [[package]]
 name = "pallet-common"
-version = "3.1.1"
+version = "3.2.0"
 dependencies = [
  "frame-support",
  "frame-system",
@@ -3824,36 +3821,14 @@ dependencies = [
 ]
 
 [[package]]
-name = "pallet-content-directory"
-version = "3.1.1"
-dependencies = [
- "frame-support",
- "frame-system",
- "parity-scale-codec",
- "serde",
- "sp-arithmetic",
- "sp-core",
- "sp-io",
- "sp-runtime",
- "sp-std",
-]
-
-[[package]]
-name = "pallet-content-working-group"
+name = "pallet-content"
 version = "3.1.1"
 dependencies = [
  "frame-support",
  "frame-system",
  "pallet-balances",
  "pallet-common",
- "pallet-hiring",
- "pallet-membership",
- "pallet-recurring-reward",
- "pallet-stake",
  "pallet-timestamp",
- "pallet-token-mint",
- "pallet-versioned-store",
- "pallet-versioned-store-permissions",
  "parity-scale-codec",
  "serde",
  "sp-arithmetic",
@@ -4357,40 +4332,6 @@ dependencies = [
  "sp-std",
 ]
 
-[[package]]
-name = "pallet-versioned-store"
-version = "3.1.1"
-dependencies = [
- "frame-support",
- "frame-system",
- "pallet-common",
- "pallet-timestamp",
- "parity-scale-codec",
- "serde",
- "sp-core",
- "sp-io",
- "sp-runtime",
- "sp-std",
-]
-
-[[package]]
-name = "pallet-versioned-store-permissions"
-version = "3.1.1"
-dependencies = [
- "frame-support",
- "frame-system",
- "pallet-common",
- "pallet-timestamp",
- "pallet-versioned-store",
- "parity-scale-codec",
- "serde",
- "sp-arithmetic",
- "sp-core",
- "sp-io",
- "sp-runtime",
- "sp-std",
-]
-
 [[package]]
 name = "pallet-working-group"
 version = "3.1.1"

+ 2 - 5
Cargo.toml

@@ -5,7 +5,6 @@ members = [
 	"runtime-modules/proposals/codex",
 	"runtime-modules/proposals/discussion",
 	"runtime-modules/common",
-	"runtime-modules/content-working-group",
 	"runtime-modules/forum",
 	"runtime-modules/governance",
 	"runtime-modules/hiring",
@@ -13,12 +12,10 @@ members = [
 	"runtime-modules/memo",
 	"runtime-modules/recurring-reward",
 	"runtime-modules/stake",
+	"runtime-modules/storage",
 	"runtime-modules/token-minting",
-	"runtime-modules/versioned-store",
-	"runtime-modules/versioned-store-permissions",
 	"runtime-modules/working-group",
-	"runtime-modules/content-directory",
-	"runtime-modules/storage",
+	"runtime-modules/content",
 	"node",
 	"utils/chain-spec-builder/",
 ]

+ 2 - 1
apps.Dockerfile

@@ -1,4 +1,4 @@
-FROM node:12 as builder
+FROM --platform=linux/x86-64 node:14 as builder
 
 WORKDIR /joystream
 COPY . /joystream
@@ -9,6 +9,7 @@ RUN  rm -fr /joystream/pioneer
 RUN yarn --forzen-lockfile
 
 RUN yarn workspace @joystream/types build
+RUN yarn workspace @joystream/content-metadata-protobuf build:ts
 RUN yarn workspace query-node-root build
 RUN yarn workspace storage-node build
 

+ 1 - 2
build-npm-packages.sh

@@ -4,8 +4,7 @@ set -e
 
 yarn
 yarn workspace @joystream/types build
-yarn workspace @joystream/cd-schemas generate:all
-yarn workspace @joystream/cd-schemas build
+yarn workspace @joystream/content-metadata-protobuf build:ts
 yarn workspace query-node-root build
 yarn workspace @joystream/cli build
 yarn workspace storage-node build

+ 1 - 0
cli/.gitignore

@@ -6,3 +6,4 @@
 /tmp
 /yarn.lock
 node_modules
+/examples/content/*__rejectedContent.json

+ 1 - 0
cli/.prettierignore

@@ -1,2 +1,3 @@
 /lib/
 .nyc_output
+/examples

文件差異過大導致無法顯示
+ 237 - 352
cli/README.md


+ 3 - 0
cli/examples/content/CreateCategory.json

@@ -0,0 +1,3 @@
+{
+  "name": "Nature"
+}

+ 10 - 0
cli/examples/content/CreateChannel.json

@@ -0,0 +1,10 @@
+{
+  "title": "Example Joystream Channel",
+  "description": "This is an awesome example channel!",
+  "isPublic": true,
+  "language": "en",
+  "category": 1,
+  "avatarPhotoPath": "./avatar-photo-1.png",
+  "coverPhotoPath": "./cover-photo-1.png",
+  "rewardAccount": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
+}

+ 20 - 0
cli/examples/content/CreateVideo.json

@@ -0,0 +1,20 @@
+{
+  "title": "Example Joystream Video",
+  "description": "This is an awesome example video!",
+  "videoPath": "./video.mp4",
+  "thumbnailPhotoPath": "./avatar-photo-1.png",
+  "language": "en",
+  "hasMarketing": false,
+  "isPublic": true,
+  "isExplicit": false,
+  "personsList": [],
+  "category": 1,
+  "license": {
+    "code": 1001,
+    "attribution": "by Joystream Contributors"
+  },
+  "publishedBeforeJoystream": {
+    "isPublished": true,
+    "date": "2020-01-01"
+  }
+}

+ 3 - 0
cli/examples/content/UpdateCategory.json

@@ -0,0 +1,3 @@
+{
+  "name": "Science"
+}

+ 5 - 0
cli/examples/content/UpdateChannel.json

@@ -0,0 +1,5 @@
+{
+  "title": "Example Joystream Channel [UPDATED!]",
+  "avatarPhotoPath": "./avatar-photo-2.png",
+  "rewardAccount": null
+}

+ 7 - 0
cli/examples/content/UpdateVideo.json

@@ -0,0 +1,7 @@
+{
+  "title": "Example Joystream Video [UPDATED!]",
+  "thumbnailPhotoPath": "./avatar-photo-2.png",
+  "publishedBeforeJoystream": {
+    "isPublished": false
+  }
+}

二進制
cli/examples/content/avatar-photo-1.png


二進制
cli/examples/content/avatar-photo-2.png


二進制
cli/examples/content/cover-photo-1.png


二進制
cli/examples/content/cover-photo-2.png


二進制
cli/examples/content/video.mp4


+ 16 - 15
cli/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@joystream/cli",
   "description": "Command Line Interface for Joystream community and governance activities",
-  "version": "0.4.0",
+  "version": "0.5.1",
   "author": "Leszek Wiesner",
   "bin": {
     "joystream-cli": "./bin/run"
@@ -10,20 +10,24 @@
   "dependencies": {
     "@apidevtools/json-schema-ref-parser": "^9.0.6",
     "@ffprobe-installer/ffprobe": "^1.1.0",
-    "@joystream/types": "^0.15.0",
-    "@joystream/cd-schemas": "^0.2.0",
+    "@joystream/content-metadata-protobuf": "^1.1.0",
+    "@joystream/types": "^0.16.1",
     "@oclif/command": "^1.5.19",
     "@oclif/config": "^1.14.0",
     "@oclif/plugin-autocomplete": "^0.2.0",
-    "@oclif/plugin-help": "^2.2.3",
+    "@oclif/plugin-help": "^3.2.2",
     "@oclif/plugin-not-found": "^1.2.4",
     "@oclif/plugin-warn-if-update-available": "^1.7.0",
     "@polkadot/api": "4.2.1",
+    "@types/cli-progress": "^3.9.1",
     "@types/fluent-ffmpeg": "^2.1.16",
     "@types/inquirer": "^6.5.0",
+    "@types/mime-types": "^2.1.0",
     "@types/proper-lockfile": "^4.1.1",
     "@types/slug": "^0.9.1",
     "ajv": "^6.11.0",
+    "axios": "^0.21.1",
+    "cli-progress": "^3.9.0",
     "cli-ux": "^5.4.5",
     "fluent-ffmpeg": "^2.1.2",
     "inquirer": "^7.1.0",
@@ -35,11 +39,11 @@
     "it-first": "^1.0.4",
     "it-last": "^1.0.4",
     "it-to-buffer": "^1.0.4",
+    "mime-types": "^2.1.30",
     "moment": "^2.24.0",
     "proper-lockfile": "^4.1.1",
     "slug": "^2.1.1",
-    "tslib": "^1.11.1",
-    "axios": "^0.21.1"
+    "tslib": "^1.11.1"
   },
   "devDependencies": {
     "@oclif/dev-cli": "^1.22.2",
@@ -53,14 +57,14 @@
     "eslint-config-oclif": "^3.1.0",
     "eslint-config-oclif-typescript": "^0.1.0",
     "globby": "^10.0.2",
+    "json-schema-to-typescript": "^9.1.1",
     "mocha": "^5.2.0",
     "nyc": "^14.1.1",
     "ts-node": "^8.8.2",
-    "typescript": "^3.8.3",
-    "json-schema-to-typescript": "^9.1.1"
+    "typescript": "^3.8.3"
   },
   "engines": {
-    "node": ">=12.18.0",
+    "node": ">=14.0.0",
     "yarn": "^1.22.0"
   },
   "publishConfig": {
@@ -102,11 +106,8 @@
       "working-groups": {
         "description": "Working group lead and worker actions"
       },
-      "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"
+      "content": {
+        "description": "Interactions with content directory module - managing vidoes, channels, assets, categories and curator groups"
       }
     }
   },
@@ -129,7 +130,7 @@
   },
   "types": "lib/index.d.ts",
   "volta": {
-    "node": "12.18.2",
+    "node": "14.16.1",
     "yarn": "1.22.4"
   }
 }

+ 81 - 38
cli/src/Api.ts

@@ -6,7 +6,7 @@ import { formatBalance } from '@polkadot/util'
 import { Balance, Moment, BlockNumber } from '@polkadot/types/interfaces'
 import { KeyringPair } from '@polkadot/keyring/types'
 import { Codec, CodecArg } from '@polkadot/types/types'
-import { Option, Vec, UInt } from '@polkadot/types'
+import { Option, Vec, UInt, Bytes } from '@polkadot/types'
 import {
   AccountSummary,
   CouncilInfoObj,
@@ -47,10 +47,19 @@ import { MemberId, Membership } from '@joystream/types/members'
 import { RewardRelationship, RewardRelationshipId } from '@joystream/types/recurring-rewards'
 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 { InputValidationLengthConstraint, ChannelId, Url } from '@joystream/types/common'
+import {
+  CuratorGroup,
+  CuratorGroupId,
+  Channel,
+  Video,
+  VideoId,
+  ChannelCategory,
+  VideoCategory,
+  ChannelCategoryId,
+  VideoCategoryId,
+} from '@joystream/types/content'
+import { ContentId, DataObject } from '@joystream/types/storage'
 import _ from 'lodash'
 
 export const DEFAULT_API_URI = 'ws://localhost:9944/'
@@ -59,12 +68,13 @@ export const DEFAULT_API_URI = 'ws://localhost:9944/'
 export const apiModuleByGroup: { [key in WorkingGroups]: string } = {
   [WorkingGroups.StorageProviders]: 'storageWorkingGroup',
   [WorkingGroups.Curators]: 'contentDirectoryWorkingGroup',
+  [WorkingGroups.Operations]: 'operationsWorkingGroup',
+  [WorkingGroups.Gateway]: 'gatewayWorkingGroup',
 }
 
 // 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
@@ -493,52 +503,83 @@ export default class Api {
   }
 
   // Content directory
-  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))
+  async availableChannels(): Promise<[ChannelId, Channel][]> {
+    return await this.entriesByIds<ChannelId, Channel>(this._api.query.content.channelById)
+  }
+
+  async availableVideos(): Promise<[VideoId, Video][]> {
+    return await this.entriesByIds<VideoId, Video>(this._api.query.content.videoById)
   }
 
   availableCuratorGroups(): Promise<[CuratorGroupId, CuratorGroup][]> {
-    return this.entriesByIds<CuratorGroupId, CuratorGroup>(this._api.query.contentDirectory.curatorGroupById)
+    return this.entriesByIds<CuratorGroupId, CuratorGroup>(this._api.query.content.curatorGroupById)
   }
 
   async curatorGroupById(id: number): Promise<CuratorGroup | null> {
-    const exists = !!(await this._api.query.contentDirectory.curatorGroupById.size(id)).toNumber()
-    return exists ? await this._api.query.contentDirectory.curatorGroupById<CuratorGroup>(id) : null
+    const exists = !!(await this._api.query.content.curatorGroupById.size(id)).toNumber()
+    return exists ? await this._api.query.content.curatorGroupById<CuratorGroup>(id) : null
   }
 
   async nextCuratorGroupId(): Promise<number> {
-    return (await this._api.query.contentDirectory.nextCuratorGroupId<CuratorGroupId>()).toNumber()
+    return (await this._api.query.content.nextCuratorGroupId<CuratorGroupId>()).toNumber()
   }
 
-  async classById(id: number): Promise<Class | null> {
-    const c = await this._api.query.contentDirectory.classById<Class>(id)
-    return c.isEmpty ? null : c
+  async channelById(channelId: ChannelId | number | string): Promise<Channel> {
+    // isEmpty will not work for { MemmberId: 0 } ownership
+    const exists = !!(await this._api.query.content.channelById.size(channelId)).toNumber()
+    if (!exists) {
+      throw new CLIError(`Channel by id ${channelId.toString()} not found!`)
+    }
+    const channel = await this._api.query.content.channelById<Channel>(channelId)
+
+    return channel
   }
 
-  async entitiesByClassId(classId: number): Promise<[EntityId, Entity][]> {
-    const entityEntries = await this.entriesByIds<EntityId, Entity>(this._api.query.contentDirectory.entityById)
-    return entityEntries.filter(([, entity]) => entity.class_id.toNumber() === classId)
+  async videosByChannelId(channelId: ChannelId | number | string): Promise<[VideoId, Video][]> {
+    const channel = await this.channelById(channelId)
+    if (channel) {
+      return Promise.all(
+        channel.videos.map(
+          async (videoId) => [videoId, await this._api.query.content.videoById<Video>(videoId)] as [VideoId, Video]
+        )
+      )
+    } else {
+      return []
+    }
   }
 
-  async entityById(id: number): Promise<Entity | null> {
-    const exists = !!(await this._api.query.contentDirectory.entityById.size(id)).toNumber()
-    return exists ? await this._api.query.contentDirectory.entityById<Entity>(id) : null
+  async videoById(videoId: VideoId | number | string): Promise<Video> {
+    const video = await this._api.query.content.videoById<Video>(videoId)
+    if (video.isEmpty) {
+      throw new CLIError(`Video by id ${videoId.toString()} not found!`)
+    }
+
+    return video
   }
 
-  async dataObjectByContentId(contentId: ContentId): Promise<DataObject | null> {
-    const dataObject = await this._api.query.dataDirectory.dataObjectByContentId<Option<DataObject>>(contentId)
-    return dataObject.unwrapOr(null)
+  async channelCategoryIds(): Promise<ChannelCategoryId[]> {
+    // There is currently no way to differentiate between unexisting and existing category
+    // other than fetching all existing category ids (event the .size() trick does not work, as the object is empty)
+    return (
+      await this.entriesByIds<ChannelCategoryId, ChannelCategory>(this._api.query.content.channelCategoryById)
+    ).map(([id]) => id)
   }
 
-  async ipnsIdentity(storageProviderId: number): Promise<string | null> {
-    const accountInfo = await this._api.query.discovery.accountInfoByStorageProviderId<ServiceProviderRecord>(
-      storageProviderId
+  async videoCategoryIds(): Promise<VideoCategoryId[]> {
+    // There is currently no way to differentiate between unexisting and existing category
+    // other than fetching all existing category ids (event the .size() trick does not work, as the object is empty)
+    return (await this.entriesByIds<VideoCategoryId, VideoCategory>(this._api.query.content.videoCategoryById)).map(
+      ([id]) => id
     )
-    return accountInfo.isEmpty || accountInfo.expires_at.toNumber() <= (await this.bestNumber())
-      ? null
-      : accountInfo.identity.toString()
+  }
+
+  async dataObjectsByContentIds(contentIds: ContentId[]): Promise<DataObject[]> {
+    const dataObjects = await this._api.query.dataDirectory.dataByContentId.multi<DataObject>(contentIds)
+    const notFoundIndex = dataObjects.findIndex((o) => o.isEmpty)
+    if (notFoundIndex !== -1) {
+      throw new CLIError(`DataObject not found by id ${contentIds[notFoundIndex].toString()}`)
+    }
+    return dataObjects
   }
 
   async getRandomBootstrapEndpoint(): Promise<string | null> {
@@ -547,12 +588,14 @@ export default class Api {
     return randomEndpoint ? randomEndpoint.toString() : null
   }
 
-  async isAnyProviderAvailable(): Promise<boolean> {
-    const accounInfoEntries = await this.entriesByIds<StorageProviderId, ServiceProviderRecord>(
-      this._api.query.discovery.accountInfoByStorageProviderId
-    )
+  async storageProviderEndpoint(storageProviderId: StorageProviderId | number): Promise<string> {
+    const value = await this._api.query.storageWorkingGroup.workerStorage<Bytes>(storageProviderId)
+    return this._api.createType('Text', value).toString()
+  }
 
-    const bestNumber = await this.bestNumber()
-    return !!accounInfoEntries.filter(([, info]) => info.expires_at.toNumber() > bestNumber).length
+  async allStorageProviderEndpoints(): Promise<string[]> {
+    const workerIds = (await this.groupWorkers(WorkingGroups.StorageProviders)).map(([id]) => id)
+    const workerStorages = await this._api.query.storageWorkingGroup.workerStorage.multi<Bytes>(workerIds)
+    return workerStorages.map((storage) => this._api.createType('Text', storage).toString())
   }
 }

+ 85 - 0
cli/src/Types.ts

@@ -9,6 +9,15 @@ import { WorkerId, OpeningType } from '@joystream/types/working-group'
 import { Membership, MemberId } from '@joystream/types/members'
 import { Opening, StakingPolicy, ApplicationStageKeys } from '@joystream/types/hiring'
 import { Validator } from 'inquirer'
+import {
+  VideoMetadata,
+  ChannelMetadata,
+  ChannelCategoryMetadata,
+  VideoCategoryMetadata,
+} from '@joystream/content-metadata-protobuf'
+import { ContentId, ContentParameters } from '@joystream/types/storage'
+
+import { JSONSchema7, JSONSchema7Definition } from 'json-schema'
 
 // KeyringPair type extended with mandatory "meta.name"
 // It's used for accounts/keys management within CLI.
@@ -71,12 +80,15 @@ export type NameValueObj = { name: string; value: string }
 export enum WorkingGroups {
   StorageProviders = 'storageProviders',
   Curators = 'curators',
+  Operations = 'operations',
+  Gateway = 'gateway',
 }
 
 // In contrast to Pioneer, currently only StorageProviders group is available in CLI
 export const AvailableGroups: readonly WorkingGroups[] = [
   WorkingGroups.StorageProviders,
   WorkingGroups.Curators,
+  WorkingGroups.Operations,
 ] as const
 
 export type Reward = {
@@ -193,3 +205,76 @@ export type ApiMethodNamedArg = {
   value: ApiMethodArg
 }
 export type ApiMethodNamedArgs = ApiMethodNamedArg[]
+
+// Content-related
+export enum AssetType {
+  AnyAsset = 1,
+}
+
+export type InputAsset = {
+  path: string
+  contentId: ContentId
+}
+
+export type InputAssetDetails = InputAsset & {
+  parameters: ContentParameters
+}
+
+export type VideoFFProbeMetadata = {
+  width?: number
+  height?: number
+  codecName?: string
+  codecFullName?: string
+  duration?: number
+}
+
+export type VideoFileMetadata = VideoFFProbeMetadata & {
+  size: number
+  container: string
+  mimeType: string
+}
+
+export type VideoInputParameters = Omit<VideoMetadata.AsObject, 'video' | 'thumbnailPhoto'> & {
+  videoPath?: string
+  thumbnailPhotoPath?: string
+}
+
+export type ChannelInputParameters = Omit<ChannelMetadata.AsObject, 'coverPhoto' | 'avatarPhoto'> & {
+  coverPhotoPath?: string
+  avatarPhotoPath?: string
+  rewardAccount?: string
+}
+
+export type ChannelCategoryInputParameters = ChannelCategoryMetadata.AsObject
+
+export type VideoCategoryInputParameters = VideoCategoryMetadata.AsObject
+
+// JSONSchema utility types
+export type JSONTypeName<T> = T extends string
+  ? 'string' | ['string', 'null']
+  : T extends number
+  ? 'number' | ['number', 'null']
+  : T extends any[]
+  ? 'array' | ['array', 'null']
+  : T extends Record<string, unknown>
+  ? 'object' | ['object', 'null']
+  : T extends boolean
+  ? 'boolean' | ['boolean', 'null']
+  : never
+
+export type PropertySchema<P> = Omit<
+  JSONSchema7Definition & {
+    type: JSONTypeName<P>
+    properties: P extends Record<string, unknown> ? JsonSchemaProperties<P> : never
+  },
+  P extends Record<string, unknown> ? '' : 'properties'
+>
+
+export type JsonSchemaProperties<T extends Record<string, unknown>> = {
+  [K in keyof Required<T>]: PropertySchema<Required<T>[K]>
+}
+
+export type JsonSchema<T extends Record<string, unknown>> = JSONSchema7 & {
+  type: 'object'
+  properties: JsonSchemaProperties<T>
+}

+ 6 - 1
cli/src/base/AccountsCommandBase.ts

@@ -181,8 +181,13 @@ export default abstract class AccountsCommandBase extends ApiCommandBase {
     message = 'Are you sure you want to execute this action?',
     defaultVal = false
   ): Promise<void> {
+    if (process.env.AUTO_CONFIRM === 'true' || parseInt(process.env.AUTO_CONFIRM || '')) {
+      return
+    }
     const { confirmed } = await inquirer.prompt([{ type: 'confirm', name: 'confirmed', message, default: defaultVal }])
-    if (!confirmed) this.exit(ExitCodes.OK)
+    if (!confirmed) {
+      this.exit(ExitCodes.OK)
+    }
   }
 
   async promptForAccount(

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

@@ -6,7 +6,7 @@ import { getTypeDef, Option, Tuple, TypeRegistry } from '@polkadot/types'
 import { Registry, Codec, CodecArg, TypeDef, TypeDefInfo } from '@polkadot/types/types'
 
 import { Vec, Struct, Enum } from '@polkadot/types/codec'
-import { ApiPromise, WsProvider } from '@polkadot/api'
+import { ApiPromise, SubmittableResult, WsProvider } from '@polkadot/api'
 import { KeyringPair } from '@polkadot/keyring/types'
 import chalk from 'chalk'
 import { InterfaceTypes } from '@polkadot/types/types/registry'
@@ -16,6 +16,7 @@ import { SubmittableExtrinsic } from '@polkadot/api/types'
 import { DistinctQuestion } from 'inquirer'
 import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
 import { DispatchError } from '@polkadot/types/interfaces/system'
+import { Event } from '@polkadot/types/interfaces'
 
 export class ExtrinsicFailedError extends Error {}
 
@@ -125,7 +126,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     return (
       '{\n' +
       Object.keys(obj)
-        .map((prop) => `  ${prop}${chalk.white(':' + obj[prop])}`)
+        .map((prop) => `  ${prop}${chalk.magentaBright(':' + obj[prop])}`)
         .join('\n') +
       '\n}'
     )
@@ -351,7 +352,7 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     return values
   }
 
-  sendExtrinsic(account: KeyringPair, tx: SubmittableExtrinsic<'promise'>) {
+  sendExtrinsic(account: KeyringPair, tx: SubmittableExtrinsic<'promise'>): Promise<SubmittableResult> {
     return new Promise((resolve, reject) => {
       let unsubscribe: () => void
       tx.signAndSend(account, {}, (result) => {
@@ -401,11 +402,11 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     account: KeyringPair,
     tx: SubmittableExtrinsic<'promise'>,
     warnOnly = false // If specified - only warning will be displayed in case of failure (instead of error beeing thrown)
-  ): Promise<boolean> {
+  ): Promise<SubmittableResult | false> {
     try {
-      await this.sendExtrinsic(account, tx)
+      const res = await this.sendExtrinsic(account, tx)
       this.log(chalk.green(`Extrinsic successful!`))
-      return true
+      return res
     } catch (e) {
       if (e instanceof ExtrinsicFailedError && warnOnly) {
         this.warn(`Extrinsic failed! ${e.message}`)
@@ -424,12 +425,21 @@ export default abstract class ApiCommandBase extends StateAwareCommandBase {
     method: string,
     params: CodecArg[],
     warnOnly = false
-  ): Promise<boolean> {
-    this.log(chalk.white(`\nSending ${module}.${method} extrinsic...`))
+  ): Promise<SubmittableResult | false> {
+    this.log(chalk.magentaBright(`\nSending ${module}.${method} extrinsic...`))
     const tx = await this.getOriginalApi().tx[module][method](...params)
     return await this.sendAndFollowTx(account, tx, warnOnly)
   }
 
+  // TODO:
+  // Switch to:
+  // public findEvent<S extends keyof AugmentedEvents<'promise'> & string, M extends keyof AugmentedEvents<'promise'>[S] & string>
+  //          (result: SubmittableResult, section: S, method: M): Event | undefined {
+  // Once augment-api is supported
+  public findEvent(result: SubmittableResult, section: string, method: string): Event | undefined {
+    return result.findRecord(section, method)?.event
+  }
+
   async buildAndSendExtrinsic(
     account: KeyringPair,
     module: string,

+ 96 - 352
cli/src/base/ContentDirectoryCommandBase.ts

@@ -1,34 +1,21 @@
 import ExitCodes from '../ExitCodes'
 import { WorkingGroups } from '../Types'
-import { ReferenceProperty } from '@joystream/cd-schemas/types/extrinsics/AddClassSchema'
-import { FlattenRelations } from '@joystream/cd-schemas/types/utility'
-import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
-import {
-  Class,
-  ClassId,
-  CuratorGroup,
-  CuratorGroupId,
-  Entity,
-  EntityId,
-  Actor,
-  PropertyType,
-  Property,
-} from '@joystream/types/content-directory'
+import { CuratorGroup, CuratorGroupId, ContentActor, Channel } from '@joystream/types/content'
 import { Worker } from '@joystream/types/working-group'
 import { CLIError } from '@oclif/errors'
-import { Codec, AnyJson } from '@polkadot/types/types'
-import { AbstractInt } from '@polkadot/types/codec/AbstractInt'
-import _ from 'lodash'
 import { RolesCommandBase } from './WorkingGroupsCommandBase'
 import { createType } from '@joystream/types'
-import chalk from 'chalk'
 import { flags } from '@oclif/command'
-import { DistinctQuestion } from 'inquirer'
+
+// TODO: Rework the contexts
 
 const CONTEXTS = ['Member', 'Curator', 'Lead'] as const
-type Context = typeof CONTEXTS[number]
+const OWNER_CONTEXTS = ['Member', 'Curator'] as const
+const CATEGORIES_CONTEXTS = ['Lead', 'Curator'] as const
 
-type ParsedPropertyValue = { value: Codec | null; type: PropertyType['type']; subtype: PropertyType['subtype'] }
+type Context = typeof CONTEXTS[number]
+type OwnerContext = typeof OWNER_CONTEXTS[number]
+type CategoriesContext = typeof CATEGORIES_CONTEXTS[number]
 
 /**
  * Abstract base class for commands related to content directory
@@ -43,6 +30,20 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     options: [...CONTEXTS],
   })
 
+  static ownerContextFlag = flags.enum({
+    name: 'ownerContext',
+    required: false,
+    description: `Actor context to execute the command in (${OWNER_CONTEXTS.join('/')})`,
+    options: [...OWNER_CONTEXTS],
+  })
+
+  static categoriesContextFlag = flags.enum({
+    name: 'categoriesContext',
+    required: false,
+    description: `Actor context to execute the command in (${CATEGORIES_CONTEXTS.join('/')})`,
+    options: [...CATEGORIES_CONTEXTS],
+  })
+
   async promptForContext(message = 'Choose in which context you wish to execute the command'): Promise<Context> {
     return this.simplePrompt({
       message,
@@ -51,63 +52,89 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     })
   }
 
+  async promptForOwnerContext(
+    message = 'Choose in which context you wish to execute the command'
+  ): Promise<OwnerContext> {
+    return this.simplePrompt({
+      message,
+      type: 'list',
+      choices: OWNER_CONTEXTS.map((c) => ({ name: c, value: c })),
+    })
+  }
+
+  async promptForCategoriesContext(
+    message = 'Choose in which context you wish to execute the command'
+  ): Promise<CategoriesContext> {
+    return this.simplePrompt({
+      message,
+      type: 'list',
+      choices: CATEGORIES_CONTEXTS.map((c) => ({ name: c, value: c })),
+    })
+  }
+
   // Use when lead access is required in given command
   async requireLead(): Promise<void> {
     await this.getRequiredLead()
   }
 
-  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)
+  async getCurationActorByChannel(channel: Channel): Promise<ContentActor> {
+    return channel.owner.isOfType('Curators') ? await this.getActor('Lead') : await this.getActor('Curator')
+  }
 
-    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()
+  async getChannelOwnerActor(channel: Channel): Promise<ContentActor> {
+    if (channel.owner.isOfType('Curators')) {
+      try {
+        return await this.getActor('Lead')
+      } catch (e) {
+        return await this.getCuratorContext(channel.owner.asType('Curators'))
+      }
     } else {
-      groupId = await this.promptForCuratorGroup('Select Curator Group context', availableGroupIds)
+      return await this.getActor('Member')
     }
-
-    return createType('Actor', { Curator: [groupId, curator.workerId.toNumber()] })
   }
 
-  async promptForClass(message = 'Select a class'): Promise<Class> {
-    const classes = await this.getApi().availableClasses()
-    const choices = classes.map(([, c]) => ({ name: c.name.toString(), value: c }))
-    if (!choices.length) {
-      this.warn('No classes exist to choose from!')
-      this.exit(ExitCodes.InvalidInput)
+  async getCategoryManagementActor(): Promise<ContentActor> {
+    try {
+      return await this.getActor('Lead')
+    } catch (e) {
+      return await this.getActor('Curator')
     }
-
-    const selectedClass = await this.simplePrompt({ message, type: 'list', choices })
-
-    return selectedClass
   }
 
-  async classEntryByNameOrId(classNameOrId: string): Promise<[ClassId, Class]> {
-    const classes = await this.getApi().availableClasses()
-    const foundClass = classes.find(([id, c]) => id.toString() === classNameOrId || c.name.toString() === classNameOrId)
-    if (!foundClass) {
-      this.error(`Class id not found by class name or id: "${classNameOrId}"!`)
+  async getCuratorContext(requiredGroupId?: CuratorGroupId): Promise<ContentActor> {
+    const curator = await this.getRequiredWorker()
+
+    let groupId: number
+    if (requiredGroupId) {
+      const group = await this.getCuratorGroup(requiredGroupId.toNumber())
+      if (!group.active.valueOf()) {
+        this.error(`Curator group ${requiredGroupId.toString()} is no longer active`, { exit: ExitCodes.AccessDenied })
+      }
+      if (!group.curators.toArray().some((curatorId) => curatorId.eq(curator.workerId))) {
+        this.error(`You don't belong to required curator group (ID: ${requiredGroupId.toString()})`, {
+          exit: ExitCodes.AccessDenied,
+        })
+      }
+      groupId = requiredGroupId.toNumber()
+    } else {
+      const groups = await this.getApi().availableCuratorGroups()
+      const availableGroupIds = groups
+        .filter(
+          ([, group]) =>
+            group.active.valueOf() && group.curators.toArray().some((curatorId) => curatorId.eq(curator.workerId))
+        )
+        .map(([id]) => id)
+
+      if (!availableGroupIds.length) {
+        this.error("You don't belong to any active curator group!", { exit: ExitCodes.AccessDenied })
+      } else if (availableGroupIds.length === 1) {
+        groupId = availableGroupIds[0].toNumber()
+      } else {
+        groupId = await this.promptForCuratorGroup('Select Curator Group context', availableGroupIds)
+      }
     }
 
-    return foundClass
+    return createType('ContentActor', { Curator: [groupId, curator.workerId.toNumber()] })
   }
 
   private async curatorGroupChoices(ids?: CuratorGroupId[]) {
@@ -118,8 +145,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
         name:
           `Group ${id.toString()} (` +
           `${group.active.valueOf() ? 'Active' : 'Inactive'}, ` +
-          `${group.curators.toArray().length} member(s), ` +
-          `${group.number_of_classes_maintained.toNumber()} classes maintained)`,
+          `${group.curators.toArray().length} member(s)), `,
         value: id.toNumber(),
       }))
   }
@@ -145,12 +171,6 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     return selectedIds
   }
 
-  async promptForClassReference(): Promise<ReferenceProperty['Reference']> {
-    const selectedClass = await this.promptForClass()
-    const sameOwner = await this.simplePrompt({ message: 'Same owner required?', ...BOOL_PROMPT_OPTIONS })
-    return { className: selectedClass.name.toString(), sameOwner }
-  }
-
   async promptForCurator(message = 'Choose a Curator', ids?: number[]): Promise<number> {
     const curators = await this.getApi().groupMembers(WorkingGroups.Curators)
     const choices = curators
@@ -206,295 +226,19 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     return group
   }
 
-  async getEntity(
-    id: string | number,
-    requiredClass?: string,
-    ownerMemberId?: number,
-    requireSchema = true
-  ): Promise<Entity> {
-    if (typeof id === 'string') {
-      id = parseInt(id)
-    }
-
-    const entity = await this.getApi().entityById(id)
-
-    if (!entity) {
-      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, className?: string): Promise<FlattenRelations<T>> {
-    const entity = await this.getEntity(id, className)
-    return this.parseToEntityJson<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 | null
-  ): 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: typeof defaultId === 'number' ? defaultId.toString() : undefined,
-    })
-
-    return entityEntries.find(([id]) => choosenEntityId === id.toString())!
-  }
-
-  async promptForEntityId(
-    message: string,
-    className: string,
-    propName?: string,
-    ownerMemberId?: number,
-    defaultId?: number | null
-  ): Promise<number> {
-    return (await this.promptForEntityEntry(message, className, propName, ownerMemberId, defaultId))[0].toNumber()
-  }
-
-  parseStoredPropertyInnerValue(value: Codec | null): AnyJson {
-    if (value === null) {
-      return null
-    }
-
-    if (value instanceof AbstractInt) {
-      return value.toNumber() // Integers (signed ones) are by default converted to hex when using .toJson()
-    }
-
-    return value.toJSON()
-  }
-
-  parseEntityPropertyValues(
-    entity: Entity,
-    entityClass: Class,
-    includedProperties?: string[]
-  ): Record<string, ParsedPropertyValue> {
-    const { properties } = entityClass
-    return Array.from(entity.getField('values').entries()).reduce((columns, [propId, propValue]) => {
-      const prop = properties[propId.toNumber()]
-      const propName = prop.name.toString()
-      const included = !includedProperties || includedProperties.some((p) => p.toLowerCase() === propName.toLowerCase())
-      const { type: propType, subtype: propSubtype } = prop.property_type
-
-      if (included) {
-        columns[propName] = {
-          // If type doesn't match (Boolean(false) for optional fields case) - use "null" as value
-          value: propType !== propValue.type || propSubtype !== propValue.subtype ? null : propValue.getValue(),
-          type: propType,
-          subtype: propSubtype,
-        }
-      }
-      return columns
-    }, {} as Record<string, ParsedPropertyValue>)
-  }
-
-  async parseToEntityJson<T = unknown>(entity: Entity): Promise<FlattenRelations<T>> {
-    const entityClass = (await this.classEntryByNameOrId(entity.class_id.toString()))[1]
-    return (_.mapValues(this.parseEntityPropertyValues(entity, entityClass), (v) =>
-      this.parseStoredPropertyInnerValue(v.value)
-    ) 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 || 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 === null ? chalk.grey('[not set]') : v.value.toString()
-        ),
-      }))
-    )) as Record<string, string>[]
-
-    return parsedEntities.filter((entity) => filters.every(([pName, pValue]) => entity[pName] === pValue))
-  }
-
-  async getActor(context: typeof CONTEXTS[number], pickedClass: Class) {
-    let actor: Actor
+  async getActor(context: typeof CONTEXTS[number]) {
+    let actor: ContentActor
     if (context === 'Member') {
       const memberId = await this.getRequiredMemberId()
-      actor = this.createType('Actor', { Member: memberId })
+      actor = this.createType('ContentActor', { Member: memberId })
     } else if (context === 'Curator') {
-      actor = await this.getCuratorContext([pickedClass.name.toString()])
+      actor = await this.getCuratorContext()
     } else {
       await this.getRequiredLead()
 
-      actor = this.createType('Actor', { Lead: null })
+      actor = this.createType('ContentActor', { Lead: null })
     }
 
     return actor
   }
-
-  isActorEntityController(actor: Actor, entity: Entity, isMaintainer: boolean): boolean {
-    const entityController = entity.entity_permissions.controller
-    return (
-      (isMaintainer && entityController.isOfType('Maintainers')) ||
-      (entityController.isOfType('Member') &&
-        actor.isOfType('Member') &&
-        entityController.asType('Member').eq(actor.asType('Member'))) ||
-      (entityController.isOfType('Lead') && actor.isOfType('Lead'))
-    )
-  }
-
-  async isEntityPropertyEditableByActor(entity: Entity, classPropertyId: number, actor: Actor): Promise<boolean> {
-    const [, entityClass] = await this.classEntryByNameOrId(entity.class_id.toString())
-
-    const isActorMaintainer =
-      actor.isOfType('Curator') &&
-      entityClass.class_permissions.maintainers.toArray().some((groupId) => groupId.eq(actor.asType('Curator')[0]))
-
-    const isActorController = this.isActorEntityController(actor, entity, isActorMaintainer)
-
-    const {
-      is_locked_from_controller: isLockedFromController,
-      is_locked_from_maintainer: isLockedFromMaintainer,
-    } = entityClass.properties[classPropertyId].locking_policy
-
-    return (
-      (isActorController && !isLockedFromController.valueOf()) ||
-      (isActorMaintainer && !isLockedFromMaintainer.valueOf())
-    )
-  }
-
-  getQuestionsFromProperties(properties: Property[], defaults?: { [key: string]: unknown }): DistinctQuestion[] {
-    return properties.reduce((previousValue, { name, property_type: propertyType, required }) => {
-      const propertySubtype = propertyType.subtype
-      const questionType = propertySubtype === 'Bool' ? 'list' : 'input'
-      const isSubtypeNumber = propertySubtype.toLowerCase().includes('int')
-      const isSubtypeReference = propertyType.isOfType('Single') && propertyType.asType('Single').isOfType('Reference')
-
-      const validate = async (answer: string | number | null) => {
-        if (answer === null) {
-          return true // Can only happen through "filter" if property is not required
-        }
-
-        if ((isSubtypeNumber || isSubtypeReference) && parseInt(answer.toString()).toString() !== answer.toString()) {
-          return `Expected integer value!`
-        }
-
-        if (isSubtypeReference) {
-          try {
-            await this.getEntity(+answer, propertyType.asType('Single').asType('Reference')[0].toString())
-          } catch (e) {
-            return e.message || JSON.stringify(e)
-          }
-        }
-
-        return true
-      }
-
-      const optionalQuestionProperties = {
-        ...{
-          filter: async (answer: string) => {
-            if (required.isFalse && !answer) {
-              return null
-            }
-
-            // Only cast to number if valid
-            // Prevents inquirer bug not allowing to edit invalid values when casted to number
-            // See: https://github.com/SBoudrias/Inquirer.js/issues/866
-            if ((isSubtypeNumber || isSubtypeReference) && (await validate(answer)) === true) {
-              return parseInt(answer)
-            }
-
-            return answer
-          },
-          validate,
-        },
-        ...(propertySubtype === 'Bool' && {
-          choices: ['true', 'false'],
-          filter: (answer: string) => {
-            return answer === 'true' || false
-          },
-        }),
-      }
-
-      const isQuestionOptional = propertySubtype === 'Bool' ? '' : required.isTrue ? '(required)' : '(optional)'
-      const classId = isSubtypeReference
-        ? ` [Class Id: ${propertyType.asType('Single').asType('Reference')[0].toString()}]`
-        : ''
-
-      return [
-        ...previousValue,
-        {
-          name: name.toString(),
-          message: `${name} - ${propertySubtype}${classId} ${isQuestionOptional}`,
-          type: questionType,
-          ...optionalQuestionProperties,
-          ...(defaults && {
-            default: propertySubtype === 'Bool' ? JSON.stringify(defaults[name.toString()]) : defaults[name.toString()],
-          }),
-        },
-      ]
-    }, [] as DistinctQuestion[])
-  }
 }

+ 1 - 1
cli/src/base/DefaultCommandBase.ts

@@ -53,7 +53,7 @@ export default abstract class DefaultCommandBase extends Command {
   }
 
   private jsonPrettyKeyVal(key: string, val: any): string {
-    return this.jsonPrettyIndented(chalk.white(`${key}: ${this.jsonPrettyAny(val)}`))
+    return this.jsonPrettyIndented(chalk.magentaBright(`${key}: ${this.jsonPrettyAny(val)}`))
   }
 
   private jsonPrettyObj(obj: { [key: string]: any }): string {

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

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

+ 283 - 0
cli/src/base/UploadCommandBase.ts

@@ -0,0 +1,283 @@
+import ContentDirectoryCommandBase from './ContentDirectoryCommandBase'
+import { VideoFFProbeMetadata, VideoFileMetadata, AssetType, InputAsset, InputAssetDetails } from '../Types'
+import { ContentId, ContentParameters } from '@joystream/types/storage'
+import { MultiBar, Options, SingleBar } from 'cli-progress'
+import { Assets } from '../json-schemas/typings/Assets.schema'
+import ExitCodes from '../ExitCodes'
+import ipfsHash from 'ipfs-only-hash'
+import fs from 'fs'
+import _ from 'lodash'
+import axios, { AxiosRequestConfig } from 'axios'
+import ffprobeInstaller from '@ffprobe-installer/ffprobe'
+import ffmpeg from 'fluent-ffmpeg'
+import path from 'path'
+import chalk from 'chalk'
+import mimeTypes from 'mime-types'
+
+ffmpeg.setFfprobePath(ffprobeInstaller.path)
+
+/**
+ * Abstract base class for commands that require uploading functionality
+ */
+export default abstract class UploadCommandBase extends ContentDirectoryCommandBase {
+  private fileSizeCache: Map<string, number> = new Map<string, number>()
+  private progressBarOptions: Options = {
+    format: `{barTitle} | {bar} | {value}/{total} KB processed`,
+  }
+
+  getFileSize(path: string): number {
+    const cachedSize = this.fileSizeCache.get(path)
+    return cachedSize !== undefined ? cachedSize : fs.statSync(path).size
+  }
+
+  normalizeEndpoint(endpoint: string) {
+    return endpoint.endsWith('/') ? endpoint : endpoint + '/'
+  }
+
+  createReadStreamWithProgressBar(
+    filePath: string,
+    barTitle: string,
+    multiBar?: MultiBar
+  ): {
+    fileStream: fs.ReadStream
+    progressBar: SingleBar
+  } {
+    // Progress CLI UX:
+    // https://github.com/oclif/cli-ux#cliprogress
+    // https://www.npmjs.com/package/cli-progress
+    const fileSize = this.getFileSize(filePath)
+    let processedKB = 0
+    const fileSizeKB = Math.ceil(fileSize / 1024)
+    const progress = multiBar
+      ? multiBar.create(fileSizeKB, processedKB, { barTitle })
+      : new SingleBar(this.progressBarOptions)
+
+    progress.start(fileSizeKB, processedKB, { barTitle })
+    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,
+    }
+  }
+
+  async getVideoFFProbeMetadata(filePath: string): Promise<VideoFFProbeMetadata> {
+    return new Promise<VideoFFProbeMetadata>((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'))
+        }
+      })
+    })
+  }
+
+  async getVideoFileMetadata(filePath: string): Promise<VideoFileMetadata> {
+    let ffProbeMetadata: VideoFFProbeMetadata = {}
+    try {
+      ffProbeMetadata = await this.getVideoFFProbeMetadata(filePath)
+    } catch (e) {
+      const message = e.message || e
+      this.warn(`Failed to get video metadata via ffprobe (${message})`)
+    }
+
+    const size = this.getFileSize(filePath)
+    const container = path.extname(filePath).slice(1)
+    const mimeType = mimeTypes.lookup(container) || `unknown`
+    return {
+      size,
+      container,
+      mimeType,
+      ...ffProbeMetadata,
+    }
+  }
+
+  async calculateFileIpfsHash(filePath: string): Promise<string> {
+    const { fileStream } = this.createReadStreamWithProgressBar(filePath, 'Calculating file hash')
+    const hash: string = await ipfsHash.of(fileStream)
+
+    return hash
+  }
+
+  validateFile(filePath: string): void {
+    // Basic file validation
+    if (!fs.existsSync(filePath)) {
+      this.error(`${filePath} - file does not exist under provided path!`, { exit: ExitCodes.FileNotFound })
+    }
+  }
+
+  assetUrl(endpointRoot: string, contentId: ContentId): string {
+    // This will also make sure the resulting url is a valid url
+    return new URL(`asset/v0/${contentId.encode()}`, this.normalizeEndpoint(endpointRoot)).toString()
+  }
+
+  async getRandomProviderEndpoint(): Promise<string | null> {
+    const endpoints = _.shuffle(await this.getApi().allStorageProviderEndpoints())
+    for (const endpoint of endpoints) {
+      try {
+        const url = new URL('swagger.json', this.normalizeEndpoint(endpoint)).toString()
+        await axios.head(url)
+        return endpoint
+      } catch (e) {
+        continue
+      }
+    }
+
+    return null
+  }
+
+  async generateContentParameters(filePath: string, type: AssetType): Promise<ContentParameters> {
+    return this.createType('ContentParameters', {
+      content_id: ContentId.generate(this.getTypesRegistry()),
+      type_id: type,
+      size: this.getFileSize(filePath),
+      ipfs_content_id: await this.calculateFileIpfsHash(filePath),
+    })
+  }
+
+  async prepareInputAssets(paths: string[], basePath?: string): Promise<InputAssetDetails[]> {
+    // Resolve assets
+    if (basePath) {
+      paths = paths.map((p) => basePath && path.resolve(path.dirname(basePath), p))
+    }
+    // Validate assets
+    paths.forEach((p) => this.validateFile(p))
+
+    // Return data
+    return await Promise.all(
+      paths.map(async (path) => {
+        const parameters = await this.generateContentParameters(path, AssetType.AnyAsset)
+        return {
+          path,
+          contentId: parameters.content_id,
+          parameters,
+        }
+      })
+    )
+  }
+
+  async uploadAsset(contentId: ContentId, filePath: string, endpoint?: string, multiBar?: MultiBar): Promise<void> {
+    const providerEndpoint = endpoint || (await this.getRandomProviderEndpoint())
+    if (!providerEndpoint) {
+      this.error('No active provider found!', { exit: ExitCodes.ActionCurrentlyUnavailable })
+    }
+    const uploadUrl = this.assetUrl(providerEndpoint, contentId)
+    const fileSize = this.getFileSize(filePath)
+    const { fileStream, progressBar } = this.createReadStreamWithProgressBar(
+      filePath,
+      `Uploading ${contentId.encode()}`,
+      multiBar
+    )
+    fileStream.on('end', () => {
+      // Temporarly disable because with Promise.all it breaks the UI
+      // 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(),
+        },
+        maxBodyLength: fileSize,
+      }
+      await axios.put(uploadUrl, fileStream, config)
+    } catch (e) {
+      progressBar.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 uploadAssets(
+    assets: InputAsset[],
+    inputFilePath: string,
+    outputFilePostfix = '__rejectedContent'
+  ): Promise<void> {
+    const endpoint = await this.getRandomProviderEndpoint()
+    if (!endpoint) {
+      this.warn('No storage provider is currently available!')
+      this.handleRejectedUploads(
+        assets,
+        assets.map(() => false),
+        inputFilePath,
+        outputFilePostfix
+      )
+      this.exit(ExitCodes.ActionCurrentlyUnavailable)
+    }
+    const multiBar = new MultiBar(this.progressBarOptions)
+    // Workaround replacement for Promise.allSettled (which is only available in ES2020)
+    const results = await Promise.all(
+      assets.map(async (a) => {
+        try {
+          await this.uploadAsset(a.contentId, a.path, endpoint, multiBar)
+          return true
+        } catch (e) {
+          return false
+        }
+      })
+    )
+    this.handleRejectedUploads(assets, results, inputFilePath, outputFilePostfix)
+    multiBar.stop()
+  }
+
+  private handleRejectedUploads(
+    assets: InputAsset[],
+    results: boolean[],
+    inputFilePath: string,
+    outputFilePostfix: string
+  ): void {
+    // Try to save rejected contentIds and paths for reupload purposes
+    const rejectedAssetsOutput: Assets = []
+    results.forEach(
+      (r, i) =>
+        r === false && rejectedAssetsOutput.push({ contentId: assets[i].contentId.encode(), path: assets[i].path })
+    )
+    if (rejectedAssetsOutput.length) {
+      this.warn(
+        `Some assets were not uploaded successfully. Try reuploading them with ${chalk.magentaBright(
+          'content:reuploadAssets'
+        )}!`
+      )
+      console.log(rejectedAssetsOutput)
+      const outputPath = inputFilePath.replace('.json', `${outputFilePostfix}.json`)
+      try {
+        fs.writeFileSync(outputPath, JSON.stringify(rejectedAssetsOutput, null, 4))
+        this.log(`Rejected content ids successfully saved to: ${chalk.magentaBright(outputPath)}!`)
+      } catch (e) {
+        console.error(e)
+        this.warn(
+          `Could not write rejected content output to ${outputPath}. Try copying the output above and creating the file manually!`
+        )
+      }
+    }
+  }
+}

+ 1 - 1
cli/src/base/WorkingGroupsCommandBase.ts

@@ -194,6 +194,6 @@ export default abstract class WorkingGroupsCommandBase extends RolesCommandBase
     if (flags.group) {
       this.group = flags.group
     }
-    this.log(chalk.white('Current Group: ' + this.group))
+    this.log(chalk.magentaBright('Current Group: ' + this.group))
   }
 }

+ 2 - 2
cli/src/commands/account/choose.ts

@@ -24,7 +24,7 @@ export default class AccountChoose extends AccountsCommandBase {
     const accounts: NamedKeyringPair[] = this.fetchAccounts(!!address || showSpecial)
     const selectedAccount: NamedKeyringPair | null = this.getSelectedAccount()
 
-    this.log(chalk.white(`Found ${accounts.length} existing accounts...\n`))
+    this.log(chalk.magentaBright(`Found ${accounts.length} existing accounts...\n`))
 
     if (accounts.length === 0) {
       this.warn('No account to choose from. Add accont using account:import or account:create.')
@@ -43,6 +43,6 @@ export default class AccountChoose extends AccountsCommandBase {
     }
 
     await this.setSelectedAccount(choosenAccount)
-    this.log(chalk.greenBright(`\nAccount switched to ${chalk.white(choosenAccount.address)}!`))
+    this.log(chalk.greenBright(`\nAccount switched to ${chalk.magentaBright(choosenAccount.address)}!`))
   }
 }

+ 3 - 3
cli/src/commands/account/create.ts

@@ -40,8 +40,8 @@ export default class AccountCreate extends AccountsCommandBase {
 
     this.saveAccount(keys, password)
 
-    this.log(chalk.greenBright(`\nAccount succesfully created!`))
-    this.log(chalk.white(`${chalk.bold('Name:    ')}${args.name}`))
-    this.log(chalk.white(`${chalk.bold('Address: ')}${keys.address}`))
+    this.log(chalk.greenBright(`\nAccount successfully created!`))
+    this.log(chalk.magentaBright(`${chalk.bold('Name:    ')}${args.name}`))
+    this.log(chalk.magentaBright(`${chalk.bold('Address: ')}${keys.address}`))
   }
 }

+ 4 - 2
cli/src/commands/account/export.ts

@@ -59,7 +59,9 @@ export default class AccountExport extends AccountsCommandBase {
         this.error(`Failed to create the export folder (${destPath})`, { exit: ExitCodes.FsOperationFailed })
       }
       for (const account of accounts) this.exportAccount(account, destPath)
-      this.log(chalk.greenBright(`All accounts succesfully exported succesfully to: ${chalk.white(destPath)}!`))
+      this.log(
+        chalk.greenBright(`All accounts successfully exported successfully to: ${chalk.magentaBright(destPath)}!`)
+      )
     } else {
       const destPath: string = args.path
       const choosenAccount: NamedKeyringPair = await this.promptForAccount(
@@ -68,7 +70,7 @@ export default class AccountExport extends AccountsCommandBase {
         'Select an account to export'
       )
       const exportedFilePath: string = this.exportAccount(choosenAccount, destPath)
-      this.log(chalk.greenBright(`Account succesfully exported to: ${chalk.white(exportedFilePath)}`))
+      this.log(chalk.greenBright(`Account successfully exported to: ${chalk.magentaBright(exportedFilePath)}`))
     }
   }
 }

+ 3 - 3
cli/src/commands/account/import.ts

@@ -37,8 +37,8 @@ export default class AccountImport extends AccountsCommandBase {
       })
     }
 
-    this.log(chalk.bold.greenBright(`ACCOUNT IMPORTED SUCCESFULLY!`))
-    this.log(chalk.bold.white(`NAME:    `), accountName)
-    this.log(chalk.bold.white(`ADDRESS: `), accountAddress)
+    this.log(chalk.bold.greenBright(`ACCOUNT IMPORTED SUCCESSFULLY!`))
+    this.log(chalk.bold.magentaBright(`NAME:    `), accountName)
+    this.log(chalk.bold.magentaBright(`ADDRESS: `), accountAddress)
   }
 }

+ 5 - 5
cli/src/commands/account/transferTokens.ts

@@ -40,7 +40,7 @@ export default class AccountTransferTokens extends AccountsCommandBase {
 
     await this.requestAccountDecoding(selectedAccount)
 
-    this.log(chalk.white('Estimating fee...'))
+    this.log(chalk.magentaBright('Estimating fee...'))
     const tx = await this.getApi().createTransferTx(args.recipient, amountBN)
     let estimatedFee: BN
     try {
@@ -49,8 +49,8 @@ export default class AccountTransferTokens extends AccountsCommandBase {
       this.error('Could not estimate the fee.', { exit: ExitCodes.UnexpectedException })
     }
     const totalAmount: BN = amountBN.add(estimatedFee)
-    this.log(chalk.white('Estimated fee:', formatBalance(estimatedFee)))
-    this.log(chalk.white('Total transfer amount:', formatBalance(totalAmount)))
+    this.log(chalk.magentaBright('Estimated fee:', formatBalance(estimatedFee)))
+    this.log(chalk.magentaBright('Total transfer amount:', formatBalance(totalAmount)))
 
     checkBalance(accBalances, totalAmount)
 
@@ -58,8 +58,8 @@ export default class AccountTransferTokens extends AccountsCommandBase {
 
     try {
       const txHash: Hash = await tx.signAndSend(selectedAccount)
-      this.log(chalk.greenBright('Transaction succesfully sent!'))
-      this.log(chalk.white('Hash:', txHash.toString()))
+      this.log(chalk.greenBright('Transaction successfully sent!'))
+      this.log(chalk.magentaBright('Hash:', txHash.toString()))
     } catch (e) {
       this.error('Could not send the transaction.', { exit: ExitCodes.UnexpectedException })
     }

+ 6 - 6
cli/src/commands/api/inspect.ts

@@ -155,7 +155,7 @@ export default class ApiInspect extends ApiCommandBase {
   async requestParamsValues(paramTypes: string[]): Promise<ApiMethodArg[]> {
     const result: ApiMethodArg[] = []
     for (const [key, paramType] of Object.entries(paramTypes)) {
-      this.log(chalk.bold.white(`Parameter no. ${parseInt(key) + 1} (${paramType}):`))
+      this.log(chalk.bold.magentaBright(`Parameter no. ${parseInt(key) + 1} (${paramType}):`))
       const paramValue = await this.promptForParam(paramType)
       result.push(paramValue)
     }
@@ -192,7 +192,7 @@ export default class ApiInspect extends ApiCommandBase {
     }
     // Describing a method
     else if (apiType && apiModule && apiMethod) {
-      this.log(chalk.bold.white(`${apiType}.${apiModule}.${apiMethod}`))
+      this.log(chalk.bold.magentaBright(`${apiType}.${apiModule}.${apiMethod}`))
       const description: string = this.getMethodDescription(apiType, apiModule, apiMethod)
       this.log(`\n${description}\n`)
       const typesRows: NameValueObj[] = []
@@ -215,17 +215,17 @@ export default class ApiInspect extends ApiCommandBase {
     }
     // Displaying all available modules
     else if (apiType) {
-      this.log(chalk.bold.white('Available modules:'))
+      this.log(chalk.bold.magentaBright('Available modules:'))
       this.log(
         Object.keys(api[apiType])
-          .map((key) => chalk.white(key))
+          .map((key) => chalk.magentaBright(key))
           .join('\n')
       )
     }
     // Displaying all available types
     else {
-      this.log(chalk.bold.white('Available types:'))
-      this.log(availableTypes.map((type) => chalk.white(type)).join('\n'))
+      this.log(chalk.bold.magentaBright('Available types:'))
+      this.log(availableTypes.map((type) => chalk.magentaBright(type)).join('\n'))
     }
   }
 }

+ 1 - 1
cli/src/commands/api/setUri.ts

@@ -32,6 +32,6 @@ export default class ApiSetUri extends ApiCommandBase {
     } else {
       newUri = await this.promptForApiUri()
     }
-    this.log(chalk.greenBright('Api uri successfuly changed! New uri: ') + chalk.white(newUri))
+    this.log(chalk.greenBright('Api uri successfuly changed! New uri: ') + chalk.magentaBright(newUri))
   }
 }

+ 0 - 79
cli/src/commands/content-directory/addClassSchema.ts

@@ -1,79 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import AddClassSchemaSchema from '@joystream/cd-schemas/schemas/extrinsics/AddClassSchema.schema.json'
-import { AddClassSchema } from '@joystream/cd-schemas/types/extrinsics/AddClassSchema'
-import { InputParser } from '@joystream/cd-schemas'
-import { JsonSchemaPrompter, JsonSchemaCustomPrompts } from '../../helpers/JsonSchemaPrompt'
-import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
-import { IOFlags, getInputJson, saveOutputJson } from '../../helpers/InputOutput'
-import { Class } from '@joystream/types/content-directory'
-
-export default class AddClassSchemaCommand extends ContentDirectoryCommandBase {
-  static description = 'Add a new schema to a class inside content directory. Requires lead access.'
-
-  static flags = {
-    ...IOFlags,
-  }
-
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
-    await this.requestAccountDecoding(account)
-
-    const { input, output } = this.parse(AddClassSchemaCommand).flags
-
-    let inputJson = await getInputJson<AddClassSchema>(input)
-    if (!inputJson) {
-      let selectedClass: Class | undefined
-      const customPrompts: JsonSchemaCustomPrompts = [
-        [
-          'className',
-          async () => {
-            selectedClass = await this.promptForClass('Select a class to add schema to')
-            return selectedClass.name.toString()
-          },
-        ],
-        [
-          'existingProperties',
-          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,
-            })
-          },
-        ],
-        [
-          /^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' },
-        ],
-      ]
-
-      const prompter = new JsonSchemaPrompter<AddClassSchema>(
-        AddClassSchemaSchema as JSONSchema,
-        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.className}Schema.json`, inputJson)
-      const inputParser = new InputParser(this.getOriginalApi())
-      this.log('Sending the extrinsic...')
-      await this.sendAndFollowTx(account, await inputParser.parseAddClassSchemaExtrinsic(inputJson))
-    }
-  }
-}

+ 0 - 44
cli/src/commands/content-directory/addMaintainerToClass.ts

@@ -1,44 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import chalk from 'chalk'
-
-export default class AddMaintainerToClassCommand extends ContentDirectoryCommandBase {
-  static description = 'Add maintainer (Curator Group) to a class.'
-  static args = [
-    {
-      name: 'className',
-      required: false,
-      description: 'Name or ID of the class (ie. Video)',
-    },
-    {
-      name: 'groupId',
-      required: false,
-      description: 'ID of the Curator Group to add as class maintainer',
-    },
-  ]
-
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
-
-    let { groupId, className } = this.parse(AddMaintainerToClassCommand).args
-
-    if (className === undefined) {
-      className = (await this.promptForClass()).name.toString()
-    }
-
-    const classId = (await this.classEntryByNameOrId(className))[0].toNumber()
-
-    if (groupId === undefined) {
-      groupId = await this.promptForCuratorGroup()
-    } else {
-      await this.getCuratorGroup(groupId)
-    }
-
-    await this.requestAccountDecoding(account)
-    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'addMaintainerToClass', [classId, groupId])
-
-    console.log(
-      chalk.green(`Curator Group ${chalk.white(groupId)} added as maintainer to ${chalk.white(className)} class!`)
-    )
-  }
-}

+ 0 - 55
cli/src/commands/content-directory/class.ts

@@ -1,55 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import chalk from 'chalk'
-import { displayCollapsedRow, displayHeader, displayTable } from '../../helpers/display'
-
-export default class ClassCommand extends ContentDirectoryCommandBase {
-  static description = 'Show Class details by id or name.'
-  static args = [
-    {
-      name: 'className',
-      required: true,
-      description: 'Name or ID of the Class',
-    },
-  ]
-
-  async run() {
-    const { className } = this.parse(ClassCommand).args
-    const [id, aClass] = await this.classEntryByNameOrId(className)
-    const permissions = aClass.class_permissions
-    const maintainers = permissions.maintainers.toArray()
-
-    displayCollapsedRow({
-      'Name': aClass.name.toString(),
-      'ID': id.toString(),
-      'Any member': permissions.any_member.toString(),
-      'Entity creation blocked': permissions.entity_creation_blocked.toString(),
-      'All property values locked': permissions.all_entity_property_values_locked.toString(),
-      'Number of entities': aClass.current_number_of_entities.toNumber(),
-      'Max. number of entities': aClass.maximum_entities_count.toNumber(),
-      'Default entity creation voucher max.': aClass.default_entity_creation_voucher_upper_bound.toNumber(),
-    })
-
-    displayHeader(`Maintainers`)
-    this.log(
-      maintainers.length ? maintainers.map((groupId) => chalk.white(`Group ${groupId.toString()}`)).join(', ') : 'NONE'
-    )
-
-    displayHeader(`Properties`)
-    if (aClass.properties.length) {
-      displayTable(
-        aClass.properties.map((p, i) => ({
-          'Index': i,
-          'Name': p.name.toString(),
-          'Type': JSON.stringify(p.property_type.toJSON()),
-          'Required': p.required.toString(),
-          'Unique': p.unique.toString(),
-          'Controller lock': p.locking_policy.is_locked_from_controller.toString(),
-          'Maintainer lock': p.locking_policy.is_locked_from_maintainer.toString(),
-        })),
-        3
-      )
-    } else {
-      this.log('NONE')
-    }
-  }
-}

+ 0 - 24
cli/src/commands/content-directory/classes.ts

@@ -1,24 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-// import chalk from 'chalk'
-import { displayTable } from '../../helpers/display'
-
-export default class ClassesCommand extends ContentDirectoryCommandBase {
-  static description = 'List existing content directory classes.'
-
-  async run() {
-    const classes = await this.getApi().availableClasses()
-
-    displayTable(
-      classes.map(([id, c]) => ({
-        'ID': id.toString(),
-        'Name': c.name.toString(),
-        'Any member': c.class_permissions.any_member.toString(),
-        'Entities': c.current_number_of_entities.toNumber(),
-        'Schemas': c.schemas.length,
-        'Maintainers': c.class_permissions.maintainers.toArray().length,
-        'Properties': c.properties.length,
-      })),
-      3
-    )
-  }
-}

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

@@ -1,50 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import CreateClassSchema from '@joystream/cd-schemas/schemas/extrinsics/CreateClass.schema.json'
-import { CreateClass } from '@joystream/cd-schemas/types/extrinsics/CreateClass'
-import { InputParser } from '@joystream/cd-schemas'
-import { JsonSchemaPrompter, JsonSchemaCustomPrompts } from '../../helpers/JsonSchemaPrompt'
-import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
-import { IOFlags, getInputJson, saveOutputJson } from '../../helpers/InputOutput'
-
-export default class CreateClassCommand extends ContentDirectoryCommandBase {
-  static description = 'Create class inside content directory. Requires lead access.'
-  static flags = {
-    ...IOFlags,
-  }
-
-  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 = await getInputJson<CreateClass>(input, CreateClassSchema as JSONSchema)
-    if (!inputJson) {
-      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')],
-      ]
-
-      const prompter = new JsonSchemaPrompter<CreateClass>(CreateClassSchema as JSONSchema, 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.name}Class.json`, inputJson)
-      this.log('Sending the extrinsic...')
-      const inputParser = new InputParser(this.getOriginalApi())
-      await this.sendAndFollowTx(account, inputParser.parseCreateClassExtrinsic(inputJson))
-    }
-  }
-}

+ 0 - 58
cli/src/commands/content-directory/createEntity.ts

@@ -1,58 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import inquirer from 'inquirer'
-import { InputParser } from '@joystream/cd-schemas'
-import ExitCodes from '../../ExitCodes'
-
-export default class CreateEntityCommand extends ContentDirectoryCommandBase {
-  static description =
-    'Creates a new entity in the specified class (can be executed in Member, Curator or Lead context)'
-
-  static args = [
-    {
-      name: 'className',
-      required: true,
-      description: 'Name or ID of the Class',
-    },
-  ]
-
-  static flags = {
-    context: ContentDirectoryCommandBase.contextFlag,
-  }
-
-  async run() {
-    const { className } = this.parse(CreateEntityCommand).args
-    let { context } = this.parse(CreateEntityCommand).flags
-
-    if (!context) {
-      context = await this.promptForContext()
-    }
-
-    const currentAccount = await this.getRequiredSelectedAccount()
-    await this.requestAccountDecoding(currentAccount)
-    const [, entityClass] = await this.classEntryByNameOrId(className)
-
-    const actor = await this.getActor(context, entityClass)
-
-    if (actor.isOfType('Member') && entityClass.class_permissions.any_member.isFalse) {
-      this.error('Choosen actor has no access to create an entity of this type', { exit: ExitCodes.AccessDenied })
-    }
-
-    const answers: {
-      [key: string]: string | number | null
-    } = await inquirer.prompt(this.getQuestionsFromProperties(entityClass.properties.toArray()))
-
-    this.jsonPrettyPrint(JSON.stringify(answers))
-    await this.requireConfirmation('Do you confirm the provided input?')
-
-    const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi(), [
-      {
-        className: entityClass.name.toString(),
-        entries: [answers],
-      },
-    ])
-
-    const operations = await inputParser.getEntityBatchOperations()
-
-    await this.sendAndFollowNamedTx(currentAccount, 'contentDirectory', 'transaction', [actor, operations])
-  }
-}

+ 0 - 45
cli/src/commands/content-directory/entities.ts

@@ -1,45 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import { displayTable } from '../../helpers/display'
-import { flags } from '@oclif/command'
-
-export default class EntitiesCommand extends ContentDirectoryCommandBase {
-  static description = 'Show entities list by class id or name.'
-  static args = [
-    {
-      name: 'className',
-      required: true,
-      description: 'Name or ID of the Class',
-    },
-    {
-      name: 'properties',
-      required: false,
-      description:
-        'Comma-separated properties to include in the results table (ie. code,name). ' +
-        'By default all property values will be included.',
-    },
-  ]
-
-  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(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 this.createEntityList(className, propsToInclude, filtersArr), 3)
-  }
-}

+ 0 - 44
cli/src/commands/content-directory/entity.ts

@@ -1,44 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import chalk from 'chalk'
-import { displayCollapsedRow, displayHeader } from '../../helpers/display'
-import _ from 'lodash'
-
-export default class EntityCommand extends ContentDirectoryCommandBase {
-  static description = 'Show Entity details by id.'
-  static args = [
-    {
-      name: 'id',
-      required: true,
-      description: 'ID of the Entity',
-    },
-  ]
-
-  async run() {
-    const { id } = this.parse(EntityCommand).args
-    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)
-
-    displayCollapsedRow({
-      'ID': id,
-      'Class name': entityClass.name.toString(),
-      'Class ID': classId.toNumber(),
-      'Supported schemas': JSON.stringify(entity.supported_schemas.toJSON()),
-      'Controller': controller.type + (controller.isOfType('Member') ? `(${controller.asType('Member')})` : ''),
-      'Frozen': frozen.toString(),
-      'Refrecencable': referenceable.toString(),
-      'Same owner references': entity.reference_counter.same_owner.toNumber(),
-      'Total references': entity.reference_counter.total.toNumber(),
-    })
-    displayHeader('Property values')
-    displayCollapsedRow(
-      _.mapValues(
-        propertyValues,
-        (v) =>
-          (v.value === null ? chalk.grey('[not set]') : v.value.toString()) +
-          ` ${chalk.green(`${v.type}<${v.subtype}>`)}`
-      )
-    )
-  }
-}

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

@@ -1,57 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import { InputParser, ExtrinsicsHelper, getInitializationInputs } from '@joystream/cd-schemas'
-import { flags } from '@oclif/command'
-
-export default class InitializeCommand extends ContentDirectoryCommandBase {
-  static description =
-    'Initialize content directory with input data from @joystream/content library or custom, provided one. Requires lead access.'
-
-  static flags = {
-    rootInputsDir: flags.string({
-      required: false,
-      description: 'Custom inputs directory (must follow @joystream/content directory structure)',
-    }),
-  }
-
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
-    await this.requestAccountDecoding(account)
-
-    const {
-      flags: { rootInputsDir },
-    } = this.parse(InitializeCommand)
-
-    const { classInputs, schemaInputs, entityBatchInputs } = getInitializationInputs(rootInputsDir)
-
-    const currentClasses = await this.getApi().availableClasses()
-
-    if (currentClasses.length) {
-      this.log('There are already some existing classes in the current content directory.')
-      await this.requireConfirmation('Do you wish to continue anyway?')
-    }
-
-    const txHelper = new ExtrinsicsHelper(this.getOriginalApi())
-    const parser = new InputParser(this.getOriginalApi(), classInputs, schemaInputs, entityBatchInputs)
-
-    this.log(`Initializing classes (${classInputs.length} input files found)...\n`)
-    const classExtrinsics = parser.getCreateClassExntrinsics()
-    await txHelper.sendAndCheck(account, classExtrinsics, 'Class initialization failed!')
-
-    this.log(`Initializing schemas (${schemaInputs.length} input files found)...\n`)
-    const schemaExtrinsics = await parser.getAddSchemaExtrinsics()
-    await txHelper.sendAndCheck(account, schemaExtrinsics, 'Schemas initialization failed!')
-
-    this.log(`Initializing entities (${entityBatchInputs.length} input files found)`)
-    const entityOperations = await parser.getEntityBatchOperations()
-
-    this.log(`Sending Transaction extrinsic (${entityOperations.length} operations)...\n`)
-    await txHelper.sendAndCheck(
-      account,
-      [this.getOriginalApi().tx.contentDirectory.transaction({ Lead: null }, entityOperations)],
-      'Entity initialization failed!'
-    )
-
-    this.log('DONE')
-  }
-}

+ 0 - 35
cli/src/commands/content-directory/removeCuratorGroup.ts

@@ -1,35 +0,0 @@
-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.'
-  static args = [
-    {
-      name: 'id',
-      required: false,
-      description: 'ID of the Curator Group to remove',
-    },
-  ]
-
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
-
-    let { id } = this.parse(AddCuratorGroupCommand).args
-    if (id === undefined) {
-      id = await this.promptForCuratorGroup('Select Curator Group to remove')
-    }
-
-    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)
-    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'removeCuratorGroup', [id])
-
-    console.log(chalk.green(`Curator Group ${chalk.white(id)} succesfully removed!`))
-  }
-}

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

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

+ 0 - 44
cli/src/commands/content-directory/removeMaintainerFromClass.ts

@@ -1,44 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import chalk from 'chalk'
-
-export default class AddMaintainerToClassCommand extends ContentDirectoryCommandBase {
-  static description = 'Remove maintainer (Curator Group) from class.'
-  static args = [
-    {
-      name: 'className',
-      required: false,
-      description: 'Name or ID of the class (ie. Video)',
-    },
-    {
-      name: 'groupId',
-      required: false,
-      description: 'ID of the Curator Group to remove from maintainers',
-    },
-  ]
-
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
-
-    let { groupId, className } = this.parse(AddMaintainerToClassCommand).args
-
-    if (className === undefined) {
-      className = (await this.promptForClass()).name.toString()
-    }
-
-    const [classId, aClass] = await this.classEntryByNameOrId(className)
-
-    if (groupId === undefined) {
-      groupId = await this.promptForCuratorGroup('Select a maintainer', aClass.class_permissions.maintainers.toArray())
-    } else {
-      await this.getCuratorGroup(groupId)
-    }
-
-    await this.requestAccountDecoding(account)
-    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'removeMaintainerFromClass', [classId, groupId])
-
-    console.log(
-      chalk.green(`Curator Group ${chalk.white(groupId)} removed as maintainer of ${chalk.white(className)} class!`)
-    )
-  }
-}

+ 0 - 55
cli/src/commands/content-directory/updateClassPermissions.ts

@@ -1,55 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import CreateClassSchema from '@joystream/cd-schemas/schemas/extrinsics/CreateClass.schema.json'
-import chalk from 'chalk'
-import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
-import { CreateClass } from '@joystream/cd-schemas/types/extrinsics/CreateClass'
-import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
-
-export default class UpdateClassPermissionsCommand extends ContentDirectoryCommandBase {
-  static description = 'Update permissions in given class.'
-  static args = [
-    {
-      name: 'className',
-      required: false,
-      description: 'Name or ID of the class (ie. Video)',
-    },
-  ]
-
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    await this.requireLead()
-
-    let { className } = this.parse(UpdateClassPermissionsCommand).args
-
-    if (className === undefined) {
-      className = (await this.promptForClass()).name.toString()
-    }
-
-    const [classId, aClass] = await this.classEntryByNameOrId(className)
-    const currentPermissions = aClass.class_permissions
-
-    const customPrompts: JsonSchemaCustomPrompts = [
-      ['class_permissions.maintainers', () => this.promptForCuratorGroups('Select class maintainers')],
-    ]
-
-    const prompter = new JsonSchemaPrompter<CreateClass>(
-      CreateClassSchema as JSONSchema,
-      { class_permissions: currentPermissions.toJSON() as CreateClass['class_permissions'] },
-      customPrompts
-    )
-
-    const newPermissions = await prompter.promptSingleProp('class_permissions')
-
-    await this.requestAccountDecoding(account)
-    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'updateClassPermissions', [
-      classId,
-      newPermissions.any_member,
-      newPermissions.entity_creation_blocked,
-      newPermissions.all_entity_property_values_locked,
-      newPermissions.maintainers,
-    ])
-
-    console.log(chalk.green(`${chalk.white(className)} class permissions updated to:`))
-    this.jsonPrettyPrint(JSON.stringify(newPermissions))
-  }
-}

+ 0 - 61
cli/src/commands/content-directory/updateEntityPropertyValues.ts

@@ -1,61 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import inquirer from 'inquirer'
-import { InputParser } from '@joystream/cd-schemas'
-import ExitCodes from '../../ExitCodes'
-
-export default class UpdateEntityPropertyValues extends ContentDirectoryCommandBase {
-  static description =
-    'Updates the property values of the specified entity (can be executed in Member, Curator or Lead context)'
-
-  static args = [
-    {
-      name: 'id',
-      required: true,
-      description: 'ID of the Entity',
-    },
-  ]
-
-  static flags = {
-    context: ContentDirectoryCommandBase.contextFlag,
-  }
-
-  async run() {
-    const { id } = this.parse(UpdateEntityPropertyValues).args
-    let { context } = this.parse(UpdateEntityPropertyValues).flags
-
-    if (!context) {
-      context = await this.promptForContext()
-    }
-
-    const currentAccount = await this.getRequiredSelectedAccount()
-    await this.requestAccountDecoding(currentAccount)
-
-    const entity = await this.getEntity(id)
-    const [, entityClass] = await this.classEntryByNameOrId(entity.class_id.toString())
-    const defaults = await this.parseToEntityJson(entity)
-
-    const actor = await this.getActor(context, entityClass)
-
-    const isPropertEditableByIndex = await Promise.all(
-      entityClass.properties.map((p, i) => this.isEntityPropertyEditableByActor(entity, i, actor))
-    )
-    const filteredProperties = entityClass.properties.filter((p, i) => isPropertEditableByIndex[i])
-
-    if (!filteredProperties.length) {
-      this.error('No entity properties are editable by choosen actor', { exit: ExitCodes.AccessDenied })
-    }
-
-    const answers: {
-      [key: string]: string | number | null
-    } = await inquirer.prompt(this.getQuestionsFromProperties(filteredProperties, defaults))
-
-    this.jsonPrettyPrint(JSON.stringify(answers))
-    await this.requireConfirmation('Do you confirm the provided input?')
-
-    const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi())
-
-    const operations = await inputParser.getEntityUpdateOperations(answers, entityClass.name.toString(), +id)
-
-    await this.sendAndFollowNamedTx(currentAccount, 'contentDirectory', 'transaction', [actor, operations])
-  }
-}

+ 6 - 2
cli/src/commands/content-directory/addCuratorToGroup.ts → cli/src/commands/content/addCuratorToGroup.ts

@@ -35,8 +35,12 @@ export default class AddCuratorToGroupCommand extends ContentDirectoryCommandBas
     }
 
     await this.requestAccountDecoding(account)
-    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'addCuratorToGroup', [groupId, curatorId])
+    await this.sendAndFollowNamedTx(account, 'content', 'addCuratorToGroup', [groupId, curatorId])
 
-    console.log(chalk.green(`Curator ${chalk.white(curatorId)} succesfully added to group ${chalk.white(groupId)}!`))
+    console.log(
+      chalk.green(
+        `Curator ${chalk.magentaBright(curatorId)} successfully added to group ${chalk.magentaBright(groupId)}!`
+      )
+    )
   }
 }

+ 44 - 0
cli/src/commands/content/channel.ts

@@ -0,0 +1,44 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { displayCollapsedRow, displayHeader } from '../../helpers/display'
+
+export default class ChannelCommand extends ContentDirectoryCommandBase {
+  static description = 'Show Channel details by id.'
+  static args = [
+    {
+      name: 'channelId',
+      required: true,
+      description: 'Name or ID of the Channel',
+    },
+  ]
+
+  async run() {
+    const { channelId } = this.parse(ChannelCommand).args
+    const channel = await this.getApi().channelById(channelId)
+    if (channel) {
+      displayCollapsedRow({
+        'ID': channelId.toString(),
+        'Owner': JSON.stringify(channel.owner.toJSON()),
+        'IsCensored': channel.is_censored.toString(),
+        'RewardAccount': channel.reward_account ? channel.reward_account.toString() : 'NONE',
+      })
+
+      displayHeader(`Media`)
+
+      displayCollapsedRow({
+        'NumberOfVideos': channel.videos.length,
+        'NumberOfPlaylists': channel.playlists.length,
+        'NumberOfSeries': channel.series.length,
+      })
+
+      displayHeader(`MediaData`)
+
+      displayCollapsedRow({
+        'Videos': JSON.stringify(channel.videos.toJSON()),
+        'Playlists': JSON.stringify(channel.playlists.toJSON()),
+        'Series': JSON.stringify(channel.series.toJSON()),
+      })
+    } else {
+      this.error(`Channel not found by channel id: "${channelId}"!`)
+    }
+  }
+}

+ 25 - 0
cli/src/commands/content/channels.ts

@@ -0,0 +1,25 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+// import chalk from 'chalk'
+import { displayTable } from '../../helpers/display'
+
+export default class ChannelsCommand extends ContentDirectoryCommandBase {
+  static description = 'List existing content directory channels.'
+
+  async run() {
+    const channels = await this.getApi().availableChannels()
+
+    if (channels.length > 0) {
+      displayTable(
+        channels.map(([id, c]) => ({
+          'ID': id.toString(),
+          'Owner': JSON.stringify(c.owner.toJSON()),
+          'IsCensored': c.is_censored.toString(),
+          'RewardAccount': c.reward_account ? c.reward_account.toString() : 'NONE',
+        })),
+        3
+      )
+    } else {
+      this.log('There are no channels yet')
+    }
+  }
+}

+ 70 - 0
cli/src/commands/content/createChannel.ts

@@ -0,0 +1,70 @@
+import { getInputJson } from '../../helpers/InputOutput'
+import { ChannelInputParameters } from '../../Types'
+import { metadataToBytes, channelMetadataFromInput } from '../../helpers/serialization'
+import { flags } from '@oclif/command'
+import { CreateInterface } from '@joystream/types'
+import { ChannelCreationParameters } from '@joystream/types/content'
+import { ChannelInputSchema } from '../../json-schemas/ContentDirectory'
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import UploadCommandBase from '../../base/UploadCommandBase'
+import chalk from 'chalk'
+
+export default class CreateChannelCommand extends UploadCommandBase {
+  static description = 'Create channel inside content directory.'
+  static flags = {
+    context: ContentDirectoryCommandBase.ownerContextFlag,
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: `Path to JSON file to use as input`,
+    }),
+  }
+
+  async run() {
+    let { context, input } = this.parse(CreateChannelCommand).flags
+
+    // Context
+    if (!context) {
+      context = await this.promptForOwnerContext()
+    }
+    const account = await this.getRequiredSelectedAccount()
+    const actor = await this.getActor(context)
+    await this.requestAccountDecoding(account)
+
+    const channelInput = await getInputJson<ChannelInputParameters>(input, ChannelInputSchema)
+
+    const meta = channelMetadataFromInput(channelInput)
+    const { coverPhotoPath, avatarPhotoPath } = channelInput
+    const assetsPaths = [coverPhotoPath, avatarPhotoPath].filter((v) => v !== undefined) as string[]
+    const inputAssets = await this.prepareInputAssets(assetsPaths, input)
+    const assets = inputAssets.map(({ parameters }) => ({ Upload: parameters }))
+    // Set assets indexes in the metadata
+    if (coverPhotoPath) {
+      meta.setCoverPhoto(0)
+    }
+    if (avatarPhotoPath) {
+      meta.setAvatarPhoto(coverPhotoPath ? 1 : 0)
+    }
+
+    const channelCreationParameters: CreateInterface<ChannelCreationParameters> = {
+      assets,
+      meta: metadataToBytes(meta),
+      reward_account: channelInput.rewardAccount,
+    }
+
+    this.jsonPrettyPrint(JSON.stringify({ assets, metadata: meta.toObject() }))
+
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    const result = await this.sendAndFollowNamedTx(account, 'content', 'createChannel', [
+      actor,
+      channelCreationParameters,
+    ])
+    if (result) {
+      const event = this.findEvent(result, 'content', 'ChannelCreated')
+      this.log(chalk.green(`Channel with id ${chalk.cyanBright(event?.data[1].toString())} successfully created!`))
+    }
+
+    await this.uploadAssets(inputAssets, input)
+  }
+}

+ 54 - 0
cli/src/commands/content/createChannelCategory.ts

@@ -0,0 +1,54 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { getInputJson } from '../../helpers/InputOutput'
+import { ChannelCategoryInputParameters } from '../../Types'
+import { channelCategoryMetadataFromInput, metadataToBytes } from '../../helpers/serialization'
+import { flags } from '@oclif/command'
+import { CreateInterface } from '@joystream/types'
+import { ChannelCategoryCreationParameters } from '@joystream/types/content'
+import { ChannelCategoryInputSchema } from '../../json-schemas/ContentDirectory'
+import chalk from 'chalk'
+
+export default class CreateChannelCategoryCommand extends ContentDirectoryCommandBase {
+  static description = 'Create channel category inside content directory.'
+  static flags = {
+    context: ContentDirectoryCommandBase.categoriesContextFlag,
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: `Path to JSON file to use as input`,
+    }),
+  }
+
+  async run() {
+    const { context, input } = this.parse(CreateChannelCategoryCommand).flags
+
+    const currentAccount = await this.getRequiredSelectedAccount()
+    await this.requestAccountDecoding(currentAccount)
+
+    const actor = context ? await this.getActor(context) : await this.getCategoryManagementActor()
+
+    const channelCategoryInput = await getInputJson<ChannelCategoryInputParameters>(input, ChannelCategoryInputSchema)
+
+    const meta = channelCategoryMetadataFromInput(channelCategoryInput)
+
+    const channelCategoryCreationParameters: CreateInterface<ChannelCategoryCreationParameters> = {
+      meta: metadataToBytes(meta),
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(channelCategoryInput))
+
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    const result = await this.sendAndFollowNamedTx(currentAccount, 'content', 'createChannelCategory', [
+      actor,
+      channelCategoryCreationParameters,
+    ])
+
+    if (result) {
+      const event = this.findEvent(result, 'content', 'ChannelCategoryCreated')
+      this.log(
+        chalk.green(`ChannelCategory with id ${chalk.cyanBright(event?.data[0].toString())} successfully created!`)
+      )
+    }
+  }
+}

+ 4 - 4
cli/src/commands/content-directory/createCuratorGroup.ts → cli/src/commands/content/createCuratorGroup.ts

@@ -1,18 +1,18 @@
 import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
 import chalk from 'chalk'
 
-export default class AddCuratorGroupCommand extends ContentDirectoryCommandBase {
+export default class CreateCuratorGroupCommand extends ContentDirectoryCommandBase {
   static description = 'Create new Curator Group.'
-  static aliases = ['addCuratorGroup']
+  static aliases = ['createCuratorGroup']
 
   async run() {
     const account = await this.getRequiredSelectedAccount()
     await this.requireLead()
 
     await this.requestAccountDecoding(account)
-    await this.buildAndSendExtrinsic(account, 'contentDirectory', 'addCuratorGroup')
+    await this.buildAndSendExtrinsic(account, 'content', 'createCuratorGroup')
 
     const newGroupId = (await this.getApi().nextCuratorGroupId()) - 1
-    console.log(chalk.green(`New group succesfully created! (ID: ${chalk.white(newGroupId)})`))
+    console.log(chalk.green(`New group successfully created! (ID: ${chalk.magentaBright(newGroupId)})`))
   }
 }

+ 95 - 0
cli/src/commands/content/createVideo.ts

@@ -0,0 +1,95 @@
+import UploadCommandBase from '../../base/UploadCommandBase'
+import { getInputJson } from '../../helpers/InputOutput'
+import { videoMetadataFromInput, metadataToBytes } from '../../helpers/serialization'
+import { VideoInputParameters, VideoFileMetadata } from '../../Types'
+import { CreateInterface } from '@joystream/types'
+import { flags } from '@oclif/command'
+import { VideoCreationParameters } from '@joystream/types/content'
+import { MediaType, VideoMetadata } from '@joystream/content-metadata-protobuf'
+import { VideoInputSchema } from '../../json-schemas/ContentDirectory'
+import chalk from 'chalk'
+
+export default class CreateVideoCommand extends UploadCommandBase {
+  static description = 'Create video under specific channel inside content directory.'
+  static flags = {
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: `Path to JSON file to use as input`,
+    }),
+    channelId: flags.integer({
+      char: 'c',
+      required: true,
+      description: 'ID of the Channel',
+    }),
+  }
+
+  setVideoMetadataDefaults(metadata: VideoMetadata, videoFileMetadata: VideoFileMetadata) {
+    const metaObj = metadata.toObject()
+    metadata.setDuration((metaObj.duration || videoFileMetadata.duration) as number)
+    metadata.setMediaPixelWidth((metaObj.mediaPixelWidth || videoFileMetadata.width) as number)
+    metadata.setMediaPixelHeight((metaObj.mediaPixelHeight || videoFileMetadata.height) as number)
+
+    const fileMediaType = new MediaType()
+    fileMediaType.setCodecName(videoFileMetadata.codecName as string)
+    fileMediaType.setContainer(videoFileMetadata.container)
+    fileMediaType.setMimeMediaType(videoFileMetadata.mimeType)
+    metadata.setMediaType(metadata.getMediaType() || fileMediaType)
+  }
+
+  async run() {
+    const { input, channelId } = this.parse(CreateVideoCommand).flags
+
+    // Get context
+    const account = await this.getRequiredSelectedAccount()
+    const channel = await this.getApi().channelById(channelId)
+    const actor = await this.getChannelOwnerActor(channel)
+    await this.requestAccountDecoding(account)
+
+    // Get input from file
+    const videoCreationParametersInput = await getInputJson<VideoInputParameters>(input, VideoInputSchema)
+
+    const meta = videoMetadataFromInput(videoCreationParametersInput)
+
+    // Assets
+    const { videoPath, thumbnailPhotoPath } = videoCreationParametersInput
+    const assetsPaths = [videoPath, thumbnailPhotoPath].filter((a) => a !== undefined) as string[]
+    const inputAssets = await this.prepareInputAssets(assetsPaths, input)
+    const assets = inputAssets.map(({ parameters }) => ({ Upload: parameters }))
+    // Set assets indexes in the metadata
+    if (videoPath) {
+      meta.setVideo(0)
+    }
+    if (thumbnailPhotoPath) {
+      meta.setThumbnailPhoto(videoPath ? 1 : 0)
+    }
+
+    // Try to get video file metadata
+    const videoFileMetadata = await this.getVideoFileMetadata(inputAssets[0].path)
+    this.log('Video media file parameters established:', videoFileMetadata)
+    this.setVideoMetadataDefaults(meta, videoFileMetadata)
+
+    // Create final extrinsic params and send the extrinsic
+    const videoCreationParameters: CreateInterface<VideoCreationParameters> = {
+      assets,
+      meta: metadataToBytes(meta),
+    }
+
+    this.jsonPrettyPrint(JSON.stringify({ assets, metadata: meta.toObject() }))
+
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    const result = await this.sendAndFollowNamedTx(account, 'content', 'createVideo', [
+      actor,
+      channelId,
+      videoCreationParameters,
+    ])
+    if (result) {
+      const event = this.findEvent(result, 'content', 'VideoCreated')
+      this.log(chalk.green(`Video with id ${chalk.cyanBright(event?.data[2].toString())} successfully created!`))
+    }
+
+    // Upload assets
+    await this.uploadAssets(inputAssets, input)
+  }
+}

+ 54 - 0
cli/src/commands/content/createVideoCategory.ts

@@ -0,0 +1,54 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { getInputJson } from '../../helpers/InputOutput'
+import { VideoCategoryInputParameters } from '../../Types'
+import { metadataToBytes, videoCategoryMetadataFromInput } from '../../helpers/serialization'
+import { flags } from '@oclif/command'
+import { CreateInterface } from '@joystream/types'
+import { VideoCategoryCreationParameters } from '@joystream/types/content'
+import { VideoCategoryInputSchema } from '../../json-schemas/ContentDirectory'
+import chalk from 'chalk'
+
+export default class CreateVideoCategoryCommand extends ContentDirectoryCommandBase {
+  static description = 'Create video category inside content directory.'
+  static flags = {
+    context: ContentDirectoryCommandBase.categoriesContextFlag,
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: `Path to JSON file to use as input`,
+    }),
+  }
+
+  async run() {
+    const { context, input } = this.parse(CreateVideoCategoryCommand).flags
+
+    const currentAccount = await this.getRequiredSelectedAccount()
+    await this.requestAccountDecoding(currentAccount)
+
+    const actor = context ? await this.getActor(context) : await this.getCategoryManagementActor()
+
+    const videoCategoryInput = await getInputJson<VideoCategoryInputParameters>(input, VideoCategoryInputSchema)
+
+    const meta = videoCategoryMetadataFromInput(videoCategoryInput)
+
+    const videoCategoryCreationParameters: CreateInterface<VideoCategoryCreationParameters> = {
+      meta: metadataToBytes(meta),
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(videoCategoryInput))
+
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    const result = await this.sendAndFollowNamedTx(currentAccount, 'content', 'createVideoCategory', [
+      actor,
+      videoCategoryCreationParameters,
+    ])
+
+    if (result) {
+      const event = this.findEvent(result, 'content', 'VideoCategoryCreated')
+      this.log(
+        chalk.green(`VideoCategory with id ${chalk.cyanBright(event?.data[1].toString())} successfully created!`)
+      )
+    }
+  }
+}

+ 1 - 6
cli/src/commands/content-directory/curatorGroup.ts → cli/src/commands/content/curatorGroup.ts

@@ -16,9 +16,6 @@ export default class CuratorGroupCommand extends ContentDirectoryCommandBase {
   async run() {
     const { id } = this.parse(CuratorGroupCommand).args
     const group = await this.getCuratorGroup(id)
-    const classesMaintained = (await this.getApi().availableClasses()).filter(([, c]) =>
-      c.class_permissions.maintainers.toArray().some((gId) => gId.toNumber() === parseInt(id))
-    )
     const members = (await this.getApi().groupMembers(WorkingGroups.Curators)).filter((curator) =>
       group.curators.toArray().some((groupCurator) => groupCurator.eq(curator.workerId))
     )
@@ -27,12 +24,10 @@ export default class CuratorGroupCommand extends ContentDirectoryCommandBase {
       'ID': id,
       'Status': group.active.valueOf() ? 'Active' : 'Inactive',
     })
-    displayHeader(`Classes maintained (${classesMaintained.length})`)
-    this.log(classesMaintained.map(([, c]) => chalk.white(c.name.toString())).join(', '))
     displayHeader(`Group Members (${members.length})`)
     this.log(
       members
-        .map((curator) => chalk.white(`${curator.profile.handle} (WorkerID: ${curator.workerId.toString()})`))
+        .map((curator) => chalk.magentaBright(`${curator.profile.handle} (WorkerID: ${curator.workerId.toString()})`))
         .join(', ')
     )
   }

+ 0 - 1
cli/src/commands/content-directory/curatorGroups.ts → cli/src/commands/content/curatorGroups.ts

@@ -13,7 +13,6 @@ export default class CuratorGroupsCommand extends ContentDirectoryCommandBase {
         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

+ 35 - 0
cli/src/commands/content/deleteChannelCategory.ts

@@ -0,0 +1,35 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+
+export default class DeleteChannelCategoryCommand extends ContentDirectoryCommandBase {
+  static description = 'Delete channel category.'
+  static flags = {
+    context: ContentDirectoryCommandBase.categoriesContextFlag,
+  }
+
+  static args = [
+    {
+      name: 'channelCategoryId',
+      required: true,
+      description: 'ID of the Channel Category',
+    },
+  ]
+
+  async run() {
+    const { context } = this.parse(DeleteChannelCategoryCommand).flags
+
+    const { channelCategoryId } = this.parse(DeleteChannelCategoryCommand).args
+
+    const channelCategoryIds = await this.getApi().channelCategoryIds()
+
+    if (channelCategoryIds.some((id) => id.toString() === channelCategoryId)) {
+      const currentAccount = await this.getRequiredSelectedAccount()
+      await this.requestAccountDecoding(currentAccount)
+
+      const actor = context ? await this.getActor(context) : await this.getCategoryManagementActor()
+
+      await this.sendAndFollowNamedTx(currentAccount, 'content', 'deleteChannelCategory', [actor, channelCategoryId])
+    } else {
+      this.error('Channel category under given id does not exist...')
+    }
+  }
+}

+ 35 - 0
cli/src/commands/content/deleteVideoCategory.ts

@@ -0,0 +1,35 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+
+export default class DeleteVideoCategoryCommand extends ContentDirectoryCommandBase {
+  static description = 'Delete video category.'
+  static flags = {
+    context: ContentDirectoryCommandBase.categoriesContextFlag,
+  }
+
+  static args = [
+    {
+      name: 'videoCategoryId',
+      required: true,
+      description: 'ID of the Video Category',
+    },
+  ]
+
+  async run() {
+    const { context } = this.parse(DeleteVideoCategoryCommand).flags
+
+    const { videoCategoryId } = this.parse(DeleteVideoCategoryCommand).args
+
+    const videoCategoryIds = await this.getApi().videoCategoryIds()
+
+    if (videoCategoryIds.some((id) => id.toString() === videoCategoryId)) {
+      const currentAccount = await this.getRequiredSelectedAccount()
+      await this.requestAccountDecoding(currentAccount)
+
+      const actor = context ? await this.getActor(context) : await this.getCategoryManagementActor()
+
+      await this.sendAndFollowNamedTx(currentAccount, 'content', 'deleteVideoCategory', [actor, videoCategoryId])
+    } else {
+      this.error('Video category under given id does not exist...')
+    }
+  }
+}

+ 7 - 3
cli/src/commands/content-directory/removeCuratorFromGroup.ts → cli/src/commands/content/removeCuratorFromGroup.ts

@@ -33,14 +33,18 @@ export default class RemoveCuratorFromGroupCommand extends ContentDirectoryComma
       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)}`)
+        this.error(`Curator ${chalk.magentaBright(curatorId)} is not part of group ${chalk.magentaBright(groupId)}`)
       }
       await this.getCurator(curatorId)
     }
 
     await this.requestAccountDecoding(account)
-    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'removeCuratorFromGroup', [groupId, curatorId])
+    await this.sendAndFollowNamedTx(account, 'content', 'removeCuratorFromGroup', [groupId, curatorId])
 
-    this.log(chalk.green(`Curator ${chalk.white(curatorId)} successfully removed from group ${chalk.white(groupId)}!`))
+    this.log(
+      chalk.green(
+        `Curator ${chalk.magentaBright(curatorId)} successfully removed from group ${chalk.magentaBright(groupId)}!`
+      )
+    )
   }
 }

+ 36 - 0
cli/src/commands/content/reuploadAssets.ts

@@ -0,0 +1,36 @@
+import UploadCommandBase from '../../base/UploadCommandBase'
+import { getInputJson } from '../../helpers/InputOutput'
+import AssetsSchema from '../../json-schemas/Assets.schema.json'
+import { Assets as AssetsInput } from '../../json-schemas/typings/Assets.schema'
+import { flags } from '@oclif/command'
+import { ContentId } from '@joystream/types/storage'
+
+export default class ReuploadVideoAssetsCommand extends UploadCommandBase {
+  static description = 'Allows reuploading assets that were not successfully uploaded during channel/video creation'
+
+  static flags = {
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: 'Path to JSON file containing array of assets to reupload (contentIds and paths)',
+    }),
+  }
+
+  async run() {
+    const { input } = this.parse(ReuploadVideoAssetsCommand).flags
+
+    // Get context
+    const account = await this.getRequiredSelectedAccount()
+    await this.requestAccountDecoding(account)
+
+    // Get input from file
+    const inputData = await getInputJson<AssetsInput>(input, AssetsSchema)
+    const inputAssets = inputData.map(({ contentId, path }) => ({
+      contentId: ContentId.decode(this.getTypesRegistry(), contentId),
+      path,
+    }))
+
+    // Upload assets
+    await this.uploadAssets(inputAssets, input, '')
+  }
+}

+ 2 - 2
cli/src/commands/content-directory/setCuratorGroupStatus.ts → cli/src/commands/content/setCuratorGroupStatus.ts

@@ -48,11 +48,11 @@ export default class SetCuratorGroupStatusCommand extends ContentDirectoryComman
     }
 
     await this.requestAccountDecoding(account)
-    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'setCuratorGroupStatus', [id, status])
+    await this.sendAndFollowNamedTx(account, 'content', 'setCuratorGroupStatus', [id, status])
 
     console.log(
       chalk.green(
-        `Curator Group ${chalk.white(id)} status succesfully changed to: ${chalk.white(
+        `Curator Group ${chalk.magentaBright(id)} status successfully changed to: ${chalk.magentaBright(
           status ? 'Active' : 'Inactive'
         )}!`
       )

+ 27 - 0
cli/src/commands/content/setFeaturedVideos.ts

@@ -0,0 +1,27 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+
+export default class SetFeaturedVideosCommand extends ContentDirectoryCommandBase {
+  static description = 'Set featured videos. Requires lead access.'
+
+  static args = [
+    {
+      name: 'featuredVideoIds',
+      required: true,
+      description: 'Comma-separated video IDs (ie. 1,2,3)',
+    },
+  ]
+
+  async run() {
+    const { featuredVideoIds } = this.parse(SetFeaturedVideosCommand).args
+
+    const currentAccount = await this.getRequiredSelectedAccount()
+    await this.requestAccountDecoding(currentAccount)
+
+    const actor = await this.getActor('Lead')
+
+    await this.sendAndFollowNamedTx(currentAccount, 'content', 'setFeaturedVideos', [
+      actor,
+      (featuredVideoIds as string).split(','),
+    ])
+  }
+}

+ 87 - 0
cli/src/commands/content/updateChannel.ts

@@ -0,0 +1,87 @@
+import { getInputJson } from '../../helpers/InputOutput'
+import { channelMetadataFromInput, metadataToBytes } from '../../helpers/serialization'
+import { ChannelInputParameters } from '../../Types'
+import { flags } from '@oclif/command'
+import UploadCommandBase from '../../base/UploadCommandBase'
+import { CreateInterface } from '@joystream/types'
+import { ChannelUpdateParameters } from '@joystream/types/content'
+import { ChannelInputSchema } from '../../json-schemas/ContentDirectory'
+
+export default class UpdateChannelCommand extends UploadCommandBase {
+  static description = 'Update existing content directory channel.'
+  static flags = {
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: `Path to JSON file to use as input`,
+    }),
+  }
+
+  static args = [
+    {
+      name: 'channelId',
+      required: true,
+      description: 'ID of the Channel',
+    },
+  ]
+
+  parseRewardAccountInput(rewardAccount?: string | null): string | null | Uint8Array {
+    if (rewardAccount === undefined) {
+      // Reward account remains unchanged
+      return null
+    } else if (rewardAccount === null) {
+      // Reward account changed to empty
+      return new Uint8Array([1, 0])
+    } else {
+      // Reward account set to new account
+      return rewardAccount
+    }
+  }
+
+  async run() {
+    const {
+      flags: { input },
+      args: { channelId },
+    } = this.parse(UpdateChannelCommand)
+
+    // Context
+    const currentAccount = await this.getRequiredSelectedAccount()
+    const channel = await this.getApi().channelById(channelId)
+    const actor = await this.getChannelOwnerActor(channel)
+    await this.requestAccountDecoding(currentAccount)
+
+    const channelInput = await getInputJson<ChannelInputParameters>(input, ChannelInputSchema)
+
+    const meta = channelMetadataFromInput(channelInput)
+
+    const { coverPhotoPath, avatarPhotoPath, rewardAccount } = channelInput
+    const inputPaths = [coverPhotoPath, avatarPhotoPath].filter((p) => p !== undefined) as string[]
+    const inputAssets = await this.prepareInputAssets(inputPaths, input)
+    const assets = inputAssets.map(({ parameters }) => ({ Upload: parameters }))
+    // Set assets indexes in the metadata
+    if (coverPhotoPath) {
+      meta.setCoverPhoto(0)
+    }
+    if (avatarPhotoPath) {
+      meta.setAvatarPhoto(coverPhotoPath ? 1 : 0)
+    }
+
+    const channelUpdateParameters: CreateInterface<ChannelUpdateParameters> = {
+      assets,
+      new_meta: metadataToBytes(meta),
+      reward_account: this.parseRewardAccountInput(rewardAccount),
+    }
+
+    this.jsonPrettyPrint(JSON.stringify({ assets, metadata: meta.toObject(), rewardAccount }))
+
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowNamedTx(currentAccount, 'content', 'updateChannel', [
+      actor,
+      channelId,
+      channelUpdateParameters,
+    ])
+
+    await this.uploadAssets(inputAssets, input)
+  }
+}

+ 56 - 0
cli/src/commands/content/updateChannelCategory.ts

@@ -0,0 +1,56 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { getInputJson } from '../../helpers/InputOutput'
+import { ChannelCategoryInputParameters } from '../../Types'
+import { channelCategoryMetadataFromInput, metadataToBytes } from '../../helpers/serialization'
+import { CreateInterface } from '@joystream/types'
+import { ChannelCategoryUpdateParameters } from '@joystream/types/content'
+import { flags } from '@oclif/command'
+import { ChannelCategoryInputSchema } from '../../json-schemas/ContentDirectory'
+export default class UpdateChannelCategoryCommand extends ContentDirectoryCommandBase {
+  static description = 'Update channel category inside content directory.'
+  static flags = {
+    context: ContentDirectoryCommandBase.categoriesContextFlag,
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: `Path to JSON file to use as input`,
+    }),
+  }
+
+  static args = [
+    {
+      name: 'channelCategoryId',
+      required: true,
+      description: 'ID of the Channel Category',
+    },
+  ]
+
+  async run() {
+    const { context, input } = this.parse(UpdateChannelCategoryCommand).flags
+
+    const { channelCategoryId } = this.parse(UpdateChannelCategoryCommand).args
+
+    const currentAccount = await this.getRequiredSelectedAccount()
+    await this.requestAccountDecoding(currentAccount)
+
+    const actor = context ? await this.getActor(context) : await this.getCategoryManagementActor()
+
+    const channelCategoryInput = await getInputJson<ChannelCategoryInputParameters>(input, ChannelCategoryInputSchema)
+
+    const meta = channelCategoryMetadataFromInput(channelCategoryInput)
+
+    const channelCategoryUpdateParameters: CreateInterface<ChannelCategoryUpdateParameters> = {
+      new_meta: metadataToBytes(meta),
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(channelCategoryInput))
+
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowNamedTx(currentAccount, 'content', 'updateChannelCategory', [
+      actor,
+      channelCategoryId,
+      channelCategoryUpdateParameters,
+    ])
+  }
+}

+ 79 - 0
cli/src/commands/content/updateChannelCensorshipStatus.ts

@@ -0,0 +1,79 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+import ExitCodes from '../../ExitCodes'
+import { flags } from '@oclif/command'
+
+export default class UpdateChannelCensorshipStatusCommand extends ContentDirectoryCommandBase {
+  static description = 'Update Channel censorship status (Censored / Not censored).'
+  static flags = {
+    rationale: flags.string({
+      name: 'rationale',
+      required: false,
+      description: 'rationale',
+    }),
+  }
+
+  static args = [
+    {
+      name: 'id',
+      required: true,
+      description: 'ID of the Channel',
+    },
+    {
+      name: 'status',
+      required: false,
+      description: 'New censorship status of the channel (1 - censored, 0 - not censored)',
+    },
+  ]
+
+  async run() {
+    let {
+      args: { id, status },
+      flags: { rationale },
+    } = this.parse(UpdateChannelCensorshipStatusCommand)
+
+    const currentAccount = await this.getRequiredSelectedAccount()
+
+    const channel = await this.getApi().channelById(id)
+    const actor = await this.getCurationActorByChannel(channel)
+
+    await this.requestAccountDecoding(currentAccount)
+
+    if (status === undefined) {
+      status = await this.simplePrompt({
+        type: 'list',
+        message: 'Select new status',
+        choices: [
+          { name: 'Censored', value: true },
+          { name: 'Not censored', value: false },
+        ],
+      })
+    } else {
+      if (status !== '0' && status !== '1') {
+        this.error('Invalid status provided. Use "1" for censored and "0" for not censored.', {
+          exit: ExitCodes.InvalidInput,
+        })
+      }
+      status = !!parseInt(status)
+    }
+
+    if (rationale === undefined) {
+      rationale = await this.simplePrompt({ message: 'Please provide the rationale for updating the status' })
+    }
+
+    await this.sendAndFollowNamedTx(currentAccount, 'content', 'updateChannelCensorshipStatus', [
+      actor,
+      id,
+      status,
+      rationale,
+    ])
+
+    console.log(
+      chalk.green(
+        `Channel ${chalk.magentaBright(id)} censorship status successfully changed to: ${chalk.magentaBright(
+          status ? 'Censored' : 'Not censored'
+        )}!`
+      )
+    )
+  }
+}

+ 69 - 0
cli/src/commands/content/updateVideo.ts

@@ -0,0 +1,69 @@
+import { getInputJson } from '../../helpers/InputOutput'
+import { VideoInputParameters } from '../../Types'
+import { metadataToBytes, videoMetadataFromInput } from '../../helpers/serialization'
+import UploadCommandBase from '../../base/UploadCommandBase'
+import { flags } from '@oclif/command'
+import { CreateInterface } from '@joystream/types'
+import { VideoUpdateParameters } from '@joystream/types/content'
+import { VideoInputSchema } from '../../json-schemas/ContentDirectory'
+
+export default class UpdateVideoCommand extends UploadCommandBase {
+  static description = 'Update video under specific id.'
+  static flags = {
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: `Path to JSON file to use as input`,
+    }),
+  }
+
+  static args = [
+    {
+      name: 'videoId',
+      required: true,
+      description: 'ID of the Video',
+    },
+  ]
+
+  async run() {
+    const {
+      flags: { input },
+      args: { videoId },
+    } = this.parse(UpdateVideoCommand)
+
+    // Context
+    const currentAccount = await this.getRequiredSelectedAccount()
+    const video = await this.getApi().videoById(videoId)
+    const channel = await this.getApi().channelById(video.in_channel.toNumber())
+    const actor = await this.getChannelOwnerActor(channel)
+    await this.requestAccountDecoding(currentAccount)
+
+    const videoInput = await getInputJson<VideoInputParameters>(input, VideoInputSchema)
+
+    const meta = videoMetadataFromInput(videoInput)
+    const { videoPath, thumbnailPhotoPath } = videoInput
+    const inputPaths = [videoPath, thumbnailPhotoPath].filter((p) => p !== undefined) as string[]
+    const inputAssets = await this.prepareInputAssets(inputPaths, input)
+    const assets = inputAssets.map(({ parameters }) => ({ Upload: parameters }))
+    // Set assets indexes in the metadata
+    if (videoPath) {
+      meta.setVideo(0)
+    }
+    if (thumbnailPhotoPath) {
+      meta.setThumbnailPhoto(videoPath ? 1 : 0)
+    }
+
+    const videoUpdateParameters: CreateInterface<VideoUpdateParameters> = {
+      assets,
+      new_meta: metadataToBytes(meta),
+    }
+
+    this.jsonPrettyPrint(JSON.stringify({ assets, newMetadata: meta.toObject() }))
+
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowNamedTx(currentAccount, 'content', 'updateVideo', [actor, videoId, videoUpdateParameters])
+
+    await this.uploadAssets(inputAssets, input)
+  }
+}

+ 57 - 0
cli/src/commands/content/updateVideoCategory.ts

@@ -0,0 +1,57 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { getInputJson } from '../../helpers/InputOutput'
+import { VideoCategoryInputParameters } from '../../Types'
+import { metadataToBytes, videoCategoryMetadataFromInput } from '../../helpers/serialization'
+import { flags } from '@oclif/command'
+import { CreateInterface } from '@joystream/types'
+import { VideoCategoryUpdateParameters } from '@joystream/types/content'
+import { VideoCategoryInputSchema } from '../../json-schemas/ContentDirectory'
+
+export default class UpdateVideoCategoryCommand extends ContentDirectoryCommandBase {
+  static description = 'Update video category inside content directory.'
+  static flags = {
+    context: ContentDirectoryCommandBase.categoriesContextFlag,
+    input: flags.string({
+      char: 'i',
+      required: true,
+      description: `Path to JSON file to use as input`,
+    }),
+  }
+
+  static args = [
+    {
+      name: 'videoCategoryId',
+      required: true,
+      description: 'ID of the Video Category',
+    },
+  ]
+
+  async run() {
+    const { context, input } = this.parse(UpdateVideoCategoryCommand).flags
+
+    const { videoCategoryId } = this.parse(UpdateVideoCategoryCommand).args
+
+    const currentAccount = await this.getRequiredSelectedAccount()
+    await this.requestAccountDecoding(currentAccount)
+
+    const actor = context ? await this.getActor(context) : await this.getCategoryManagementActor()
+
+    const videoCategoryInput = await getInputJson<VideoCategoryInputParameters>(input, VideoCategoryInputSchema)
+
+    const meta = videoCategoryMetadataFromInput(videoCategoryInput)
+
+    const videoCategoryUpdateParameters: CreateInterface<VideoCategoryUpdateParameters> = {
+      new_meta: metadataToBytes(meta),
+    }
+
+    this.jsonPrettyPrint(JSON.stringify(videoCategoryInput))
+
+    await this.requireConfirmation('Do you confirm the provided input?', true)
+
+    await this.sendAndFollowNamedTx(currentAccount, 'content', 'updateVideoCategory', [
+      actor,
+      videoCategoryId,
+      videoCategoryUpdateParameters,
+    ])
+  }
+}

+ 80 - 0
cli/src/commands/content/updateVideoCensorshipStatus.ts

@@ -0,0 +1,80 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import chalk from 'chalk'
+import ExitCodes from '../../ExitCodes'
+import { flags } from '@oclif/command'
+
+export default class UpdateVideoCensorshipStatusCommand extends ContentDirectoryCommandBase {
+  static description = 'Update Video censorship status (Censored / Not censored).'
+  static flags = {
+    rationale: flags.string({
+      name: 'rationale',
+      required: false,
+      description: 'rationale',
+    }),
+  }
+
+  static args = [
+    {
+      name: 'id',
+      required: true,
+      description: 'ID of the Video',
+    },
+    {
+      name: 'status',
+      required: false,
+      description: 'New video censorship status (1 - censored, 0 - not censored)',
+    },
+  ]
+
+  async run() {
+    let {
+      args: { id, status },
+      flags: { rationale },
+    } = this.parse(UpdateVideoCensorshipStatusCommand)
+
+    const currentAccount = await this.getRequiredSelectedAccount()
+
+    const video = await this.getApi().videoById(id)
+    const channel = await this.getApi().channelById(video.in_channel.toNumber())
+    const actor = await this.getCurationActorByChannel(channel)
+
+    await this.requestAccountDecoding(currentAccount)
+
+    if (status === undefined) {
+      status = await this.simplePrompt({
+        type: 'list',
+        message: 'Select new status',
+        choices: [
+          { name: 'Censored', value: true },
+          { name: 'Not censored', value: false },
+        ],
+      })
+    } else {
+      if (status !== '0' && status !== '1') {
+        this.error('Invalid status provided. Use "1" for Censored and "0" for Not censored.', {
+          exit: ExitCodes.InvalidInput,
+        })
+      }
+      status = !!parseInt(status)
+    }
+
+    if (rationale === undefined) {
+      rationale = await this.simplePrompt({ message: 'Please provide the rationale for updating the status' })
+    }
+
+    await this.sendAndFollowNamedTx(currentAccount, 'content', 'updateVideoCensorshipStatus', [
+      actor,
+      id,
+      status,
+      rationale,
+    ])
+
+    console.log(
+      chalk.green(
+        `Video ${chalk.magentaBright(id)} censorship status successfully changed to: ${chalk.magentaBright(
+          status ? 'Censored' : 'Not censored'
+        )}!`
+      )
+    )
+  }
+}

+ 28 - 0
cli/src/commands/content/video.ts

@@ -0,0 +1,28 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { displayCollapsedRow } from '../../helpers/display'
+
+export default class VideoCommand extends ContentDirectoryCommandBase {
+  static description = 'Show Video details by id.'
+  static args = [
+    {
+      name: 'videoId',
+      required: true,
+      description: 'ID of the Video',
+    },
+  ]
+
+  async run() {
+    const { videoId } = this.parse(VideoCommand).args
+    const aVideo = await this.getApi().videoById(videoId)
+    if (aVideo) {
+      displayCollapsedRow({
+        'ID': videoId.toString(),
+        'InChannel': aVideo.in_channel.toString(),
+        'InSeries': aVideo.in_series.toString(),
+        'IsCensored': aVideo.is_censored.toString(),
+      })
+    } else {
+      this.error(`Video not found by channel id: "${videoId}"!`)
+    }
+  }
+}

+ 40 - 0
cli/src/commands/content/videos.ts

@@ -0,0 +1,40 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { Video, VideoId } from '@joystream/types/content'
+import { displayTable } from '../../helpers/display'
+
+export default class VideosCommand extends ContentDirectoryCommandBase {
+  static description = 'List existing content directory videos.'
+
+  static args = [
+    {
+      name: 'channelId',
+      required: false,
+      description: 'ID of the Channel',
+    },
+  ]
+
+  async run() {
+    const { channelId } = this.parse(VideosCommand).args
+
+    let videos: [VideoId, Video][]
+    if (channelId) {
+      videos = await this.getApi().videosByChannelId(channelId)
+    } else {
+      videos = await this.getApi().availableVideos()
+    }
+
+    if (videos.length > 0) {
+      displayTable(
+        videos.map(([id, v]) => ({
+          'ID': id.toString(),
+          'InChannel': v.in_channel.toString(),
+          'InSeries': v.in_series.toString(),
+          'IsCensored': v.is_censored.toString(),
+        })),
+        3
+      )
+    } else {
+      this.log(`There are no videos${channelId ? ' in this channel' : ''} yet`)
+    }
+  }
+}

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

@@ -1,81 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import ChannelEntitySchema from '@joystream/cd-schemas/schemas/entities/ChannelEntity.schema.json'
-import { ChannelEntity } from '@joystream/cd-schemas/types/entities/ChannelEntity'
-import { InputParser } from '@joystream/cd-schemas'
-import { IOFlags, getInputJson, saveOutputJson } from '../../helpers/InputOutput'
-import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
-import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
-import { cli } from 'cli-ux'
-
-import { flags } from '@oclif/command'
-import _ from 'lodash'
-
-export default class CreateChannelCommand extends ContentDirectoryCommandBase {
-  static description = 'Create a new channel on Joystream (requires a membership).'
-  static flags = {
-    ...IOFlags,
-    confirm: flags.boolean({ char: 'y', name: 'confirm', required: false, description: 'Confirm the provided input' }),
-  }
-
-  async getExistingChannelHandles(): Promise<string[]> {
-    cli.action.start('Fetching chain data...')
-    const result = await Promise.all(
-      (await this.entitiesByClassAndOwner('Channel'))
-        .filter(([, c]) => c.supported_schemas.toArray().length)
-        .map(async ([, channel]) => {
-          const { handle } = await this.parseToEntityJson<ChannelEntity>(channel)
-          return handle
-        })
-    )
-    cli.action.stop()
-
-    return result
-  }
-
-  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, confirm } = this.parse(CreateChannelCommand).flags
-
-    // Can potentially slow things down quite a bit
-    const existingHandles = await this.getExistingChannelHandles()
-
-    let inputJson = await getInputJson<ChannelEntity>(input, channelJsonSchema)
-    if (!inputJson) {
-      const customPrompts: JsonSchemaCustomPrompts = [
-        [
-          'handle',
-          { validate: (h) => (existingHandles.includes(h) ? 'Channel with such handle already exists' : true) },
-        ],
-        ['language', () => this.promptForEntityId('Choose channel language', 'Language', 'name')],
-        ['isCensored', 'skip'],
-      ]
-
-      const prompter = new JsonSchemaPrompter<ChannelEntity>(channelJsonSchema, undefined, customPrompts)
-
-      inputJson = await prompter.promptAll()
-    }
-
-    this.jsonPrettyPrint(JSON.stringify(inputJson))
-    const confirmed =
-      confirm || (await this.simplePrompt({ type: 'confirm', message: 'Do you confirm the provided input?' }))
-
-    if (confirmed) {
-      saveOutputJson(output, `${_.startCase(inputJson.handle)}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])
-    }
-  }
-}

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

@@ -1,57 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import { InputParser } from '@joystream/cd-schemas'
-import { flags } from '@oclif/command'
-import { ChannelEntity } from '@joystream/cd-schemas/types/entities/ChannelEntity'
-import { VideoEntity } from '@joystream/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)
-    }
-  }
-}

+ 0 - 36
cli/src/commands/media/featuredVideos.ts

@@ -1,36 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import { displayTable } from '../../helpers/display'
-import { FeaturedVideoEntity, VideoEntity } from '@joystream/cd-schemas/types/entities'
-import chalk from 'chalk'
-
-export default class FeaturedVideosCommand extends ContentDirectoryCommandBase {
-  static description = 'Show a list of currently featured videos.'
-
-  async run() {
-    const featuredEntries = await this.entitiesByClassAndOwner('FeaturedVideo')
-    const featured = await Promise.all(
-      featuredEntries
-        .filter(([, entity]) => entity.supported_schemas.toArray().length) // Ignore FeaturedVideo entities without schema
-        .map(([, entity]) => this.parseToEntityJson<FeaturedVideoEntity>(entity))
-    )
-
-    const videoIds: number[] = featured.map(({ video: videoId }) => videoId)
-
-    const videos = await Promise.all(videoIds.map((videoId) => this.getAndParseKnownEntity<VideoEntity>(videoId)))
-
-    if (videos.length) {
-      displayTable(
-        videos.map(({ title, channel }, index) => ({
-          featuredVideoEntityId: featuredEntries[index][0].toNumber(),
-          videoId: videoIds[index],
-          channelId: channel,
-          title,
-        })),
-        3
-      )
-      this.log(`\nTIP: Use ${chalk.bold('content-directory:entity ID')} command to see more details about given video`)
-    } else {
-      this.log(`No videos have been featured yet! Set some with ${chalk.bold('media:setFeaturedVideos')}`)
-    }
-  }
-}

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

@@ -1,25 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import { ChannelEntity } from '@joystream/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)[] = ['handle', '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')}`)
-    }
-  }
-}

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

@@ -1,33 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import { VideoEntity } from '@joystream/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')}`)
-    }
-  }
-}

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

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

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

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

+ 0 - 79
cli/src/commands/media/setFeaturedVideos.ts

@@ -1,79 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import { VideoEntity } from '@joystream/cd-schemas/types/entities'
-import { InputParser, ExtrinsicsHelper } from '@joystream/cd-schemas'
-import { FlattenRelations } from '@joystream/cd-schemas/types/utility'
-import { flags } from '@oclif/command'
-import { createType } from '@joystream/types'
-
-export default class SetFeaturedVideosCommand extends ContentDirectoryCommandBase {
-  static description = 'Set currently featured videos (requires lead/maintainer access).'
-  static args = [
-    {
-      name: 'videoIds',
-      required: true,
-      description: 'Comma-separated video ids',
-    },
-  ]
-
-  static flags = {
-    add: flags.boolean({
-      description: 'If provided - currently featured videos will not be removed.',
-      required: false,
-    }),
-  }
-
-  async run() {
-    const account = await this.getRequiredSelectedAccount()
-    let actor = createType('Actor', { Lead: null })
-    try {
-      await this.getRequiredLead()
-    } catch (e) {
-      actor = await this.getCuratorContext(['FeaturedVideo'])
-    }
-
-    await this.requestAccountDecoding(account)
-
-    const {
-      args: { videoIds },
-      flags: { add },
-    } = this.parse(SetFeaturedVideosCommand)
-
-    const ids: number[] = videoIds.split(',').map((id: string) => parseInt(id))
-
-    const videos: [number, FlattenRelations<VideoEntity>][] = (
-      await Promise.all(ids.map((id) => this.getAndParseKnownEntity<VideoEntity>(id, 'Video')))
-    ).map((video, index) => [ids[index], video])
-
-    this.log(
-      `Featured videos that will ${add ? 'be added to' : 'replace'} existing ones:`,
-      videos.map(([id, { title }]) => ({ id, title }))
-    )
-
-    await this.requireConfirmation('Do you confirm the provided input?')
-
-    if (!add) {
-      const currentlyFeaturedIds = (await this.entitiesByClassAndOwner('FeaturedVideo')).map(([id]) => id.toNumber())
-      const removeTxs = currentlyFeaturedIds.map((id) =>
-        this.getOriginalApi().tx.contentDirectory.removeEntity(actor, id)
-      )
-
-      if (currentlyFeaturedIds.length) {
-        this.log(`Removing existing FeaturedVideo entities (${currentlyFeaturedIds.join(', ')})...`)
-
-        const txHelper = new ExtrinsicsHelper(this.getOriginalApi())
-        await txHelper.sendAndCheck(account, removeTxs, 'The removal of existing FeaturedVideo entities failed')
-      }
-    }
-
-    this.log('Adding new FeaturedVideo entities...')
-    const featuredVideoEntries = videos.map(([id]) => ({ video: id }))
-    const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi(), [
-      {
-        className: 'FeaturedVideo',
-        entries: featuredVideoEntries,
-      },
-    ])
-    const operations = await inputParser.getEntityBatchOperations()
-    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'transaction', [actor, operations])
-  }
-}

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

@@ -1,98 +0,0 @@
-import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
-import ChannelEntitySchema from '@joystream/cd-schemas/schemas/entities/ChannelEntity.schema.json'
-import { ChannelEntity } from '@joystream/cd-schemas/types/entities/ChannelEntity'
-import { InputParser } from '@joystream/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'
-import _ from 'lodash'
-
-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', 'handle', memberId)
-      channelId = id.toNumber()
-      channelEntity = channel
-    }
-
-    const currentValues = await this.parseToEntityJson<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', 'skip'])
-      }
-
-      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, `${_.startCase(inputJson.handle)}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])
-    }
-  }
-}

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

@@ -1,106 +0,0 @@
-import VideoEntitySchema from '@joystream/cd-schemas/schemas/entities/VideoEntity.schema.json'
-import { VideoEntity } from '@joystream/cd-schemas/types/entities/VideoEntity'
-import { InputParser } from '@joystream/cd-schemas'
-import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
-import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
-import { Actor, Entity } from '@joystream/types/content-directory'
-import { createType } from '@joystream/types'
-import { flags } from '@oclif/command'
-import MediaCommandBase from '../../base/MediaCommandBase'
-
-export default class UpdateVideoCommand extends MediaCommandBase {
-  static description = 'Update existing video information (requires controller/maintainer access).'
-  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'])
-    } 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.parseToEntityJson<VideoEntity>(videoEntity)
-    const videoJsonSchema = (VideoEntitySchema as unknown) as JSONSchema
-
-    const {
-      language: currLanguageId,
-      category: currCategoryId,
-      publishedBeforeJoystream: currPublishedBeforeJoystream,
-    } = 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),
-      ],
-      ['publishedBeforeJoystream', () => this.promptForPublishedBeforeJoystream(currPublishedBeforeJoystream)],
-    ]
-    const videoPrompter = new JsonSchemaPrompter<VideoEntity>(videoJsonSchema, currentValues, customizedPrompts)
-
-    // Prompt for other video data
-    const updatedProps: Partial<VideoEntity> = await videoPrompter.promptMultipleProps([
-      'language',
-      'category',
-      'title',
-      'description',
-      'thumbnailUrl',
-      'duration',
-      'isPublic',
-      'isExplicit',
-      'hasMarketing',
-      'publishedBeforeJoystream',
-      'skippableIntroDuration',
-    ])
-
-    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)
-    await this.sendAndFollowNamedTx(account, 'contentDirectory', 'transaction', [actor, videoUpdateOperations], true)
-  }
-}

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

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

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

@@ -1,441 +0,0 @@
-import VideoEntitySchema from '@joystream/cd-schemas/schemas/entities/VideoEntity.schema.json'
-import VideoMediaEntitySchema from '@joystream/cd-schemas/schemas/entities/VideoMediaEntity.schema.json'
-import { VideoEntity } from '@joystream/cd-schemas/types/entities/VideoEntity'
-import { VideoMediaEntity } from '@joystream/cd-schemas/types/entities/VideoMediaEntity'
-import { InputParser } from '@joystream/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 ffprobeInstaller from '@ffprobe-installer/ffprobe'
-import ffmpeg from 'fluent-ffmpeg'
-import MediaCommandBase from '../../base/MediaCommandBase'
-import { getInputJson, validateInput, IOFlags } from '../../helpers/InputOutput'
-
-ffmpeg.setFfprobePath(ffprobeInstaller.path)
-
-const DATA_OBJECT_TYPE_ID = 1
-const MAX_FILE_SIZE = 2000 * 1024 * 1024
-
-type VideoMetadata = {
-  width?: number
-  height?: number
-  codecName?: string
-  codecFullName?: string
-  duration?: number
-}
-
-export default class UploadVideoCommand extends MediaCommandBase {
-  static description = 'Upload a new Video to a channel (requires a membership).'
-  static flags = {
-    input: IOFlags.input,
-    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)',
-    }),
-    confirm: flags.boolean({ char: 'y', name: 'confirm', required: false, description: 'Confirm the provided input' }),
-  }
-
-  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,
-        maxBodyLength: 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,
-      })
-    }
-  }
-
-  private async promptForVideoInput(
-    channelId: number,
-    fileSize: number,
-    contentId: ContentId,
-    videoMetadata: VideoMetadata | null
-  ) {
-    // Set the defaults
-    const videoMediaDefaults: Partial<VideoMediaEntity> = {
-      pixelWidth: videoMetadata?.width,
-      pixelHeight: videoMetadata?.height,
-    }
-    const videoDefaults: Partial<VideoEntity> = {
-      duration: videoMetadata?.duration,
-      skippableIntroDuration: 0,
-    }
-
-    // Prompt for data
-    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 videoProps = await videoPrompter.promptMultipleProps([
-      'title',
-      'description',
-      'thumbnailUrl',
-      'duration',
-      'isPublic',
-      'isExplicit',
-      'hasMarketing',
-      'skippableIntroDuration',
-    ])
-
-    const license = await videoPrompter.promptSingleProp('license', () => this.promptForNewLicense())
-    const publishedBeforeJoystream = await videoPrompter.promptSingleProp('publishedBeforeJoystream', () =>
-      this.promptForPublishedBeforeJoystream()
-    )
-
-    // Create final inputs
-    const videoMediaInput: VideoMediaEntity = {
-      encoding,
-      pixelWidth,
-      pixelHeight,
-      size: fileSize,
-      location: { new: { joystreamMediaLocation: { new: { dataObjectId: contentId.encode() } } } },
-    }
-    return {
-      ...videoProps,
-      channel: channelId,
-      language,
-      category,
-      license,
-      media: { new: videoMediaInput },
-      publishedBeforeJoystream,
-    }
-  }
-
-  private async getVideoInputFromFile(
-    filePath: string,
-    channelId: number,
-    fileSize: number,
-    contentId: ContentId,
-    videoMetadata: VideoMetadata | null
-  ) {
-    let videoInput = await getInputJson<any>(filePath)
-    if (typeof videoInput !== 'object' || videoInput === null) {
-      this.error('Invalid input json - expected an object', { exit: ExitCodes.InvalidInput })
-    }
-    const videoMediaDefaults: Partial<VideoMediaEntity> = {
-      pixelWidth: videoMetadata?.width,
-      pixelHeight: videoMetadata?.height,
-      size: fileSize,
-    }
-    const videoDefaults: Partial<VideoEntity> = {
-      channel: channelId,
-      duration: videoMetadata?.duration,
-    }
-    const inputVideoMedia =
-      videoInput.media && typeof videoInput.media === 'object' && (videoInput.media as any).new
-        ? (videoInput.media as any).new
-        : {}
-    videoInput = {
-      ...videoDefaults,
-      ...videoInput,
-      media: {
-        new: {
-          ...videoMediaDefaults,
-          ...inputVideoMedia,
-          location: { new: { joystreamMediaLocation: { new: { dataObjectId: contentId.encode() } } } },
-        },
-      },
-    }
-
-    const videoJsonSchema = (VideoEntitySchema as unknown) as JSONSchema
-    await validateInput(videoInput, videoJsonSchema)
-
-    return videoInput as VideoEntity
-  }
-
-  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, input, confirm },
-    } = 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',
-        'handle',
-        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)
-
-    // No input, create prompting helpers
-    const videoInput = input
-      ? await this.getVideoInputFromFile(input, channelId, fileSize, contentId, videoMetadata)
-      : await this.promptForVideoInput(channelId, fileSize, contentId, videoMetadata)
-
-    this.jsonPrettyPrint(JSON.stringify(videoInput))
-
-    if (!confirm) {
-      await this.requireConfirmation('Do you confirm the provided input?', true)
-    }
-
-    // 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])
-  }
-}

部分文件因文件數量過多而無法顯示