Sfoglia il codice sorgente

Add orion query for ids with the most views by given period for videos and channels (#14)

Rafał Pawłow 3 anni fa
parent
commit
b2d843af61
8 ha cambiato i file con 158 aggiunte e 1 eliminazioni
  1. 1 0
      package.json
  2. 6 0
      schema.graphql
  3. 7 0
      src/aggregates/views.ts
  4. 63 1
      src/resolvers/viewsInfo.ts
  5. 30 0
      tests/queries/views.ts
  6. 2 0
      tests/server.test.ts
  7. 44 0
      tests/views.test.ts
  8. 5 0
      yarn.lock

+ 1 - 0
package.json

@@ -34,6 +34,7 @@
     "@typegoose/typegoose": "^7.4.1",
     "apollo-server-express": "^2.19.1",
     "class-validator": "^0.12.2",
+    "date-fns": "^2.22.1",
     "dotenv": "^8.2.0",
     "express": "^4.17.1",
     "graphql": "15",

+ 6 - 0
schema.graphql

@@ -40,6 +40,12 @@ type Query {
   """Get views count for a single channel"""
   channelViews(channelId: ID!): EntityViewsInfo
 
+  """Get most viewed list of channels"""
+  mostViewedChannels(limit: Int, period: Int): [EntityViewsInfo!]
+
+  """Get most viewed list of videos"""
+  mostViewedVideos(limit: Int, period: Int): [EntityViewsInfo!]
+
   """Get views count for a single video"""
   videoViews(videoId: ID!): EntityViewsInfo
 }

+ 7 - 0
src/aggregates/views.ts

@@ -7,6 +7,7 @@ type VideoEventsAggregationResult = {
 export class ViewsAggregate {
   private videoViewsMap: Record<string, number> = {}
   private channelViewsMap: Record<string, number> = {}
+  private allViewsEvents: Partial<UnsequencedVideoEvent>[] = []
 
   public videoViews(videoId: string): number | null {
     return this.videoViewsMap[videoId] ?? null
@@ -16,6 +17,10 @@ export class ViewsAggregate {
     return this.channelViewsMap[channelId] ?? null
   }
 
+  public getAllViewsEvents() {
+    return this.allViewsEvents
+  }
+
   public getVideoViewsMap() {
     return Object.freeze(this.videoViewsMap)
   }
@@ -41,6 +46,7 @@ export class ViewsAggregate {
   }
 
   public applyEvent(event: UnsequencedVideoEvent) {
+    const { videoId, channelId, timestamp } = event
     const currentVideoViews = this.videoViewsMap[event.videoId] || 0
     const currentChannelViews = this.channelViewsMap[event.channelId] || 0
 
@@ -48,6 +54,7 @@ export class ViewsAggregate {
       case VideoEventType.AddView:
         this.videoViewsMap[event.videoId] = currentVideoViews + 1
         this.channelViewsMap[event.channelId] = currentChannelViews + 1
+        this.allViewsEvents = [...this.allViewsEvents, { videoId, channelId, timestamp }]
         break
       default:
         console.error(`Parsing unknown video event: ${event.type}`)

+ 63 - 1
src/resolvers/viewsInfo.ts

@@ -1,4 +1,5 @@
-import { Args, ArgsType, Ctx, Field, ID, Mutation, Query, Resolver } from 'type-graphql'
+import { Args, ArgsType, Ctx, Field, ID, Int, Mutation, Query, Resolver } from 'type-graphql'
+import { differenceInCalendarDays } from 'date-fns'
 import { EntityViewsInfo } from '../entities/EntityViewsInfo'
 import { saveVideoEvent, VideoEventType, UnsequencedVideoEvent } from '../models/VideoEvent'
 import { OrionContext } from '../types'
@@ -15,6 +16,24 @@ class BatchedVideoViewsArgs {
   videoIdList: string[]
 }
 
+@ArgsType()
+class MostViewedVideosArgs {
+  @Field(() => Int, { nullable: true })
+  period?: number
+
+  @Field(() => Int, { nullable: true })
+  limit?: number
+}
+
+@ArgsType()
+class MostViewedChannelArgs {
+  @Field(() => Int, { nullable: true })
+  period?: number
+
+  @Field(() => Int, { nullable: true })
+  limit?: number
+}
+
 @ArgsType()
 class ChannelViewsArgs {
   @Field(() => ID)
@@ -51,6 +70,22 @@ export class VideoViewsInfosResolver {
     return videoIdList.map((videoId) => getVideoViewsInfo(videoId, ctx))
   }
 
+  @Query(() => [EntityViewsInfo], { nullable: true, description: 'Get most viewed list of videos' })
+  async mostViewedVideos(
+    @Args() { period, limit }: MostViewedVideosArgs,
+    @Ctx() ctx: OrionContext
+  ): Promise<EntityViewsInfo[]> {
+    return mapMostViewedArray(buildMostViewedVideosArray(ctx, period), limit)
+  }
+
+  @Query(() => [EntityViewsInfo], { nullable: true, description: 'Get most viewed list of channels' })
+  async mostViewedChannels(
+    @Args() { period, limit }: MostViewedChannelArgs,
+    @Ctx() ctx: OrionContext
+  ): Promise<EntityViewsInfo[]> {
+    return mapMostViewedArray(buildMostViewedChannelsArray(ctx, period), limit)
+  }
+
   @Query(() => EntityViewsInfo, { nullable: true, description: 'Get views count for a single channel' })
   async channelViews(
     @Args() { channelId }: ChannelViewsArgs,
@@ -87,6 +122,33 @@ export class VideoViewsInfosResolver {
   }
 }
 
+const mapMostViewedArray = (views: Record<string, number>, limit?: number) =>
+  Object.keys(views)
+    .map((id) => ({ id, views: views[id] }))
+    .sort((a, b) => (a.views > b.views ? -1 : 1))
+    .slice(0, limit)
+
+const filterAllViewsByPeriod = (ctx: OrionContext, period?: number): Partial<UnsequencedVideoEvent>[] => {
+  const views = ctx.viewsAggregate.getAllViewsEvents()
+  if (!period) return views
+  return views.filter(({ timestamp }) => {
+    if (!period) return true
+    return timestamp && differenceInCalendarDays(new Date(), timestamp) <= period
+  })
+}
+
+const buildMostViewedVideosArray = (ctx: OrionContext, period?: number) =>
+  filterAllViewsByPeriod(ctx, period).reduce(
+    (entity: Record<string, number>, { videoId = '' }) => ({ ...entity, [videoId]: (entity[videoId] || 0) + 1 }),
+    {}
+  )
+
+const buildMostViewedChannelsArray = (ctx: OrionContext, period?: number) =>
+  filterAllViewsByPeriod(ctx, period).reduce(
+    (entity: Record<string, number>, { channelId = '' }) => ({ ...entity, [channelId]: (entity[channelId] || 0) + 1 }),
+    {}
+  )
+
 const buildViewsObject = (id: string, views: number | null): EntityViewsInfo | null => {
   if (views != null) {
     return {

+ 30 - 0
tests/queries/views.ts

@@ -9,12 +9,27 @@ export const GET_VIDEO_VIEWS = gql`
     }
   }
 `
+
+export const GET_MOST_VIEWED_VIDEOS = gql`
+  query GetMostViewedVideos($period: Int) {
+    mostViewedVideos(period: $period) {
+      id
+      views
+    }
+  }
+`
 export type GetVideoViews = {
   videoViews: EntityViewsInfo | null
 }
+export type GetMostViewedVideos = {
+  mostViewedVideos: EntityViewsInfo[]
+}
 export type GetVideoViewsArgs = {
   videoId: string
 }
+export type GetMostViewedVideosArgs = {
+  period?: number
+}
 
 export const GET_CHANNEL_VIEWS = gql`
   query GetChannelViews($channelId: ID!) {
@@ -24,12 +39,27 @@ export const GET_CHANNEL_VIEWS = gql`
     }
   }
 `
+
+export const GET_MOST_VIEWED_CHANNELS = gql`
+  query GetMostViewedChannels($period: Int) {
+    mostViewedChannels(period: $period) {
+      id
+      views
+    }
+  }
+`
 export type GetChannelViews = {
   channelViews: EntityViewsInfo | null
 }
+export type GetMostViewedChannels = {
+  mostViewedChannels: EntityViewsInfo[]
+}
 export type GetChannelViewsArgs = {
   channelId: string
 }
+export type GetMostViewedChannelsArgs = {
+  period?: number
+}
 
 export const ADD_VIDEO_VIEW = gql`
   mutation AddVideoView($videoId: ID!, $channelId: ID!) {

+ 2 - 0
tests/server.test.ts

@@ -26,10 +26,12 @@ describe('The server', () => {
   it('should start with empty aggregates', async () => {
     const videoViewsMap = aggregates.viewsAggregate.getVideoViewsMap()
     const channelViewsMap = aggregates.viewsAggregate.getChannelViewsMap()
+    const allViewsEvents = aggregates.viewsAggregate.getAllViewsEvents()
     const channelFollowsMap = aggregates.followsAggregate.getChannelFollowsMap()
 
     expect(videoViewsMap).toEqual({})
     expect(channelViewsMap).toEqual({})
     expect(channelFollowsMap).toEqual({})
+    expect(allViewsEvents).toHaveLength(0)
   })
 })

+ 44 - 0
tests/views.test.ts

@@ -9,11 +9,17 @@ import {
   AddVideoView,
   AddVideoViewArgs,
   GET_CHANNEL_VIEWS,
+  GET_MOST_VIEWED_CHANNELS,
   GET_VIDEO_VIEWS,
+  GET_MOST_VIEWED_VIDEOS,
   GetChannelViews,
   GetChannelViewsArgs,
   GetVideoViews,
   GetVideoViewsArgs,
+  GetMostViewedVideosArgs,
+  GetMostViewedChannelsArgs,
+  GetMostViewedVideos,
+  GetMostViewedChannels,
 } from './queries/views'
 import { EntityViewsInfo } from '../src/entities/EntityViewsInfo'
 import { VideoEventsBucketModel } from '../src/models/VideoEvent'
@@ -64,6 +70,15 @@ describe('Video and channel views resolver', () => {
     return videoViewsResponse.data?.videoViews
   }
 
+  const getMostViewedVideos = async (period?: number) => {
+    const mostViewedVideosResponse = await query<GetMostViewedVideos, GetMostViewedVideosArgs>({
+      query: GET_MOST_VIEWED_VIDEOS,
+      variables: { period },
+    })
+    expect(mostViewedVideosResponse.errors).toBeUndefined()
+    return mostViewedVideosResponse.data?.mostViewedVideos
+  }
+
   const getChannelViews = async (channelId: string) => {
     const channelViewsResponse = await query<GetChannelViews, GetChannelViewsArgs>({
       query: GET_CHANNEL_VIEWS,
@@ -73,12 +88,25 @@ describe('Video and channel views resolver', () => {
     return channelViewsResponse.data?.channelViews
   }
 
+  const getMostViewedChannels = async (period?: number) => {
+    const mostViewedChannelsResponse = await query<GetMostViewedChannels, GetMostViewedChannelsArgs>({
+      query: GET_MOST_VIEWED_CHANNELS,
+      variables: { period },
+    })
+    expect(mostViewedChannelsResponse.errors).toBeUndefined()
+    return mostViewedChannelsResponse.data?.mostViewedChannels
+  }
+
   it('should return null for unknown video and channel views', async () => {
     const videoViews = await getVideoViews(FIRST_VIDEO_ID)
+    const mostViewedVideos = await getMostViewedVideos()
     const channelViews = await getChannelViews(FIRST_CHANNEL_ID)
+    const mostViewedChannels = await getMostViewedChannels()
 
     expect(videoViews).toBeNull()
+    expect(mostViewedVideos).toHaveLength(0)
     expect(channelViews).toBeNull()
+    expect(mostViewedChannels).toHaveLength(0)
   })
 
   it('should properly save video and channel views', async () => {
@@ -92,10 +120,14 @@ describe('Video and channel views resolver', () => {
     }
     const checkViews = async () => {
       const videoViews = await getVideoViews(FIRST_VIDEO_ID)
+      const mostViewedVideos = await getMostViewedVideos()
       const channelViews = await getChannelViews(FIRST_CHANNEL_ID)
+      const mostViewedChannels = await getMostViewedChannels()
 
       expect(videoViews).toEqual(expectedVideoViews)
+      expect(mostViewedVideos).toEqual([expectedVideoViews])
       expect(channelViews).toEqual(expectedChannelViews)
+      expect(mostViewedChannels).toEqual([expectedChannelViews])
     }
 
     let addVideoViewData = await addVideoView(FIRST_VIDEO_ID, FIRST_CHANNEL_ID)
@@ -134,9 +166,11 @@ describe('Video and channel views resolver', () => {
 
     const firstVideoViews = await getVideoViews(FIRST_VIDEO_ID)
     const secondVideoViews = await getVideoViews(SECOND_VIDEO_ID)
+    const mostViewedVideos = await getMostViewedVideos()
 
     expect(firstVideoViews).toEqual(expectedFirstVideoViews)
     expect(secondVideoViews).toEqual(expectedSecondVideoViews)
+    expect(mostViewedVideos).toEqual([expectedFirstVideoViews, expectedSecondVideoViews])
   })
 
   it('should distinct views of separate channels', async () => {
@@ -154,9 +188,11 @@ describe('Video and channel views resolver', () => {
 
     const firstChannelViews = await getChannelViews(FIRST_CHANNEL_ID)
     const secondChannelViews = await getChannelViews(SECOND_CHANNEL_ID)
+    const mostViewedChannels = await getMostViewedChannels()
 
     expect(firstChannelViews).toEqual(expectedFirstChanelViews)
     expect(secondChannelViews).toEqual(expectedSecondChannelViews)
+    expect(mostViewedChannels).toEqual([expectedFirstChanelViews, expectedSecondChannelViews])
   })
 
   it('should properly aggregate views of a channel', async () => {
@@ -169,8 +205,10 @@ describe('Video and channel views resolver', () => {
     await addVideoView(SECOND_VIDEO_ID, FIRST_CHANNEL_ID)
 
     const channelViews = await getChannelViews(FIRST_CHANNEL_ID)
+    const mostViewedChannels = await getMostViewedChannels()
 
     expect(channelViews).toEqual(expectedChannelViews)
+    expect(mostViewedChannels).toEqual([expectedChannelViews])
   })
 
   it('should properly rebuild the aggregate', async () => {
@@ -191,10 +229,14 @@ describe('Video and channel views resolver', () => {
       const firstVideoViews = await getVideoViews(FIRST_VIDEO_ID)
       const secondVideoViews = await getVideoViews(SECOND_VIDEO_ID)
       const channelViews = await getChannelViews(FIRST_CHANNEL_ID)
+      const mostViewedVideos = await getMostViewedVideos()
+      const mostViewedChannels = await getMostViewedChannels()
 
       expect(firstVideoViews).toEqual(expectedFirstVideoViews)
       expect(secondVideoViews).toEqual(expectedSecondVideoViews)
+      expect(mostViewedVideos).toEqual([expectedSecondVideoViews, expectedFirstVideoViews])
       expect(channelViews).toEqual(expectedChannelViews)
+      expect(mostViewedChannels).toEqual([expectedChannelViews])
     }
 
     await addVideoView(FIRST_VIDEO_ID, FIRST_CHANNEL_ID)
@@ -230,6 +272,8 @@ describe('Video and channel views resolver', () => {
     }
 
     const videoViews = await getVideoViews(FIRST_VIDEO_ID)
+    const mostViewedVideos = await getMostViewedVideos()
     expect(videoViews).toEqual(expectedVideoViews)
+    expect(mostViewedVideos).toEqual([expectedVideoViews])
   })
 })

+ 5 - 0
yarn.lock

@@ -2217,6 +2217,11 @@ data-urls@^2.0.0:
     whatwg-mimetype "^2.3.0"
     whatwg-url "^8.0.0"
 
+date-fns@^2.22.1:
+  version "2.22.1"
+  resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.22.1.tgz#1e5af959831ebb1d82992bf67b765052d8f0efc4"
+  integrity sha512-yUFPQjrxEmIsMqlHhAhmxkuH769baF21Kk+nZwZGyrMoyLA+LugaQtC0+Tqf9CBUUULWwUJt6Q5ySI3LJDDCGg==
+
 dateformat@~1.0.4-1.2.3:
   version "1.0.12"
   resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-1.0.12.tgz#9f124b67594c937ff706932e4a642cca8dbbfee9"