mappingsContent.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701
  1. // TODO: add logging of mapping events (entity found/not found, entity updated/deleted, etc.)
  2. // TODO: update event list - some events were added/removed recently and are missing in this file
  3. // TODO: handling of Language, MediaType, etc.
  4. // TODO: fix TS imports from joystream packages
  5. // TODO: split file into multiple files
  6. import { SubstrateEvent } from '@dzlzv/hydra-common'
  7. import { DatabaseManager } from '@dzlzv/hydra-db-utils'
  8. // protobuf definitions
  9. import {
  10. ChannelMetadata,
  11. ChannelCategoryMetadata,
  12. PublishedBeforeJoystream as PublishedBeforeJoystreamMetadata,
  13. License as LicenseMetadata,
  14. MediaType as MediaTypeMetadata,
  15. VideoMetadata,
  16. VideoCategoryMetadata,
  17. } from '@joystream/content-metadata-protobuf'
  18. /*
  19. import {
  20. ChannelMetadata,
  21. ChannelCategoryMetadata
  22. } from '../../content-metadata-protobuf/compiled/proto/Channel_pb'
  23. import {
  24. PublishedBeforeJoystream as PublishedBeforeJoystreamMetadata,
  25. License as LicenseMetadata,
  26. MediaType as MediaTypeMetadata,
  27. VideoMetadata,
  28. VideoCategoryMetadata,
  29. } from '../../content-metadata-protobuf/compiled/proto/Video_pb'
  30. */
  31. import {
  32. // primary entites
  33. Network,
  34. Block,
  35. Channel,
  36. ChannelCategory,
  37. Video,
  38. VideoCategory,
  39. // secondary entities
  40. Language,
  41. License,
  42. MediaType,
  43. VideoMediaEncoding,
  44. VideoMediaMetadata,
  45. // Asset
  46. Asset,
  47. AssetUrl,
  48. AssetUploadStatus,
  49. AssetDataObject,
  50. LiaisonJudgement,
  51. AssetStorage,
  52. AssetOwner,
  53. AssetOwnerMember,
  54. } from 'query-node'
  55. import {
  56. contentDirectory
  57. } from '@joystream/types'
  58. /*
  59. // enums
  60. import { Network } from '../generated/graphql-server/src/modules/enums/enums'
  61. // input schema models
  62. import { Block } from '../generated/graphql-server/src/modules/block/block.model'
  63. import { Channel } from '../generated/graphql-server/src/modules/channel/channel.model'
  64. import { ChannelCategory } from '../generated/graphql-server/src/modules/channelCategory/channelCategory.model'
  65. import { Video } from '../generated/graphql-server/src/modules/video/video.model'
  66. import { VideoCategory } from '../generated/graphql-server/src/modules/videoCategory/videoCategory.model'
  67. */
  68. const currentNetwork = Network.BABYLON
  69. /////////////////// Utils //////////////////////////////////////////////////////
  70. enum ProtobufEntity {
  71. Channel,
  72. ChannelCategory,
  73. Video,
  74. VideoCategory,
  75. }
  76. // TODO: tweak generic types to make them actually work
  77. //function readProtobuf(type: ProtobufEntity, metadata: Uint8Array) {
  78. async function readProtobuf<T extends ProtobufEntity>(
  79. type: ProtobufEntity,
  80. metadata: Uint8Array,
  81. assets: contentDirectory.RawAsset[],
  82. db: DatabaseManager,
  83. ): Promise<Partial<T>> {
  84. // TODO: consider getting rid of this function - it makes sense to keep it only complex logic will be executed here
  85. // for example retriving language for channel, retrieving new assets (channel photo), etc.
  86. // process channel
  87. if (type == ProtobufEntity.Channel) {
  88. const meta = ChannelMetadata.deserializeBinary(metadata)
  89. const result = meta.toObject()
  90. // prepare cover photo asset if needed
  91. if (result.coverPhoto !== undefined) {
  92. result.coverPhoto = extractAsset(result.coverPhoto, assets)
  93. }
  94. // prepare avatar photo asset if needed
  95. if (result.avatarPhoto !== undefined) {
  96. result.avatarPhoto = extractAsset(result.avatarPhoto, assets)
  97. }
  98. // prepare language if needed
  99. if (result.language) {
  100. result.language = await prepareLanguage(result.language, db)
  101. }
  102. return result
  103. }
  104. // process channel category
  105. if (type == ProtobufEntity.ChannelCategory) {
  106. return ChannelCategoryMetadata.deserializeBinary(metadata).toObject()
  107. }
  108. // process video
  109. if (type == ProtobufEntity.Video) {
  110. const meta = VideoMetadata.deserializeBinary(metadata)
  111. const result = meta.toObject()
  112. // prepare video category if needed
  113. if (result.category !== undefined) {
  114. result.category = prepareVideoCategory(result.category, db)
  115. }
  116. // prepare media meta information if needed
  117. if (result.mediaType) {
  118. result.mediaType = prepareVideoMetadata(result)
  119. }
  120. // prepare license if needed
  121. if (result.license) {
  122. result.license = prepareLicense(result.license)
  123. }
  124. // prepare thumbnail photo asset if needed
  125. if (result.thumbnail !== undefined) {
  126. result.thumbnail = extractAsset(result.thumbnail, assets)
  127. }
  128. // prepare video asset if needed
  129. if (result.media !== undefined) {
  130. result.media = extractAsset(result.media, assets)
  131. }
  132. // prepare language if needed
  133. if (result.language) {
  134. result.language = await prepareLanguage(result.language, db)
  135. }
  136. // prepare information about media published somewhere else before Joystream if needed.
  137. if (result.publishedBeforeJoystream) {
  138. // TODO: is ok to just ignore `isPublished?: boolean` here?
  139. if (result.publishedBeforeJoystream.hasDate()) {
  140. result.publishedBeforeJoystream = new Date(result.publishedBeforeJoystream.getDate())
  141. } else {
  142. delete result.publishedBeforeJoystream
  143. }
  144. }
  145. return result
  146. }
  147. // process video category
  148. if (type == ProtobufEntity.VideoCategory) {
  149. return VideoCategoryMetadata.deserializeBinary(metadata).toObject()
  150. }
  151. // this should never happen
  152. throw `Not implemented type: ${type}`
  153. }
  154. // temporary function used before proper block is retrieved
  155. function convertBlockNumberToBlock(block: number): Block {
  156. return new Block({
  157. block: block,
  158. executedAt: new Date(), // TODO get real block execution time
  159. network: currentNetwork,
  160. })
  161. }
  162. function convertAsset(rawAsset: contentDirectory.RawAsset): Asset {
  163. if (rawAsset.isUrl) {
  164. const assetUrl = new AssetUrl({
  165. url: rawAsset.asUrl()[0] // TODO: find out why asUrl() returns array
  166. })
  167. const asset = new Asset(assetUrl) // TODO: make sure this is a proper way to initialize Asset (on all places)
  168. return asset
  169. }
  170. // !rawAsset.isUrl && rawAsset.isUpload
  171. const contentParameters: contentDirectory.ContentParameters = rawAsset.asStorage()
  172. const assetOwner = new AssetOwner(new AssetOwnerMember(0)) // TODO: proper owner
  173. const assetDataObject = new AssetDataObject({
  174. owner: new AssetOwner(),
  175. addedAt: convertBlockNumberToBlock(0), // TODO: proper addedAt
  176. typeId: contentParameters.type_id,
  177. size: 0, // TODO: retrieve proper file size
  178. liaisonId: 0, // TODO: proper id
  179. liaisonJudgement: LiaisonJudgement.PENDING, // TODO: proper judgement
  180. ipfsContentId: contentParameters.ipfs_content_id,
  181. joystreamContentId: contentParameters.content_id,
  182. })
  183. // TODO: handle `AssetNeverProvided` and `AssetDeleted` states
  184. const uploadingStatus = new AssetUploadStatus({
  185. dataObject: new AssetDataObject,
  186. oldDataObject: undefined // TODO: handle oldDataObject
  187. })
  188. const assetStorage = new AssetStorage({
  189. uploadStatus: uploadingStatus
  190. })
  191. const asset = new Asset(assetStorage)
  192. return asset
  193. }
  194. function extractAsset(assetIndex: number | undefined, assets: contentDirectory.RawAsset[]): Asset | undefined {
  195. if (assetIndex === undefined) {
  196. return undefined
  197. }
  198. if (assetIndex > assets.length) {
  199. throw 'Inconsistent state' // TODO: more sophisticated inconsistency handling; unify handling with other critical errors
  200. }
  201. return convertAsset(assets[assetIndex])
  202. }
  203. async function prepareLanguage(languageIso: string, db: DatabaseManager): Promise<Language> {
  204. // TODO: ensure language is ISO name
  205. const isValidIso = true;
  206. if (!isValidIso) {
  207. throw // TODO: create a proper way of handling inconsistent state
  208. }
  209. const language = await db.get(Language, { where: { iso: languageIso }})
  210. if (language) {
  211. return language;
  212. }
  213. const newLanguage = new Language({
  214. iso: languageIso
  215. })
  216. return newLanguage
  217. }
  218. async function prepareLicense(licenseProtobuf: LicenseMetadata.AsObject): Promise<License> {
  219. // TODO: add old license removal (when existing) or rework the whole function
  220. const license = new License(licenseProtobuf.toObject())
  221. return license
  222. }
  223. async function prepareVideoMetadata(videoProtobuf: VideoMetadata.AsObject): Promise<MediaType> {
  224. const encoding = new VideoMediaEncoding(videoProtobuf.mediaType)
  225. const videoMeta = new VideoMediaMetadata({
  226. encoding,
  227. pixelWidth: videoProtobuf.mediaPixelWidth,
  228. pixelHeight: videoProtobuf.mediaPixelHeight,
  229. size: 0, // TODO: retrieve proper file size
  230. })
  231. return videoMeta
  232. }
  233. async function prepareVideoCategory(categoryId: number, db: DatabaseManager): Promise<VideoCategory> {
  234. const category = await db.get(VideoCategory, { where: { id: categoryId }})
  235. if (!category) {
  236. throw // TODO: create a proper way of handling inconsistent state
  237. }
  238. return category
  239. }
  240. /////////////////// Channel ////////////////////////////////////////////////////
  241. // eslint-disable-next-line @typescript-eslint/naming-convention
  242. export async function content_ChannelCreated(db: DatabaseManager, event: SubstrateEvent): Promise<void> {
  243. /* event arguments
  244. ChannelId,
  245. ChannelOwner<MemberId, CuratorGroupId, DAOId>,
  246. Vec<NewAsset>,
  247. ChannelCreationParameters<ContentParameters>,
  248. */
  249. const protobufContent = await readProtobuf(ProtobufEntity.Channel, (event.params[3].value as any).meta, event.params[2].value as any[], db) // TODO: get rid of `any` typecast
  250. const channel = new Channel({
  251. id: event.params[0].value.toString(), // ChannelId
  252. isCensored: false,
  253. videos: [],
  254. happenedIn: convertBlockNumberToBlock(event.blockNumber),
  255. ...Object(protobufContent)
  256. })
  257. await db.save<Channel>(channel)
  258. }
  259. // eslint-disable-next-line @typescript-eslint/naming-convention
  260. export async function content_ChannelUpdated(
  261. db: DatabaseManager,
  262. event: SubstrateEvent
  263. ) {
  264. /* event arguments
  265. ContentActor,
  266. ChannelId,
  267. Channel,
  268. ChannelUpdateParameters<ContentParameters, AccountId>,
  269. */
  270. const channelId = event.params[1].value.toString()
  271. const channel = await db.get(Channel, { where: { id: channelId } })
  272. if (!channel) {
  273. throw // TODO: create a proper way of handling inconsistent state
  274. }
  275. const protobufContent = await readProtobuf(ProtobufEntity.Channel, (event.params[3].value as any).new_meta, (event.params[3].value as any).assets, db) // TODO: get rid of `any` typecast
  276. for (let [key, value] of Object(protobufContent).entries()) {
  277. channel[key] = value
  278. }
  279. await db.save<Channel>(channel)
  280. }
  281. // eslint-disable-next-line @typescript-eslint/naming-convention
  282. export async function content_ChannelDeleted(
  283. db: DatabaseManager,
  284. event: SubstrateEvent
  285. ) {
  286. const channelId = event.params[1].value.toString()
  287. const channel = await db.get(Channel, { where: { id: channelId } })
  288. await db.remove<Channel>(channel)
  289. }
  290. // eslint-disable-next-line @typescript-eslint/naming-convention
  291. export async function content_ChannelCensored(
  292. db: DatabaseManager,
  293. event: SubstrateEvent
  294. ) {
  295. /* event arguments
  296. ContentActor,
  297. ChannelId,
  298. Vec<u8>
  299. */
  300. const channelId = event.params[1].value.toString()
  301. const channel = await db.get(Channel, { where: { id: channelId } })
  302. if (!channel) {
  303. throw // TODO: create a proper way of handling inconsistent state
  304. }
  305. channel.isCensored = true;
  306. await db.save<Channel>(channel)
  307. }
  308. // eslint-disable-next-line @typescript-eslint/naming-convention
  309. export async function content_ChannelUncensored(
  310. db: DatabaseManager,
  311. event: SubstrateEvent
  312. ) {
  313. /* event arguments
  314. ContentActor,
  315. ChannelId,
  316. Vec<u8>
  317. */
  318. const channelId = event.params[1].value.toString()
  319. const channel = await db.get(Channel, { where: { id: channelId } })
  320. if (!channel) {
  321. throw // TODO: create a proper way of handling inconsistent state
  322. }
  323. channel.isCensored = false;
  324. await db.save<Channel>(channel)
  325. }
  326. // eslint-disable-next-line @typescript-eslint/naming-convention
  327. export async function content_ChannelOwnershipTransferRequested(
  328. db: DatabaseManager,
  329. event: SubstrateEvent
  330. ) {
  331. // TODO - is mapping for this event needed?
  332. }
  333. // eslint-disable-next-line @typescript-eslint/naming-convention
  334. export async function content_ChannelOwnershipTransferRequestWithdrawn(
  335. db: DatabaseManager,
  336. event: SubstrateEvent
  337. ) {
  338. // TODO - is mapping for this event needed?
  339. }
  340. // eslint-disable-next-line @typescript-eslint/naming-convention
  341. export async function content_ChannelOwnershipTransferred(
  342. db: DatabaseManager,
  343. event: SubstrateEvent
  344. ) {
  345. // TODO
  346. }
  347. /////////////////// ChannelCategory ////////////////////////////////////////////
  348. // eslint-disable-next-line @typescript-eslint/naming-convention
  349. export async function content_ChannelCategoryCreated(
  350. db: DatabaseManager,
  351. event: SubstrateEvent
  352. ) {
  353. /* event arguments
  354. ChannelCategoryId,
  355. ChannelCategory,
  356. ChannelCategoryCreationParameters,
  357. */
  358. const protobufContent = await readProtobuf(ProtobufEntity.ChannelCategory, (event.params[2].value as any).meta, [], db) // TODO: get rid of `any` typecast
  359. const channelCategory = new ChannelCategory({
  360. id: event.params[0].value.toString(), // ChannelCategoryId
  361. channels: [],
  362. happenedIn: convertBlockNumberToBlock(event.blockNumber),
  363. ...Object(protobufContent)
  364. })
  365. await db.save<ChannelCategory>(channelCategory)
  366. }
  367. // eslint-disable-next-line @typescript-eslint/naming-convention
  368. export async function content_ChannelCategoryUpdated(
  369. db: DatabaseManager,
  370. event: SubstrateEvent
  371. ) {
  372. /* event arguments
  373. ContentActor,
  374. ChannelCategoryId,
  375. ChannelCategoryUpdateParameters,
  376. */
  377. const channelCategoryId = event.params[1].value.toString()
  378. const channelCategory = await db.get(ChannelCategory, { where: { id: channelCategoryId } })
  379. if (!channelCategory) {
  380. throw // TODO: create a proper way of handling inconsistent state
  381. }
  382. const protobufContent = await readProtobuf(ProtobufEntity.ChannelCategory, (event.params[2].value as any).meta, [], db) // TODO: get rid of `any` typecast
  383. for (let [key, value] of Object(protobufContent).entries()) {
  384. channelCategory[key] = value
  385. }
  386. await db.save<ChannelCategory>(channelCategory)
  387. }
  388. // eslint-disable-next-line @typescript-eslint/naming-convention
  389. export async function content_ChannelCategoryDeleted(
  390. db: DatabaseManager,
  391. event: SubstrateEvent
  392. ) {
  393. /* event arguments
  394. ContentActor,
  395. ChannelCategoryId
  396. */
  397. const channelCategoryId = event.params[1].value.toString()
  398. const channelCategory = await db.get(ChannelCategory, { where: { id: channelCategoryId } })
  399. await db.remove<ChannelCategory>(channelCategory)
  400. }
  401. /////////////////// VideoCategory //////////////////////////////////////////////
  402. // eslint-disable-next-line @typescript-eslint/naming-convention
  403. export async function content_VideoCategoryCreated(
  404. db: DatabaseManager,
  405. event: SubstrateEvent
  406. ) {
  407. /* event arguments
  408. ContentActor,
  409. VideoCategoryId,
  410. VideoCategoryCreationParameters,
  411. */
  412. const protobufContent = readProtobuf(ProtobufEntity.VideoCategory, (event.params[2].value as any).meta, [], db) // TODO: get rid of `any` typecast
  413. const videoCategory = new VideoCategory({
  414. id: event.params[0].value.toString(), // ChannelId
  415. isCensored: false,
  416. videos: [],
  417. happenedIn: convertBlockNumberToBlock(event.blockNumber),
  418. ...Object(protobufContent)
  419. })
  420. await db.save<VideoCategory>(videoCategory)
  421. }
  422. // eslint-disable-next-line @typescript-eslint/naming-convention
  423. export async function content_VideoCategoryUpdated(
  424. db: DatabaseManager,
  425. event: SubstrateEvent
  426. ) {
  427. /* event arguments
  428. ContentActor,
  429. VideoCategoryId,
  430. VideoCategoryUpdateParameters,
  431. */
  432. const videoCategoryId = event.params[1].toString()
  433. const videoCategory = await db.get(VideoCategory, { where: { id: videoCategoryId } })
  434. if (!videoCategory) {
  435. throw // TODO: create a proper way of handling inconsistent state
  436. }
  437. const protobufContent = await readProtobuf(ProtobufEntity.VideoCategory, (event.params[2].value as any).meta, [], db) // TODO: get rid of `any` typecast
  438. for (let [key, value] of Object(protobufContent).entries()) {
  439. videoCategory[key] = value
  440. }
  441. await db.save<VideoCategory>(videoCategory)
  442. }
  443. // eslint-disable-next-line @typescript-eslint/naming-convention
  444. export async function content_VideoCategoryDeleted(
  445. db: DatabaseManager,
  446. event: SubstrateEvent
  447. ) {
  448. /* event arguments
  449. ContentActor,
  450. VideoCategoryId,
  451. */
  452. const videoCategoryId = event.params[1].toString()
  453. const videoCategory = await db.get(VideoCategory, { where: { id: videoCategoryId } })
  454. await db.remove<VideoCategory>(videoCategory)
  455. }
  456. /////////////////// Video //////////////////////////////////////////////////////
  457. // eslint-disable-next-line @typescript-eslint/naming-convention
  458. export async function content_VideoCreated(
  459. db: DatabaseManager,
  460. event: SubstrateEvent
  461. ) {
  462. /* event arguments
  463. ContentActor,
  464. ChannelId,
  465. VideoId,
  466. VideoCreationParameters<ContentParameters>,
  467. */
  468. const protobufContent = await readProtobuf(ProtobufEntity.Video, (event.params[3].value as any).meta, (event.params[3].value as any).assets, db) // TODO: get rid of `any` typecast
  469. const channel = new Video({
  470. id: event.params[2].toString(), // ChannelId
  471. isCensored: false,
  472. channel: event.params[1],
  473. happenedIn: convertBlockNumberToBlock(event.blockNumber),
  474. ...Object(protobufContent)
  475. })
  476. await db.save<Video>(channel)
  477. }
  478. // eslint-disable-next-line @typescript-eslint/naming-convention
  479. export async function content_VideoUpdated(
  480. db: DatabaseManager,
  481. event: SubstrateEvent
  482. ) {
  483. /* event arguments
  484. ContentActor,
  485. VideoId,
  486. VideoUpdateParameters<ContentParameters>,
  487. */
  488. const videoId = event.params[1].toString()
  489. const video = await db.get(Video, { where: { id: videoId } })
  490. if (!video) {
  491. throw // TODO: create a proper way of handling inconsistent state
  492. }
  493. const protobufContent = await readProtobuf(ProtobufEntity.Video, (event.params[2].value as any).meta, (event.params[2].value as any).assets, db) // TODO: get rid of `any` typecast
  494. for (let [key, value] of Object(protobufContent).entries()) {
  495. video[key] = value
  496. }
  497. await db.save<Video>(video)
  498. }
  499. // eslint-disable-next-line @typescript-eslint/naming-convention
  500. export async function content_VideoDeleted(
  501. db: DatabaseManager,
  502. event: SubstrateEvent
  503. ) {
  504. /* event arguments
  505. ContentActor,
  506. VideoCategoryId,
  507. */
  508. const videoId = event.params[1].toString()
  509. const video = await db.get(Video, { where: { id: videoId } })
  510. await db.remove<Video>(video)
  511. }
  512. // eslint-disable-next-line @typescript-eslint/naming-convention
  513. export async function content_VideoCensored(
  514. db: DatabaseManager,
  515. event: SubstrateEvent
  516. ) {
  517. /* event arguments
  518. ContentActor,
  519. VideoId,
  520. Vec<u8>
  521. */
  522. const videoId = event.params[1].toString()
  523. const video = await db.get(Video, { where: { id: videoId } })
  524. video.isCensored = true;
  525. await db.save<Video>(video)
  526. }
  527. // eslint-disable-next-line @typescript-eslint/naming-convention
  528. export async function content_VideoUncensored(
  529. db: DatabaseManager,
  530. event: SubstrateEvent
  531. ) {
  532. /* event arguments
  533. ContentActor,
  534. VideoId,
  535. Vec<u8>
  536. */
  537. const channelId = event.params[1].toString()
  538. const video = await db.get(Video, { where: { id: videoId } })
  539. video.isCensored = false;
  540. await db.save<Video>(video)
  541. }
  542. // eslint-disable-next-line @typescript-eslint/naming-convention
  543. export async function content_FeaturedVideosSet(
  544. db: DatabaseManager,
  545. event: SubstrateEvent
  546. ) {
  547. /* event arguments
  548. ContentActor,
  549. Vec<VideoId>,
  550. */
  551. const videoIds = event.params[1].value as string[]
  552. const existingFeaturedVideos = await db.getMany(Video, { where: { isFeatured: true } })
  553. const isSame = (videoA: Video) => (videoB: Video) => videoA.id == videoB
  554. const toRemove = existingFeaturedVideos.filter(existingFV => !videoIds.some(isSame(existingFV)))
  555. const toAdd = videoIds.filter(video => !existingFeaturedVideos.some(isSame(video)))
  556. for (let video in toRemove) {
  557. video.isFeatured = false;
  558. await db.save<Video>(video)
  559. }
  560. for (let video in toAdd) {
  561. video.isFeatured = true;
  562. await db.save<Video>(video)
  563. }
  564. }