Browse Source

"media:myVideos", "media:myChannels" + few improvements

Leszek Wiesner 4 years ago
parent
commit
6f006d81e0

+ 22 - 3
cli/src/Api.ts

@@ -32,6 +32,7 @@ import {
   RoleStakeProfile,
   Opening as WGOpening,
   Application as WGApplication,
+  StorageProviderId,
 } from '@joystream/types/working-group'
 import {
   Opening,
@@ -64,6 +65,7 @@ export const apiModuleByGroup: { [key in WorkingGroups]: string } = {
 // Api wrapper for handling most common api calls and allowing easy API implementation switch in the future
 export default class Api {
   private _api: ApiPromise
+  private _cdClassesCache: [ClassId, Class][] | null = null
 
   private constructor(originalApi: ApiPromise) {
     this._api = originalApi
@@ -114,6 +116,10 @@ export default class Api {
     })
   }
 
+  async bestNumber(): Promise<number> {
+    return (await this._api.derive.chain.bestNumber()).toNumber()
+  }
+
   async getAccountsBalancesInfo(accountAddresses: string[]): Promise<DeriveBalancesAll[]> {
     const accountsBalances: DeriveBalancesAll[] = await Promise.all(
       accountAddresses.map((addr) => this._api.derive.balances.all(addr))
@@ -484,8 +490,10 @@ export default class Api {
   }
 
   // Content directory
-  availableClasses(): Promise<[ClassId, Class][]> {
-    return this.entriesByIds<ClassId, Class>(this._api.query.contentDirectory.classById)
+  async availableClasses(useCache = true): Promise<[ClassId, Class][]> {
+    return useCache && this._cdClassesCache
+      ? this._cdClassesCache
+      : await this.entriesByIds<ClassId, Class>(this._api.query.contentDirectory.classById)
   }
 
   availableCuratorGroups(): Promise<[CuratorGroupId, CuratorGroup][]> {
@@ -525,7 +533,9 @@ export default class Api {
     const accountInfo = await this._api.query.discovery.accountInfoByStorageProviderId<ServiceProviderRecord>(
       storageProviderId
     )
-    return accountInfo.isEmpty ? null : accountInfo.identity.toString()
+    return accountInfo.isEmpty || accountInfo.expires_at.toNumber() <= (await this.bestNumber())
+      ? null
+      : accountInfo.identity.toString()
   }
 
   async getRandomBootstrapEndpoint(): Promise<string | null> {
@@ -533,4 +543,13 @@ export default class Api {
     const randomEndpoint = _.sample(endpoints.toArray())
     return randomEndpoint ? randomEndpoint.toString() : null
   }
+
+  async isAnyProviderAvailable(): Promise<boolean> {
+    const accounInfoEntries = await this.entriesByIds<StorageProviderId, ServiceProviderRecord>(
+      this._api.query.discovery.accountInfoByStorageProviderId
+    )
+
+    const bestNumber = await this.bestNumber()
+    return !!accounInfoEntries.filter(([, info]) => info.expires_at.toNumber() > bestNumber)
+  }
 }

+ 1 - 0
cli/src/ExitCodes.ts

@@ -12,5 +12,6 @@ enum ExitCodes {
   FsOperationFailed = 501,
   ApiError = 502,
   ExternalInfrastructureError = 503,
+  ActionCurrentlyUnavailable = 504,
 }
 export = ExitCodes

+ 49 - 7
cli/src/base/ContentDirectoryCommandBase.ts

@@ -131,7 +131,7 @@ export default abstract class ContentDirectoryCommandBase extends AccountsComman
     return group
   }
 
-  async getEntity(id: string | number): Promise<Entity> {
+  async getEntity(id: string | number, requiredClass?: string, ownerMemberId?: number): Promise<Entity> {
     if (typeof id === 'string') {
       id = parseInt(id)
     }
@@ -142,6 +142,23 @@ export default abstract class ContentDirectoryCommandBase extends AccountsComman
       this.error(`Entity not found by id: ${id}`, { exit: ExitCodes.InvalidInput })
     }
 
+    if (requiredClass) {
+      const [classId] = await this.classEntryByNameOrId(requiredClass)
+      if (entity.class_id.toNumber() !== classId.toNumber()) {
+        this.error(`Entity of id ${id} is not of class ${requiredClass}!`, { exit: ExitCodes.InvalidInput })
+      }
+    }
+
+    const { controller } = entity.entity_permissions
+    if (
+      ownerMemberId !== undefined &&
+      (!controller.isOfType('Member') || controller.asType('Member').toNumber() !== ownerMemberId)
+    ) {
+      this.error('Cannot execute this action for specified entity - invalid ownership.', {
+        exit: ExitCodes.AccessDenied,
+      })
+    }
+
     return entity
   }
 
@@ -150,6 +167,18 @@ export default abstract class ContentDirectoryCommandBase extends AccountsComman
     return this.parseToKnownEntityJson<T>(entity)
   }
 
+  async entitiesByClassAndOwner(classNameOrId: number | string, ownerMemberId?: number): Promise<[EntityId, Entity][]> {
+    const classId =
+      typeof classNameOrId === 'number' ? classNameOrId : (await this.classEntryByNameOrId(classNameOrId))[0].toNumber()
+
+    return (await this.getApi().entitiesByClassId(classId)).filter(([, entity]) => {
+      const controller = entity.entity_permissions.controller
+      return ownerMemberId !== undefined
+        ? controller.isOfType('Member') && controller.asType('Member').toNumber() === ownerMemberId
+        : true
+    })
+  }
+
   async promptForEntityEntry(
     message: string,
     className: string,
@@ -158,12 +187,7 @@ export default abstract class ContentDirectoryCommandBase extends AccountsComman
     defaultId?: number
   ): Promise<[EntityId, Entity]> {
     const [classId, entityClass] = await this.classEntryByNameOrId(className)
-    const entityEntries = (await this.getApi().entitiesByClassId(classId.toNumber())).filter(([, entity]) => {
-      const controller = entity.entity_permissions.controller
-      return ownerMemberId !== undefined
-        ? controller.isOfType('Member') && controller.asType('Member').toNumber() === ownerMemberId
-        : true
-    })
+    const entityEntries = await this.entitiesByClassAndOwner(classId.toNumber(), ownerMemberId)
 
     if (!entityEntries.length) {
       this.log(`${message}:`)
@@ -225,4 +249,22 @@ export default abstract class ContentDirectoryCommandBase extends AccountsComman
       v.value.toJSON()
     ) as unknown) as FlattenRelations<T>
   }
+
+  async createEntityList(
+    className: string,
+    includedProps?: string[],
+    filters: [string, string][] = [],
+    ownerMemberId?: number
+  ): Promise<Record<string, string>[]> {
+    const [classId, entityClass] = await this.classEntryByNameOrId(className)
+    const entityEntries = await this.entitiesByClassAndOwner(classId.toNumber(), ownerMemberId)
+    const parsedEntities = (await Promise.all(
+      entityEntries.map(([id, entity]) => ({
+        'ID': id.toString(),
+        ..._.mapValues(this.parseEntityPropertyValues(entity, entityClass, includedProps), (v) => v.value.toString()),
+      }))
+    )) as Record<string, string>[]
+
+    return parsedEntities.filter((entity) => filters.every(([pName, pValue]) => entity[pName] === pValue))
+  }
 }

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

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

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

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

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

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

+ 27 - 11
cli/src/commands/media/updateChannel.ts

@@ -5,26 +5,42 @@ import { InputParser } from 'cd-schemas'
 import { IOFlags, getInputJson, saveOutputJson } from '../../helpers/InputOutput'
 import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
 import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
+import { Entity } from '@joystream/types/content-directory'
 
 export default class UpdateChannelCommand extends ContentDirectoryCommandBase {
   static description = 'Update one of the owned channels on Joystream (requires a membership).'
   static flags = {
     ...IOFlags,
   }
-  // TODO: ChannelId as arg?
+
+  static args = [
+    {
+      name: 'id',
+      description: 'ID of the channel to update',
+      required: false,
+    },
+  ]
 
   async run() {
     const account = await this.getRequiredSelectedAccount()
     const memberId = await this.getRequiredMemberId()
     const actor = { Member: memberId }
 
-    const [channelId, channel] = await this.promptForEntityEntry(
-      'Select a channel to update',
-      'Channel',
-      'title',
-      memberId
-    )
-    const currentValues = await this.parseToKnownEntityJson<ChannelEntity>(channel)
+    await this.requestAccountDecoding(account)
+
+    const { id } = this.parse(UpdateChannelCommand).args
+
+    let channelEntity: Entity, channelId: number
+    if (id) {
+      channelId = parseInt(id)
+      channelEntity = await this.getEntity(channelId, 'Channel', memberId)
+    } else {
+      const [id, channel] = await this.promptForEntityEntry('Select a channel to update', 'Channel', 'title', memberId)
+      channelId = id.toNumber()
+      channelEntity = channel
+    }
+
+    const currentValues = await this.parseToKnownEntityJson<ChannelEntity>(channelEntity)
     this.jsonPrettyPrint(JSON.stringify(currentValues))
 
     const channelJsonSchema = (ChannelEntitySchema as unknown) as JSONSchema
@@ -36,7 +52,8 @@ export default class UpdateChannelCommand extends ContentDirectoryCommandBase {
       const customPrompts: JsonSchemaCustomPrompts = [
         [
           'language',
-          () => this.promptForEntityId('Choose channel language', 'Language', 'name', currentValues.language),
+          () =>
+            this.promptForEntityId('Choose channel language', 'Language', 'name', undefined, currentValues.language),
         ],
         ['curationStatus', async () => undefined],
       ]
@@ -51,8 +68,7 @@ export default class UpdateChannelCommand extends ContentDirectoryCommandBase {
 
     if (confirmed) {
       const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi())
-      const updateOperation = await inputParser.createEntityUpdateOperation(inputJson, 'Channel', channelId.toNumber())
-      await this.requestAccountDecoding(account)
+      const updateOperation = await inputParser.createEntityUpdateOperation(inputJson, 'Channel', channelId)
       this.log('Sending the extrinsic...')
       await this.sendAndFollowNamedTx(account, 'contentDirectory', 'transaction', [actor, [updateOperation]], true)
       saveOutputJson(output, `${inputJson.title}Channel.json`, inputJson)

+ 22 - 8
cli/src/commands/media/updateVideo.ts

@@ -5,14 +5,22 @@ import { LicenseEntity } from 'cd-schemas/types/entities/LicenseEntity'
 import { InputParser } from 'cd-schemas'
 import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
 import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
+import { Entity } from '@joystream/types/content-directory'
 
 export default class UpdateVideoCommand extends ContentDirectoryCommandBase {
   static description = 'Update existing video information (requires a membership).'
-  // TODO: Id as arg
   static flags = {
     // TODO: ...IOFlags, - providing input as json
   }
 
+  static args = [
+    {
+      name: 'id',
+      description: 'ID of the Video to update',
+      required: false,
+    },
+  ]
+
   async run() {
     const account = await this.getRequiredSelectedAccount()
     const memberId = await this.getRequiredMemberId()
@@ -20,9 +28,19 @@ export default class UpdateVideoCommand extends ContentDirectoryCommandBase {
 
     await this.requestAccountDecoding(account)
 
-    const [videoId, video] = await this.promptForEntityEntry('Select a video to update', 'Video', 'title', memberId)
+    const { id } = this.parse(UpdateVideoCommand).args
+
+    let videoEntity: Entity, videoId: number
+    if (id) {
+      videoId = parseInt(id)
+      videoEntity = await this.getEntity(videoId, 'Video', memberId)
+    } else {
+      const [id, video] = await this.promptForEntityEntry('Select a video to update', 'Video', 'title', memberId)
+      videoId = id.toNumber()
+      videoEntity = video
+    }
 
-    const currentValues = await this.parseToKnownEntityJson<VideoEntity>(video)
+    const currentValues = await this.parseToKnownEntityJson<VideoEntity>(videoEntity)
     const videoJsonSchema = (VideoEntitySchema as unknown) as JSONSchema
 
     const { language: currLanguageId, category: currCategoryId, license: currLicenseId } = currentValues
@@ -66,11 +84,7 @@ export default class UpdateVideoCommand extends ContentDirectoryCommandBase {
 
     // Parse inputs into operations and send final extrinsic
     const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi())
-    const videoUpdateOperation = await inputParser.createEntityUpdateOperation(
-      updatedProps,
-      'Video',
-      videoId.toNumber()
-    )
+    const videoUpdateOperation = await inputParser.createEntityUpdateOperation(updatedProps, 'Video', videoId)
     const licenseUpdateOperation = await inputParser.createEntityUpdateOperation(
       updatedLicense,
       'License',

+ 26 - 7
cli/src/commands/media/uploadVideo.ts

@@ -43,6 +43,12 @@ export default class UploadVideoCommand extends ContentDirectoryCommandBase {
       required: true,
       description: 'Path to the media file to upload',
     }),
+    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)',
+    }),
   }
 
   private createReadStreamWithProgressBar(filePath: string, barTitle: string, fileSize?: number) {
@@ -215,7 +221,7 @@ export default class UploadVideoCommand extends ContentDirectoryCommandBase {
 
     await this.requestAccountDecoding(account)
 
-    const { filePath } = this.parse(UploadVideoCommand).flags
+    const { filePath, channel: inputChannelId } = this.parse(UploadVideoCommand).flags
 
     // Basic file validation
     if (!fs.existsSync(filePath)) {
@@ -230,13 +236,26 @@ export default class UploadVideoCommand extends ContentDirectoryCommandBase {
     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
-    const channelId = await this.promptForEntityId(
-      'Select a channel to publish the video under',
-      'Channel',
-      'title',
-      memberId
-    )
+    let channelId: number
+    if (inputChannelId === undefined) {
+      channelId = await this.promptForEntityId(
+        'Select a channel to publish the video under',
+        'Channel',
+        'title',
+        memberId
+      )
+    } else {
+      await this.getEntity(inputChannelId, 'Channel', memberId) // Validates if exists and belongs to member
+      channelId = inputChannelId
+    }
 
     // Calculate hash and create content id
     const contentId = ContentId.generate(this.getTypesRegistry())