Browse Source

Merge branch 'babylon' into query-node/two-db-instances

Mokhtar Naamani 4 years ago
parent
commit
6e7870a673

+ 8 - 1
cli/README.md

@@ -136,7 +136,8 @@ USAGE
   $ joystream-cli account:choose
 
 OPTIONS
-  --showSpecial  Whether to show special (DEV chain) accounts
+  -S, --showSpecial      Whether to show special (DEV chain) accounts
+  -a, --address=address  Select account by address (if available)
 ```
 
 _See code: [src/commands/account/choose.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/account/choose.ts)_
@@ -628,6 +629,8 @@ OPTIONS
 
   -o, --output=output  Path to the directory where the output JSON file should be placed (the output file can be then
                        reused as input)
+
+  -y, --confirm        Confirm the provided input
 ```
 
 _See code: [src/commands/media/createChannel.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/media/createChannel.ts)_
@@ -796,6 +799,10 @@ ARGUMENTS
 OPTIONS
   -c, --channel=channel  ID of the channel to assign the video to (if omitted - one of the owned channels can be
                          selected from the list)
+
+  -i, --input=input      Path to JSON file to use as input (if not specified - the input can be provided interactively)
+
+  -y, --confirm          Confirm the provided input
 ```
 
 _See code: [src/commands/media/uploadVideo.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/media/uploadVideo.ts)_

+ 5 - 2
cli/src/base/AccountsCommandBase.ts

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

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

@@ -11,10 +11,13 @@ import {
   Entity,
   EntityId,
   Actor,
+  PropertyType,
 } from '@joystream/types/content-directory'
 import { Worker } from '@joystream/types/working-group'
 import { CLIError } from '@oclif/errors'
 import { Codec } from '@polkadot/types/types'
+import AbstractInt from '@polkadot/types/codec/AbstractInt'
+import { AnyJson } from '@polkadot/types/types/helpers'
 import _ from 'lodash'
 import { RolesCommandBase } from './WorkingGroupsCommandBase'
 import { createType } from '@joystream/types'
@@ -24,6 +27,8 @@ import { flags } from '@oclif/command'
 const CONTEXTS = ['Member', 'Curator', 'Lead'] as const
 type Context = typeof CONTEXTS[number]
 
+type ParsedPropertyValue = { value: Codec | null; type: PropertyType['type']; subtype: PropertyType['subtype'] }
+
 /**
  * Abstract base class for commands related to content directory
  */
@@ -278,7 +283,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
       choices: entityEntries.map(([id, entity]) => {
         const parsedEntityPropertyValues = this.parseEntityPropertyValues(entity, entityClass)
         return {
-          name: (propName && parsedEntityPropertyValues[propName]?.value.toString()) || `ID:${id.toString()}`,
+          name: (propName && parsedEntityPropertyValues[propName]?.value?.toString()) || `ID:${id.toString()}`,
           value: id.toString(), // With numbers there are issues with "default"
         }
       }),
@@ -298,31 +303,46 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     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, { value: Codec; type: 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] = {
-          value: propValue.getValue(),
-          type: `${prop.property_type.type}<${prop.property_type.subtype}>`,
+          // 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, { value: Codec; type: string }>)
+    }, {} as Record<string, ParsedPropertyValue>)
   }
 
   async parseToKnownEntityJson<T>(entity: Entity): Promise<FlattenRelations<T>> {
     const entityClass = (await this.classEntryByNameOrId(entity.class_id.toString()))[1]
     return (_.mapValues(this.parseEntityPropertyValues(entity, entityClass), (v) =>
-      v.type !== 'Single<Bool>' && v.value.toJSON() === false ? null : v.value.toJSON()
+      this.parseStoredPropertyInnerValue(v.value)
     ) as unknown) as FlattenRelations<T>
   }
 
@@ -349,7 +369,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
         'ID': id.toString(),
         ...defaultValues,
         ..._.mapValues(this.parseEntityPropertyValues(entity, entityClass, includedProps), (v) =>
-          v.value.toJSON() === false && v.type !== 'Single<Bool>' ? chalk.grey('[not set]') : v.value.toString()
+          v.value === null ? chalk.grey('[not set]') : v.value.toString()
         ),
       }))
     )) as Record<string, string>[]

+ 10 - 5
cli/src/base/MediaCommandBase.ts

@@ -1,5 +1,5 @@
 import ContentDirectoryCommandBase from './ContentDirectoryCommandBase'
-import { VideoEntity } from '@joystream/cd-schemas/types/entities'
+import { VideoEntity, KnownLicenseEntity, LicenseEntity } from '@joystream/cd-schemas/types/entities'
 import fs from 'fs'
 import { DistinctQuestion } from 'inquirer'
 import path from 'path'
@@ -12,7 +12,7 @@ const MAX_USER_LICENSE_CONTENT_LENGTH = 4096
  */
 export default abstract class MediaCommandBase extends ContentDirectoryCommandBase {
   async promptForNewLicense(): Promise<VideoEntity['license']> {
-    let license: VideoEntity['license']
+    let licenseInput: LicenseEntity
     const licenseType: 'known' | 'custom' = await this.simplePrompt({
       type: 'list',
       message: 'Choose license type',
@@ -22,7 +22,12 @@ export default abstract class MediaCommandBase extends ContentDirectoryCommandBa
       ],
     })
     if (licenseType === 'known') {
-      license = { new: { knownLicense: await this.promptForEntityId('Choose License', 'KnownLicense', 'code') } }
+      const [id, knownLicenseEntity] = await this.promptForEntityEntry('Choose License', 'KnownLicense', 'code')
+      const knownLicense = await this.parseToKnownEntityJson<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) {
@@ -38,10 +43,10 @@ export default abstract class MediaCommandBase extends ContentDirectoryCommandBa
           licenseContent = null
         }
       }
-      license = { new: { userDefinedLicense: { new: { content: licenseContent } } } }
+      licenseInput = { userDefinedLicense: { new: { content: licenseContent } } }
     }
 
-    return license
+    return { new: licenseInput }
   }
 
   async promptForPublishedBeforeJoystream(current?: number | null): Promise<number | null> {

+ 19 - 4
cli/src/commands/account/choose.ts

@@ -9,13 +9,19 @@ export default class AccountChoose extends AccountsCommandBase {
   static flags = {
     showSpecial: flags.boolean({
       description: 'Whether to show special (DEV chain) accounts',
+      char: 'S',
+      required: false,
+    }),
+    address: flags.string({
+      description: 'Select account by address (if available)',
+      char: 'a',
       required: false,
     }),
   }
 
   async run() {
-    const { showSpecial } = this.parse(AccountChoose).flags
-    const accounts: NamedKeyringPair[] = this.fetchAccounts(showSpecial)
+    const { showSpecial, address } = this.parse(AccountChoose).flags
+    const accounts: NamedKeyringPair[] = this.fetchAccounts(!!address || showSpecial)
     const selectedAccount: NamedKeyringPair | null = this.getSelectedAccount()
 
     this.log(chalk.white(`Found ${accounts.length} existing accounts...\n`))
@@ -25,9 +31,18 @@ export default class AccountChoose extends AccountsCommandBase {
       this.exit(ExitCodes.NoAccountFound)
     }
 
-    const choosenAccount: NamedKeyringPair = await this.promptForAccount(accounts, selectedAccount)
+    let choosenAccount: NamedKeyringPair
+    if (address) {
+      const matchingAccount = accounts.find((a) => a.address === address)
+      if (!matchingAccount) {
+        this.error(`No matching account found by address: ${address}`, { exit: ExitCodes.InvalidInput })
+      }
+      choosenAccount = matchingAccount
+    } else {
+      choosenAccount = await this.promptForAccount(accounts, selectedAccount)
+    }
 
     await this.setSelectedAccount(choosenAccount)
-    this.log(chalk.greenBright('\nAccount switched!'))
+    this.log(chalk.greenBright(`\nAccount switched to ${chalk.white(choosenAccount.address)}!`))
   }
 }

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

@@ -36,8 +36,8 @@ export default class EntityCommand extends ContentDirectoryCommandBase {
       _.mapValues(
         propertyValues,
         (v) =>
-          (v.value.toJSON() === false && v.type !== 'Single<Bool>' ? chalk.grey('[not set]') : v.value.toString()) +
-          ` ${chalk.green(`${v.type}`)}`
+          (v.value === null ? chalk.grey('[not set]') : v.value.toString()) +
+          ` ${chalk.green(`${v.type}<${v.subtype}>`)}`
       )
     )
   }

+ 6 - 2
cli/src/commands/media/createChannel.ts

@@ -5,12 +5,15 @@ 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 { 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 run() {
@@ -22,7 +25,7 @@ export default class CreateChannelCommand extends ContentDirectoryCommandBase {
 
     const channelJsonSchema = (ChannelEntitySchema as unknown) as JSONSchema
 
-    const { input, output } = this.parse(CreateChannelCommand).flags
+    const { input, output, confirm } = this.parse(CreateChannelCommand).flags
 
     let inputJson = await getInputJson<ChannelEntity>(input, channelJsonSchema)
     if (!inputJson) {
@@ -37,7 +40,8 @@ export default class CreateChannelCommand extends ContentDirectoryCommandBase {
     }
 
     this.jsonPrettyPrint(JSON.stringify(inputJson))
-    const confirmed = await this.simplePrompt({ type: 'confirm', message: 'Do you confirm the provided input?' })
+    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)

+ 123 - 65
cli/src/commands/media/uploadVideo.ts

@@ -20,11 +20,12 @@ 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 = 500 * 1024 * 1024
+const MAX_FILE_SIZE = 2000 * 1024 * 1024
 
 type VideoMetadata = {
   width?: number
@@ -37,13 +38,14 @@ type VideoMetadata = {
 export default class UploadVideoCommand extends MediaCommandBase {
   static description = 'Upload a new Video to a channel (requires a membership).'
   static flags = {
-    // TODO: ...IOFlags, - providing input as json
+    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 = [
@@ -217,6 +219,117 @@ export default class UploadVideoCommand extends MediaCommandBase {
     }
   }
 
+  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()
@@ -226,7 +339,7 @@ export default class UploadVideoCommand extends MediaCommandBase {
 
     const {
       args: { filePath },
-      flags: { channel: inputChannelId },
+      flags: { channel: inputChannelId, input, confirm },
     } = this.parse(UploadVideoCommand)
 
     // Basic file validation
@@ -303,71 +416,16 @@ export default class UploadVideoCommand extends MediaCommandBase {
 
     await this.uploadVideo(filePath, fileSize, uploadUrl)
 
-    // Prompting for the data:
+    // No input, create prompting helpers
+    const videoInput = input
+      ? await this.getVideoInputFromFile(input, channelId, fileSize, contentId, videoMetadata)
+      : await this.promptForVideoInput(channelId, fileSize, contentId, videoMetadata)
 
-    // Set the defaults
-    const videoMediaDefaults: Partial<VideoMediaEntity> = {
-      pixelWidth: videoMetadata?.width,
-      pixelHeight: videoMetadata?.height,
-    }
-    const videoDefaults: Partial<VideoEntity> = {
-      duration: videoMetadata?.duration,
-      skippableIntroDuration: 0,
-    }
-    // Create prompting helpers
-    const videoJsonSchema = (VideoEntitySchema as unknown) as JSONSchema
-    const videoMediaJsonSchema = (VideoMediaEntitySchema as unknown) as JSONSchema
-
-    const videoMediaPrompter = new JsonSchemaPrompter<VideoMediaEntity>(videoMediaJsonSchema, videoMediaDefaults)
-    const videoPrompter = new JsonSchemaPrompter<VideoEntity>(videoJsonSchema, videoDefaults)
-
-    // Prompt for the data
-    const encodingSuggestion =
-      videoMetadata && videoMetadata.codecFullName ? ` (suggested: ${videoMetadata.codecFullName})` : ''
-    const encoding = await this.promptForEntityId(
-      `Choose Video encoding${encodingSuggestion}`,
-      'VideoMediaEncoding',
-      'name'
-    )
-    const { pixelWidth, pixelHeight } = await videoMediaPrompter.promptMultipleProps(['pixelWidth', 'pixelHeight'])
-    const language = await this.promptForEntityId('Choose Video language', 'Language', 'name')
-    const category = await this.promptForEntityId('Choose Video category', 'ContentCategory', 'name')
-    const 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()
-    )
+    this.jsonPrettyPrint(JSON.stringify(videoInput))
 
-    // Create final inputs
-    const videoMediaInput: VideoMediaEntity = {
-      encoding,
-      pixelWidth,
-      pixelHeight,
-      size: fileSize,
-      location: { new: { joystreamMediaLocation: { new: { dataObjectId: contentId.encode() } } } },
+    if (!confirm) {
+      await this.requireConfirmation('Do you confirm the provided input?', true)
     }
-    const videoInput: VideoEntity = {
-      ...videoProps,
-      channel: channelId,
-      language,
-      category,
-      license,
-      media: { new: videoMediaInput },
-      publishedBeforeJoystream,
-    }
-
-    this.jsonPrettyPrint(JSON.stringify(videoInput))
-    await this.requireConfirmation('Do you confirm the provided input?')
 
     // Parse inputs into operations and send final extrinsic
     const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi(), [

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

@@ -39,12 +39,7 @@ export async function getInputJson<T>(inputPath?: string, schema?: JSONSchema, s
       throw new CLIError(`JSON parsing failed for file: ${inputPath}`, { exit: ExitCodes.InvalidInput })
     }
     if (schema) {
-      const ajv = new Ajv()
-      schema = await $RefParser.dereference(schemaPath || DEFAULT_SCHEMA_PATH, schema, {})
-      const valid = ajv.validate(schema, jsonObj) as boolean
-      if (!valid) {
-        throw new CLIError(`Input JSON file is not valid: ${ajv.errorsText()}`)
-      }
+      await validateInput(jsonObj, schema, schemaPath)
     }
 
     return jsonObj as T
@@ -53,6 +48,15 @@ export async function getInputJson<T>(inputPath?: string, schema?: JSONSchema, s
   return null
 }
 
+export async function validateInput(input: unknown, schema: JSONSchema, schemaPath?: string): Promise<void> {
+  const ajv = new Ajv({ allErrors: true })
+  schema = await $RefParser.dereference(schemaPath || DEFAULT_SCHEMA_PATH, schema, {})
+  const valid = ajv.validate(schema, input) as boolean
+  if (!valid) {
+    throw new CLIError(`Input JSON file is not valid: ${ajv.errorsText()}`)
+  }
+}
+
 export function saveOutputJson(outputPath: string | undefined, fileName: string, data: any): void {
   if (outputPath) {
     let outputFilePath = path.join(outputPath, fileName)

+ 48 - 6
content-directory-schemas/inputs/entityBatches/KnownLicenseBatch.json

@@ -1,11 +1,53 @@
 {
   "className": "KnownLicense",
   "entries": [
-    { "code": "CC_BY" },
-    { "code": "CC_BY_SA" },
-    { "code": "CC_BY_ND" },
-    { "code": "CC_BY_NC" },
-    { "code": "CC_BY_NC_SA" },
-    { "code": "CC_BY_NC_ND" }
+    {
+      "code": "PDM",
+      "name": "Public Domain",
+      "url": "https://creativecommons.org/share-your-work/public-domain/pdm",
+      "attributionRequired": false
+    },
+    {
+      "code": "CC0",
+      "name": "Public Domain Dedication",
+      "url": "https://creativecommons.org/share-your-work/public-domain/cc0",
+      "attributionRequired": true
+    },
+    {
+      "code": "CC_BY",
+      "name": "Creative Commons Attribution License",
+      "url": "https://creativecommons.org/licenses/by/4.0",
+      "attributionRequired": true
+    },
+    {
+      "code": "CC_BY_SA",
+      "name": "Creative Commons Attribution-ShareAlike License",
+      "url": "https://creativecommons.org/licenses/by-sa/4.0",
+      "attributionRequired": true
+    },
+    {
+      "code": "CC_BY_ND",
+      "name": "Creative Commons Attribution-NoDerivs License",
+      "url": "https://creativecommons.org/licenses/by-nd/4.0",
+      "attributionRequired": true
+    },
+    {
+      "code": "CC_BY_NC",
+      "name": "Creative Commons Attribution-NonCommercial License",
+      "url": "https://creativecommons.org/licenses/by-nc/4.0",
+      "attributionRequired": true
+    },
+    {
+      "code": "CC_BY_NC_SA",
+      "name": "Creative Commons Attribution-NonCommercial-ShareAlike License",
+      "url": "https://creativecommons.org/licenses/by-nc-sa/4.0",
+      "attributionRequired": true
+    },
+    {
+      "code": "CC_BY_NC_ND",
+      "name": "Creative Commons Attribution-NonCommercial-NoDerivs License",
+      "url": "https://creativecommons.org/licenses/by-nc-nd/4.0",
+      "attributionRequired": true
+    }
   ]
 }

+ 9 - 0
content-directory-schemas/inputs/schemas/KnownLicenseSchema.json

@@ -38,6 +38,15 @@
         "Single": { "Text": 256 }
       },
       "locking_policy": { "is_locked_from_controller": true }
+    },
+    {
+      "name": "attributionRequired",
+      "description": "Whether this license requires an attribution",
+      "required": false,
+      "property_type": {
+        "Single": "Bool"
+      },
+      "locking_policy": { "is_locked_from_controller": true }
     }
   ]
 }

+ 6 - 0
content-directory-schemas/inputs/schemas/LicenseSchema.json

@@ -12,6 +12,12 @@
       "description": "Reference to user-defined license",
       "required": false,
       "property_type": { "Single": { "Reference": { "className": "UserDefinedLicense", "sameOwner": true } } }
+    },
+    {
+      "name": "attribution",
+      "description": "Attribution (if required by the license)",
+      "required": false,
+      "property_type": { "Single": { "Text": 512 } }
     }
   ]
 }

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

@@ -67,7 +67,7 @@
       "name": "publishedBeforeJoystream",
       "description": "If the Video was published on other platform before beeing published on Joystream - the original publication date",
       "required": false,
-      "property_type": { "Single": "Uint32" }
+      "property_type": { "Single": "Int32" }
     },
     {
       "name": "isPublic",

+ 1 - 0
query-node/mappings/content-directory/content-dir-consts.ts

@@ -52,6 +52,7 @@ export const channelPropertyNamesWithId: IPropertyWithId = {
 export const licensePropertyNamesWithId: IPropertyWithId = {
   0: { name: 'knownLicense', type: 'number', required: false },
   1: { name: 'userDefinedLicense', type: 'number', required: false },
+  2: { name: 'attribution', type: 'string', required: false },
 }
 
 export const knownLicensePropertyNamesWIthId: IPropertyWithId = {

+ 13 - 4
query-node/mappings/content-directory/decode.ts

@@ -10,8 +10,13 @@ import {
 } from '../types'
 import Debug from 'debug'
 
-import { ParametrizedClassPropertyValue, UpdatePropertyValuesOperation } from '@joystream/types/content-directory'
+import {
+  OperationType,
+  ParametrizedClassPropertyValue,
+  UpdatePropertyValuesOperation,
+} from '@joystream/types/content-directory'
 import { createType } from '@joystream/types'
+import { Vec } from '@polkadot/types'
 
 const debug = Debug('mappings:cd:decode')
 
@@ -108,9 +113,12 @@ function getEntityProperties(propertyValues: ParametrizedClassPropertyValue[]):
   return properties
 }
 
-function getOperations({ extrinsic }: SubstrateEvent): IBatchOperation {
-  const operations = createType('Vec<OperationType>', extrinsic!.args[1].value as any)
+function getOperations(event: SubstrateEvent): Vec<OperationType> {
+  if (!event.extrinsic) throw Error(`No extrinsic found for ${event.id}`)
+  return createType('Vec<OperationType>', (event.extrinsic.args[1].value as unknown) as Vec<OperationType>)
+}
 
+function getOperationsByTypes(operations: OperationType[]): IBatchOperation {
   const updatePropertyValuesOperations: IEntity[] = []
   const addSchemaSupportToEntityOperations: IEntity[] = []
   const createEntityOperations: ICreateEntityOperation[] = []
@@ -160,6 +168,7 @@ export const decode = {
   getClassEntity,
   setEntityPropertyValues,
   getEntityProperties,
-  getOperations,
+  getOperationsByTypes,
   setProperties,
+  getOperations,
 }

+ 25 - 33
query-node/mappings/content-directory/entity/create.ts

@@ -205,14 +205,12 @@ async function createVideoMedia(
     const { httpMediaLocation, joystreamMediaLocation } = m
     if (httpMediaLocation) {
       const mediaLoc = new HttpMediaLocation()
-      mediaLoc.isTypeOf = 'HttpMediaLocation'
       mediaLoc.port = httpMediaLocation.port
       mediaLoc.url = httpMediaLocation.url
       videoMedia.location = mediaLoc
     }
     if (joystreamMediaLocation) {
       const mediaLoc = new JoystreamMediaLocation()
-      mediaLoc.isTypeOf = 'JoystreamMediaLocation'
       mediaLoc.dataObjectId = joystreamMediaLocation.dataObjectId
       videoMedia.location = mediaLoc
     }
@@ -257,28 +255,9 @@ async function createVideo(
       nextEntityIdBeforeTransaction
     )
   }
-  if (license !== undefined) {
-    const { knownLicense, userdefinedLicense } = await getOrCreate.license(
-      { db, block, id },
-      classEntityMap,
-      license,
-      nextEntityIdBeforeTransaction
-    )
-    if (knownLicense) {
-      const lic = new KnownLicense()
-      lic.code = knownLicense.code
-      lic.description = knownLicense.description
-      lic.isTypeOf = 'KnownLicense'
-      lic.name = knownLicense.name
-      lic.url = knownLicense.url
-      video.license = lic
-    }
-    if (userdefinedLicense) {
-      const lic = new UserDefinedLicense()
-      lic.content = userdefinedLicense.content
-      lic.isTypeOf = 'UserDefinedLicense'
-      video.license = lic
-    }
+  if (license) {
+    const lic = await getOrCreate.license({ db, block, id }, classEntityMap, license, nextEntityIdBeforeTransaction)
+    video.license = lic
   }
   if (category !== undefined) {
     video.category = await getOrCreate.category(
@@ -340,27 +319,40 @@ async function createLicense(
   const record = await db.get(LicenseEntity, { where: { id } })
   if (record) return record
 
-  const { knownLicense, userDefinedLicense } = p
-
   const license = new LicenseEntity()
-  license.id = id
-  if (knownLicense !== undefined) {
-    license.knownLicense = await getOrCreate.knownLicense(
+
+  if (p.knownLicense) {
+    const kLicense = await getOrCreate.knownLicense(
       { db, block, id },
       classEntityMap,
-      knownLicense,
+      p.knownLicense,
       nextEntityIdBeforeTransaction
     )
+    const k = new KnownLicense()
+    k.code = kLicense.code
+    k.description = kLicense.description
+    k.name = kLicense.name
+    k.url = kLicense.url
+    // Set the license type
+    license.type = k
   }
-  if (userDefinedLicense !== undefined) {
-    license.userdefinedLicense = await getOrCreate.userDefinedLicense(
+  if (p.userDefinedLicense) {
+    const { content } = await getOrCreate.userDefinedLicense(
       { db, block, id },
       classEntityMap,
-      userDefinedLicense,
+      p.userDefinedLicense,
       nextEntityIdBeforeTransaction
     )
+    const u = new UserDefinedLicense()
+    u.content = content
+    // Set the license type
+    license.type = u
   }
+
+  license.id = id
+  license.attribution = p.attribution
   license.happenedIn = await createBlockOrGetFromDatabase(db, block)
+
   await db.save<LicenseEntity>(license)
   return license
 }

+ 36 - 68
query-node/mappings/content-directory/entity/remove.ts

@@ -1,4 +1,4 @@
-import Debug from 'debug'
+import assert from 'assert'
 
 import { DB } from '../../../generated/indexer'
 import { Channel } from '../../../generated/graphql-server/src/modules/channel/channel.model'
@@ -17,127 +17,95 @@ import { FeaturedVideo } from '../../../generated/graphql-server/src/modules/fea
 
 import { IWhereCond } from '../../types'
 
-const debug = Debug('mappings:remove-entity')
+function assertKeyViolation(entityName: string, entityId: string) {
+  assert(false, `Can not remove ${entityName}(${entityId})! There are references to this entity`)
+}
 
 async function removeChannel(db: DB, where: IWhereCond): Promise<void> {
   const record = await db.get(Channel, where)
-  if (record === undefined) throw Error(`Channel not found`)
-  if (record.videos) record.videos.map(async (v) => await removeVideo(db, { where: { id: v.id } }))
+  if (!record) throw Error(`Channel(${where.where.id}) not found`)
+  if (record.videos && record.videos.length) assertKeyViolation(`Channel`, record.id)
   await db.remove<Channel>(record)
 }
 
 async function removeCategory(db: DB, where: IWhereCond): Promise<void> {
   const record = await db.get(Category, where)
-  if (record === undefined) throw Error(`Category not found`)
-  if (record.videos) record.videos.map(async (v) => await removeVideo(db, { where: { id: v.id } }))
+  if (!record) throw Error(`Category(${where.where.id}) not found`)
+  if (record.videos && record.videos.length) assertKeyViolation(`Category`, record.id)
   await db.remove<Category>(record)
 }
 async function removeVideoMedia(db: DB, where: IWhereCond): Promise<void> {
   const record = await db.get(VideoMedia, where)
-  if (record === undefined) throw Error(`VideoMedia not found`)
-  if (record.video) await db.remove<Video>(record.video)
+  if (!record) throw Error(`VideoMedia(${where.where.id}) not found`)
+  if (record.video) assertKeyViolation(`VideoMedia`, record.id)
   await db.remove<VideoMedia>(record)
 }
 async function removeVideo(db: DB, where: IWhereCond): Promise<void> {
   const record = await db.get(Video, where)
-  if (record === undefined) throw Error(`Video not found`)
+  if (!record) throw Error(`Video(${where.where.id}) not found`)
   await db.remove<Video>(record)
 }
 
 async function removeLicense(db: DB, where: IWhereCond): Promise<void> {
   const record = await db.get(LicenseEntity, where)
-  if (record === undefined) throw Error(`License not found`)
-
-  const { knownLicense, userdefinedLicense } = record
-  let videos: Video[] = []
-
-  if (knownLicense) {
-    videos = await db.getMany(Video, {
-      where: {
-        license: {
-          isTypeOf: 'KnownLicense',
-          code: knownLicense.code,
-          description: knownLicense.description,
-          name: knownLicense.name,
-          url: knownLicense.url,
-        },
-      },
-    })
-  }
-  if (userdefinedLicense) {
-    videos = await db.getMany(Video, {
-      where: { license: { isTypeOf: 'UserDefinedLicense', content: userdefinedLicense.content } },
-    })
-  }
-  // Remove all the videos under this license
-  videos.map(async (v) => await removeVideo(db, { where: { id: v.id } }))
+  if (!record) throw Error(`License(${where.where.id}) not found`)
+  if (record.videolicense && record.videolicense.length) assertKeyViolation(`License`, record.id)
   await db.remove<LicenseEntity>(record)
 }
+
 async function removeUserDefinedLicense(db: DB, where: IWhereCond): Promise<void> {
   const record = await db.get(UserDefinedLicenseEntity, where)
-  if (record === undefined) throw Error(`UserDefinedLicense not found`)
-  if (record.licenseentityuserdefinedLicense)
-    record.licenseentityuserdefinedLicense.map(async (l) => await removeLicense(db, { where: { id: l.id } }))
+  if (!record) throw Error(`UserDefinedLicense(${where.where.id}) not found`)
   await db.remove<UserDefinedLicenseEntity>(record)
 }
+
 async function removeKnownLicense(db: DB, where: IWhereCond): Promise<void> {
   const record = await db.get(KnownLicenseEntity, where)
-  if (record === undefined) throw Error(`KnownLicense not found`)
-  if (record.licenseentityknownLicense)
-    record.licenseentityknownLicense.map(async (k) => await removeLicense(db, { where: { id: k.id } }))
+  if (!record) throw Error(`KnownLicense(${where.where.id}) not found`)
   await db.remove<KnownLicenseEntity>(record)
 }
 async function removeMediaLocation(db: DB, where: IWhereCond): Promise<void> {
   const record = await db.get(MediaLocationEntity, where)
-  if (record === undefined) throw Error(`MediaLocation not found`)
-  if (record.videoMedia) await removeVideo(db, { where: { id: record.videoMedia.id } })
-
-  const { httpMediaLocation, joystreamMediaLocation } = record
-
-  let videoMedia: VideoMedia | undefined
-  if (httpMediaLocation) {
-    videoMedia = await db.get(VideoMedia, {
-      where: { location: { isTypeOf: 'HttpMediaLocation', url: httpMediaLocation.url, port: httpMediaLocation.port } },
-    })
-  }
-  if (joystreamMediaLocation) {
-    videoMedia = await db.get(VideoMedia, {
-      where: { location: { isTypeOf: 'JoystreamMediaLocation', dataObjectId: joystreamMediaLocation.dataObjectId } },
-    })
-  }
-  if (videoMedia) await db.remove<VideoMedia>(videoMedia)
+  if (!record) throw Error(`MediaLocation(${where.where.id}) not found`)
+  if (record.videoMedia) assertKeyViolation('MediaLocation', record.id)
   await db.remove<MediaLocationEntity>(record)
 }
+
 async function removeHttpMediaLocation(db: DB, where: IWhereCond): Promise<void> {
   const record = await db.get(HttpMediaLocationEntity, where)
-  if (record === undefined) throw Error(`HttpMediaLocation not found`)
-  if (record.medialocationentityhttpMediaLocation)
-    record.medialocationentityhttpMediaLocation.map(async (v) => await removeMediaLocation(db, { where: { id: v.id } }))
+  if (!record) throw Error(`HttpMediaLocation(${where.where.id}) not found`)
+  if (record.medialocationentityhttpMediaLocation && record.medialocationentityhttpMediaLocation.length) {
+    assertKeyViolation('HttpMediaLocation', record.id)
+  }
   await db.remove<HttpMediaLocationEntity>(record)
 }
+
 async function removeJoystreamMediaLocation(db: DB, where: IWhereCond): Promise<void> {
   const record = await db.get(JoystreamMediaLocationEntity, where)
-  if (record === undefined) throw Error(`JoystreamMediaLocation not found`)
-  if (record.medialocationentityjoystreamMediaLocation)
-    record.medialocationentityjoystreamMediaLocation.map(async (v) => await removeVideo(db, { where: { id: v.id } }))
+  if (!record) throw Error(`JoystreamMediaLocation(${where.where.id}) not found`)
+  if (record.medialocationentityjoystreamMediaLocation && record.medialocationentityjoystreamMediaLocation.length) {
+    assertKeyViolation('JoystreamMediaLocation', record.id)
+  }
   await db.remove<JoystreamMediaLocationEntity>(record)
 }
+
 async function removeLanguage(db: DB, where: IWhereCond): Promise<void> {
   const record = await db.get(Language, where)
-  if (record === undefined) throw Error(`Language not found`)
-  if (record.channellanguage) record.channellanguage.map(async (c) => await removeChannel(db, { where: { id: c.id } }))
-  if (record.videolanguage) record.videolanguage.map(async (v) => await removeVideo(db, { where: { id: v.id } }))
+  if (!record) throw Error(`Language(${where.where.id}) not found`)
+  if (record.channellanguage && record.channellanguage.length) assertKeyViolation('Language', record.id)
+  if (record.videolanguage && record.videolanguage.length) assertKeyViolation('Language', record.id)
   await db.remove<Language>(record)
 }
+
 async function removeVideoMediaEncoding(db: DB, where: IWhereCond): Promise<void> {
   const record = await db.get(VideoMediaEncoding, where)
-  if (record === undefined) throw Error(`Language not found`)
+  if (!record) throw Error(`VideoMediaEncoding(${where.where.id}) not found`)
   await db.remove<VideoMediaEncoding>(record)
 }
 
 async function removeFeaturedVideo(db: DB, where: IWhereCond): Promise<void> {
   const record = await db.get(FeaturedVideo, { ...where, relations: ['video'] })
-  if (!record) throw Error(`FeaturedVideo not found. id: ${where.where.id}`)
+  if (!record) throw Error(`FeaturedVideo(${where.where.id}) not found`)
 
   record.video.isFeatured = false
   record.video.featured = undefined

+ 29 - 24
query-node/mappings/content-directory/entity/update.ts

@@ -76,12 +76,29 @@ async function updateLicenseEntityPropertyValues(
   const { knownLicense, userDefinedLicense } = props
   if (knownLicense) {
     const id = getEntityIdFromReferencedField(knownLicense, entityIdBeforeTransaction)
-    record.knownLicense = await db.get(KnownLicenseEntity, { where: { id } })
+    const kLicense = await db.get(KnownLicenseEntity, { where: { id } })
+    if (!kLicense) throw Error(`KnownLicense not found ${id}`)
+
+    const k = new KnownLicense()
+    k.code = kLicense.code
+    k.description = kLicense.description
+    k.name = kLicense.name
+    k.url = kLicense.url
+    // Set the license type
+    record.type = k
   }
   if (userDefinedLicense) {
     const id = getEntityIdFromReferencedField(userDefinedLicense, entityIdBeforeTransaction)
-    record.userdefinedLicense = await db.get(UserDefinedLicenseEntity, { where: { id } })
+    const udl = await db.get(UserDefinedLicenseEntity, { where: { id } })
+    if (!udl) throw Error(`UserDefinedLicense not found ${id}`)
+
+    const u = new UserDefinedLicense()
+    u.content = udl.content
+    // Set the license type
+    record.type = u
   }
+
+  record.attribution = props.attribution || record.attribution
   await db.save<LicenseEntity>(record)
 }
 
@@ -91,6 +108,7 @@ async function updateCategoryEntityPropertyValues(db: DB, where: IWhereCond, pro
   Object.assign(record, props)
   await db.save<Category>(record)
 }
+
 async function updateChannelEntityPropertyValues(
   db: DB,
   where: IWhereCond,
@@ -112,6 +130,7 @@ async function updateChannelEntityPropertyValues(
   record.language = lang
   await db.save<Channel>(record)
 }
+
 async function updateVideoMediaEntityPropertyValues(
   db: DB,
   where: IWhereCond,
@@ -139,13 +158,11 @@ async function updateVideoMediaEntityPropertyValues(
 
     if (httpMediaLocation) {
       mediaLoc = new HttpMediaLocation()
-      mediaLoc.isTypeOf = typeof HttpMediaLocation
       mediaLoc.url = httpMediaLocation.url
       mediaLoc.port = httpMediaLocation.port
     }
     if (joystreamMediaLocation) {
       mediaLoc = new JoystreamMediaLocation()
-      mediaLoc.isTypeOf = typeof JoystreamMediaLocation
       mediaLoc.dataObjectId = joystreamMediaLocation.dataObjectId
     }
     props.location = undefined
@@ -156,6 +173,7 @@ async function updateVideoMediaEntityPropertyValues(
   record.location = mediaLoc
   await db.save<VideoMedia>(record)
 }
+
 async function updateVideoEntityPropertyValues(
   db: DB,
   where: IWhereCond,
@@ -169,7 +187,6 @@ async function updateVideoEntityPropertyValues(
   let cat: Category | undefined
   let lang: Language | undefined
   let vMedia: VideoMedia | undefined
-  let lic: KnownLicense | UserDefinedLicense = record.license
 
   const { channel, category, language, media, license } = props
   if (channel) {
@@ -192,25 +209,9 @@ async function updateVideoEntityPropertyValues(
   }
   if (license) {
     const id = getEntityIdFromReferencedField(license, entityIdBeforeTransaction)
-    const licenseEntity = await db.get(LicenseEntity, {
-      where: { id },
-      relations: ['knownLicense', 'userdefinedLicense'],
-    })
+    const licenseEntity = await db.get(LicenseEntity, { where: { id } })
     if (!licenseEntity) throw Error(`License entity not found: ${id}`)
-    const { knownLicense, userdefinedLicense } = licenseEntity
-    if (knownLicense) {
-      lic = new KnownLicense()
-      lic.code = knownLicense.code
-      lic.description = knownLicense.description
-      lic.isTypeOf = 'KnownLicense'
-      lic.name = knownLicense.name
-      lic.url = knownLicense.url
-    }
-    if (userdefinedLicense) {
-      lic = new UserDefinedLicense()
-      lic.content = userdefinedLicense.content
-      lic.isTypeOf = 'UserDefinedLicense'
-    }
+    record.license = licenseEntity
     props.license = undefined
   }
   if (language) {
@@ -225,11 +226,11 @@ async function updateVideoEntityPropertyValues(
   record.channel = chann || record.channel
   record.category = cat || record.category
   record.media = vMedia || record.media
-  record.license = lic
   record.language = lang
 
   await db.save<Video>(record)
 }
+
 async function updateUserDefinedLicenseEntityPropertyValues(
   db: DB,
   where: IWhereCond,
@@ -240,12 +241,14 @@ async function updateUserDefinedLicenseEntityPropertyValues(
   Object.assign(record, props)
   await db.save<UserDefinedLicenseEntity>(record)
 }
+
 async function updateKnownLicenseEntityPropertyValues(db: DB, where: IWhereCond, props: IKnownLicense): Promise<void> {
   const record = await db.get(KnownLicenseEntity, where)
   if (record === undefined) throw Error(`Entity not found: ${where.where.id}`)
   Object.assign(record, props)
   await db.save<KnownLicenseEntity>(record)
 }
+
 async function updateHttpMediaLocationEntityPropertyValues(
   db: DB,
   where: IWhereCond,
@@ -267,12 +270,14 @@ async function updateJoystreamMediaLocationEntityPropertyValues(
   Object.assign(record, props)
   await db.save<JoystreamMediaLocationEntity>(record)
 }
+
 async function updateLanguageEntityPropertyValues(db: DB, where: IWhereCond, props: ILanguage): Promise<void> {
   const record = await db.get(Language, where)
   if (record === undefined) throw Error(`Entity not found: ${where.where.id}`)
   Object.assign(record, props)
   await db.save<Language>(record)
 }
+
 async function updateVideoMediaEncodingEntityPropertyValues(
   db: DB,
   where: IWhereCond,

+ 1 - 1
query-node/mappings/content-directory/get-or-create.ts

@@ -352,7 +352,7 @@ async function license(
 
   const id = generateEntityIdFromIndex(nextEntityIdBeforeTransaction + entityId)
   // could be created in the transaction
-  lic = await db.get(LicenseEntity, { where: { id }, relations: ['knownLicense', 'userdefinedLicense'] })
+  lic = await db.get(LicenseEntity, { where: { id } })
   if (lic) return lic
 
   const { properties } = findEntity(entityId, 'License', classEntityMap)

+ 1 - 1
query-node/mappings/content-directory/mapping.ts

@@ -4,4 +4,4 @@ export {
   contentDirectory_EntityCreated,
   contentDirectory_EntityPropertyValuesUpdated,
 } from './entity'
-export { contentDirectory_TransactionCompleted } from './transaction'
+export { contentDirectory_TransactionCompleted, contentDirectory_TransactionFailed } from './transaction'

+ 21 - 18
query-node/mappings/content-directory/transaction.ts

@@ -7,6 +7,7 @@ import { ClassEntity } from '../../generated/graphql-server/src/modules/class-en
 import { decode } from './decode'
 import {
   ClassEntityMap,
+  IBatchOperation,
   ICategory,
   IChannel,
   ICreateEntityOperation,
@@ -85,33 +86,35 @@ async function getNextEntityId(db: DB): Promise<number> {
   return e.nextId
 }
 
+// eslint-disable-next-line @typescript-eslint/naming-convention
+export async function contentDirectory_TransactionFailed(db: DB, event: SubstrateEvent): Promise<void> {
+  debug(`TransactionFailed event: ${JSON.stringify(event)}`)
+
+  const failedOperationIndex = event.params[1].value as number
+  const operations = decode.getOperations(event)
+
+  const successfulOperations = operations.toArray().slice(0, failedOperationIndex)
+  if (!successfulOperations.length) return // No succesfull operations
+
+  await applyOperations(decode.getOperationsByTypes(successfulOperations), db, event)
+}
+
 // eslint-disable-next-line @typescript-eslint/naming-convention
 export async function contentDirectory_TransactionCompleted(db: DB, event: SubstrateEvent): Promise<void> {
   debug(`TransactionCompleted event: ${JSON.stringify(event)}`)
 
-  const { extrinsic, blockNumber: block } = event
-  if (!extrinsic) {
-    throw Error(`No extrinsic found for the event: ${event.id}`)
-  }
-
-  const { 1: operations } = extrinsic.args
-  if (operations.name.toString() !== 'operations') {
-    throw Error(`Could not found 'operations' in the extrinsic.args[1]`)
-  }
+  const operations = decode.getOperations(event)
 
-  const {
-    addSchemaSupportToEntityOperations,
-    createEntityOperations,
-    updatePropertyValuesOperations,
-  } = decode.getOperations(event)
+  await applyOperations(decode.getOperationsByTypes(operations), db, event)
+}
 
+async function applyOperations(operations: IBatchOperation, db: DB, event: SubstrateEvent) {
+  const { addSchemaSupportToEntityOperations, createEntityOperations, updatePropertyValuesOperations } = operations
   // Create entities before adding schema support
   // We need this to know which entity belongs to which class(we will need to know to update/create
   // Channel, Video etc.). For example if there is a property update operation there is no class id
-  await batchCreateClassEntities(db, block, createEntityOperations)
-
-  await batchAddSchemaSupportToEntity(db, createEntityOperations, addSchemaSupportToEntityOperations, block)
-
+  await batchCreateClassEntities(db, event.blockNumber, createEntityOperations)
+  await batchAddSchemaSupportToEntity(db, createEntityOperations, addSchemaSupportToEntityOperations, event.blockNumber)
   await batchUpdatePropertyValue(db, createEntityOperations, updatePropertyValuesOperations)
 }
 

+ 1 - 0
query-node/mappings/types.ts

@@ -115,6 +115,7 @@ export interface IVideo {
 export interface ILicense {
   knownLicense?: IReference
   userDefinedLicense?: IReference
+  attribution?: string
 }
 
 export interface IMediaLocation {

+ 15 - 18
query-node/schema.graphql

@@ -163,21 +163,6 @@ type UserDefinedLicenseEntity @entity {
   happenedIn: Block!
 }
 
-type LicenseEntity @entity {
-  "Runtime entity identifier (EntityId)"
-  id: ID!
-
-  # One of the following field will be non-null
-
-  "Reference to a known license"
-  knownLicense: KnownLicenseEntity
-
-  "Reference to user-defined license"
-  userdefinedLicense: UserDefinedLicenseEntity
-
-  happenedIn: Block!
-}
-
 type MediaLocationEntity @entity {
   "Runtime entity identifier (EntityId)"
   id: ID!
@@ -290,7 +275,7 @@ type Video @entity {
   "Whether the Video contains explicit material."
   isExplicit: Boolean!
 
-  license: License!
+  license: LicenseEntity!
 
   happenedIn: Block!
 
@@ -313,6 +298,8 @@ type HttpMediaLocation @variant {
   port: Int
 }
 
+union MediaLocation = HttpMediaLocation | JoystreamMediaLocation
+
 type KnownLicense @variant {
   "Short, commonly recognized code of the licence (ie. CC_BY_SA)"
   code: String!
@@ -332,10 +319,20 @@ type UserDefinedLicense @variant {
   content: String!
 }
 
-union MediaLocation = HttpMediaLocation | JoystreamMediaLocation
-
 union License = KnownLicense | UserDefinedLicense
 
+type LicenseEntity @entity {
+  "Runtime entity identifier (EntityId)"
+  id: ID!
+
+  type: License!
+
+  "Attribution (if required by the license)"
+  attribution: String
+
+  happenedIn: Block!
+}
+
 type FeaturedVideo @entity {
   "Runtime entity identifier (EntityId)"
   id: ID!

+ 1 - 1
storage-node/packages/storage/filter.js

@@ -20,7 +20,7 @@
 
 const debug = require('debug')('joystream:storage:filter')
 
-const DEFAULT_MAX_FILE_SIZE = 500 * 1024 * 1024
+const DEFAULT_MAX_FILE_SIZE = 2000 * 1024 * 1024
 const DEFAULT_ACCEPT_TYPES = ['video/*', 'audio/*', 'image/*']
 const DEFAULT_REJECT_TYPES = []