itube-specs 0.0.987 → 0.0.988
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/components/search/item.vue +5 -9
- package/components/search/result.vue +2 -0
- package/components/ui/chips.vue +2 -1
- package/components/view/video.vue +3 -0
- package/package.json +1 -1
- package/runtime/index.ts +2 -1
- package/runtime/utils/__tests__/video-data-add-model-portraits.test.ts +47 -0
- package/runtime/utils/cleaners/__tests__/clean-suggest-models.test.ts +40 -0
- package/runtime/utils/cleaners/__tests__/clean-video-data.test.ts +46 -1
- package/runtime/utils/cleaners/clean-suggest-models.ts +23 -0
- package/runtime/utils/cleaners/clean-video-data.ts +19 -2
- package/runtime/utils/video-data-add-model-portraits.ts +27 -0
- package/server/api/search/multi-suggest.get.ts +10 -4
- package/server/api/videos/get-video-by-id/[id].get.ts +6 -2
- package/server/api/videos/get-video-by-url/[url].get.ts +6 -2
- package/types/chips-item.d.ts +2 -0
- package/types/multi-suggest.d.ts +20 -2
- package/types/raw/raw-video-data.d.ts +3 -0
- package/types/search-top-models.d.ts +3 -6
- package/types/video-data.d.ts +3 -0
- package/runtime/utils/__tests__/video-data-add-model-icon.test.ts +0 -32
- package/runtime/utils/video-data-add-model-icon.ts +0 -25
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
<script setup lang="ts">
|
|
33
33
|
import type { ISearchTopModelsType } from '../../types';
|
|
34
|
-
import { convertString
|
|
34
|
+
import { convertString } from '../../runtime';
|
|
35
35
|
import DOMPurify from 'dompurify';
|
|
36
36
|
|
|
37
37
|
const props = defineProps<{
|
|
@@ -42,14 +42,10 @@ const props = defineProps<{
|
|
|
42
42
|
}>();
|
|
43
43
|
|
|
44
44
|
/** Иконка 32px — квадратный кроп по лицу, если он есть в ответе. */
|
|
45
|
-
const icon = computed(() => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
webp: square?.webp || '/img/model-placeholder-square.webp',
|
|
50
|
-
avif: square?.webp ? square.avif : '/img/model-placeholder-square.avif',
|
|
51
|
-
};
|
|
52
|
-
});
|
|
45
|
+
const icon = computed(() => ({
|
|
46
|
+
webp: props.item?.imageUrl || '/img/model-placeholder-square.webp',
|
|
47
|
+
avif: props.item?.imageUrl ? props.item.imageAvifUrl : '/img/model-placeholder-square.avif',
|
|
48
|
+
}));
|
|
53
49
|
|
|
54
50
|
const title = computed(() => props.item?.title?.replace(/<[^>]*>/g, ''));
|
|
55
51
|
const { generateLink } = useGenerateLink();
|
package/components/ui/chips.vue
CHANGED
|
@@ -24,12 +24,13 @@
|
|
|
24
24
|
<UiImg
|
|
25
25
|
v-if="item.image"
|
|
26
26
|
:src="item.image"
|
|
27
|
+
:avif-src="item.imageAvif"
|
|
27
28
|
:alt="name"
|
|
28
29
|
:class="`--${imageFit}`"
|
|
29
30
|
:width="imageBox.width"
|
|
30
31
|
:height="imageBox.height"
|
|
31
32
|
class="ui-chips__img"
|
|
32
|
-
sizes="
|
|
33
|
+
sizes="auto, 24px"
|
|
33
34
|
/>
|
|
34
35
|
{{ name }}
|
|
35
36
|
<UiIcon
|
|
@@ -204,11 +204,14 @@ const topChips = computed(() => {
|
|
|
204
204
|
|
|
205
205
|
const topChipsStrings = computed(() => topChips.value?.map(item => item.value));
|
|
206
206
|
|
|
207
|
+
/** Портрет в чипсе — квадратный кроп по лицу; своего у видео нет, его подмешивает BFF. */
|
|
207
208
|
function getModelChips(model: IVideoData['models'][number]): IChipsItem {
|
|
208
209
|
return {
|
|
209
210
|
prefix: '/models',
|
|
210
211
|
value: model.title,
|
|
211
212
|
title: model.title,
|
|
213
|
+
image: model.imageUrl || '/img/model-placeholder-square.webp',
|
|
214
|
+
imageAvif: model.imageUrl ? model.imageAvifUrl : '/img/model-placeholder-square.avif',
|
|
212
215
|
};
|
|
213
216
|
}
|
|
214
217
|
</script>
|
package/package.json
CHANGED
package/runtime/index.ts
CHANGED
|
@@ -37,7 +37,8 @@ export * from './utils/cleaners/clean-profile-data';
|
|
|
37
37
|
export * from './utils/cleaners/clean-video-data';
|
|
38
38
|
export * from './utils/cleaners/clean-video-card';
|
|
39
39
|
export * from './utils/cleaners/clean-model-info';
|
|
40
|
-
export * from './utils/
|
|
40
|
+
export * from './utils/cleaners/clean-suggest-models';
|
|
41
|
+
export * from './utils/video-data-add-model-portraits';
|
|
41
42
|
export * from './utils/converters/convert-categories-to-footer';
|
|
42
43
|
export * from './utils/converters/convert-categories-to-chips';
|
|
43
44
|
export * from './utils/converters/convert-model-card-to-chips';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { videoDataAddModelPortraits } from '../video-data-add-model-portraits';
|
|
3
|
+
|
|
4
|
+
const portraits = {
|
|
5
|
+
dark: {
|
|
6
|
+
tall: { width: 300, height: 450, webp: 'a-tall.webp', avif: 'a-tall.avif' },
|
|
7
|
+
square: { width: 200, height: 200, webp: 'a-square.webp', avif: 'a-square.avif' },
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe('videoDataAddModelPortraits', () => {
|
|
12
|
+
it('добавляет portraits к matching модели', () => {
|
|
13
|
+
const videoData = {
|
|
14
|
+
models: [
|
|
15
|
+
{ guid: 'm-1', title: 'Model A' },
|
|
16
|
+
{ guid: 'm-2', title: 'Model B' },
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
const modelsData = [
|
|
20
|
+
{ guid: 'm-1', portraits },
|
|
21
|
+
];
|
|
22
|
+
const result = videoDataAddModelPortraits(videoData as any, modelsData as any);
|
|
23
|
+
expect(result.models[0].portraits).toEqual(portraits);
|
|
24
|
+
expect(result.models[1].portraits).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('карточка без портретов ничего не добавляет', () => {
|
|
28
|
+
const videoData = { models: [{ guid: 'm-1', title: 'A' }] };
|
|
29
|
+
const modelsData = [{ guid: 'm-1', primary_image_url: 'https://img.com/a.jpg' }];
|
|
30
|
+
const result = videoDataAddModelPortraits(videoData as any, modelsData as any);
|
|
31
|
+
expect(result.models[0].portraits).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('не мутирует исходный объект', () => {
|
|
35
|
+
const videoData = { models: [{ guid: 'm-1', title: 'A' }] };
|
|
36
|
+
const modelsData = [{ guid: 'm-1', portraits }];
|
|
37
|
+
const result = videoDataAddModelPortraits(videoData as any, modelsData as any);
|
|
38
|
+
expect(result).not.toBe(videoData);
|
|
39
|
+
expect((videoData.models[0] as any).portraits).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('пустой массив моделей', () => {
|
|
43
|
+
const videoData = { models: [{ guid: 'm-1', title: 'A' }] };
|
|
44
|
+
const result = videoDataAddModelPortraits(videoData as any, []);
|
|
45
|
+
expect(result.models[0].portraits).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { cleanSuggestModels } from '../clean-suggest-models';
|
|
3
|
+
|
|
4
|
+
const portraits = {
|
|
5
|
+
dark: {
|
|
6
|
+
tall: { width: 640, height: 960, webp: 'tall.webp', avif: 'tall.avif' },
|
|
7
|
+
square: { width: 256, height: 256, webp: 'square.webp', avif: 'square.avif' },
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe('cleanSuggestModels', () => {
|
|
12
|
+
it('берёт квадратный кроп, вертикальный игнорирует', () => {
|
|
13
|
+
const result = cleanSuggestModels([
|
|
14
|
+
{ title: 'Alex Ink', videosCount: 5, portraits },
|
|
15
|
+
] as any);
|
|
16
|
+
|
|
17
|
+
expect(result).toEqual([
|
|
18
|
+
{ title: 'Alex Ink', videosCount: 5, imageUrl: 'square.webp', imageAvifUrl: 'square.avif' },
|
|
19
|
+
]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('модель без портретов остаётся без картинки', () => {
|
|
23
|
+
const result = cleanSuggestModels([{ title: 'Alex Rim', videosCount: 1 }] as any);
|
|
24
|
+
|
|
25
|
+
expect(result).toEqual([{ title: 'Alex Rim', videosCount: 1 }]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('только вертикальный портрет — картинки нет', () => {
|
|
29
|
+
const result = cleanSuggestModels([
|
|
30
|
+
{ title: 'A', videosCount: 2, portraits: { dark: { tall: portraits.dark.tall } } },
|
|
31
|
+
] as any);
|
|
32
|
+
|
|
33
|
+
expect(result).toEqual([{ title: 'A', videosCount: 2 }]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('пустой список остаётся null, чтобы не всплыл таб «models»', () => {
|
|
37
|
+
expect(cleanSuggestModels(null)).toBeNull();
|
|
38
|
+
expect(cleanSuggestModels(undefined as any)).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -13,7 +13,7 @@ describe('cleanVideoData', () => {
|
|
|
13
13
|
likes: 100,
|
|
14
14
|
id: 'id-1',
|
|
15
15
|
tags: 'tag1',
|
|
16
|
-
models: '
|
|
16
|
+
models: [{ guid: 'm-1', title: 'Model A' }],
|
|
17
17
|
categories: 'cat1',
|
|
18
18
|
actions: { liked: true },
|
|
19
19
|
channel: { name: 'Channel', avatar_url: 'https://img.com/ch.jpg' },
|
|
@@ -37,6 +37,51 @@ describe('cleanVideoData', () => {
|
|
|
37
37
|
expect(result.videoUrl).toBe('/stream');
|
|
38
38
|
expect(result.isDeleted).toBe(false);
|
|
39
39
|
expect(result.thumbNum).toBe(5);
|
|
40
|
+
expect(result.models).toEqual([{ guid: 'm-1', title: 'Model A' }]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('модели: квадратный кроп портрета в imageUrl/imageAvifUrl', () => {
|
|
44
|
+
const raw = {
|
|
45
|
+
models: [
|
|
46
|
+
{
|
|
47
|
+
guid: 'm-1',
|
|
48
|
+
title: 'Model A',
|
|
49
|
+
portraits: {
|
|
50
|
+
dark: {
|
|
51
|
+
tall: { width: 300, height: 450, webp: 'tall.webp', avif: 'tall.avif' },
|
|
52
|
+
square: { width: 200, height: 200, webp: 'square.webp', avif: 'square.avif' },
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{ guid: 'm-2', title: 'Model B' },
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
const result = cleanVideoData(raw as any);
|
|
60
|
+
expect(result.models[0]).toEqual({
|
|
61
|
+
guid: 'm-1',
|
|
62
|
+
title: 'Model A',
|
|
63
|
+
imageUrl: 'square.webp',
|
|
64
|
+
imageAvifUrl: 'square.avif',
|
|
65
|
+
});
|
|
66
|
+
expect(result.models[1]).toEqual({ guid: 'm-2', title: 'Model B' });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('модели: только вертикальный портрет — картинки нет', () => {
|
|
70
|
+
const raw = {
|
|
71
|
+
models: [
|
|
72
|
+
{
|
|
73
|
+
guid: 'm-1',
|
|
74
|
+
title: 'Model A',
|
|
75
|
+
portraits: { dark: { tall: { width: 300, height: 450, webp: 'tall.webp', avif: 'tall.avif' } } },
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
const result = cleanVideoData(raw as any);
|
|
80
|
+
expect(result.models[0]).toEqual({ guid: 'm-1', title: 'Model A' });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('моделей нет вовсе', () => {
|
|
84
|
+
expect(cleanVideoData({} as any).models).toEqual([]);
|
|
40
85
|
});
|
|
41
86
|
|
|
42
87
|
it('таймлайны: конец периода из бекендного end_duration', () => {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ISuggestModel, IRawSuggestModel } from '../../../types';
|
|
2
|
+
import { pickPortraitVariant } from '../pick-portrait-variant';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Приводит модели из multi-suggest к готовым ссылкам на портрет: подсказке нужен квадратный
|
|
6
|
+
* кроп по лицу, вертикальный `tall` в иконку 32px не годится. Пустой список бек отдаёт как
|
|
7
|
+
* `null` — сохраняем как есть, иначе в поиске появится пустой таб «models».
|
|
8
|
+
* @param models - модели из ответа multi-suggest, уже приведённого к camelCase
|
|
9
|
+
* @param theme - тема сайта: ею выбирается вариант портрета
|
|
10
|
+
*/
|
|
11
|
+
export const cleanSuggestModels = (models: IRawSuggestModel[] | null, theme?: string): ISuggestModel[] | null => {
|
|
12
|
+
if (!Array.isArray(models)) return models ?? null;
|
|
13
|
+
|
|
14
|
+
return models.map((model) => {
|
|
15
|
+
const square = pickPortraitVariant(model.portraits, theme)?.square;
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
title: model.title,
|
|
19
|
+
videosCount: model.videosCount,
|
|
20
|
+
...(square?.webp ? { imageUrl: square.webp, imageAvifUrl: square.avif } : {}),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IRawVideoData, IVideoData, ISceneGroup, ITimelineGroup } from '../../../types';
|
|
2
|
+
import { pickPortraitVariant } from '../pick-portrait-variant';
|
|
2
3
|
|
|
3
4
|
// «locations» → «Locations», «living_room» → «Living room».
|
|
4
5
|
const humanize = (value?: string | null): string => {
|
|
@@ -45,8 +46,24 @@ const cleanHotspots = (raw: string[] | null): number[] | null => {
|
|
|
45
46
|
return values.length ? values : null;
|
|
46
47
|
};
|
|
47
48
|
|
|
49
|
+
// Моделям видео нужен только квадратный кроп по лицу — он идёт в чипсу под заголовком.
|
|
50
|
+
// Вертикальный `tall` сюда не годится, как и в круглые аватары.
|
|
51
|
+
const cleanVideoModels = (models: IRawVideoData['models'], theme?: string): IVideoData['models'] => {
|
|
52
|
+
if (!Array.isArray(models)) return [];
|
|
53
|
+
|
|
54
|
+
return models.map((model) => {
|
|
55
|
+
const square = pickPortraitVariant(model.portraits, theme)?.square;
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
guid: model.guid,
|
|
59
|
+
title: model.title,
|
|
60
|
+
...(square?.webp ? { imageUrl: square.webp, imageAvifUrl: square.avif } : {}),
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
48
65
|
/** Нормализует сырые данные видео к типу IVideoData. */
|
|
49
|
-
export const cleanVideoData = (data: IRawVideoData): IVideoData => ({
|
|
66
|
+
export const cleanVideoData = (data: IRawVideoData, theme?: string): IVideoData => ({
|
|
50
67
|
guid: data.guid || '',
|
|
51
68
|
md5: data.md5 || '',
|
|
52
69
|
duration: data.duration || 0,
|
|
@@ -56,7 +73,7 @@ export const cleanVideoData = (data: IRawVideoData): IVideoData => ({
|
|
|
56
73
|
likes: data.likes || 0,
|
|
57
74
|
id: data.id || '',
|
|
58
75
|
tags: data.tags || [],
|
|
59
|
-
models: data.models
|
|
76
|
+
models: cleanVideoModels(data.models, theme),
|
|
60
77
|
categories: data.categories || [],
|
|
61
78
|
actions: data.actions || null,
|
|
62
79
|
channelName: data.channel?.name || '',
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { IRawModelCard, IRawVideoData } from '../../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Подмешивает portraits из карточек моделей в каждую модель videoData.models по совпадению guid.
|
|
5
|
+
* Ответ видео своих портретов не отдаёт — их приносит параллельный запрос
|
|
6
|
+
* `models/list-by-video-id|url`, а вариант выбирает уже клинер.
|
|
7
|
+
* @param videoData - сырые данные видео
|
|
8
|
+
* @param modelsData - массив карточек моделей с portraits
|
|
9
|
+
*/
|
|
10
|
+
export function videoDataAddModelPortraits(videoData: IRawVideoData, modelsData: Array<IRawModelCard>): IRawVideoData {
|
|
11
|
+
const transformedData = { ...videoData };
|
|
12
|
+
|
|
13
|
+
transformedData.models = transformedData.models?.map((model) => {
|
|
14
|
+
const modelData = modelsData.find((item) => item.guid === model.guid);
|
|
15
|
+
|
|
16
|
+
if (modelData?.portraits) {
|
|
17
|
+
return {
|
|
18
|
+
...model,
|
|
19
|
+
portraits: modelData.portraits,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return model;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return transformedData;
|
|
27
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { getUrlWithProxiedParams, convertSnakeKeysToCamel } from '../../../runtime';
|
|
2
|
-
import type { IMultiSuggest } from '../../../types';
|
|
1
|
+
import { getUrlWithProxiedParams, convertSnakeKeysToCamel, cleanSuggestModels } from '../../../runtime';
|
|
2
|
+
import type { IMultiSuggest, IRawMultiSuggest } from '../../../types';
|
|
3
3
|
|
|
4
|
-
export default defineEventHandler(async (event) => {
|
|
4
|
+
export default defineEventHandler(async (event): Promise<IMultiSuggest> => {
|
|
5
|
+
const theme = useRuntimeConfig(event).public.theme;
|
|
5
6
|
const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
|
|
6
7
|
const url = getUrlWithProxiedParams(`${apiUrl}/v2/search/multi-suggest`, event);
|
|
7
8
|
|
|
@@ -10,5 +11,10 @@ export default defineEventHandler(async (event) => {
|
|
|
10
11
|
headers: getRequestHeaders(event) as HeadersInit,
|
|
11
12
|
});
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
const data = convertSnakeKeysToCamel<IRawMultiSuggest>(rawData.data);
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
...data,
|
|
18
|
+
models: cleanSuggestModels(data?.models, theme),
|
|
19
|
+
};
|
|
14
20
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { IRawVideoData, IVideoData, IRawModelCard } from '../../../../types';
|
|
2
|
-
import { cleanVideoData,
|
|
2
|
+
import { cleanVideoData, videoDataAddModelPortraits, getUrlWithProxiedParams } from '../../../../runtime';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
5
|
+
const theme = useRuntimeConfig(event).public.theme;
|
|
5
6
|
const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
|
|
6
7
|
const id = getRouterParam(event, 'id');
|
|
7
8
|
|
|
@@ -26,5 +27,8 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
|
26
27
|
const videoData = videoRaw.data as IRawVideoData;
|
|
27
28
|
const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
|
|
28
29
|
|
|
29
|
-
return cleanVideoData(
|
|
30
|
+
return cleanVideoData(
|
|
31
|
+
videoData?.models ? videoDataAddModelPortraits(videoData, modelsData?.items || []) : videoData,
|
|
32
|
+
theme,
|
|
33
|
+
);
|
|
30
34
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { cleanVideoData,
|
|
1
|
+
import { cleanVideoData, videoDataAddModelPortraits, getUrlWithProxiedParams } from '../../../../runtime';
|
|
2
2
|
import type { IRawVideoData, IVideoData, IRawModelCard } from '../../../../types';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
5
|
+
const theme = useRuntimeConfig(event).public.theme;
|
|
5
6
|
const apiUrl = process.env.NUXT_PUBLIC_API_DOMAIN;
|
|
6
7
|
const url = getRouterParam(event, 'url');
|
|
7
8
|
|
|
@@ -25,5 +26,8 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
|
25
26
|
const videoData = videoRaw.data as IRawVideoData;
|
|
26
27
|
const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
|
|
27
28
|
|
|
28
|
-
return cleanVideoData(
|
|
29
|
+
return cleanVideoData(
|
|
30
|
+
videoData?.models ? videoDataAddModelPortraits(videoData, modelsData?.items || []) : videoData,
|
|
31
|
+
theme,
|
|
32
|
+
);
|
|
29
33
|
});
|
package/types/chips-item.d.ts
CHANGED
package/types/multi-suggest.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { IRawPortraits } from './raw/raw-portraits';
|
|
2
|
+
|
|
1
3
|
interface AboutItem {
|
|
2
4
|
key: string;
|
|
3
5
|
sizeCache: string;
|
|
@@ -62,9 +64,19 @@ interface Category {
|
|
|
62
64
|
videosCount: number;
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
|
|
67
|
+
/** Модель, как её отдаёт multi-suggest: с портретами, но до выбора варианта. */
|
|
68
|
+
export interface IRawSuggestModel {
|
|
66
69
|
title: string;
|
|
67
70
|
videosCount: number;
|
|
71
|
+
portraits?: IRawPortraits;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Та же модель после `cleanSuggestModels`: квадратный кроп уже выбран. */
|
|
75
|
+
export interface ISuggestModel {
|
|
76
|
+
title: string;
|
|
77
|
+
videosCount: number;
|
|
78
|
+
imageUrl?: string;
|
|
79
|
+
imageAvifUrl?: string;
|
|
68
80
|
}
|
|
69
81
|
|
|
70
82
|
interface Stream {
|
|
@@ -96,10 +108,16 @@ interface StripcashModel {
|
|
|
96
108
|
viewersCount: number;
|
|
97
109
|
}
|
|
98
110
|
|
|
111
|
+
/** Ответ multi-suggest после BFF: модели уже прошли `cleanSuggestModels`. */
|
|
99
112
|
export interface IMultiSuggest {
|
|
100
113
|
awempireModels: AwempireModel[];
|
|
101
114
|
categories: Category[];
|
|
102
|
-
models:
|
|
115
|
+
models: ISuggestModel[] | null;
|
|
103
116
|
searchPhrases: string[];
|
|
104
117
|
stripcashModels: StripcashModel[];
|
|
105
118
|
}
|
|
119
|
+
|
|
120
|
+
/** Тот же ответ до чистки моделей — с ним работает хендлер. */
|
|
121
|
+
export interface IRawMultiSuggest extends Omit<IMultiSuggest, 'models'> {
|
|
122
|
+
models: IRawSuggestModel[] | null;
|
|
123
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IRawThumbUrls } from './raw-thumbs-urls';
|
|
2
|
+
import type { IRawPortraits } from './raw-portraits';
|
|
2
3
|
import type { ITimelineGroup } from '../video-data';
|
|
3
4
|
|
|
4
5
|
export interface IRawVideoData {
|
|
@@ -21,6 +22,8 @@ export interface IRawVideoData {
|
|
|
21
22
|
models: {
|
|
22
23
|
guid: string;
|
|
23
24
|
title: string;
|
|
25
|
+
/** Своего поля у видео нет — портреты подмешивает BFF из карточек `models/list-by-video-*`. */
|
|
26
|
+
portraits?: IRawPortraits;
|
|
24
27
|
}[];
|
|
25
28
|
categories: {
|
|
26
29
|
icon?: string,
|
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
import type { IRawPortraits } from './raw/raw-portraits';
|
|
2
|
-
|
|
3
1
|
export interface ISearchTopModelsType {
|
|
4
2
|
videosCount?: number
|
|
5
3
|
name: string
|
|
6
4
|
title: string
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
portraits?: IRawPortraits
|
|
5
|
+
/** Квадратный кроп портрета: и multi-suggest, и `models/top` отдают его уже выбранным. */
|
|
6
|
+
imageUrl?: string
|
|
7
|
+
imageAvifUrl?: string
|
|
11
8
|
}
|
package/types/video-data.d.ts
CHANGED
|
@@ -49,6 +49,9 @@ export interface IVideoData {
|
|
|
49
49
|
models: {
|
|
50
50
|
guid: string;
|
|
51
51
|
title: string;
|
|
52
|
+
/** Квадратный кроп портрета под чипсу модели: его подмешивает BFF, в ответе видео его нет. */
|
|
53
|
+
imageUrl?: string;
|
|
54
|
+
imageAvifUrl?: string;
|
|
52
55
|
}[];
|
|
53
56
|
categories: {
|
|
54
57
|
name: string;
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { videoDataAddModelIcon } from '../video-data-add-model-icon';
|
|
3
|
-
|
|
4
|
-
describe('videoDataAddModelIcon', () => {
|
|
5
|
-
it('добавляет icon к matching модели', () => {
|
|
6
|
-
const videoData = {
|
|
7
|
-
models: [
|
|
8
|
-
{ guid: 'm-1', title: 'Model A' },
|
|
9
|
-
{ guid: 'm-2', title: 'Model B' },
|
|
10
|
-
],
|
|
11
|
-
};
|
|
12
|
-
const modelsData = [
|
|
13
|
-
{ guid: 'm-1', primary_image_url: 'https://img.com/a.jpg' },
|
|
14
|
-
];
|
|
15
|
-
const result = videoDataAddModelIcon(videoData as any, modelsData as any);
|
|
16
|
-
expect(result.models[0].icon).toBe('https://img.com/a.jpg');
|
|
17
|
-
expect(result.models[1].icon).toBeUndefined();
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('не мутирует исходный объект', () => {
|
|
21
|
-
const videoData = { models: [{ guid: 'm-1', title: 'A' }] };
|
|
22
|
-
const modelsData = [{ guid: 'm-1', primary_image_url: 'url' }];
|
|
23
|
-
const result = videoDataAddModelIcon(videoData as any, modelsData as any);
|
|
24
|
-
expect(result).not.toBe(videoData);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('пустой массив моделей', () => {
|
|
28
|
-
const videoData = { models: [{ guid: 'm-1', title: 'A' }] };
|
|
29
|
-
const result = videoDataAddModelIcon(videoData as any, []);
|
|
30
|
-
expect(result.models[0].icon).toBeUndefined();
|
|
31
|
-
});
|
|
32
|
-
});
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import type { IRawModelCard, IRawVideoData } from '../../types';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Добавляет поле icon из modelsData в каждую модель videoData.models по совпадению guid.
|
|
5
|
-
* @param videoData - сырые данные видео
|
|
6
|
-
* @param modelsData - массив карточек моделей с primary_image_url
|
|
7
|
-
*/
|
|
8
|
-
export function videoDataAddModelIcon(videoData: IRawVideoData, modelsData: Array<IRawModelCard>): IRawVideoData {
|
|
9
|
-
const transformedData = { ...videoData };
|
|
10
|
-
|
|
11
|
-
transformedData.models = transformedData.models?.map((model) => {
|
|
12
|
-
const modelData = modelsData.find((item) => item.guid === model.guid);
|
|
13
|
-
|
|
14
|
-
if (modelData) {
|
|
15
|
-
return {
|
|
16
|
-
...model,
|
|
17
|
-
icon: modelData.primary_image_url,
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
return model;
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
return transformedData;
|
|
25
|
-
}
|