itube-specs 0.0.1058 → 0.0.1060

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 CHANGED
@@ -11,6 +11,33 @@
11
11
  коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
12
12
  кому именно (всем / сайтам с N), что будет, если не сделать.
13
13
 
14
+ ## 0.0.1060 — 2026-11-02
15
+
16
+ - **Сайтам со своими `assets/scss/vars` (hdsex-remaster и др.), срочно:** с 0.0.1059 (лоадер, `0d412182`) без новых
17
+ токенов сборка падает на `Undefined variable`. В `_vars.scss`: `$blur-loading: 4px;` и
18
+ `$blur-loading-spill: $blur-loading * 3;`. В теме: `$loading-bg` (тёмная — `transparent`, светлая —
19
+ `$color-white-75`) и `$video-preview-loading-bg: transparent;`. Образец — hdsex-remaster.
20
+
21
+ - **Всем — к сведению:** в строке чипсов над плеером — только топ-категории ролика (`categories[].isTop`, ставит BFF
22
+ видео по закешированному `/v1/categories/top`, к бекенду — раз в 10 минут на домен/язык/нишу). Нетоповые остаются в
23
+ блоке под описанием и не возвращаются в строку тегом. Топ-список не пришёл — как раньше, все категории. Делать ничего
24
+ не надо.
25
+
26
+ ## 0.0.1059 — 2026-10-30
27
+
28
+ - **Всем, срочно к сведению:** с 0.0.1056 блок Live Cams и врезки вебкамов пропали из сеток видео на страницах, которые
29
+ не передают `webcamsQuery` (канал, поиск, листинги на `ViewVideos` без запроса): проп был `{…} | false`, и Vue приводил
30
+ отсутствующий булев проп к `false` = «без вебкамов». Починено. Сайтам, где сетке передавали `:webcams-query="false"`,
31
+ — заменить на `no-webcams` (новый булев проп `GridVideos` / `ViewVideos`), иначе вебкамы там снова появятся.
32
+
33
+ - **Всем — к сведению:** `GridInsert` / `useGridRowAfter` умеют `afterCards` — после строки с N-й карточкой (без дыр,
34
+ вебкам-тумбы с `data-grid-extra` не в счёт; карточек меньше — за последней строкой); пересчёт и на перестановку
35
+ соседних вставок (раньше реклама могла сдвинуть блок, а он не замечал). Новые ключи: `gridWebcams.videos.afterCards`,
36
+ `gridWebcams.models.afterCards` (важнее `afterRows`), `videoPage.webcamsInRelatedAfter` — блок Live Cams внутри
37
+ сетки похожих. Дефолты — как было, делать ничего не надо; videosection-redesign ставит 14 / 10 / 14.
38
+ - **Всем — к сведению:** у `WebcamMain` проп `fullRows` (по умолчанию `true` — только полные строки, как было);
39
+ `false` — все модели, последняя строка может быть неполной.
40
+
14
41
  ## 0.0.1058 — 2026-10-30
15
42
 
16
43
  - **Всем — к сведению:** новый ключ `appConfig.channelTagsSource` (по умолчанию `'popular'` — как было, популярные теги
package/app.config.ts CHANGED
@@ -61,8 +61,9 @@ export default defineAppConfig({
61
61
  * карточек и сколько врезок вебкам-тумб (хвост того же списка) в сетке видео листинга (не похожие, не плейлист,
62
62
  * не когда страница передала врезки сама); `models` — в основной сетке моделей (`priority`, не футер). */
63
63
  gridWebcams: {
64
- videos: null as { afterRows: number, inserts: number } | null,
65
- models: null as { afterRows: number } | null,
64
+ /** `afterCards` — после строки с N-й карточкой (карточек меньше — в конец сетки); задан — важнее `afterRows`. */
65
+ videos: null as { afterRows: number, afterCards?: number, inserts: number } | null,
66
+ models: null as { afterRows: number, afterCards?: number } | null,
66
67
  /** Раскладка videosection.com (`WEBCAMS_LEGACY_LAYOUT`): список 42 на страницу, блок — модели 0–11, врезки в сетку
67
68
  * видео — 21–24 после ¼, ½, ¾ сетки и за 4 карточки до конца (`inserts` из `videos` не используется), мини-блок
68
69
  * после сетки видео и моделей — 25–36. `false` — раскладка слоя: блок 12 + врезки из хвоста на случайных местах. */
@@ -86,6 +87,9 @@ export default defineAppConfig({
86
87
  relatedModels: true,
87
88
  /** Блок Live Cams после похожих (при `FeatureWebcamsEnabled`); врезки в похожие берут хвост того же списка. */
88
89
  webcamsBlock: false,
90
+ /** Блок Live Cams внутри сетки похожих — после строки с N-й карточкой (похожих меньше — в конце сетки);
91
+ * `null` — после секции похожих. */
92
+ webcamsInRelatedAfter: null as number | null,
89
93
  /** Сколько моделей из хвоста списка уходит во врезки похожих, когда блок Live Cams включён. */
90
94
  webcamInserts: 8,
91
95
  /** С 1024px прятать чипсы моделей над плеером, у которых есть блок в боковой колонке (не дублировать ссылку). */
@@ -1,5 +1,6 @@
1
1
  <!-- Полноширинная вставка в CSS-сетку карточек (`GridVideos`, `GridModels`): встаёт после N-й строки карточек
2
- при любом числе колонок (`useGridRowAfter`), пустая места не занимает. Класть прямым потомком сетки — через её слоты. -->
2
+ (`afterRows`) или после строки с N-й карточкой (`afterCards`, карточек меньше — в конец) при любом числе колонок
3
+ (`useGridRowAfter`), пустая места не занимает. Класть прямым потомком сетки — через её слоты. -->
3
4
  <template>
4
5
  <div
5
6
  ref="root"
@@ -12,12 +13,14 @@
12
13
  <script setup lang="ts">
13
14
  const props = defineProps<{
14
15
  /** После какой строки карточек встать. */
15
- afterRows: number
16
+ afterRows?: number
17
+ /** После строки, в которой лежит N-я карточка (без дыр перед блоком); задан — важнее `afterRows`. */
18
+ afterCards?: number
16
19
  }>();
17
20
 
18
21
  const root = ref<HTMLElement | null>(null);
19
22
 
20
- useGridRowAfter(root, () => props.afterRows);
23
+ useGridRowAfter(root, () => (props.afterCards ? { cards: props.afterCards } : { rows: props.afterRows ?? 0 }));
21
24
  </script>
22
25
 
23
26
  <style lang="scss">
@@ -17,6 +17,7 @@
17
17
  <GridInsert
18
18
  v-if="webcams"
19
19
  :after-rows="webcams.afterRows"
20
+ :after-cards="webcams.afterCards"
20
21
  >
21
22
  <LazyWebcamMain
22
23
  :list="legacy ? WEBCAMS_LEGACY_LAYOUT.list : undefined"
@@ -21,6 +21,7 @@
21
21
  v-if="cell.webcam"
22
22
  :model="cell.webcam"
23
23
  class="grid-videos__webcam"
24
+ data-grid-extra
24
25
  @click="onInsertClick(cell)"
25
26
  @poster="element => onInsertView(cell, element)"
26
27
  />
@@ -44,6 +45,7 @@
44
45
  <GridInsert
45
46
  v-if="webcams"
46
47
  :after-rows="webcams.afterRows"
48
+ :after-cards="webcams.afterCards"
47
49
  >
48
50
  <LazyWebcamMain
49
51
  v-if="legacyList"
@@ -64,6 +66,7 @@
64
66
  v-if="cell.webcam"
65
67
  :model="cell.webcam"
66
68
  class="grid-videos__webcam"
69
+ data-grid-extra
67
70
  @click="onInsertClick(cell)"
68
71
  @poster="element => onInsertView(cell, element)"
69
72
  />
@@ -90,6 +93,7 @@
90
93
  v-if="cell.webcam"
91
94
  :model="cell.webcam"
92
95
  class="grid-videos__webcam"
96
+ data-grid-extra
93
97
  @click="onInsertClick(cell)"
94
98
  @poster="element => onInsertView(cell, element)"
95
99
  />
@@ -159,9 +163,11 @@ const props = defineProps<{
159
163
  insertSeed?: string
160
164
  /**
161
165
  * Выборка вебкамов листинга при `gridWebcams.legacy`: категория провайдера, `random` и размер списка (по умолчанию
162
- * `WEBCAMS_LEGACY_LAYOUT.list`, случайные). `false` — без блока и врезок (в любой раскладке).
166
+ * `WEBCAMS_LEGACY_LAYOUT.list`, случайные). Только объект: булев тип в пропе Vue приводит отсутствующий проп к `false`.
163
167
  */
164
- webcamsQuery?: { category?: string, random?: boolean, list?: number } | false
168
+ webcamsQuery?: { category?: string, random?: boolean, list?: number }
169
+ /** Без блока Live Cams и врезок (в любой раскладке). */
170
+ noWebcams?: boolean
165
171
  }>();
166
172
 
167
173
  /** Клики и показы врезанных тумб уходят наружу — статистику отправляет сайт. */
@@ -189,16 +195,16 @@ const cardTriggerClass = appConfig.cardTriggerClass || undefined;
189
195
  const legacy = !!appConfig.gridWebcams?.legacy;
190
196
 
191
197
  /** Блок Live Cams и свои врезки — у листингов (похожие и плейлист без них, страница видео передаёт врезки сама). */
192
- const webcams = !props.inserts && !props.related && !props.playlist && props.webcamsQuery !== false
198
+ const webcams = !props.inserts && !props.related && !props.playlist && !props.noWebcams
193
199
  ? appConfig.gridWebcams?.videos
194
200
  : null;
195
201
 
196
202
  /** Общий список страницы в раскладке videosection.com (`gridWebcams.legacy`): блок, врезки и мини-блок режут его по индексам. */
197
203
  const legacyList = webcams && legacy
198
204
  ? {
199
- list: (props.webcamsQuery && props.webcamsQuery.list) || WEBCAMS_LEGACY_LAYOUT.list,
200
- category: (props.webcamsQuery && props.webcamsQuery.category) || undefined,
201
- random: props.webcamsQuery ? props.webcamsQuery.random !== false : true,
205
+ list: props.webcamsQuery?.list || WEBCAMS_LEGACY_LAYOUT.list,
206
+ category: props.webcamsQuery?.category || undefined,
207
+ random: props.webcamsQuery?.random !== false,
202
208
  }
203
209
  : null;
204
210
 
@@ -81,6 +81,20 @@
81
81
  <template #grid-end>
82
82
  <AdvNativeBottom v-if="featureFlags.AdsNativeEnabled"/>
83
83
  </template>
84
+
85
+ <GridInsert
86
+ v-if="videoPage.webcamsBlock && videoPage.webcamsInRelatedAfter"
87
+ :after-cards="videoPage.webcamsInRelatedAfter"
88
+ >
89
+ <WebcamMain
90
+ v-if="legacyWebcams"
91
+ v-bind="legacyWebcams"
92
+ />
93
+ <WebcamMain
94
+ v-else
95
+ :reserve-tail="videoPage.webcamInserts"
96
+ />
97
+ </GridInsert>
84
98
  </LazyGridVideos>
85
99
  </section>
86
100
 
@@ -102,16 +116,18 @@
102
116
  hydrate-on-visible
103
117
  />
104
118
 
105
- <WebcamMain
106
- v-if="videoPage.webcamsBlock && legacyWebcams"
107
- v-bind="legacyWebcams"
108
- class="_mt-24"
109
- />
110
- <WebcamMain
111
- v-else-if="videoPage.webcamsBlock"
112
- :reserve-tail="videoPage.webcamInserts"
113
- class="_mt-24"
114
- />
119
+ <template v-if="videoPage.webcamsBlock && !videoPage.webcamsInRelatedAfter">
120
+ <WebcamMain
121
+ v-if="legacyWebcams"
122
+ v-bind="legacyWebcams"
123
+ class="_mt-24"
124
+ />
125
+ <WebcamMain
126
+ v-else
127
+ :reserve-tail="videoPage.webcamInserts"
128
+ class="_mt-24"
129
+ />
130
+ </template>
115
131
 
116
132
  <ViewVideoAfterRelatedSlot :data="data"/>
117
133
 
@@ -218,6 +234,11 @@ const categoriesChips = computed((): IChipsItem[] => props.data.categories && pr
218
234
  prefix: 'categories',
219
235
  })));
220
236
 
237
+ // Над плеером — только топ-категории (`isTop` ставит BFF); без пометки (топ-список не пришёл) — все.
238
+ // Теги, совпавшие с нетоповой категорией, всё равно отсекаются по полному списку ниже.
239
+ const topCategoriesChips = computed((): IChipsItem[] => (categoriesChips.value ?? [])
240
+ .filter((_, index) => props.data.categories[index]?.isTop !== false));
241
+
221
242
  const categoriesTitles = computed<string[]>(() => props.data.categories && props.data.categories?.map(item => item.title?.replace(/\s+/g, '').toLowerCase() ?? ''));
222
243
 
223
244
  const withoutCategoryTitles = (chips: IChipsItem[]) => (categoriesTitles.value?.length
@@ -242,9 +263,9 @@ const topChips = computed(() => {
242
263
  prefix: 'search',
243
264
  } as IChipsItem));
244
265
  } else if (tagGroupChips.value.length) {
245
- return [...categoriesChips.value || [], ...withoutCategoryTitles(tagGroupChips.value)];
266
+ return [...topCategoriesChips.value, ...withoutCategoryTitles(tagGroupChips.value)];
246
267
  } else if (categoriesChips.value || filterTagChips.value) {
247
- return [...categoriesChips.value || [], ...filterTagChips.value || []];
268
+ return [...topCategoriesChips.value, ...filterTagChips.value || []];
248
269
  }
249
270
  return null;
250
271
  });
@@ -31,6 +31,7 @@
31
31
  v-if="data && data.items && data.items?.length > 0"
32
32
  :items="data.items"
33
33
  :webcams-query="webcamsQuery"
34
+ :no-webcams="noWebcams"
34
35
  :class="{'_loading': status === 'pending'}"
35
36
  class="view-videos__grid"
36
37
  id="anchor"
@@ -118,7 +119,9 @@ const props = defineProps<{
118
119
  /** Чипсы поисковых фраз во второй строке грида, как на странице категории. */
119
120
  relatedSearch?: IChipsItem[]
120
121
  /** Выборка вебкамов сетки (`GridVideos`). */
121
- webcamsQuery?: { category?: string, random?: boolean, list?: number } | false
122
+ webcamsQuery?: { category?: string, random?: boolean, list?: number }
123
+ /** Сетка без вебкамов (`GridVideos`). */
124
+ noWebcams?: boolean
122
125
  }>();
123
126
 
124
127
  const route = useRoute();
@@ -55,10 +55,13 @@ const props = withDefaults(defineProps<{
55
55
  id?: string
56
56
  /** Без счётчиков онлайна в шапке (мини-блок videosection.com). */
57
57
  hideCounts?: boolean
58
+ /** Только полные строки (по умолчанию); `false` — все модели, последняя строка может быть неполной. */
59
+ fullRows?: boolean
58
60
  }>(), {
59
61
  reserveTail: 0,
60
62
  offset: 0,
61
63
  random: true,
64
+ fullRows: true,
62
65
  });
63
66
 
64
67
  const NO_COUNTS = { total: 0, byLanguages: {} };
@@ -103,7 +106,7 @@ const columns = ref(0);
103
106
  */
104
107
  const visibleItems = computed<IStripcashModel[]>(() => {
105
108
  const cols = columns.value;
106
- if (!cols) return blockItems.value;
109
+ if (!cols || !props.fullRows) return blockItems.value;
107
110
 
108
111
  return blockItems.value.slice(0, Math.floor(blockItems.value.length / cols) * cols);
109
112
  });
@@ -1,35 +1,61 @@
1
1
  import { useDebounceFn } from '@vueuse/core';
2
2
 
3
+ /** Где встать: после N строк карточек или после строки, в которой лежит N-я карточка. */
4
+ export type GridInsertPlace = { rows: number } | { cards: number };
5
+
3
6
  /**
4
7
  * Ставит полноширинный блок CSS-сетки после N-й строки карточек — при любом числе колонок.
5
8
  *
6
9
  * Считаем по карточкам, а не по строкам сетки: в сетку попадают и полноширинные врезки (карусель
7
10
  * моделей, блок вебкамов, реклама), они занимают строку целиком. Карточку отличаем геометрически —
8
- * шириной в одну колонку. Карточек до нужной строки не хватает — блок остаётся на своём месте в DOM.
11
+ * шириной в одну колонку; скрытые (`display: none`) не считаем.
12
+ *
13
+ * `{ cards: N }` — после строки с N-й карточкой (без дыр в строке перед блоком; врезки с `data-grid-extra` не в счёт);
14
+ * карточек меньше — сразу за строкой последней. `{ rows: N }` — карточек до нужной строки не хватает — блок остаётся
15
+ * на своём месте в DOM.
9
16
  */
10
- export function placeAfterCardRows(el: HTMLElement | null, rows: number) {
17
+ export function placeAfterCardRows(el: HTMLElement | null, place: number | GridInsertPlace) {
11
18
  const grid = el?.parentElement;
12
19
  if (!el || !grid) return;
13
20
 
14
21
  const columns = getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean);
15
22
  const columnWidth = Number.parseFloat(columns[0]) || 0;
16
23
 
17
- const siblings = Array.from(grid.children).filter(item => item !== el) as HTMLElement[];
24
+ const siblings = (Array.from(grid.children) as HTMLElement[]).filter(item => item !== el && item.offsetWidth > 0);
18
25
  const cards = siblings.filter(item => item.offsetWidth <= columnWidth * 1.5);
26
+ const setRow = (row: string) => {
27
+ if (el.style.gridRow !== row) el.style.gridRow = row;
28
+ };
29
+
30
+ if (typeof place === 'object' && 'cards' in place) {
31
+ // N-я карточка выдачи (врезки с `data-grid-extra` — вебкам-тумбы — не в счёт) или последняя, если их меньше;
32
+ // блок — сразу за её строкой
33
+ const counted = cards.filter(item => !item.hasAttribute('data-grid-extra'));
34
+ const anchor = counted[place.cards - 1] || counted.at(-1);
35
+ const rowsUpToAnchor = anchor
36
+ ? new Set(siblings.filter(item => item.offsetTop <= anchor.offsetTop).map(item => item.offsetTop)).size
37
+ : 0;
38
+
39
+ setRow(anchor ? String(rowsUpToAnchor + 1) : '');
40
+
41
+ return;
42
+ }
43
+
44
+ const target = cards[(typeof place === 'number' ? place : place.rows) * columns.length];
19
45
 
20
- const target = cards[rows * columns.length];
21
46
  if (!target) {
22
- el.style.gridRow = '';
47
+ setRow('');
23
48
 
24
49
  return;
25
50
  }
26
51
 
27
52
  const rowsAbove = new Set(siblings.filter(item => item.offsetTop < target.offsetTop).map(item => item.offsetTop));
28
- el.style.gridRow = String(rowsAbove.size + 1);
53
+ setRow(String(rowsAbove.size + 1));
29
54
  }
30
55
 
31
- /** То же для компонента: пересчёт на resize (меняются колонки) и на доезд карточек (ленивая гидрация). */
32
- export const useGridRowAfter = (el: Ref<HTMLElement | null>, rows: () => number) => {
56
+ /** То же для компонента: пересчёт на resize (меняются колонки), на доезд карточек (ленивая гидрация) и на перестановку
57
+ * соседних вставок. */
58
+ export const useGridRowAfter = (el: Ref<HTMLElement | null>, rows: () => number | GridInsertPlace) => {
33
59
  const place = () => placeAfterCardRows(el.value, rows());
34
60
 
35
61
  onMounted(() => {
@@ -37,11 +63,19 @@ export const useGridRowAfter = (el: Ref<HTMLElement | null>, rows: () => number)
37
63
  if (!grid) return;
38
64
 
39
65
  const replace = useDebounceFn(place, 200);
40
- const observer = new MutationObserver(replace);
66
+ // соседние вставки (реклама, другие блоки) тоже переставляют себя через style — пересчитываемся вслед за ними;
67
+ // style внутри карточек (превью и пр.) не интересен — только у прямых детей сетки, кроме себя
68
+ const observer = new MutationObserver((mutations) => {
69
+ if (mutations.some(item => (item.type === 'childList'
70
+ ? item.target === grid
71
+ : item.target.parentElement === grid && item.target !== el.value))) {
72
+ replace();
73
+ }
74
+ });
41
75
 
42
76
  place();
43
77
  window.addEventListener('resize', replace, { passive: true });
44
- observer.observe(grid, { childList: true });
78
+ observer.observe(grid, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
45
79
 
46
80
  onBeforeUnmount(() => {
47
81
  window.removeEventListener('resize', replace);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.1058",
4
+ "version": "0.0.1060",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
package/runtime/index.ts CHANGED
@@ -46,6 +46,7 @@ export * from './utils/cleaners/clean-video-card';
46
46
  export * from './utils/cleaners/clean-model-info';
47
47
  export * from './utils/cleaners/clean-suggest-models';
48
48
  export * from './utils/video-data-add-model-portraits';
49
+ export * from './utils/video-data-mark-top-categories';
49
50
  export * from './utils/converters/convert-categories-to-footer';
50
51
  export * from './utils/converters/convert-categories-to-chips';
51
52
  export * from './utils/converters/convert-tag-group-item-to-chips';
@@ -0,0 +1,19 @@
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
+ });
@@ -0,0 +1,21 @@
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,5 +1,5 @@
1
1
  import type { IRawVideoData, IVideoData, IRawModelCard } from '../../../../types';
2
- import { cleanVideoData, videoDataAddModelPortraits, getUrlWithProxiedParams } from '../../../../runtime';
2
+ import { cleanVideoData, videoDataAddModelPortraits, videoDataMarkTopCategories, getUrlWithProxiedParams } from '../../../../runtime';
3
3
 
4
4
  export default defineEventHandler(async (event): Promise<IVideoData> => {
5
5
  const theme = useRuntimeConfig(event).public.theme;
@@ -17,15 +17,16 @@ 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] = await Promise.all([
20
+ const [videoRaw, modelsRaw, topCategoryNames] = 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),
26
27
  ]);
27
28
 
28
- const videoData = withDemoFallbacks(videoRaw.data as IRawVideoData);
29
+ const videoData = videoDataMarkTopCategories(withDemoFallbacks(videoRaw.data as IRawVideoData), topCategoryNames);
29
30
  const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
30
31
 
31
32
  return cleanVideoData(
@@ -1,4 +1,4 @@
1
- import { cleanVideoData, videoDataAddModelPortraits, getUrlWithProxiedParams } from '../../../../runtime';
1
+ import { cleanVideoData, videoDataAddModelPortraits, videoDataMarkTopCategories, getUrlWithProxiedParams } from '../../../../runtime';
2
2
  import type { IRawVideoData, IVideoData, IRawModelCard } from '../../../../types';
3
3
 
4
4
  export default defineEventHandler(async (event): Promise<IVideoData> => {
@@ -15,15 +15,16 @@ 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] = await Promise.all([
18
+ const [videoRaw, modelsRaw, topCategoryNames] = 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),
24
25
  ]);
25
26
 
26
- const videoData = withDemoFallbacks(videoRaw.data as IRawVideoData);
27
+ const videoData = videoDataMarkTopCategories(withDemoFallbacks(videoRaw.data as IRawVideoData), topCategoryNames);
27
28
  const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
28
29
 
29
30
  return cleanVideoData(
@@ -0,0 +1,14 @@
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
+ }
@@ -36,7 +36,9 @@ export interface IRawVideoData {
36
36
  sizeCache?: string,
37
37
  state?: string,
38
38
  title: string,
39
- unknownFields?: string
39
+ unknownFields?: string,
40
+ /** Не из API: ставит BFF по топ-списку категорий. */
41
+ isTop?: boolean
40
42
  }[];
41
43
  actions: null;
42
44
  primary_thumb_rank: number; // unused
@@ -82,6 +82,8 @@ export interface IVideoData {
82
82
  name: string;
83
83
  title: string;
84
84
  icon?: string;
85
+ /** Топ-категория — попадает в строку над плеером (BFF, по кешу `/v1/categories/top`). */
86
+ isTop?: boolean;
85
87
  }[];
86
88
  actions: null;
87
89
  channelName: string;