itube-specs 0.0.1073 → 0.0.1074
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/SITE_UPDATES.md +16 -0
- package/assets/scss/themes/dark/_theme.scss +14 -0
- package/assets/scss/themes/light/_theme.scss +14 -0
- package/components/player/core.vue +69 -2
- package/components/player/scenes.vue +138 -0
- package/components/view/video.vue +9 -4
- package/i18n/locales/de-DE.json +1 -0
- package/i18n/locales/en-US.json +1 -0
- package/i18n/locales/es-ES.json +1 -0
- package/i18n/locales/fr-FR.json +1 -0
- package/i18n/locales/it-IT.json +1 -0
- package/i18n/locales/ja-JP.json +1 -0
- package/i18n/locales/ko-KR.json +1 -0
- package/i18n/locales/ru-RU.json +1 -0
- package/i18n/locales/zh-CN.json +1 -0
- package/package.json +1 -1
- package/runtime/index.ts +0 -1
- package/runtime/utils/cleaners/__tests__/clean-video-data.test.ts +17 -0
- package/runtime/utils/cleaners/clean-video-data.ts +8 -1
- package/runtime/utils/converters/convert-tag-group-item-to-chips.ts +1 -1
- package/server/api/videos/get-video-by-id/[id].get.ts +3 -4
- package/server/api/videos/get-video-by-url/[url].get.ts +3 -4
- package/types/raw/raw-video-data.d.ts +0 -2
- package/types/video-data.d.ts +4 -3
- package/runtime/utils/__tests__/video-data-mark-top-categories.test.ts +0 -19
- package/runtime/utils/video-data-mark-top-categories.ts +0 -21
- package/server/utils/top-category-names.ts +0 -14
package/SITE_UPDATES.md
CHANGED
|
@@ -11,6 +11,22 @@
|
|
|
11
11
|
коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
|
|
12
12
|
кому именно (всем / сайтам с N), что будет, если не сделать.
|
|
13
13
|
|
|
14
|
+
## 0.0.1074 — 2026-11-05
|
|
15
|
+
|
|
16
|
+
- **Сайтам со своей темой в `assets/scss/vars/_theme.scss` (hdsex-remaster), срочно:** без новых токенов сборка
|
|
17
|
+
падает на `Undefined variable`. Добавить `$player-scenes-text`, `$player-scenes-text--current`, `$player-scenes-bg`,
|
|
18
|
+
`$player-scenes-bg--hover`, `$player-scenes-bg--current`, `$player-scenes-border`, `$player-scenes-border--current`,
|
|
19
|
+
`$player-scenes-time`, `$player-scenes-time-bg`, `$player-scenes-time-bg--current`,
|
|
20
|
+
`$player-scenes-time-border--current`, `$player-scenes-mask` — значения из `themes/dark/_theme.scss` слоя.
|
|
21
|
+
- **Всем — к сведению:** под плеером ряд сцен ролика (`PlayerScenes`, макет v0-dark-eta /video/2): «тайм-код |
|
|
22
|
+
название», текущая подсвечена, клик перематывает (до загрузки плеера — стартует его и перематывает). Сцены — первый
|
|
23
|
+
тип из `timelines_groups` (дальше — выбранный в плеере), без групп — `actions`. Нет сцен у ролика — ряда нет. Новый
|
|
24
|
+
общий ключ `scenes`. Делать ничего не надо.
|
|
25
|
+
- **Всем — к сведению:** категории в строке чипсов над плеером теперь берутся из группы `top_categories` ответа
|
|
26
|
+
видео v2 (`IVideoData.topCategories`) как есть, без сверки с `categories`; в блок под роликом эта группа не
|
|
27
|
+
попадает. Пометка `categories[].isTop`, `videoDataMarkTopCategories` и запрос BFF к `/v1/categories/top` убраны.
|
|
28
|
+
Ролик без `top_categories` — категорий над плеером нет. Делать ничего не надо, если сайт не читал `isTop` сам.
|
|
29
|
+
|
|
14
30
|
## 0.0.1073 — 2026-11-03
|
|
15
31
|
|
|
16
32
|
- **Всем — к сведению:** гость больше не видит заглушку «Sign in to upload» на `/upload`: клик по Upload (шапка,
|
|
@@ -783,6 +783,20 @@ $player-menu-option-bg--hover: $mono-white-5; // hover:bg-white/5
|
|
|
783
783
|
$player-menu-toggle-bg--active: $color-indigo-500; // switch on: bg-indigo-500
|
|
784
784
|
$player-center-label-shadow: $color-black-50; // мета в центре бара поверх видео
|
|
785
785
|
|
|
786
|
+
// ─── Player scenes (сцены под плеером) ───
|
|
787
|
+
$player-scenes-text: $color-zinc-300;
|
|
788
|
+
$player-scenes-text--current: $color-white;
|
|
789
|
+
$player-scenes-bg: transparent;
|
|
790
|
+
$player-scenes-bg--hover: $color-zinc-800;
|
|
791
|
+
$player-scenes-bg--current: $color-zinc-800;
|
|
792
|
+
$player-scenes-border: $color-zinc-800;
|
|
793
|
+
$player-scenes-border--current: $color-zinc-500;
|
|
794
|
+
$player-scenes-time: $color-zinc-400;
|
|
795
|
+
$player-scenes-time-bg: $color-zinc-900;
|
|
796
|
+
$player-scenes-time-bg--current: $color-zinc-700;
|
|
797
|
+
$player-scenes-time-border--current: $color-zinc-600;
|
|
798
|
+
$player-scenes-mask: $color-black; // непрозрачная часть маски затухания ленты
|
|
799
|
+
|
|
786
800
|
// accordion (scene types)
|
|
787
801
|
$player-menu-acc-border: $color-zinc-800-60; // border-zinc-800/60
|
|
788
802
|
$player-menu-acc-head-text: $color-zinc-200; // text-zinc-200
|
|
@@ -835,6 +835,20 @@ $player-menu-option-bg--hover: $mono-white-5; // hover:bg-white/5
|
|
|
835
835
|
$player-menu-toggle-bg--active: $color-indigo-500; // switch on: bg-indigo-500
|
|
836
836
|
$player-center-label-shadow: $color-black-50; // мета в центре бара поверх видео
|
|
837
837
|
|
|
838
|
+
// ─── Player scenes (сцены под плеером) ───
|
|
839
|
+
$player-scenes-text: $color-gray-700;
|
|
840
|
+
$player-scenes-text--current: $color-gray-900;
|
|
841
|
+
$player-scenes-bg: transparent;
|
|
842
|
+
$player-scenes-bg--hover: $color-gray-100;
|
|
843
|
+
$player-scenes-bg--current: $color-gray-200;
|
|
844
|
+
$player-scenes-border: $color-gray-200;
|
|
845
|
+
$player-scenes-border--current: $color-gray-400;
|
|
846
|
+
$player-scenes-time: $color-gray-500;
|
|
847
|
+
$player-scenes-time-bg: $color-gray-100;
|
|
848
|
+
$player-scenes-time-bg--current: $color-gray-300;
|
|
849
|
+
$player-scenes-time-border--current: $color-gray-400;
|
|
850
|
+
$player-scenes-mask: $color-black; // непрозрачная часть маски затухания ленты
|
|
851
|
+
|
|
838
852
|
// accordion (scene types)
|
|
839
853
|
$player-menu-acc-border: $color-slate-700; // border-zinc-800/60
|
|
840
854
|
$player-menu-acc-head-text: $color-gray-200; // text-zinc-200
|
|
@@ -18,8 +18,10 @@
|
|
|
18
18
|
@advancerequest="onAdvanceRequest"
|
|
19
19
|
@sourcechange="onSourceChange"
|
|
20
20
|
@menurequest="onMenuRequest"
|
|
21
|
+
@chapterchange="onChapterChange"
|
|
22
|
+
@scenetypechange="onSceneTypeChange"
|
|
21
23
|
@pause="onPause"
|
|
22
|
-
@ready="
|
|
24
|
+
@ready="onReady"
|
|
23
25
|
@blockedclick="startVerification(route.fullPath)"
|
|
24
26
|
>
|
|
25
27
|
<template
|
|
@@ -34,6 +36,13 @@
|
|
|
34
36
|
</template>
|
|
35
37
|
</ITubePlayer>
|
|
36
38
|
|
|
39
|
+
<PlayerScenes
|
|
40
|
+
:scenes="scenes"
|
|
41
|
+
:current="currentSceneStart"
|
|
42
|
+
class="player-core__scenes"
|
|
43
|
+
@select="onSceneSelect"
|
|
44
|
+
/>
|
|
45
|
+
|
|
37
46
|
<!-- Внешнее меню плеера в нашем ui-popup (только мобила — menus: 'external-mobile'). -->
|
|
38
47
|
<PlayerMenu
|
|
39
48
|
v-model:open="menuOpen"
|
|
@@ -44,7 +53,7 @@
|
|
|
44
53
|
<script setup lang="ts">
|
|
45
54
|
import { ITubePlayer } from 'itube-modern-player/vue';
|
|
46
55
|
|
|
47
|
-
import type { Player, PlayerOptions, VideoSource, LocaleCode, AdsOptions, PreviewMetaItem, SceneGroup, MenuRequestPayload } from 'itube-modern-player';
|
|
56
|
+
import type { Player, PlayerOptions, VideoSource, LocaleCode, AdsOptions, PreviewMetaItem, SceneGroup, MenuRequestPayload, Chapter } from 'itube-modern-player';
|
|
48
57
|
import { ThumbSize } from '../../runtime';
|
|
49
58
|
import type { IVideoData, IVideoCard } from '../../types';
|
|
50
59
|
import playIcon from '../../assets/icons/player/play.svg?raw';
|
|
@@ -107,6 +116,57 @@ function onMenuRequest(request: MenuRequestPayload): void {
|
|
|
107
116
|
menuOpen.value = true;
|
|
108
117
|
}
|
|
109
118
|
|
|
119
|
+
// Сцены под плеером (PlayerScenes): до загрузки ядра — первый тип сцен, как в заглушке плеера (или главы), дальше
|
|
120
|
+
// активный тип и текущая сцена приходят событиями. Клик до загрузки стартует плеер через кнопку play заглушки и
|
|
121
|
+
// перематывает на ready.
|
|
122
|
+
const initialScenes = (): Chapter[] =>
|
|
123
|
+
currentSource.value.sceneGroups?.[0]?.scenes
|
|
124
|
+
?? (Array.isArray(currentSource.value.chapters) ? currentSource.value.chapters : []);
|
|
125
|
+
|
|
126
|
+
const scenes = shallowRef<Chapter[]>([]);
|
|
127
|
+
const currentSceneStart = ref<number | null>(null);
|
|
128
|
+
let pendingSeek: number | null = null;
|
|
129
|
+
|
|
130
|
+
function onChapterChange({ chapter }: { chapter: Chapter | null }): void {
|
|
131
|
+
currentSceneStart.value = chapter?.start ?? null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function onSceneTypeChange({ group }: { group: SceneGroup | null }): void {
|
|
135
|
+
scenes.value = group?.scenes ?? initialScenes();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// На ready у HLS ещё нет метаданных и перемотка теряется — ждём первый timeupdate с известной длительностью.
|
|
139
|
+
function onReady(): void {
|
|
140
|
+
siteHooks.onReady?.();
|
|
141
|
+
const player = playerRef.value?.player;
|
|
142
|
+
if (pendingSeek === null || !player) return;
|
|
143
|
+
|
|
144
|
+
const stop = player.on('timeupdate', ({ duration }) => {
|
|
145
|
+
if (!(duration > 0) || pendingSeek === null) return;
|
|
146
|
+
|
|
147
|
+
player.seek(pendingSeek);
|
|
148
|
+
pendingSeek = null;
|
|
149
|
+
stop();
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function onSceneSelect(start: number): void {
|
|
154
|
+
if (isAgeGated.value) {
|
|
155
|
+
startVerification(route.fullPath);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const player = playerRef.value?.player;
|
|
160
|
+
if (player) {
|
|
161
|
+
player.seek(start);
|
|
162
|
+
player.play();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
pendingSeek = start;
|
|
167
|
+
(playerRef.value?.$el as HTMLElement | undefined)?.querySelector<HTMLElement>('.imp-ph-play, .imp-ph-btn--play')?.click();
|
|
168
|
+
}
|
|
169
|
+
|
|
110
170
|
// Плеер эмитит advancerequest 'ended' только при включённом тумблере Autoplay, а на
|
|
111
171
|
// кнопку next — 'next'. Значит play после свапа нужен ТОЛЬКО когда переход инициировали
|
|
112
172
|
// мы (next / автопереход), а не когда source пересобрался по другой причине (сменилось
|
|
@@ -278,8 +338,11 @@ const buildSource = (): VideoSource | VideoSource[] =>
|
|
|
278
338
|
// следующего (префетч) дёргала бы player.load() и перезапускала текущий ролик + ломала
|
|
279
339
|
// автоплей (лишние sourcechange). Один переход = один reload.
|
|
280
340
|
const source = shallowRef<VideoSource | VideoSource[]>(buildSource());
|
|
341
|
+
scenes.value = initialScenes();
|
|
281
342
|
watch(() => props.data.videoUrl, () => {
|
|
282
343
|
source.value = buildSource();
|
|
344
|
+
scenes.value = initialScenes();
|
|
345
|
+
currentSceneStart.value = null;
|
|
283
346
|
});
|
|
284
347
|
|
|
285
348
|
/**
|
|
@@ -370,6 +433,10 @@ async function loadAdConfig(): Promise<AdsOptions | undefined> {
|
|
|
370
433
|
}
|
|
371
434
|
}
|
|
372
435
|
|
|
436
|
+
.player-core__scenes {
|
|
437
|
+
margin-top: 12px;
|
|
438
|
+
}
|
|
439
|
+
|
|
373
440
|
// Мета ролика в центре бара: по макету белая с тенью и средней жирностью,
|
|
374
441
|
// у плеера по умолчанию — приглушённая и без тени.
|
|
375
442
|
.player-core .imp-controls__chapter {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<!-- Сцены ролика под плеером (внешний контрол, как PlayerMenu): «тайм-код | название», текущая подсвечена.
|
|
2
|
+
Данные и перемотку держит PlayerCore — сцены активного типа и текущую сцену он берёт из событий плеера.
|
|
3
|
+
До 960px — одна строка с прокруткой и затуханием у правого края, шире — перенос строк. -->
|
|
4
|
+
<template>
|
|
5
|
+
<nav
|
|
6
|
+
v-if="scenes.length"
|
|
7
|
+
:aria-label="t('scenes')"
|
|
8
|
+
class="player-scenes"
|
|
9
|
+
>
|
|
10
|
+
<ol class="player-scenes__list">
|
|
11
|
+
<li
|
|
12
|
+
v-for="scene in scenes"
|
|
13
|
+
:key="scene.start"
|
|
14
|
+
class="player-scenes__item"
|
|
15
|
+
>
|
|
16
|
+
<button
|
|
17
|
+
:class="{ '--current': scene.start === current }"
|
|
18
|
+
:aria-current="scene.start === current || undefined"
|
|
19
|
+
:aria-label="`${scene.title}, ${formatSceneTime(scene.start)}`"
|
|
20
|
+
class="player-scenes__btn"
|
|
21
|
+
type="button"
|
|
22
|
+
@click="emit('select', scene.start)"
|
|
23
|
+
>
|
|
24
|
+
<span class="player-scenes__time">{{ formatSceneTime(scene.start) }}</span>
|
|
25
|
+
<span class="player-scenes__title">{{ scene.title }}</span>
|
|
26
|
+
</button>
|
|
27
|
+
</li>
|
|
28
|
+
</ol>
|
|
29
|
+
</nav>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<script setup lang="ts">
|
|
33
|
+
import type { Chapter } from 'itube-modern-player';
|
|
34
|
+
|
|
35
|
+
defineProps<{
|
|
36
|
+
scenes: Chapter[]
|
|
37
|
+
/** `start` текущей сцены, `null` — ни одна не идёт. */
|
|
38
|
+
current: number | null
|
|
39
|
+
}>();
|
|
40
|
+
|
|
41
|
+
const emit = defineEmits<{
|
|
42
|
+
(eventName: 'select', start: number): void
|
|
43
|
+
}>();
|
|
44
|
+
|
|
45
|
+
const { t } = useI18n();
|
|
46
|
+
|
|
47
|
+
/** Как в контрол-баре плеера: минуты без ведущего нуля — `0:55`, `1:02:03`. */
|
|
48
|
+
function formatSceneTime(seconds: number): string {
|
|
49
|
+
const total = Math.max(0, Math.floor(seconds));
|
|
50
|
+
const h = Math.floor(total / 3600);
|
|
51
|
+
const m = Math.floor((total % 3600) / 60);
|
|
52
|
+
const s = String(total % 60).padStart(2, '0');
|
|
53
|
+
|
|
54
|
+
return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<style lang="scss">
|
|
59
|
+
.player-scenes__list {
|
|
60
|
+
display: flex;
|
|
61
|
+
flex-wrap: nowrap;
|
|
62
|
+
gap: 8px;
|
|
63
|
+
overflow-x: auto;
|
|
64
|
+
margin-inline: calc(-1 * var(--container-padding-inline));
|
|
65
|
+
padding-inline: var(--container-padding-inline) 40px;
|
|
66
|
+
scrollbar-width: none;
|
|
67
|
+
mask-image: linear-gradient(to right, $player-scenes-mask calc(100% - 40px), transparent);
|
|
68
|
+
|
|
69
|
+
&::-webkit-scrollbar {
|
|
70
|
+
display: none;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@include from-br(sm) {
|
|
74
|
+
flex-wrap: wrap;
|
|
75
|
+
overflow-x: visible;
|
|
76
|
+
margin-inline: 0;
|
|
77
|
+
padding-inline: 0;
|
|
78
|
+
mask-image: none;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.player-scenes__item {
|
|
83
|
+
flex-shrink: 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.player-scenes__btn {
|
|
87
|
+
@include font-xs;
|
|
88
|
+
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
height: 36px;
|
|
92
|
+
overflow: hidden;
|
|
93
|
+
white-space: nowrap;
|
|
94
|
+
color: $player-scenes-text;
|
|
95
|
+
background-color: $player-scenes-bg;
|
|
96
|
+
border: 1px solid $player-scenes-border;
|
|
97
|
+
border-radius: $radius-md;
|
|
98
|
+
|
|
99
|
+
@include hover(background-color, color) {
|
|
100
|
+
color: $player-scenes-text--current;
|
|
101
|
+
background-color: $player-scenes-bg--hover;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@include from-br(sm) {
|
|
105
|
+
@include font-sm;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
&.--current {
|
|
109
|
+
color: $player-scenes-text--current;
|
|
110
|
+
background-color: $player-scenes-bg--current;
|
|
111
|
+
border-color: $player-scenes-border--current;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.player-scenes__time {
|
|
116
|
+
@include font-xs;
|
|
117
|
+
|
|
118
|
+
display: flex;
|
|
119
|
+
align-items: center;
|
|
120
|
+
height: 100%;
|
|
121
|
+
padding-inline: 10px;
|
|
122
|
+
font-weight: 500;
|
|
123
|
+
font-variant-numeric: tabular-nums;
|
|
124
|
+
color: $player-scenes-time;
|
|
125
|
+
background-color: $player-scenes-time-bg;
|
|
126
|
+
border-right: 1px solid $player-scenes-border;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.player-scenes__btn.--current .player-scenes__time {
|
|
130
|
+
color: $player-scenes-text--current;
|
|
131
|
+
background-color: $player-scenes-time-bg--current;
|
|
132
|
+
border-right-color: $player-scenes-time-border--current;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.player-scenes__title {
|
|
136
|
+
padding-inline: 12px;
|
|
137
|
+
}
|
|
138
|
+
</style>
|
|
@@ -239,10 +239,15 @@ const categoriesChips = computed((): IChipsItem[] => props.data.categories && pr
|
|
|
239
239
|
prefix: 'categories',
|
|
240
240
|
})));
|
|
241
241
|
|
|
242
|
-
// Над плеером —
|
|
243
|
-
// Теги, совпавшие с
|
|
244
|
-
const topCategoriesChips = computed((): IChipsItem[] => (
|
|
245
|
-
.
|
|
242
|
+
// Над плеером — категории из группы `top_categories`, как прислал бекенд.
|
|
243
|
+
// Теги, совпавшие с любой категорией ролика, всё равно отсекаются по полному списку ниже.
|
|
244
|
+
const topCategoriesChips = computed((): IChipsItem[] => (props.data.topCategories ?? []).map(item => ({
|
|
245
|
+
title: item.title,
|
|
246
|
+
value: item.name,
|
|
247
|
+
image: item.icon ? `/icons/flags/${item.icon}.svg` : undefined,
|
|
248
|
+
imageFit: 'contain',
|
|
249
|
+
prefix: 'categories',
|
|
250
|
+
})));
|
|
246
251
|
|
|
247
252
|
const categoriesTitles = computed<string[]>(() => props.data.categories && props.data.categories?.map(item => item.title?.replace(/\s+/g, '').toLowerCase() ?? ''));
|
|
248
253
|
|
package/i18n/locales/de-DE.json
CHANGED
package/i18n/locales/en-US.json
CHANGED
package/i18n/locales/es-ES.json
CHANGED
package/i18n/locales/fr-FR.json
CHANGED
package/i18n/locales/it-IT.json
CHANGED
package/i18n/locales/ja-JP.json
CHANGED
package/i18n/locales/ko-KR.json
CHANGED
package/i18n/locales/ru-RU.json
CHANGED
package/i18n/locales/zh-CN.json
CHANGED
package/package.json
CHANGED
package/runtime/index.ts
CHANGED
|
@@ -47,7 +47,6 @@ export * from './utils/cleaners/clean-video-card';
|
|
|
47
47
|
export * from './utils/cleaners/clean-model-info';
|
|
48
48
|
export * from './utils/cleaners/clean-suggest-models';
|
|
49
49
|
export * from './utils/video-data-add-model-portraits';
|
|
50
|
-
export * from './utils/video-data-mark-top-categories';
|
|
51
50
|
export * from './utils/converters/convert-categories-to-footer';
|
|
52
51
|
export * from './utils/converters/convert-categories-to-chips';
|
|
53
52
|
export * from './utils/converters/convert-tag-group-item-to-chips';
|
|
@@ -197,4 +197,21 @@ describe('cleanVideoData — группы под плеером', () => {
|
|
|
197
197
|
expect(data.tagGroups[0].items.map(item => item.name)).toEqual(['anal', 'rimming', 'doggy']);
|
|
198
198
|
expect(data.tags).toEqual(['anal']);
|
|
199
199
|
});
|
|
200
|
+
|
|
201
|
+
it('v2: top_categories — в topCategories по order, в группы под плеером не попадает', () => {
|
|
202
|
+
const data = cleanVideoData(raw([
|
|
203
|
+
{ type: 'category', name: 'actions', title: 'Actions', order: 1, items: [{ name: 'anal', title: 'Anal' }] },
|
|
204
|
+
{ type: 'top_categories', name: 'top_categories', title: 'top_categories', order: 2, items: [
|
|
205
|
+
{ name: 'hunk', title: 'Hunk', order: 2, icon: '' },
|
|
206
|
+
{ name: 'business man', title: 'Businessman', order: 1, icon: '' },
|
|
207
|
+
] },
|
|
208
|
+
]));
|
|
209
|
+
|
|
210
|
+
expect(data.topCategories.map(item => item.name)).toEqual(['business man', 'hunk']);
|
|
211
|
+
expect(data.tagGroups.map(group => group.name)).toEqual(['actions']);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('без top_categories — строка над плеером без категорий', () => {
|
|
215
|
+
expect(cleanVideoData(raw(undefined)).topCategories).toEqual([]);
|
|
216
|
+
});
|
|
200
217
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IRawVideoData, IVideoData, ISceneGroup, ITimelineGroup, IVideoTagGroup } from '../../../types';
|
|
1
|
+
import type { IRawVideoData, IVideoData, ISceneGroup, ITimelineGroup, IVideoTagGroup, IVideoTagGroupItem } from '../../../types';
|
|
2
2
|
import { pickPortraitVariant } from '../pick-portrait-variant';
|
|
3
3
|
import { modelGender } from '../model-placeholder';
|
|
4
4
|
|
|
@@ -82,6 +82,12 @@ const cleanTagGroups = (raw: IRawVideoData['entities']): IVideoTagGroup[] => (Ar
|
|
|
82
82
|
.filter(group => group.items.length > 0)
|
|
83
83
|
.sort(byOrder);
|
|
84
84
|
|
|
85
|
+
// Строка над плеером: элементы группы `top_categories` как есть, без сверки с `categories`.
|
|
86
|
+
const cleanTopCategories = (raw: IRawVideoData['entities']): IVideoTagGroupItem[] => ((Array.isArray(raw) ? raw : [])
|
|
87
|
+
.find(group => group?.type === 'top_categories')?.items ?? [])
|
|
88
|
+
.filter(item => item?.title && item.name)
|
|
89
|
+
.sort(byOrder);
|
|
90
|
+
|
|
85
91
|
/** Нормализует сырые данные видео к типу IVideoData. */
|
|
86
92
|
export const cleanVideoData = (data: IRawVideoData, theme?: string): IVideoData => ({
|
|
87
93
|
guid: data.guid || '',
|
|
@@ -96,6 +102,7 @@ export const cleanVideoData = (data: IRawVideoData, theme?: string): IVideoData
|
|
|
96
102
|
tagGroups: cleanTagGroups(data.entities),
|
|
97
103
|
models: cleanVideoModels(data.models, theme),
|
|
98
104
|
categories: data.categories || [],
|
|
105
|
+
topCategories: cleanTopCategories(data.entities),
|
|
99
106
|
actions: data.actions || null,
|
|
100
107
|
channelName: data.channel?.name || '',
|
|
101
108
|
channelAvatar: data.channel?.avatar_url || '',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IChipsItem, IVideoTagGroupItem, VideoTagGroupType } from '../../../types';
|
|
2
2
|
|
|
3
|
-
const PREFIX: Record<VideoTagGroupType, string> = { tag: 'tag', category: 'categories', search: 'search' };
|
|
3
|
+
const PREFIX: Record<VideoTagGroupType, string> = { tag: 'tag', category: 'categories', search: 'search', top_categories: 'categories' };
|
|
4
4
|
|
|
5
5
|
/** Элемент группы под плеером (контракт видео v2) → чипса: ссылка по типу группы и машинному `name`, показ по `title`. */
|
|
6
6
|
export function convertTagGroupItemToChips(type: VideoTagGroupType, item: IVideoTagGroupItem): IChipsItem {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IRawVideoData, IVideoData, IRawModelCard } from '../../../../types';
|
|
2
|
-
import { cleanVideoData, videoDataAddModelPortraits,
|
|
2
|
+
import { cleanVideoData, videoDataAddModelPortraits, getUrlWithProxiedParams } from '../../../../runtime';
|
|
3
3
|
|
|
4
4
|
export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
5
5
|
const theme = useRuntimeConfig(event).public.theme;
|
|
@@ -17,16 +17,15 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
|
17
17
|
const videoUrl = getUrlWithProxiedParams(`${apiUrl}/v2/video/url/${id}`, event);
|
|
18
18
|
const modelsUrl = getUrlWithProxiedParams(`${apiUrl}/v2/models/list-by-video-id/${id}`, event);
|
|
19
19
|
|
|
20
|
-
const [videoRaw, modelsRaw
|
|
20
|
+
const [videoRaw, modelsRaw] = await Promise.all([
|
|
21
21
|
apiFetch(videoUrl, {
|
|
22
22
|
method: 'GET',
|
|
23
23
|
headers: clientHeaders,
|
|
24
24
|
}),
|
|
25
25
|
apiFetch(modelsUrl, { method: 'GET', headers: clientHeaders }).catch(() => null),
|
|
26
|
-
getTopCategoryNames(event),
|
|
27
26
|
]);
|
|
28
27
|
|
|
29
|
-
const videoData =
|
|
28
|
+
const videoData = withDemoFallbacks(videoRaw.data as IRawVideoData);
|
|
30
29
|
const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
|
|
31
30
|
|
|
32
31
|
return cleanVideoData(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cleanVideoData, videoDataAddModelPortraits,
|
|
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> => {
|
|
@@ -15,16 +15,15 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
|
|
|
15
15
|
const videoUrl = getUrlWithProxiedParams(`${apiUrl}/v2/video/url/${url}`, event);
|
|
16
16
|
const modelsUrl = getUrlWithProxiedParams(`${apiUrl}/v2/models/list-by-video-url/${url}`, event);
|
|
17
17
|
|
|
18
|
-
const [videoRaw, modelsRaw
|
|
18
|
+
const [videoRaw, modelsRaw] = await Promise.all([
|
|
19
19
|
apiFetch(videoUrl, {
|
|
20
20
|
method: 'GET',
|
|
21
21
|
headers: clientHeaders,
|
|
22
22
|
}),
|
|
23
23
|
apiFetch(modelsUrl, { method: 'GET', headers: clientHeaders }).catch(() => null),
|
|
24
|
-
getTopCategoryNames(event),
|
|
25
24
|
]);
|
|
26
25
|
|
|
27
|
-
const videoData =
|
|
26
|
+
const videoData = withDemoFallbacks(videoRaw.data as IRawVideoData);
|
|
28
27
|
const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
|
|
29
28
|
|
|
30
29
|
return cleanVideoData(
|
package/types/video-data.d.ts
CHANGED
|
@@ -30,7 +30,8 @@ export interface ISceneGroupScene {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/** Тип узла группы (контракт ручки видео v2): по нему строится ссылка элементов — тег, категория или поиск. */
|
|
33
|
-
|
|
33
|
+
/** `top_categories` — не группа под плеером, а список категорий для строки над плеером (`IVideoData.topCategories`). */
|
|
34
|
+
export type VideoTagGroupType = 'tag' | 'category' | 'search' | 'top_categories';
|
|
34
35
|
|
|
35
36
|
/** Элемент группы (контракт ручки видео v2). `name` — машинное название (ссылка), `title` — для показа. */
|
|
36
37
|
export interface IVideoTagGroupItem {
|
|
@@ -82,9 +83,9 @@ export interface IVideoData {
|
|
|
82
83
|
name: string;
|
|
83
84
|
title: string;
|
|
84
85
|
icon?: string;
|
|
85
|
-
/** Топ-категория — попадает в строку над плеером (BFF, по кешу `/v1/categories/top`). */
|
|
86
|
-
isTop?: boolean;
|
|
87
86
|
}[];
|
|
87
|
+
/** Категории строки над плеером — из группы `top_categories` ответа v2, как прислал бекенд. */
|
|
88
|
+
topCategories: IVideoTagGroupItem[];
|
|
88
89
|
actions: null;
|
|
89
90
|
channelName: string;
|
|
90
91
|
channelAvatar: string;
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import type { IRawVideoData } from '../../../types';
|
|
3
|
-
import { videoDataMarkTopCategories } from '../video-data-mark-top-categories';
|
|
4
|
-
|
|
5
|
-
const video = {
|
|
6
|
-
categories: [{ name: 'cartoon', title: 'Cartoon' }, { name: '3d', title: '3D' }, { name: 'Masturbation', title: 'Masturbation' }],
|
|
7
|
-
} as IRawVideoData;
|
|
8
|
-
|
|
9
|
-
describe('videoDataMarkTopCategories', () => {
|
|
10
|
-
it('marks categories from the top list, ignoring case and edge spaces', () => {
|
|
11
|
-
const result = videoDataMarkTopCategories(video, ['cartoon', 'masturbation ']);
|
|
12
|
-
|
|
13
|
-
expect(result.categories.map(category => category.isTop)).toEqual([true, false, true]);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it('leaves the video untouched without the top list', () => {
|
|
17
|
-
expect(videoDataMarkTopCategories(video, null)).toBe(video);
|
|
18
|
-
});
|
|
19
|
-
});
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import type { IRawVideoData } from '../../types';
|
|
2
|
-
|
|
3
|
-
const normalize = (name: string) => name.trim().toLowerCase();
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Помечает категории ролика `isTop` по списку топ-категорий: в строку над плеером идут только топовые.
|
|
7
|
-
* Списка нет (ручка упала) — ролик без пометок, строка покажет все категории, как раньше.
|
|
8
|
-
* Имена сравниваются без регистра и краевых пробелов: в API встречается `masturbation `.
|
|
9
|
-
* @param videoData - сырые данные видео
|
|
10
|
-
* @param topNames - имена топ-категорий
|
|
11
|
-
*/
|
|
12
|
-
export function videoDataMarkTopCategories(videoData: IRawVideoData, topNames: string[] | null): IRawVideoData {
|
|
13
|
-
if (!videoData?.categories?.length || !topNames) return videoData;
|
|
14
|
-
|
|
15
|
-
const top = new Set(topNames.map(normalize));
|
|
16
|
-
|
|
17
|
-
return {
|
|
18
|
-
...videoData,
|
|
19
|
-
categories: videoData.categories.map(category => ({ ...category, isTop: top.has(normalize(category.name)) })),
|
|
20
|
-
};
|
|
21
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import type { H3Event } from 'h3';
|
|
2
|
-
import type { IRawCategoryCard } from '../../types';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Имена топ-категорий из кеша BFF (`cachedApiFetch`, 10 минут на домен + язык + нишу): к бекенду ходим
|
|
6
|
-
* раз в TTL, а не на каждый ролик. Ошибка ручки — `null`, ролик от этого не падает.
|
|
7
|
-
*/
|
|
8
|
-
export async function getTopCategoryNames(event: H3Event): Promise<string[] | null> {
|
|
9
|
-
const url = `${process.env.NUXT_PUBLIC_API_DOMAIN}/v1/categories/top`;
|
|
10
|
-
|
|
11
|
-
return cachedApiFetch<Array<IRawCategoryCard>>(url, event)
|
|
12
|
-
.then(categories => (Array.isArray(categories) ? categories.map(category => category.name) : null))
|
|
13
|
-
.catch(() => null);
|
|
14
|
-
}
|