665 lines
23 KiB
Markdown
665 lines
23 KiB
Markdown
|
|
# ТЗ: OBS Виджеты для стрима
|
|||
|
|
|
|||
|
|
## Описание задачи
|
|||
|
|
|
|||
|
|
Создать набор виджетов для отображения информации о марафоне в OBS через Browser Source. Виджеты позволяют стримерам показывать зрителям актуальную информацию о марафоне в реальном времени.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Виджеты
|
|||
|
|
|
|||
|
|
### 1. Лидерборд
|
|||
|
|
|
|||
|
|
Таблица участников марафона с их позициями и очками.
|
|||
|
|
|
|||
|
|
| Поле | Описание |
|
|||
|
|
|------|----------|
|
|||
|
|
| Место | Позиция в рейтинге (1, 2, 3...) |
|
|||
|
|
| Аватар | Аватарка участника (круглая, 32x32 px) |
|
|||
|
|
| Никнейм | Имя участника |
|
|||
|
|
| Очки | Текущее количество очков |
|
|||
|
|
| Стрик | Текущий стрик (опционально) |
|
|||
|
|
|
|||
|
|
**Настройки:**
|
|||
|
|
- Количество отображаемых участников (3, 5, 10, все)
|
|||
|
|
- Подсветка текущего стримера
|
|||
|
|
- Показ/скрытие аватарок
|
|||
|
|
- Показ/скрытие стриков
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 2. Текущее задание
|
|||
|
|
|
|||
|
|
Отображает активное задание стримера.
|
|||
|
|
|
|||
|
|
| Поле | Описание |
|
|||
|
|
|------|----------|
|
|||
|
|
| Игра | Название игры |
|
|||
|
|
| Задание | Описание челленджа / прохождения |
|
|||
|
|
| Очки | Количество очков за выполнение |
|
|||
|
|
| Тип | Челлендж / Прохождение |
|
|||
|
|
| Прогресс бонусов | Для прохождений: X/Y бонусных челленджей |
|
|||
|
|
|
|||
|
|
**Состояния:**
|
|||
|
|
- Активное задание — показывает детали
|
|||
|
|
- Нет задания — "Ожидание спина" или скрыт
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 3. Прогресс марафона
|
|||
|
|
|
|||
|
|
Общая статистика стримера в марафоне.
|
|||
|
|
|
|||
|
|
| Поле | Описание |
|
|||
|
|
|------|----------|
|
|||
|
|
| Позиция | Текущее место в рейтинге |
|
|||
|
|
| Очки | Набранные очки |
|
|||
|
|
| Стрик | Текущий стрик |
|
|||
|
|
| Выполнено | Количество выполненных заданий |
|
|||
|
|
| Дропнуто | Количество дропнутых заданий |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### 4. Комбинированный виджет (опционально)
|
|||
|
|
|
|||
|
|
Объединяет несколько блоков в одном виджете:
|
|||
|
|
- Мини-лидерборд (топ-3)
|
|||
|
|
- Текущее задание
|
|||
|
|
- Статистика стримера
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Техническая реализация
|
|||
|
|
|
|||
|
|
### Архитектура
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|||
|
|
│ OBS Browser Source │
|
|||
|
|
│ │ │
|
|||
|
|
│ ▼ │
|
|||
|
|
│ ┌─────────────────────────────┐ │
|
|||
|
|
│ │ /widget/{type}?params │ │
|
|||
|
|
│ │ │ │
|
|||
|
|
│ │ Frontend страница │ │
|
|||
|
|
│ │ (React / статический HTML)│ │
|
|||
|
|
│ └─────────────────────────────┘ │
|
|||
|
|
│ │ │
|
|||
|
|
│ ▼ │
|
|||
|
|
│ ┌─────────────────────────────┐ │
|
|||
|
|
│ │ WebSocket / Polling │ │
|
|||
|
|
│ │ Обновление данных │ │
|
|||
|
|
│ └─────────────────────────────┘ │
|
|||
|
|
│ │ │
|
|||
|
|
│ ▼ │
|
|||
|
|
│ ┌─────────────────────────────┐ │
|
|||
|
|
│ │ Backend API │ │
|
|||
|
|
│ │ /api/v1/widget/* │ │
|
|||
|
|
│ └─────────────────────────────┘ │
|
|||
|
|
└─────────────────────────────────────────────────────────────────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### URL структура
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
/widget/leaderboard?marathon={id}&token={token}&theme={theme}&count={count}
|
|||
|
|
/widget/current?marathon={id}&token={token}&theme={theme}
|
|||
|
|
/widget/progress?marathon={id}&token={token}&theme={theme}
|
|||
|
|
/widget/combined?marathon={id}&token={token}&theme={theme}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Параметры URL
|
|||
|
|
|
|||
|
|
| Параметр | Обязательный | Описание |
|
|||
|
|
|----------|--------------|----------|
|
|||
|
|
| `marathon` | Да | ID марафона |
|
|||
|
|
| `token` | Да | Токен виджета (привязан к участнику) |
|
|||
|
|
| `theme` | Нет | Тема оформления (dark, light, custom) |
|
|||
|
|
| `count` | Нет | Количество участников (для лидерборда) |
|
|||
|
|
| `highlight` | Нет | Подсветить пользователя (true/false) |
|
|||
|
|
| `avatars` | Нет | Показывать аватарки (true/false, по умолчанию true) |
|
|||
|
|
| `fontSize` | Нет | Размер шрифта (sm, md, lg) |
|
|||
|
|
| `width` | Нет | Ширина виджета в пикселях |
|
|||
|
|
| `transparent` | Нет | Прозрачный фон (true/false) |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Backend API
|
|||
|
|
|
|||
|
|
### Токен виджета
|
|||
|
|
|
|||
|
|
Для авторизации виджетов используется специальный токен, привязанный к участнику марафона. Это позволяет:
|
|||
|
|
- Идентифицировать стримера для подсветки в лидерборде
|
|||
|
|
- Показывать личную статистику и задания
|
|||
|
|
- Не требовать полной авторизации в OBS
|
|||
|
|
|
|||
|
|
#### Генерация токена
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
POST /api/v1/marathons/{marathon_id}/widget-token
|
|||
|
|
Authorization: Bearer {jwt_token}
|
|||
|
|
|
|||
|
|
Response:
|
|||
|
|
{
|
|||
|
|
"token": "wgt_abc123xyz...",
|
|||
|
|
"expires_at": null, // Бессрочный или с датой
|
|||
|
|
"urls": {
|
|||
|
|
"leaderboard": "https://marathon.example.com/widget/leaderboard?marathon=1&token=wgt_abc123xyz",
|
|||
|
|
"current": "https://marathon.example.com/widget/current?marathon=1&token=wgt_abc123xyz",
|
|||
|
|
"progress": "https://marathon.example.com/widget/progress?marathon=1&token=wgt_abc123xyz"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Модель токена
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
class WidgetToken(Base):
|
|||
|
|
__tablename__ = "widget_tokens"
|
|||
|
|
|
|||
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|||
|
|
token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
|||
|
|
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id"))
|
|||
|
|
marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id"))
|
|||
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
|||
|
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|||
|
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
|||
|
|
|
|||
|
|
participant: Mapped["Participant"] = relationship()
|
|||
|
|
marathon: Mapped["Marathon"] = relationship()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Эндпоинты виджетов
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# Публичные эндпоинты (авторизация через widget token)
|
|||
|
|
|
|||
|
|
@router.get("/widget/leaderboard")
|
|||
|
|
async def widget_leaderboard(
|
|||
|
|
marathon: int,
|
|||
|
|
token: str,
|
|||
|
|
count: int = 10,
|
|||
|
|
db: DbSession
|
|||
|
|
) -> WidgetLeaderboardResponse:
|
|||
|
|
"""
|
|||
|
|
Получить данные лидерборда для виджета.
|
|||
|
|
Возвращает топ участников и позицию владельца токена.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
@router.get("/widget/current")
|
|||
|
|
async def widget_current_assignment(
|
|||
|
|
marathon: int,
|
|||
|
|
token: str,
|
|||
|
|
db: DbSession
|
|||
|
|
) -> WidgetCurrentResponse:
|
|||
|
|
"""
|
|||
|
|
Получить текущее задание владельца токена.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
@router.get("/widget/progress")
|
|||
|
|
async def widget_progress(
|
|||
|
|
marathon: int,
|
|||
|
|
token: str,
|
|||
|
|
db: DbSession
|
|||
|
|
) -> WidgetProgressResponse:
|
|||
|
|
"""
|
|||
|
|
Получить статистику владельца токена.
|
|||
|
|
"""
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Схемы ответов
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
class WidgetLeaderboardEntry(BaseModel):
|
|||
|
|
rank: int
|
|||
|
|
nickname: str
|
|||
|
|
avatar_url: str | None
|
|||
|
|
total_points: int
|
|||
|
|
current_streak: int
|
|||
|
|
is_current_user: bool # Для подсветки
|
|||
|
|
|
|||
|
|
class WidgetLeaderboardResponse(BaseModel):
|
|||
|
|
entries: list[WidgetLeaderboardEntry]
|
|||
|
|
current_user_rank: int | None
|
|||
|
|
total_participants: int
|
|||
|
|
marathon_title: str
|
|||
|
|
|
|||
|
|
class WidgetCurrentResponse(BaseModel):
|
|||
|
|
has_assignment: bool
|
|||
|
|
game_title: str | None
|
|||
|
|
game_cover_url: str | None
|
|||
|
|
assignment_type: str | None # "challenge" | "playthrough"
|
|||
|
|
challenge_title: str | None
|
|||
|
|
challenge_description: str | None
|
|||
|
|
points: int | None
|
|||
|
|
bonus_completed: int | None # Для прохождений
|
|||
|
|
bonus_total: int | None
|
|||
|
|
|
|||
|
|
class WidgetProgressResponse(BaseModel):
|
|||
|
|
nickname: str
|
|||
|
|
avatar_url: str | None
|
|||
|
|
rank: int
|
|||
|
|
total_points: int
|
|||
|
|
current_streak: int
|
|||
|
|
completed_count: int
|
|||
|
|
dropped_count: int
|
|||
|
|
marathon_title: str
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Frontend
|
|||
|
|
|
|||
|
|
### Структура файлов
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
frontend/
|
|||
|
|
├── src/
|
|||
|
|
│ ├── pages/
|
|||
|
|
│ │ └── widget/
|
|||
|
|
│ │ ├── LeaderboardWidget.tsx
|
|||
|
|
│ │ ├── CurrentWidget.tsx
|
|||
|
|
│ │ ├── ProgressWidget.tsx
|
|||
|
|
│ │ └── CombinedWidget.tsx
|
|||
|
|
│ ├── components/
|
|||
|
|
│ │ └── widget/
|
|||
|
|
│ │ ├── WidgetContainer.tsx
|
|||
|
|
│ │ ├── LeaderboardRow.tsx
|
|||
|
|
│ │ ├── AssignmentCard.tsx
|
|||
|
|
│ │ └── StatsBlock.tsx
|
|||
|
|
│ └── styles/
|
|||
|
|
│ └── widget/
|
|||
|
|
│ ├── themes/
|
|||
|
|
│ │ ├── dark.css
|
|||
|
|
│ │ ├── light.css
|
|||
|
|
│ │ └── neon.css
|
|||
|
|
│ └── widget.css
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Роутинг
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// App.tsx или router config
|
|||
|
|
<Route path="/widget/leaderboard" element={<LeaderboardWidget />} />
|
|||
|
|
<Route path="/widget/current" element={<CurrentWidget />} />
|
|||
|
|
<Route path="/widget/progress" element={<ProgressWidget />} />
|
|||
|
|
<Route path="/widget/combined" element={<CombinedWidget />} />
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Компонент виджета
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// pages/widget/LeaderboardWidget.tsx
|
|||
|
|
import { useSearchParams } from 'react-router-dom'
|
|||
|
|
import { useQuery } from '@tanstack/react-query'
|
|||
|
|
import { widgetApi } from '@/api/widget'
|
|||
|
|
|
|||
|
|
const LeaderboardWidget = () => {
|
|||
|
|
const [params] = useSearchParams()
|
|||
|
|
const marathon = params.get('marathon')
|
|||
|
|
const token = params.get('token')
|
|||
|
|
const theme = params.get('theme') || 'dark'
|
|||
|
|
const count = parseInt(params.get('count') || '5')
|
|||
|
|
const highlight = params.get('highlight') !== 'false'
|
|||
|
|
|
|||
|
|
const { data, isLoading } = useQuery({
|
|||
|
|
queryKey: ['widget-leaderboard', marathon, token],
|
|||
|
|
queryFn: () => widgetApi.getLeaderboard(marathon, token, count),
|
|||
|
|
refetchInterval: 30000, // Обновление каждые 30 сек
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
if (isLoading) return <WidgetLoader />
|
|||
|
|
if (!data) return null
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<WidgetContainer theme={theme} transparent={params.get('transparent') === 'true'}>
|
|||
|
|
<div className="widget-leaderboard">
|
|||
|
|
<h3 className="widget-title">{data.marathon_title}</h3>
|
|||
|
|
{data.entries.map((entry) => (
|
|||
|
|
<LeaderboardRow
|
|||
|
|
key={entry.rank}
|
|||
|
|
entry={entry}
|
|||
|
|
highlight={highlight && entry.is_current_user}
|
|||
|
|
/>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</WidgetContainer>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Темы оформления
|
|||
|
|
|
|||
|
|
### Базовые темы
|
|||
|
|
|
|||
|
|
#### Dark (по умолчанию)
|
|||
|
|
```css
|
|||
|
|
.widget-theme-dark {
|
|||
|
|
--widget-bg: rgba(18, 18, 18, 0.95);
|
|||
|
|
--widget-text: #ffffff;
|
|||
|
|
--widget-text-secondary: #a0a0a0;
|
|||
|
|
--widget-accent: #8b5cf6;
|
|||
|
|
--widget-highlight: rgba(139, 92, 246, 0.2);
|
|||
|
|
--widget-border: rgba(255, 255, 255, 0.1);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Light
|
|||
|
|
```css
|
|||
|
|
.widget-theme-light {
|
|||
|
|
--widget-bg: rgba(255, 255, 255, 0.95);
|
|||
|
|
--widget-text: #1a1a1a;
|
|||
|
|
--widget-text-secondary: #666666;
|
|||
|
|
--widget-accent: #7c3aed;
|
|||
|
|
--widget-highlight: rgba(124, 58, 237, 0.1);
|
|||
|
|
--widget-border: rgba(0, 0, 0, 0.1);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Neon
|
|||
|
|
```css
|
|||
|
|
.widget-theme-neon {
|
|||
|
|
--widget-bg: rgba(0, 0, 0, 0.9);
|
|||
|
|
--widget-text: #00ff88;
|
|||
|
|
--widget-text-secondary: #00cc6a;
|
|||
|
|
--widget-accent: #ff00ff;
|
|||
|
|
--widget-highlight: rgba(255, 0, 255, 0.2);
|
|||
|
|
--widget-border: #00ff88;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### Transparent
|
|||
|
|
```css
|
|||
|
|
.widget-transparent {
|
|||
|
|
--widget-bg: transparent;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Кастомизация через URL
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
?theme=dark
|
|||
|
|
?theme=light
|
|||
|
|
?theme=neon
|
|||
|
|
?theme=custom&bg=1a1a1a&text=ffffff&accent=ff6600
|
|||
|
|
?transparent=true
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Обновление данных
|
|||
|
|
|
|||
|
|
### Варианты
|
|||
|
|
|
|||
|
|
| Способ | Описание | Плюсы | Минусы |
|
|||
|
|
|--------|----------|-------|--------|
|
|||
|
|
| Polling | Периодический запрос (30 сек) | Простота | Задержка, нагрузка |
|
|||
|
|
| WebSocket | Реал-тайм обновления | Мгновенно | Сложность |
|
|||
|
|
| SSE | Server-Sent Events | Простой real-time | Односторонний |
|
|||
|
|
|
|||
|
|
### Рекомендация
|
|||
|
|
|
|||
|
|
**Polling с интервалом 30 секунд** — оптимальный баланс:
|
|||
|
|
- Простая реализация
|
|||
|
|
- Минимальная нагрузка на сервер
|
|||
|
|
- Достаточная актуальность для стрима
|
|||
|
|
|
|||
|
|
Для будущего развития можно добавить WebSocket.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Интерфейс настройки
|
|||
|
|
|
|||
|
|
### Страница генерации виджетов
|
|||
|
|
|
|||
|
|
В личном кабинете участника добавить раздел "Виджеты для стрима":
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// pages/WidgetSettingsPage.tsx
|
|||
|
|
const WidgetSettingsPage = () => {
|
|||
|
|
const [widgetToken, setWidgetToken] = useState<string | null>(null)
|
|||
|
|
const [selectedTheme, setSelectedTheme] = useState('dark')
|
|||
|
|
const [leaderboardCount, setLeaderboardCount] = useState(5)
|
|||
|
|
|
|||
|
|
const generateToken = async () => {
|
|||
|
|
const response = await api.createWidgetToken(marathonId)
|
|||
|
|
setWidgetToken(response.token)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const widgetUrl = (type: string) => {
|
|||
|
|
const params = new URLSearchParams({
|
|||
|
|
marathon: marathonId.toString(),
|
|||
|
|
token: widgetToken,
|
|||
|
|
theme: selectedTheme,
|
|||
|
|
...(type === 'leaderboard' && { count: leaderboardCount.toString() }),
|
|||
|
|
})
|
|||
|
|
return `${window.location.origin}/widget/${type}?${params}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div>
|
|||
|
|
<h1>Виджеты для OBS</h1>
|
|||
|
|
|
|||
|
|
{!widgetToken ? (
|
|||
|
|
<Button onClick={generateToken}>Создать токен</Button>
|
|||
|
|
) : (
|
|||
|
|
<>
|
|||
|
|
<Section title="Настройки">
|
|||
|
|
<Select
|
|||
|
|
label="Тема"
|
|||
|
|
value={selectedTheme}
|
|||
|
|
options={['dark', 'light', 'neon']}
|
|||
|
|
onChange={setSelectedTheme}
|
|||
|
|
/>
|
|||
|
|
<Input
|
|||
|
|
label="Участников в лидерборде"
|
|||
|
|
type="number"
|
|||
|
|
value={leaderboardCount}
|
|||
|
|
onChange={setLeaderboardCount}
|
|||
|
|
/>
|
|||
|
|
</Section>
|
|||
|
|
|
|||
|
|
<Section title="Ссылки для OBS">
|
|||
|
|
<WidgetUrlBlock
|
|||
|
|
title="Лидерборд"
|
|||
|
|
url={widgetUrl('leaderboard')}
|
|||
|
|
preview={<LeaderboardPreview />}
|
|||
|
|
/>
|
|||
|
|
<WidgetUrlBlock
|
|||
|
|
title="Текущее задание"
|
|||
|
|
url={widgetUrl('current')}
|
|||
|
|
preview={<CurrentPreview />}
|
|||
|
|
/>
|
|||
|
|
<WidgetUrlBlock
|
|||
|
|
title="Прогресс"
|
|||
|
|
url={widgetUrl('progress')}
|
|||
|
|
/>
|
|||
|
|
</Section>
|
|||
|
|
|
|||
|
|
<Section title="Инструкция">
|
|||
|
|
<ol>
|
|||
|
|
<li>Скопируйте нужную ссылку</li>
|
|||
|
|
<li>В OBS добавьте источник "Browser"</li>
|
|||
|
|
<li>Вставьте ссылку в поле URL</li>
|
|||
|
|
<li>Установите размер (рекомендуется: 400x300)</li>
|
|||
|
|
</ol>
|
|||
|
|
</Section>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Превью виджетов
|
|||
|
|
|
|||
|
|
Показывать живой превью виджета с текущими настройками:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
const WidgetPreview = ({ type, params }) => {
|
|||
|
|
return (
|
|||
|
|
<div className="widget-preview">
|
|||
|
|
<iframe
|
|||
|
|
src={`/widget/${type}?${params}`}
|
|||
|
|
width="400"
|
|||
|
|
height="300"
|
|||
|
|
style={{ border: 'none', borderRadius: 8 }}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Безопасность
|
|||
|
|
|
|||
|
|
### Токены виджетов
|
|||
|
|
|
|||
|
|
- Токен привязан к конкретному участнику и марафону
|
|||
|
|
- Токен можно отозвать (деактивировать)
|
|||
|
|
- Токен даёт доступ только к публичной информации марафона
|
|||
|
|
- Нельзя использовать для изменения данных
|
|||
|
|
|
|||
|
|
### Rate Limiting
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# Ограничения для widget эндпоинтов
|
|||
|
|
WIDGET_RATE_LIMIT = "60/minute" # 60 запросов в минуту на токен
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Валидация токена
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
async def validate_widget_token(token: str, marathon_id: int, db: AsyncSession) -> WidgetToken:
|
|||
|
|
widget_token = await db.scalar(
|
|||
|
|
select(WidgetToken)
|
|||
|
|
.options(selectinload(WidgetToken.participant))
|
|||
|
|
.where(
|
|||
|
|
WidgetToken.token == token,
|
|||
|
|
WidgetToken.marathon_id == marathon_id,
|
|||
|
|
WidgetToken.is_active == True,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if not widget_token:
|
|||
|
|
raise HTTPException(status_code=401, detail="Invalid widget token")
|
|||
|
|
|
|||
|
|
if widget_token.expires_at and widget_token.expires_at < datetime.utcnow():
|
|||
|
|
raise HTTPException(status_code=401, detail="Widget token expired")
|
|||
|
|
|
|||
|
|
return widget_token
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## План реализации
|
|||
|
|
|
|||
|
|
### Этап 1: Backend — модель и токены
|
|||
|
|
- [ ] Создать модель `WidgetToken`
|
|||
|
|
- [ ] Миграция для таблицы `widget_tokens`
|
|||
|
|
- [ ] API создания токена (`POST /marathons/{id}/widget-token`)
|
|||
|
|
- [ ] API отзыва токена (`DELETE /widget-tokens/{id}`)
|
|||
|
|
- [ ] Валидация токена
|
|||
|
|
|
|||
|
|
### Этап 2: Backend — API виджетов
|
|||
|
|
- [ ] Эндпоинт `/widget/leaderboard`
|
|||
|
|
- [ ] Эндпоинт `/widget/current`
|
|||
|
|
- [ ] Эндпоинт `/widget/progress`
|
|||
|
|
- [ ] Схемы ответов
|
|||
|
|
- [ ] Rate limiting
|
|||
|
|
|
|||
|
|
### Этап 3: Frontend — страницы виджетов
|
|||
|
|
- [ ] Роутинг `/widget/*`
|
|||
|
|
- [ ] Компонент `LeaderboardWidget`
|
|||
|
|
- [ ] Компонент `CurrentWidget`
|
|||
|
|
- [ ] Компонент `ProgressWidget`
|
|||
|
|
- [ ] Polling обновлений
|
|||
|
|
|
|||
|
|
### Этап 4: Frontend — темы и стили
|
|||
|
|
- [ ] Базовые стили виджетов
|
|||
|
|
- [ ] Тема Dark
|
|||
|
|
- [ ] Тема Light
|
|||
|
|
- [ ] Тема Neon
|
|||
|
|
- [ ] Поддержка прозрачного фона
|
|||
|
|
- [ ] Параметры кастомизации через URL
|
|||
|
|
|
|||
|
|
### Этап 5: Frontend — страница настроек
|
|||
|
|
- [ ] Страница генерации виджетов
|
|||
|
|
- [ ] Форма настроек (тема, количество и т.д.)
|
|||
|
|
- [ ] Копирование URL
|
|||
|
|
- [ ] Превью виджетов
|
|||
|
|
- [ ] Инструкция по добавлению в OBS
|
|||
|
|
|
|||
|
|
### Этап 6: Тестирование
|
|||
|
|
- [ ] Проверка в OBS Browser Source
|
|||
|
|
- [ ] Тестирование тем
|
|||
|
|
- [ ] Проверка обновления данных
|
|||
|
|
- [ ] Тестирование на разных разрешениях
|
|||
|
|
- [ ] Проверка производительности (polling)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Примеры виджетов
|
|||
|
|
|
|||
|
|
### Лидерборд (Dark theme)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌─────────────────────────────────────┐
|
|||
|
|
│ 🏆 Game Marathon │
|
|||
|
|
├─────────────────────────────────────┤
|
|||
|
|
│ 1. 🟣 PlayerOne 1250 pts │
|
|||
|
|
│ 2. 🔵 StreamerPro 980 pts │
|
|||
|
|
│ ▶3. 🟢 CurrentUser 875 pts ◀│
|
|||
|
|
│ 4. 🟡 GamerX 720 pts │
|
|||
|
|
│ 5. 🔴 ProPlayer 650 pts │
|
|||
|
|
└─────────────────────────────────────┘
|
|||
|
|
↑
|
|||
|
|
аватарки
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Текущее задание
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌─────────────────────────────────┐
|
|||
|
|
│ 🎮 Dark Souls III │
|
|||
|
|
├─────────────────────────────────┤
|
|||
|
|
│ Челлендж: │
|
|||
|
|
│ Победить Намлесс Кинга │
|
|||
|
|
│ без брони │
|
|||
|
|
│ │
|
|||
|
|
│ Очки: +150 │
|
|||
|
|
│ │
|
|||
|
|
│ Сложность: ⭐⭐⭐ │
|
|||
|
|
└─────────────────────────────────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Прогресс
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌─────────────────────────────────┐
|
|||
|
|
│ 🟢 CurrentUser │
|
|||
|
|
│ ↑ │
|
|||
|
|
│ аватарка │
|
|||
|
|
├─────────────────────────────────┤
|
|||
|
|
│ Место: #3 │
|
|||
|
|
│ Очки: 875 │
|
|||
|
|
│ Стрик: 🔥 5 │
|
|||
|
|
│ Выполнено: 12 │
|
|||
|
|
│ Дропнуто: 2 │
|
|||
|
|
└─────────────────────────────────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Дополнительные идеи (будущее)
|
|||
|
|
|
|||
|
|
- **Анимации** — анимация при изменении позиций в лидерборде
|
|||
|
|
- **Звуковые оповещения** — звук при выполнении задания
|
|||
|
|
- **WebSocket** — мгновенные обновления без polling
|
|||
|
|
- **Кастомный CSS** — возможность вставить свой CSS
|
|||
|
|
- **Виджет событий** — показ активных событий марафона
|
|||
|
|
- **Виджет колеса** — мини-версия колеса фортуны
|