itube-specs 0.0.991 → 0.0.993
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/card/webcam/index.vue +5 -3
- package/components/card/webcam/languages.vue +3 -1
- package/components/info/main.vue +0 -1
- package/components/layout/logo.vue +2 -1
- package/components/player/aside-videos.vue +11 -5
- package/components/view/webcams.vue +15 -0
- package/components/webcam/header.vue +3 -1
- package/composables/fetch/use-fetch-aside-models.ts +22 -4
- package/composables/use-webcams-path.ts +14 -0
- package/package.json +1 -1
- package/pages/webcams/[[tag]].vue +3 -1
- package/runtime/constants/webcams.ts +6 -0
- package/runtime/index.ts +1 -0
- package/runtime/utils/__tests__/aside-model-videos.test.ts +27 -0
- package/runtime/utils/__tests__/pick-portrait-variant.test.ts +21 -0
- package/runtime/utils/aside-model-videos.ts +16 -0
- package/runtime/utils/pick-portrait-variant.ts +18 -4
- package/types/webcams-config.d.ts +2 -0
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
<!-- Страна — в строке имени справа (в макете на узких экранах её нет вовсе). -->
|
|
122
122
|
<a
|
|
123
123
|
v-if="model.country"
|
|
124
|
-
:href="`${nichePath}
|
|
124
|
+
:href="`${nichePath}${webcamsPath}?${WEBCAM_FILTER_QUERY_PREFIX}country=${model.country}`"
|
|
125
125
|
class="card-webcam__country"
|
|
126
126
|
>{{ countryName }}</a>
|
|
127
127
|
</div>
|
|
@@ -154,7 +154,7 @@
|
|
|
154
154
|
:key="`tag-${tag}`"
|
|
155
155
|
>
|
|
156
156
|
<a
|
|
157
|
-
:href="`${nichePath}
|
|
157
|
+
:href="`${nichePath}${webcamsPath}/${tag}`"
|
|
158
158
|
class="card-webcam__tag"
|
|
159
159
|
>{{ tagTitle(tag) }}</a>
|
|
160
160
|
</li>
|
|
@@ -228,9 +228,11 @@ const ageFilterUrl = computed(() => {
|
|
|
228
228
|
|
|
229
229
|
const prefix = WEBCAM_FILTER_QUERY_PREFIX;
|
|
230
230
|
|
|
231
|
-
return `${nichePath.value}
|
|
231
|
+
return `${nichePath.value}${webcamsPath.value}?${prefix}age_from=${age - 1}&${prefix}age_to=${age + 1}`;
|
|
232
232
|
});
|
|
233
233
|
|
|
234
|
+
const webcamsPath = useWebcamsPath();
|
|
235
|
+
|
|
234
236
|
const nichePath = computed(() => {
|
|
235
237
|
const niche = NicheHelper.getSiteDefaultNiche();
|
|
236
238
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
<a
|
|
20
20
|
v-for="item in items"
|
|
21
21
|
:key="`lang-${item.lang}`"
|
|
22
|
-
:href="`${nichePath}
|
|
22
|
+
:href="`${nichePath}${webcamsPath}?${WEBCAM_FILTER_QUERY_PREFIX}language=${item.lang}`"
|
|
23
23
|
:aria-label="item.lang"
|
|
24
24
|
class="card-webcam-languages__link"
|
|
25
25
|
>
|
|
@@ -92,6 +92,8 @@ const names = computed(() => {
|
|
|
92
92
|
|
|
93
93
|
return (props.languages || []).map(lang => display.of(lang) || lang.toUpperCase()).join(', ');
|
|
94
94
|
});
|
|
95
|
+
|
|
96
|
+
const webcamsPath = useWebcamsPath();
|
|
95
97
|
</script>
|
|
96
98
|
|
|
97
99
|
<style lang="scss">
|
package/components/info/main.vue
CHANGED
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
@click="onLogoClick"
|
|
12
12
|
>
|
|
13
13
|
<img
|
|
14
|
+
:alt="brandName"
|
|
14
15
|
class="layout-logo__image"
|
|
15
16
|
src="/img/logo.svg"
|
|
16
17
|
width="120"
|
|
17
18
|
height="20"
|
|
18
|
-
alt="gay.cool"
|
|
19
19
|
>
|
|
20
20
|
</NuxtLink>
|
|
21
21
|
</template>
|
|
@@ -29,6 +29,7 @@ const emit = defineEmits<{
|
|
|
29
29
|
}>();
|
|
30
30
|
|
|
31
31
|
const { generateLink } = useGenerateLink();
|
|
32
|
+
const { brandName } = useAppConfig();
|
|
32
33
|
|
|
33
34
|
function onLogoClick() {
|
|
34
35
|
emit('click');
|
|
@@ -18,18 +18,18 @@
|
|
|
18
18
|
>
|
|
19
19
|
<div class="player-aside-videos__inner">
|
|
20
20
|
<PlayerAsideModels
|
|
21
|
-
v-for="item in
|
|
21
|
+
v-for="item in shownModels"
|
|
22
22
|
:key="`aside-model-${item.slug}`"
|
|
23
23
|
:title="item.title"
|
|
24
24
|
:slug="item.slug"
|
|
25
25
|
:info="item.info"
|
|
26
|
-
:videos="item.videos
|
|
26
|
+
:videos="item.videos"
|
|
27
27
|
class="player-aside-videos__model"
|
|
28
28
|
/>
|
|
29
29
|
|
|
30
30
|
<AdvNtv v-if="featureFlags.AdsNTVEnabled" />
|
|
31
31
|
|
|
32
|
-
<template v-if="!
|
|
32
|
+
<template v-if="!shownModels.length && !featureFlags.AdsNTVEnabled">
|
|
33
33
|
<CardVideo
|
|
34
34
|
v-for="item in asideVideos?.items?.slice(0, 2)"
|
|
35
35
|
:key="`aside-video-${item.guid}`"
|
|
@@ -47,7 +47,7 @@ import type { AsyncDataRequestStatus } from '#app';
|
|
|
47
47
|
|
|
48
48
|
const featureFlags = useRuntimeConfig().public.featureFlags;
|
|
49
49
|
|
|
50
|
-
defineProps<{
|
|
50
|
+
const props = defineProps<{
|
|
51
51
|
asideVideos?: PaginatedResponse<IVideoCard> | null
|
|
52
52
|
status?: AsyncDataRequestStatus
|
|
53
53
|
/** Модели ролика с их карточками и роликами (useFetchAsideModels). */
|
|
@@ -55,9 +55,15 @@ defineProps<{
|
|
|
55
55
|
title: string
|
|
56
56
|
slug: string
|
|
57
57
|
info: ICardInfo | null
|
|
58
|
-
videos:
|
|
58
|
+
videos: IVideoCard[]
|
|
59
59
|
}>
|
|
60
60
|
}>();
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Блок получает только та модель, чьих роликов хватает на полную строку сетки —
|
|
64
|
+
* список уже подрезан в `useFetchAsideModels`. Остальные уходят чипсами над плеером.
|
|
65
|
+
*/
|
|
66
|
+
const shownModels = computed(() => (props.models || []).filter(model => model.videos.length));
|
|
61
67
|
</script>
|
|
62
68
|
|
|
63
69
|
<style lang="scss">
|
|
@@ -22,6 +22,7 @@ query-параметрах `filter_*`. Формат провайдера при
|
|
|
22
22
|
:items="sections"
|
|
23
23
|
:model-value="'webcams'"
|
|
24
24
|
class="view-webcams__sections"
|
|
25
|
+
@update:model-value="resetSection"
|
|
25
26
|
/>
|
|
26
27
|
</div>
|
|
27
28
|
|
|
@@ -238,6 +239,20 @@ const sections = computed<ISwitcherItem[]>(() => [
|
|
|
238
239
|
},
|
|
239
240
|
]);
|
|
240
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Клик по уже активному разделу — это «показать раздел целиком»: снимаем фильтры и
|
|
244
|
+
* страницу пагинации, всё это живёт в query. Тег остаётся: он часть пути, а не фильтр.
|
|
245
|
+
* Query пуст — раздел и так целиком, и клик ничего не делает.
|
|
246
|
+
*
|
|
247
|
+
* Навигация именно `path` + `query`, а не `{ query }`: у location без пути роутер
|
|
248
|
+
* берёт текущий адрес вместе с параметрами и на нишевых страницах терял бы префикс.
|
|
249
|
+
*/
|
|
250
|
+
function resetSection() {
|
|
251
|
+
if (!Object.keys(route.query).length) return;
|
|
252
|
+
|
|
253
|
+
router.replace({ path: route.path, query: {} });
|
|
254
|
+
}
|
|
255
|
+
|
|
241
256
|
/**
|
|
242
257
|
* Вход в раздел: сразу показываем моделей, которые говорят на языке пользователя.
|
|
243
258
|
* Только при первом заходе за сессию — иначе снятый пользователем фильтр
|
|
@@ -61,7 +61,9 @@ const promoLink = computed(() => {
|
|
|
61
61
|
return links[niche.value] || links['straight'] || '';
|
|
62
62
|
});
|
|
63
63
|
const isPromoBlocked = ref(false);
|
|
64
|
-
const
|
|
64
|
+
const webcamsPath = useWebcamsPath();
|
|
65
|
+
|
|
66
|
+
const promoHref = computed(() => (isPromoBlocked.value ? `${nichePath.value}${webcamsPath.value}` : promoLink.value));
|
|
65
67
|
|
|
66
68
|
const promoRef = ref<HTMLElement | null>(null);
|
|
67
69
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Ref } from 'vue';
|
|
2
2
|
import type { Language } from '../../runtime';
|
|
3
|
-
import { AsyncData, convertString } from '../../runtime';
|
|
3
|
+
import { AsyncData, asideModelVideos, convertString } from '../../runtime';
|
|
4
4
|
import { ModelsApiService } from '../../services/api/models.service';
|
|
5
5
|
import { VideosApiService } from '../../services/api/videos.service';
|
|
6
6
|
import type { ICardInfo, IVideoCard, PaginatedResponse } from '../../types';
|
|
@@ -15,6 +15,13 @@ export const ASIDE_MODELS_LIMIT = 3;
|
|
|
15
15
|
/** Сколько роликов модели показывать — в макете сетка 2×2. */
|
|
16
16
|
export const ASIDE_MODEL_VIDEOS = 4;
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Слаги моделей, у которых блок в колонке есть. Кому роликов не хватило, тот
|
|
20
|
+
* остаётся чипсой над плеером на всех ширинах — берут этот список те, кто чипсы
|
|
21
|
+
* рисует.
|
|
22
|
+
*/
|
|
23
|
+
export const useAsideModelSlugs = () => useState<string[]>('aside-model-slugs', () => []);
|
|
24
|
+
|
|
18
25
|
/** Порядок роликов модели — тот же, что на её странице по умолчанию. */
|
|
19
26
|
const ORDER = '-popularity';
|
|
20
27
|
|
|
@@ -23,7 +30,8 @@ export interface IAsideModel {
|
|
|
23
30
|
/** Слаг для ссылки на страницу модели. */
|
|
24
31
|
slug: string
|
|
25
32
|
info: Ref<ICardInfo | null>
|
|
26
|
-
|
|
33
|
+
/** Ролики модели, уже подрезанные под сетку 2×2 (`asideModelVideos`). */
|
|
34
|
+
videos: Ref<IVideoCard[]>
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
/**
|
|
@@ -41,8 +49,9 @@ export const useFetchAsideModels = async (
|
|
|
41
49
|
limit = ASIDE_MODELS_LIMIT,
|
|
42
50
|
): Promise<IAsideModel[]> => {
|
|
43
51
|
const lang = useLang() as Language;
|
|
52
|
+
const shown = useAsideModelSlugs();
|
|
44
53
|
|
|
45
|
-
|
|
54
|
+
const models = await Promise.all(titles.slice(0, limit).map(async (title) => {
|
|
46
55
|
const slug = convertString().toSlug(title);
|
|
47
56
|
const name = convertString().fromSlug(slug);
|
|
48
57
|
const infoKey = `data-${AsyncData.ModelsByName}-aside-${slug}-${lang}`;
|
|
@@ -67,6 +76,15 @@ export const useFetchAsideModels = async (
|
|
|
67
76
|
),
|
|
68
77
|
]);
|
|
69
78
|
|
|
70
|
-
|
|
79
|
+
// Список режем здесь, а не в разметке: решение про нечётный хвост общее для
|
|
80
|
+
// блока и для чипсов, и принимать его дважды в разных местах — расходиться.
|
|
81
|
+
return { title, slug, info, videos: computed(() => asideModelVideos(videos.value?.items, ASIDE_MODEL_VIDEOS)) };
|
|
71
82
|
}));
|
|
83
|
+
|
|
84
|
+
// Кому роликов хватило — тот в колонке; остальные остаются чипсами над плеером.
|
|
85
|
+
watchEffect(() => {
|
|
86
|
+
shown.value = models.filter(model => model.videos.value.length).map(model => model.slug);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return models;
|
|
72
90
|
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { WEBCAMS_DEFAULTS } from '../runtime';
|
|
2
|
+
import type { IWebcamsConfig } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Адрес раздела вебкамов без нишевого префикса. Сайт задаёт его в
|
|
6
|
+
* `webcamsConfig.path` — раздел может жить на своём URL (например `/live-sex`),
|
|
7
|
+
* и все ссылки на фильтры, теги и промо обязаны вести туда же, иначе каждая
|
|
8
|
+
* уходит через редирект.
|
|
9
|
+
*/
|
|
10
|
+
export function useWebcamsPath() {
|
|
11
|
+
const config = useRuntimeConfig().public.webcamsConfig as IWebcamsConfig | undefined;
|
|
12
|
+
|
|
13
|
+
return computed(() => config?.path || WEBCAMS_DEFAULTS.path);
|
|
14
|
+
}
|
package/package.json
CHANGED
|
@@ -29,6 +29,8 @@ import { sortWebcams } from '../../lib';
|
|
|
29
29
|
const { t } = useI18n();
|
|
30
30
|
const route = useRoute();
|
|
31
31
|
|
|
32
|
+
const webcamsPath = useWebcamsPath();
|
|
33
|
+
|
|
32
34
|
/** Тег — сегмент пути (`/webcams/{tag}`), как на боевом движке. */
|
|
33
35
|
const tag = computed(() => String(route.params.tag || ''));
|
|
34
36
|
|
|
@@ -63,7 +65,7 @@ const breadcrumbs = computed<IBreadcrumbItem[]>(() => [
|
|
|
63
65
|
to: '/',
|
|
64
66
|
},
|
|
65
67
|
...(tag.value
|
|
66
|
-
? [{ name: t('navigation.webcams'), to:
|
|
68
|
+
? [{ name: t('navigation.webcams'), to: webcamsPath.value }, { name: tagTitle.value }]
|
|
67
69
|
: [{ name: t('navigation.webcams') }]),
|
|
68
70
|
]);
|
|
69
71
|
</script>
|
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
export const WEBCAMS_DEFAULTS = {
|
|
7
7
|
/** Ключ адаптера провайдера (см. `utils/webcams/adapters.ts`). */
|
|
8
8
|
provider: 'stripcash',
|
|
9
|
+
/**
|
|
10
|
+
* Адрес раздела без нишевого префикса. Сайт может держать его на своём URL
|
|
11
|
+
* (`/live-sex`), и тогда ссылки на фильтры, теги и промо должны вести туда же.
|
|
12
|
+
* Сам роут переименовывает сайт у себя в `pages:extend`.
|
|
13
|
+
*/
|
|
14
|
+
path: '/webcams',
|
|
9
15
|
/**
|
|
10
16
|
* Сколько моделей просить. 12 не случайно: сетка повторяет основной грид
|
|
11
17
|
* (2 / 3 / 4 колонки по брейкпоинтам), и 12 делится на каждое из них без
|
package/runtime/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export * from './constants/report-forms-subjects';
|
|
|
9
9
|
export * from './constants/playlist-step';
|
|
10
10
|
export * from './constants/thumb-size';
|
|
11
11
|
export * from './constants/webcams';
|
|
12
|
+
export * from './utils/aside-model-videos';
|
|
12
13
|
export * from './utils/build-srcset';
|
|
13
14
|
export * from './utils/pick-portrait-variant';
|
|
14
15
|
export * from './utils/format-date';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { asideModelVideos } from '../aside-model-videos';
|
|
3
|
+
|
|
4
|
+
const list = (n: number) => Array.from({ length: n }, (_, i) => i);
|
|
5
|
+
|
|
6
|
+
describe('asideModelVideos', () => {
|
|
7
|
+
it('чётное число показываем как есть', () => {
|
|
8
|
+
expect(asideModelVideos(list(4))).toEqual([0, 1, 2, 3]);
|
|
9
|
+
expect(asideModelVideos(list(2))).toEqual([0, 1]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('нечётное режем до чётного — иначе в сетке 2×2 дырка', () => {
|
|
13
|
+
expect(asideModelVideos(list(3))).toEqual([0, 1]);
|
|
14
|
+
expect(asideModelVideos(list(5))).toEqual([0, 1, 2, 3]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('меньше двух — блока нет, модель уйдёт чипсой', () => {
|
|
18
|
+
expect(asideModelVideos(list(1))).toEqual([]);
|
|
19
|
+
expect(asideModelVideos([])).toEqual([]);
|
|
20
|
+
expect(asideModelVideos(undefined)).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('потолок не превышаем', () => {
|
|
24
|
+
expect(asideModelVideos(list(9))).toHaveLength(4);
|
|
25
|
+
expect(asideModelVideos(list(9), 2)).toHaveLength(2);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -18,6 +18,27 @@ describe('pickPortraitVariant', () => {
|
|
|
18
18
|
expect(pickPortraitVariant({ light: { tall: { ...tall, webp: 'light' } } })?.tall?.webp).toBe('light');
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
+
it('задвоенный слэш в пути схлопывается — иначе CDN отвечает 301 на каждую тумбу', () => {
|
|
22
|
+
const portraits = {
|
|
23
|
+
dark: {
|
|
24
|
+
tall: { width: 640, height: 960, webp: 'https://nli02.joystorage.com//7/e/0/a.webp', avif: 'https://nli02.joystorage.com//7/e/0/a.avif' },
|
|
25
|
+
square: { width: 256, height: 256, webp: 'https://nli01.joystorage.com//f/0/5/b.webp', avif: '' },
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
const result = pickPortraitVariant(portraits);
|
|
29
|
+
|
|
30
|
+
expect(result?.tall?.webp).toBe('https://nli02.joystorage.com/7/e/0/a.webp');
|
|
31
|
+
expect(result?.tall?.avif).toBe('https://nli02.joystorage.com/7/e/0/a.avif');
|
|
32
|
+
expect(result?.square?.webp).toBe('https://nli01.joystorage.com/f/0/5/b.webp');
|
|
33
|
+
expect(result?.square?.avif).toBe('');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('нормальную ссылку не трогаем — протокольные слэши на месте', () => {
|
|
37
|
+
const portraits = { dark: { tall: { ...tall, webp: 'https://cache1.itube.dev/0/1/2/a.webp' } } };
|
|
38
|
+
|
|
39
|
+
expect(pickPortraitVariant(portraits)?.tall?.webp).toBe('https://cache1.itube.dev/0/1/2/a.webp');
|
|
40
|
+
});
|
|
41
|
+
|
|
21
42
|
it('пусто → undefined', () => {
|
|
22
43
|
expect(pickPortraitVariant()).toBeUndefined();
|
|
23
44
|
expect(pickPortraitVariant({})).toBeUndefined();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Сколько роликов модели показать в боковой колонке плеера.
|
|
3
|
+
*
|
|
4
|
+
* Сетка там в две колонки, поэтому нечётный хвост оставлял бы дырку: при трёх
|
|
5
|
+
* роликах показываем два. Если даже на одну строку не набирается (меньше двух),
|
|
6
|
+
* блока у модели нет вовсе — она уходит чипсой над плеером.
|
|
7
|
+
*
|
|
8
|
+
* @param videos - ролики модели, как их вернула ручка
|
|
9
|
+
* @param limit - потолок выдачи блока (в макете 2×2)
|
|
10
|
+
*/
|
|
11
|
+
export const asideModelVideos = <T>(videos: T[] | undefined, limit = 4): T[] => {
|
|
12
|
+
const count = Math.min(videos?.length || 0, limit);
|
|
13
|
+
const even = count - (count % 2);
|
|
14
|
+
|
|
15
|
+
return even < 2 ? [] : (videos as T[]).slice(0, even);
|
|
16
|
+
};
|
|
@@ -1,4 +1,18 @@
|
|
|
1
|
-
import type { IRawPortraits, IRawPortraitVariant } from '../../types';
|
|
1
|
+
import type { IRawPortraits, IRawPortraitFile, IRawPortraitVariant } from '../../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Схлопывает задвоенный слэш в пути. Прод-сборщик ссылок на портреты клеит хост со
|
|
5
|
+
* слэшем на конце с путём со слэшем в начале и отдаёт `https://host//a/b/c.avif`.
|
|
6
|
+
* CDN такой адрес принимает, но отвечает 301 на нормальный — то есть каждая тумба
|
|
7
|
+
* стоит лишнего похода, а на части узлов этот поход срывается в 502.
|
|
8
|
+
*/
|
|
9
|
+
const normalizeUrl = (url?: string): string => (url || '').replace(/([^:])\/{2,}/g, '$1/');
|
|
10
|
+
|
|
11
|
+
const normalizeFile = (file?: IRawPortraitFile): IRawPortraitFile | undefined => (file && {
|
|
12
|
+
...file,
|
|
13
|
+
webp: normalizeUrl(file.webp),
|
|
14
|
+
avif: normalizeUrl(file.avif),
|
|
15
|
+
});
|
|
2
16
|
|
|
3
17
|
/**
|
|
4
18
|
* Выбирает вариант портрета из карты, которую отдаёт API. Ключ — фон съёмки.
|
|
@@ -11,8 +25,8 @@ import type { IRawPortraits, IRawPortraitVariant } from '../../types';
|
|
|
11
25
|
export const pickPortraitVariant = (portraits?: IRawPortraits, preferred?: string): IRawPortraitVariant | undefined => {
|
|
12
26
|
if (!portraits) return undefined;
|
|
13
27
|
|
|
14
|
-
|
|
15
|
-
if (
|
|
28
|
+
const variant = (preferred && portraits[preferred]) || portraits['dark'] || Object.values(portraits)[0];
|
|
29
|
+
if (!variant) return undefined;
|
|
16
30
|
|
|
17
|
-
return
|
|
31
|
+
return { tall: normalizeFile(variant.tall), square: normalizeFile(variant.square) };
|
|
18
32
|
};
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export interface IWebcamsConfig {
|
|
7
7
|
provider?: string;
|
|
8
|
+
/** Адрес раздела без нишевого префикса; по умолчанию `/webcams`. */
|
|
9
|
+
path?: string;
|
|
8
10
|
limit?: number;
|
|
9
11
|
previewQuality?: string[];
|
|
10
12
|
/** Период перепроверки онлайна показанных моделей, мс; 0 — не проверять. */
|