musora-content-services 2.185.0 → 2.186.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent/decisions/2026-10-01-agi-remove-limit-from-queries.md +60 -0
- package/.claude/settings.local.json +9 -14
- package/CHANGELOG.md +10 -0
- package/jest.config.js +0 -1
- package/package.json +1 -1
- package/src/index.d.ts +17 -2
- package/src/index.js +17 -2
- package/src/lib/ads/async-either.ts +148 -0
- package/src/lib/ads/coproduct.ts +36 -0
- package/src/lib/sanity/decorators/navigate-to.ts +20 -14
- package/src/lib/sanity/examples.ts +27 -31
- package/src/lib/sanity/filter.ts +62 -0
- package/src/lib/sanity/groq.ts +60 -19
- package/src/lib/sanity/runner.ts +3 -2
- package/src/lib/sanity/types/marketing.d.ts +33 -0
- package/src/services/content/artist.ts +4 -3
- package/src/services/content/genre.ts +4 -3
- package/src/services/content/instructor.ts +5 -4
- package/src/services/content.js +181 -191
- package/src/services/endScreen/endScreen.ts +1 -1
- package/src/services/marketing/promotions.ts +178 -0
- package/src/services/recommendations.ts +307 -0
- package/src/services/sanity.js +1 -1
- package/src/services/user/account.ts +23 -0
- package/src/version-info.js +1 -1
- package/.yarn/install-state.gz +0 -0
- package/dist/services/api/types.js +0 -2
- package/src/services/recommendations.js +0 -142
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { GET, POST } from '../../infrastructure/http/HttpClient'
|
|
2
|
+
import { decorateAsync } from '../../lib/sanity/decorators/base'
|
|
3
|
+
import type { Decoratable, FieldDecoratorAsync } from '../../lib/sanity/decorators/base'
|
|
4
|
+
import { Filters as f } from '../../lib/sanity/filter'
|
|
5
|
+
import { groq } from '../../lib/sanity/groq'
|
|
6
|
+
import type { GroqBuilder } from '../../lib/sanity/groq'
|
|
7
|
+
import type { PromotionalCampaignDocument } from '../../lib/sanity/types/marketing'
|
|
8
|
+
import { SanityQueryError } from '@/lib/sanity/runner'
|
|
9
|
+
import { AsyncEither } from '@/lib/ads/async-either'
|
|
10
|
+
|
|
11
|
+
const excludeFromGeneratedIndex = [
|
|
12
|
+
'campaignQuery',
|
|
13
|
+
'campaignProjection',
|
|
14
|
+
'userCampaignStateDecorator',
|
|
15
|
+
'decorateUserCampaignState',
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
// ============================================
|
|
19
|
+
// TYPES
|
|
20
|
+
// ============================================
|
|
21
|
+
|
|
22
|
+
export const USER_CAMPAIGN_STATE_FIELD = 'user_state' as const
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A campaign's placements are authored in Sanity, but whether a user still sees one is answered by
|
|
26
|
+
* the backend. `decorateUserCampaignState` attaches the second to the first.
|
|
27
|
+
*/
|
|
28
|
+
export interface UserCampaignState {
|
|
29
|
+
claimed: boolean
|
|
30
|
+
dismissed: boolean
|
|
31
|
+
eligible: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type WithUserCampaignState<T extends PromotionalCampaignDocument> = T & {
|
|
35
|
+
user_state: UserCampaignState
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CampaignQueryOptions {
|
|
39
|
+
/** Restricts the query to one campaign, or to any of several. */
|
|
40
|
+
slug?: string | string[]
|
|
41
|
+
/** The moment the campaign window is tested against. Defaults to now. */
|
|
42
|
+
on?: Date
|
|
43
|
+
/** Set false to return campaigns whose window does not cover `on`. */
|
|
44
|
+
running?: boolean
|
|
45
|
+
/** Set false to include campaigns that are toggled off in Sanity. */
|
|
46
|
+
active?: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const UNTOUCHED: UserCampaignState = { claimed: false, dismissed: false, eligible: true }
|
|
50
|
+
|
|
51
|
+
// ============================================
|
|
52
|
+
// UTILITIES
|
|
53
|
+
// ============================================
|
|
54
|
+
|
|
55
|
+
const filterCampaignsForUser = (campaigns: WithUserCampaignState<PromotionalCampaignDocument>[]) =>
|
|
56
|
+
campaigns.filter(
|
|
57
|
+
(campaign) =>
|
|
58
|
+
campaign.user_state.eligible && !campaign.user_state.claimed && !campaign.user_state.dismissed
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
// ============================================
|
|
62
|
+
// QUERY BUILDING
|
|
63
|
+
// ============================================
|
|
64
|
+
|
|
65
|
+
export const campaignProjection: string[] = [
|
|
66
|
+
'...',
|
|
67
|
+
'placements[]{ ..., "dark_mode_image": dark_mode_image.asset->url, "light_mode_image": light_mode_image.asset->url }',
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
function slugFilter(slug: CampaignQueryOptions['slug']): string {
|
|
71
|
+
const slugs = (Array.isArray(slug) ? slug : [slug]).filter(Boolean) as string[]
|
|
72
|
+
|
|
73
|
+
if (!slugs.length) return f.empty
|
|
74
|
+
|
|
75
|
+
return slugs.length === 1 ? f.slug(slugs[0]) : f.slugIn(slugs)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function campaignQuery(
|
|
79
|
+
options: CampaignQueryOptions = {}
|
|
80
|
+
): GroqBuilder<PromotionalCampaignDocument[]> {
|
|
81
|
+
const { slug, on = new Date(), running = true, active = true } = options
|
|
82
|
+
|
|
83
|
+
return groq<PromotionalCampaignDocument[]>()
|
|
84
|
+
.and(
|
|
85
|
+
f.combine(
|
|
86
|
+
f.type('promotional-campaign'),
|
|
87
|
+
active && f.flag('active'),
|
|
88
|
+
running && f.dateWithin('starts_at', 'ends_at', on.toISOString()),
|
|
89
|
+
slugFilter(slug)
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
.order('starts_at desc')
|
|
93
|
+
.select(...campaignProjection)
|
|
94
|
+
.default([])
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ============================================
|
|
98
|
+
// FETCHING
|
|
99
|
+
// ============================================
|
|
100
|
+
|
|
101
|
+
export async function fetchPromotionalCampaigns(
|
|
102
|
+
options: CampaignQueryOptions = {}
|
|
103
|
+
): Promise<PromotionalCampaignDocument[]> {
|
|
104
|
+
return campaignQuery(options).run().recover([])
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Every running campaign the user has not already claimed or dismissed, and is eligible.
|
|
109
|
+
*/
|
|
110
|
+
export async function fetchAvailablePromotionalCampaigns(
|
|
111
|
+
options: CampaignQueryOptions = {}
|
|
112
|
+
): Promise<WithUserCampaignState<PromotionalCampaignDocument>[]> {
|
|
113
|
+
return campaignQuery(options)
|
|
114
|
+
.run()
|
|
115
|
+
.flatMap(decorateUserCampaignState)
|
|
116
|
+
.map(filterCampaignsForUser)
|
|
117
|
+
.recover([])
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Fetches the state of every given campaign in a single request, keyed by slug. The backend leaves
|
|
122
|
+
* out slugs it has no campaign for, so a missing key means the campaign is unknown to it.
|
|
123
|
+
*/
|
|
124
|
+
export async function fetchUserCampaignStates(
|
|
125
|
+
slugs: string[]
|
|
126
|
+
): Promise<Record<string, UserCampaignState>> {
|
|
127
|
+
if (!slugs.length) return {}
|
|
128
|
+
|
|
129
|
+
const query = slugs.map((slug) => `slugs[]=${encodeURIComponent(slug)}`).join('&')
|
|
130
|
+
|
|
131
|
+
return (await GET(`/api/promotions/v1/campaigns/state?${query}`)) ?? {}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ============================================
|
|
135
|
+
// DECORATION
|
|
136
|
+
// ============================================
|
|
137
|
+
|
|
138
|
+
export function userCampaignStateDecorator(
|
|
139
|
+
states: Record<string, UserCampaignState>
|
|
140
|
+
): FieldDecoratorAsync<Decoratable, typeof USER_CAMPAIGN_STATE_FIELD, UserCampaignState> {
|
|
141
|
+
return {
|
|
142
|
+
field: USER_CAMPAIGN_STATE_FIELD,
|
|
143
|
+
compute: async (item) => {
|
|
144
|
+
const campaign = item as unknown as PromotionalCampaignDocument
|
|
145
|
+
|
|
146
|
+
return states[campaign.slug?.current] ?? UNTOUCHED
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Attaches each campaign's claim and dismiss state in a single backend request.
|
|
153
|
+
*/
|
|
154
|
+
export function decorateUserCampaignState<T extends PromotionalCampaignDocument>(
|
|
155
|
+
campaigns: T[]
|
|
156
|
+
): AsyncEither<Error, WithUserCampaignState<T>[]> {
|
|
157
|
+
return AsyncEither.attempt(async () => {
|
|
158
|
+
if (!campaigns.length) return []
|
|
159
|
+
|
|
160
|
+
const states = await fetchUserCampaignStates(campaigns.map((campaign) => campaign.slug.current))
|
|
161
|
+
const { field, compute } = userCampaignStateDecorator(states)
|
|
162
|
+
const decorated = await decorateAsync(campaigns as unknown as Decoratable[], field, compute)
|
|
163
|
+
|
|
164
|
+
return decorated as unknown as WithUserCampaignState<T>[]
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ============================================
|
|
169
|
+
// MUTATIONS
|
|
170
|
+
// ============================================
|
|
171
|
+
|
|
172
|
+
export async function claimPromotionalCampaign(slug: string): Promise<void> {
|
|
173
|
+
await POST(`/api/promotions/v1/campaigns/${encodeURIComponent(slug)}/claim`)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function dismissPromotionalCampaign(slug: string): Promise<void> {
|
|
177
|
+
await POST(`/api/promotions/v1/campaigns/${encodeURIComponent(slug)}/dismiss`)
|
|
178
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Recommendations
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { TabResponseType } from '../contentMetaData.js'
|
|
6
|
+
import { getFieldsForContentTypeWithFilteredChildren } from '../contentTypeConfig.js'
|
|
7
|
+
import { decorateAll, type FieldDecorator } from '../lib/sanity/decorators/base'
|
|
8
|
+
import { accessDecorator, type WithNeedAccess } from '../lib/sanity/decorators/need-access'
|
|
9
|
+
import {
|
|
10
|
+
lifetimeUpgradeDecorator,
|
|
11
|
+
type WithNeedLifetimeUpgrade,
|
|
12
|
+
} from '../lib/sanity/decorators/need-lifetime-upgrade'
|
|
13
|
+
import { decorateNavigateTo, type WithNavigateTo } from '../lib/sanity/decorators/navigate-to'
|
|
14
|
+
import { pageTypeDecorator, type WithPageType } from '../lib/sanity/decorators/page-type'
|
|
15
|
+
import { Filters as f } from '../lib/sanity/filter'
|
|
16
|
+
import { groq } from '../lib/sanity/groq'
|
|
17
|
+
import { globalConfig } from './config.js'
|
|
18
|
+
import { GET, HttpClient } from '../infrastructure/http/HttpClient'
|
|
19
|
+
import { fetchUserPermissions, UserPermissions } from './permissions/index'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @type {string[]}
|
|
23
|
+
*/
|
|
24
|
+
const excludeFromGeneratedIndex = []
|
|
25
|
+
|
|
26
|
+
const RECOMMENDED_ROW_ID = 'recommended'
|
|
27
|
+
const RECOMMENDED_CONTENT_TYPE = 'tab-data'
|
|
28
|
+
|
|
29
|
+
const RECOMMENDER_URL = 'https://recommender.musora.com'
|
|
30
|
+
const recommenderClient = new HttpClient(RECOMMENDER_URL, null, null, null, 'omit')
|
|
31
|
+
|
|
32
|
+
interface RecommendedContent {
|
|
33
|
+
id: number
|
|
34
|
+
type: string
|
|
35
|
+
brand: string
|
|
36
|
+
thumbnail: string
|
|
37
|
+
published_on: string | null
|
|
38
|
+
status: string
|
|
39
|
+
live_event_start_time?: string | null
|
|
40
|
+
live_event_end_time?: string | null
|
|
41
|
+
children?: RecommendedContent[]
|
|
42
|
+
[key: string]: unknown
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type DecoratedRecommendedContent = WithNeedLifetimeUpgrade<
|
|
46
|
+
WithPageType<WithNeedAccess<RecommendedContent>>
|
|
47
|
+
> & { isLive: boolean }
|
|
48
|
+
|
|
49
|
+
type NavigableRecommendedContent = WithNavigateTo<DecoratedRecommendedContent>
|
|
50
|
+
|
|
51
|
+
interface RecommendedRow {
|
|
52
|
+
id: string
|
|
53
|
+
title: string
|
|
54
|
+
items: NavigableRecommendedContent[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface RecommendedCatalog {
|
|
58
|
+
type: string
|
|
59
|
+
data: NavigableRecommendedContent[]
|
|
60
|
+
meta: Record<string, never>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RecommendedForYouOptions {
|
|
64
|
+
page?: number
|
|
65
|
+
limit?: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RecommendationsOptions {
|
|
69
|
+
section?: string
|
|
70
|
+
contentTypes?: string[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RankedCategory {
|
|
74
|
+
slug: string
|
|
75
|
+
items: number[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type CategoriesToRank = Record<string, number[]>
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {number|string} contentId
|
|
82
|
+
* @param {string} brand
|
|
83
|
+
* @param {number} [count=10]
|
|
84
|
+
* @returns {Promise<number[]|null>}
|
|
85
|
+
* @example
|
|
86
|
+
* fetchSimilarItems(1113, 'drumeo')
|
|
87
|
+
* .then(ids => console.log(ids))
|
|
88
|
+
* .catch(error => console.error(error));
|
|
89
|
+
*/
|
|
90
|
+
export async function fetchSimilarItems(
|
|
91
|
+
contentId: number | string,
|
|
92
|
+
brand: string,
|
|
93
|
+
count: number = 10
|
|
94
|
+
): Promise<number[] | null> {
|
|
95
|
+
if (!contentId) {
|
|
96
|
+
return []
|
|
97
|
+
}
|
|
98
|
+
const id = parseInt(String(contentId))
|
|
99
|
+
const data = {
|
|
100
|
+
brand,
|
|
101
|
+
content_ids: id,
|
|
102
|
+
num_similar: count + 1,
|
|
103
|
+
page_size: count + 1,
|
|
104
|
+
page: 1,
|
|
105
|
+
exclude_interacted: true,
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const response = await recommenderClient.post('/similar_items/', data)
|
|
109
|
+
return response['similar_items'].filter((item: number) => item !== id).slice(0, count)
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error('Fetch error:', error)
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {string} brand
|
|
118
|
+
* @param {CategoriesToRank} categories
|
|
119
|
+
* @returns {Promise<RankedCategory[]>}
|
|
120
|
+
* @example
|
|
121
|
+
* rankCategories('drumeo', { 1: [111222, 23120], 2: [2222, 33333] })
|
|
122
|
+
* .then(categories => console.log(categories))
|
|
123
|
+
* .catch(error => console.error(error));
|
|
124
|
+
*/
|
|
125
|
+
export async function rankCategories(
|
|
126
|
+
brand: string,
|
|
127
|
+
categories: CategoriesToRank
|
|
128
|
+
): Promise<RankedCategory[]> {
|
|
129
|
+
const data = {
|
|
130
|
+
brand,
|
|
131
|
+
user_id: globalConfig.sessionConfig.userId,
|
|
132
|
+
playlists: categories,
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const response = await recommenderClient.post('/rank_each_list/', data)
|
|
136
|
+
return response['ranked_playlists'].map((rankedPlaylist: any) => ({
|
|
137
|
+
slug: rankedPlaylist.playlist_id,
|
|
138
|
+
items: rankedPlaylist.ranked_items,
|
|
139
|
+
}))
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.error('RankCategories fetch error:', error)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return Object.entries(categories).map(([slug, items]) => ({ slug, items }))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {string} brand
|
|
149
|
+
* @param {number[]} contentIds
|
|
150
|
+
* @returns {Promise<number[]>}
|
|
151
|
+
* @example
|
|
152
|
+
* rankItems('drumeo', [111222, 23120, 402199])
|
|
153
|
+
* .then(ids => console.log(ids))
|
|
154
|
+
* .catch(error => console.error(error));
|
|
155
|
+
*/
|
|
156
|
+
export async function rankItems(brand: string, contentIds: number[]): Promise<number[]> {
|
|
157
|
+
if (contentIds.length === 0) {
|
|
158
|
+
return []
|
|
159
|
+
}
|
|
160
|
+
const data = {
|
|
161
|
+
brand,
|
|
162
|
+
user_id: globalConfig.sessionConfig.userId,
|
|
163
|
+
content_ids: contentIds,
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const response = await recommenderClient.post('/rank_items/', data)
|
|
167
|
+
return response['ranked_content_ids']
|
|
168
|
+
} catch (error) {
|
|
169
|
+
console.error('rankItems fetch error:', error)
|
|
170
|
+
return contentIds
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {string} brand
|
|
176
|
+
* @param {RecommendationsOptions} [options={}]
|
|
177
|
+
* @returns {Promise<number[]>}
|
|
178
|
+
* @example
|
|
179
|
+
* recommendations('drumeo', { section: 'lessons', contentTypes: ['course'] })
|
|
180
|
+
* .then(ids => console.log(ids))
|
|
181
|
+
* .catch(error => console.error(error));
|
|
182
|
+
*/
|
|
183
|
+
export async function recommendations(
|
|
184
|
+
brand: string,
|
|
185
|
+
{ section = '', contentTypes = [] }: RecommendationsOptions = {}
|
|
186
|
+
): Promise<number[]> {
|
|
187
|
+
const sectionParam = section.toUpperCase().replace('-', '_')
|
|
188
|
+
const sectionString = sectionParam ? `§ion=${sectionParam}` : ''
|
|
189
|
+
const contentTypesString = contentTypes
|
|
190
|
+
.map((type) => `&content_types[]=${encodeURIComponent(type)}`)
|
|
191
|
+
.join('')
|
|
192
|
+
|
|
193
|
+
return await GET(
|
|
194
|
+
`/api/content/v1/recommendations?brand=${brand}${sectionString}${contentTypesString}`
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const isLiveDecorator: FieldDecorator<RecommendedContent, 'isLive', boolean> = {
|
|
199
|
+
field: 'isLive',
|
|
200
|
+
recurse: false,
|
|
201
|
+
compute: (content) => {
|
|
202
|
+
const now = new Date().toISOString()
|
|
203
|
+
return Boolean(
|
|
204
|
+
content.live_event_start_time &&
|
|
205
|
+
content.live_event_end_time &&
|
|
206
|
+
content.live_event_start_time <= now &&
|
|
207
|
+
content.live_event_end_time >= now
|
|
208
|
+
)
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const uniqueIds = (ids: Array<number | null | undefined>): number[] => [
|
|
213
|
+
...new Set(ids.filter((id) => id !== null && id !== undefined) as number[]),
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
const sortByRecommendedOrder =
|
|
217
|
+
(ids: number[]) =>
|
|
218
|
+
<T extends { id: number }>(content: T[]): T[] =>
|
|
219
|
+
content.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id))
|
|
220
|
+
|
|
221
|
+
const decorateRecommendations =
|
|
222
|
+
(permissions: UserPermissions) =>
|
|
223
|
+
(contents: RecommendedContent[]): DecoratedRecommendedContent[] =>
|
|
224
|
+
decorateAll(contents, [
|
|
225
|
+
accessDecorator(permissions),
|
|
226
|
+
lifetimeUpgradeDecorator(permissions),
|
|
227
|
+
pageTypeDecorator,
|
|
228
|
+
isLiveDecorator,
|
|
229
|
+
]) as DecoratedRecommendedContent[]
|
|
230
|
+
|
|
231
|
+
async function fetchRecommendedContent(
|
|
232
|
+
ids: number[],
|
|
233
|
+
brand: string
|
|
234
|
+
): Promise<NavigableRecommendedContent[]> {
|
|
235
|
+
if (!ids.length) return []
|
|
236
|
+
|
|
237
|
+
const [restrictions, lessonCountFilter, fields, permissions] = await Promise.all([
|
|
238
|
+
f.combineAsync(
|
|
239
|
+
f.idIn(ids),
|
|
240
|
+
brand ? f.brand(brand) : f.empty,
|
|
241
|
+
f.status(),
|
|
242
|
+
f.notDeprecated(),
|
|
243
|
+
f.permissions({ showMembershipRestrictedContent: true })
|
|
244
|
+
),
|
|
245
|
+
f.combineAsync('_id in ^.child[]._ref', f.status(), f.notDeprecated(), f.permissions()),
|
|
246
|
+
getFieldsForContentTypeWithFilteredChildren(RECOMMENDED_CONTENT_TYPE, false) as Promise<
|
|
247
|
+
string[]
|
|
248
|
+
>,
|
|
249
|
+
fetchUserPermissions(),
|
|
250
|
+
])
|
|
251
|
+
|
|
252
|
+
return groq<RecommendedContent[]>()
|
|
253
|
+
.and(restrictions)
|
|
254
|
+
.select(
|
|
255
|
+
...fields,
|
|
256
|
+
`"lesson_count": coalesce(${f.count(lessonCountFilter)}, 0)`,
|
|
257
|
+
'live_event_start_time',
|
|
258
|
+
'live_event_end_time'
|
|
259
|
+
)
|
|
260
|
+
.default([])
|
|
261
|
+
.run()
|
|
262
|
+
.map(decorateRecommendations(permissions))
|
|
263
|
+
.mapAsync((contents) => decorateNavigateTo(contents))
|
|
264
|
+
.map(sortByRecommendedOrder(ids))
|
|
265
|
+
.recover([])
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* @param {string} brand
|
|
270
|
+
* @param {string|null} [rowId=null]
|
|
271
|
+
* @param {Object} [params={}]
|
|
272
|
+
* @param {number} [params.page=1]
|
|
273
|
+
* @param {number} [params.limit=10]
|
|
274
|
+
* @returns {Promise<RecommendedRow|RecommendedCatalog>}
|
|
275
|
+
* @example
|
|
276
|
+
* getRecommendedForYou('drumeo')
|
|
277
|
+
* .then(content => console.log(content))
|
|
278
|
+
* .catch(error => console.error(error));
|
|
279
|
+
* @example
|
|
280
|
+
* getRecommendedForYou('drumeo', null, { page: 2, limit: 5 })
|
|
281
|
+
* .then(content => console.log(content))
|
|
282
|
+
* .catch(error => console.error(error));
|
|
283
|
+
*/
|
|
284
|
+
export async function getRecommendedForYou(
|
|
285
|
+
brand: string,
|
|
286
|
+
rowId: string | null = null,
|
|
287
|
+
{ page = 1, limit = 10 }: RecommendedForYouOptions = {}
|
|
288
|
+
): Promise<RecommendedRow | RecommendedCatalog> {
|
|
289
|
+
const title = brand === 'playbass' ? 'You Might Like' : 'Recommended For You'
|
|
290
|
+
const data = await recommendations(brand)
|
|
291
|
+
|
|
292
|
+
if (!data || !data.length) {
|
|
293
|
+
return { id: RECOMMENDED_ROW_ID, title, items: [] }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const offset = (page - 1) * limit
|
|
297
|
+
const contents = await fetchRecommendedContent(
|
|
298
|
+
uniqueIds(data.slice(offset, offset + limit)),
|
|
299
|
+
brand
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
if (rowId) {
|
|
303
|
+
return { type: TabResponseType.CATALOG, data: contents, meta: {} }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return { id: RECOMMENDED_ROW_ID, title, items: contents }
|
|
307
|
+
}
|
package/src/services/sanity.js
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
transcriptionsLessonTypes,
|
|
34
34
|
tutorialsLessonTypes,
|
|
35
35
|
} from '../contentTypeConfig.js'
|
|
36
|
-
import { fetchSimilarItems } from './recommendations
|
|
36
|
+
import { fetchSimilarItems } from './recommendations'
|
|
37
37
|
import {
|
|
38
38
|
ALWAYS_VISIBLE_TABS,
|
|
39
39
|
CONTENT_STATUSES,
|
|
@@ -9,6 +9,8 @@ import { Onboarding } from './onboarding'
|
|
|
9
9
|
import { OAuthProvider } from './session'
|
|
10
10
|
import { AuthResponse } from './types'
|
|
11
11
|
|
|
12
|
+
const LOCAL_PART_MASK = '***'
|
|
13
|
+
|
|
12
14
|
export interface AccountStatus {
|
|
13
15
|
requires_setup: boolean
|
|
14
16
|
last_login_provider?: OAuthProvider
|
|
@@ -256,3 +258,24 @@ export async function toggleStudentView(useStudentView: boolean): Promise<UserRe
|
|
|
256
258
|
const httpClient = new HttpClient(globalConfig.baseUrl, globalConfig.sessionConfig.token)
|
|
257
259
|
return httpClient.patch<UserResource>(apiUrl, { use_student_view: useStudentView })
|
|
258
260
|
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Masks the local part of an email, keeping the domain intact.
|
|
264
|
+
* Shows the first character of the local part unless it's 3 characters or shorter,
|
|
265
|
+
* in which case the whole local part is masked.
|
|
266
|
+
* @example
|
|
267
|
+
* maskEmail('abcd@gmail.com') // 'a***@gmail.com'
|
|
268
|
+
* maskEmail('ab@abc.com') // '***@abc.com'
|
|
269
|
+
*/
|
|
270
|
+
export function maskEmail(email: string): string {
|
|
271
|
+
const atIndex = email.indexOf('@')
|
|
272
|
+
if (atIndex === -1) {
|
|
273
|
+
return email
|
|
274
|
+
}
|
|
275
|
+
const localPart = email.slice(0, atIndex)
|
|
276
|
+
const domain = email.slice(atIndex)
|
|
277
|
+
|
|
278
|
+
return localPart.length <= 3
|
|
279
|
+
? `${LOCAL_PART_MASK}${domain}`
|
|
280
|
+
: `${localPart[0]}${LOCAL_PART_MASK}${domain}`
|
|
281
|
+
}
|
package/src/version-info.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const MCS_VERSION = '2.
|
|
1
|
+
export const MCS_VERSION = '2.186.0'
|
package/.yarn/install-state.gz
DELETED
|
Binary file
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module Railcontent-Services
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { globalConfig } from './config.js'
|
|
6
|
-
import { GET, HttpClient } from '../infrastructure/http/HttpClient.ts'
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Exported functions that are excluded from index generation.
|
|
10
|
-
*
|
|
11
|
-
* @type {string[]}
|
|
12
|
-
*/
|
|
13
|
-
const excludeFromGeneratedIndex = []
|
|
14
|
-
|
|
15
|
-
const RECOMMENDER_URL = 'https://recommender.musora.com'
|
|
16
|
-
const recommenderClient = new HttpClient(RECOMMENDER_URL, null, null, null, 'omit')
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Fetches similar content to the provided content id
|
|
20
|
-
*
|
|
21
|
-
* @param {integer} content_id - The ID of the content to find similar items for
|
|
22
|
-
* @param {brand} brand - brand of the content to filter
|
|
23
|
-
* @param {integer} count - number of items to return
|
|
24
|
-
* @returns {Promise<Object|null>} - Returns the content_ids sorted by rank (most significant first)
|
|
25
|
-
* @example
|
|
26
|
-
* rankItems('drumeo', 1113)
|
|
27
|
-
* .then(status => console.log(status))
|
|
28
|
-
* .catch(error => console.error(error));
|
|
29
|
-
*/
|
|
30
|
-
export async function fetchSimilarItems(content_id, brand, count = 10) {
|
|
31
|
-
if (!content_id) {
|
|
32
|
-
return []
|
|
33
|
-
}
|
|
34
|
-
content_id = parseInt(content_id)
|
|
35
|
-
const data = {
|
|
36
|
-
brand: brand,
|
|
37
|
-
content_ids: content_id,
|
|
38
|
-
num_similar: count + 1,
|
|
39
|
-
page_size: count + 1,
|
|
40
|
-
page: 1,
|
|
41
|
-
exclude_interacted: true,
|
|
42
|
-
}
|
|
43
|
-
const url = `/similar_items/`
|
|
44
|
-
try {
|
|
45
|
-
const response = await recommenderClient.post(url, data)
|
|
46
|
-
return response['similar_items'].filter((item) => item !== content_id).slice(0, count)
|
|
47
|
-
} catch (error) {
|
|
48
|
-
console.error('Fetch error:', error)
|
|
49
|
-
return null
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Sorts the provided categories based on the user's match
|
|
55
|
-
*
|
|
56
|
-
* @param {brand} brand - brand of the content to filter
|
|
57
|
-
* @param {Object} categories - Keyed arrays of content ids
|
|
58
|
-
* @returns {Promise<Object|null>} - Returns the content_ids sorted by rank (most significant first), elements in each category are also ranked
|
|
59
|
-
* @example
|
|
60
|
-
* rankCategories('drumeo', {
|
|
61
|
-
* 1: [111222, 23120, 402199],
|
|
62
|
-
* 2: [2222, 33333, 44444]
|
|
63
|
-
* }
|
|
64
|
-
* )
|
|
65
|
-
* .then(status => console.log(status))
|
|
66
|
-
* .catch(error => console.error(error));
|
|
67
|
-
*/
|
|
68
|
-
export async function rankCategories(brand, categories) {
|
|
69
|
-
if (categories.length === 0) {
|
|
70
|
-
return []
|
|
71
|
-
}
|
|
72
|
-
const data = {
|
|
73
|
-
brand: brand,
|
|
74
|
-
user_id: globalConfig.sessionConfig.userId,
|
|
75
|
-
playlists: categories,
|
|
76
|
-
}
|
|
77
|
-
const url = `/rank_each_list/`
|
|
78
|
-
try {
|
|
79
|
-
const response = await recommenderClient.post(url, data)
|
|
80
|
-
const rankedCategories = []
|
|
81
|
-
|
|
82
|
-
for (const rankedPlaylist of response['ranked_playlists']) {
|
|
83
|
-
rankedCategories.push({
|
|
84
|
-
slug: rankedPlaylist.playlist_id,
|
|
85
|
-
items: rankedPlaylist.ranked_items,
|
|
86
|
-
})
|
|
87
|
-
}
|
|
88
|
-
return rankedCategories
|
|
89
|
-
} catch (error) {
|
|
90
|
-
console.error('RankCategories fetch error:', error)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const defaultSorting = []
|
|
94
|
-
for (const slug in categories) {
|
|
95
|
-
defaultSorting.push({
|
|
96
|
-
slug: slug,
|
|
97
|
-
items: categories[slug],
|
|
98
|
-
})
|
|
99
|
-
}
|
|
100
|
-
return defaultSorting
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Fetches the completion status of a specific lesson for the current user.
|
|
105
|
-
*
|
|
106
|
-
* @param {brand} brand - brand of the content to filter
|
|
107
|
-
* @param {Array<string>} content_ids - The IDs of the content to rank
|
|
108
|
-
* @returns {Promise<Object|null>} - Returns the content_ids sorted by rank (most significant first)
|
|
109
|
-
* @example
|
|
110
|
-
* rankItems('drumeo', ([111222, 23120, 402199])
|
|
111
|
-
* .then(status => console.log(status))
|
|
112
|
-
* .catch(error => console.error(error));
|
|
113
|
-
*/
|
|
114
|
-
export async function rankItems(brand, content_ids) {
|
|
115
|
-
if (content_ids.length === 0) {
|
|
116
|
-
return []
|
|
117
|
-
}
|
|
118
|
-
const data = {
|
|
119
|
-
brand: brand,
|
|
120
|
-
user_id: globalConfig.sessionConfig.userId,
|
|
121
|
-
content_ids: content_ids,
|
|
122
|
-
}
|
|
123
|
-
const url = `/rank_items/`
|
|
124
|
-
try {
|
|
125
|
-
const response = await recommenderClient.post(url, data)
|
|
126
|
-
return response['ranked_content_ids']
|
|
127
|
-
} catch (error) {
|
|
128
|
-
console.error('rankItems fetch error:', error)
|
|
129
|
-
return content_ids
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export async function recommendations(brand, { section = '', contentTypes = [] } = {}) {
|
|
134
|
-
section = section.toUpperCase().replace('-', '_')
|
|
135
|
-
const sectionString = section ? `§ion=${section}` : ''
|
|
136
|
-
const contentTypesString =
|
|
137
|
-
contentTypes.length > 0
|
|
138
|
-
? contentTypes.map((type) => `&content_types[]=${encodeURIComponent(type)}`).join('')
|
|
139
|
-
: ''
|
|
140
|
-
const url = `/api/content/v1/recommendations?brand=${brand}${sectionString}${contentTypesString}`
|
|
141
|
-
return await GET(url)
|
|
142
|
-
}
|