Compare commits
10 commits
8ddea8762d
...
e10df8dcbd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e10df8dcbd | ||
|
|
fde9a439df | ||
|
|
6c704cac97 | ||
|
|
c4a772b1e3 | ||
|
|
90eb012e0d | ||
|
|
44f81f0f0d | ||
|
|
cdb1c2ded2 | ||
|
|
b29bcfb8d7 | ||
|
|
62f8fb746b | ||
|
|
441a3ef9df |
64 changed files with 2986 additions and 3839 deletions
21
.env.example
21
.env.example
|
|
@ -12,13 +12,30 @@ GOOGLE_CLIENT_SECRET=
|
|||
GOOGLE_REDIRECT_URI=https://testmyyoutube.vrubel.xyz/api/auth/google/callback
|
||||
ALLOWED_GOOGLE_EMAIL=
|
||||
|
||||
# Google/YouTube endpoints (необязательно, есть дефолты)
|
||||
GOOGLE_AUTH_URI=https://accounts.google.com/o/oauth2/auth
|
||||
GOOGLE_TOKEN_URI=https://oauth2.googleapis.com/token
|
||||
GOOGLE_USERINFO_URI=https://www.googleapis.com/oauth2/v3/userinfo
|
||||
GOOGLE_REVOKE_URI=https://oauth2.googleapis.com/revoke
|
||||
YOUTUBE_API_BASE_URL=https://www.googleapis.com/youtube/v3
|
||||
YOUTUBE_WATCH_URL_TEMPLATE=https://www.youtube.com/watch?v={video_id}
|
||||
|
||||
METUBE_API_BASE_URL=http://192.168.8.177:8081
|
||||
METUBE_PUBLIC_BASE_URL=http://192.168.8.177:8081
|
||||
METUBE_CONTAINER_DOWNLOAD_DIR=/downloads
|
||||
METUBE_REQUEST_TIMEOUT_SECONDS=30
|
||||
|
||||
SUBSCRIPTIONS_SYNC_INTERVAL_HOURS=6
|
||||
VIDEOS_SYNC_INTERVAL_MINUTES=60
|
||||
VIDEOS_PER_CHANNEL_SYNC=10
|
||||
|
||||
# Необязательные настройки
|
||||
# Авто-синк видео запускается по активности (запросы к API) после простоя
|
||||
# в VIDEOS_SYNC_IDLE_HOURS с последнего завершённого синка.
|
||||
VIDEOS_SYNC_IDLE_HOURS=2
|
||||
# Максимум элементов плейлиста, рассматриваемых на канал за один синк.
|
||||
VIDEOS_BACKFILL_CAP=200
|
||||
# Остановить пагинацию плейлиста канала после стольких уже известных
|
||||
# видео подряд (дальше — уже синхронизированная история).
|
||||
VIDEOS_KNOWN_STOP_THRESHOLD=50
|
||||
NEW_VIDEOS_WINDOW_DAYS=2
|
||||
|
||||
LOG_LEVEL=INFO
|
||||
|
|
|
|||
26
.opencode/agent/coder.md
Normal file
26
.opencode/agent/coder.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
description: Implementation owner. Makes the smallest coherent change for an assigned bounded task, runs the relevant checks, and reports CODER_DONE.
|
||||
mode: subagent
|
||||
---
|
||||
|
||||
# Coder
|
||||
|
||||
You are the implementation owner in an opencode agent team. The Orchestrator (main session) gives you bounded tasks. You are the only agent allowed to change tracked project files in this worktree.
|
||||
|
||||
## Working rules
|
||||
|
||||
1. Read `AGENTS.md` and the relevant project specification before changing code. Inspect existing changes (`git status`, `git diff`) and preserve them.
|
||||
2. Implement the assigned acceptance criteria with the smallest coherent change. Follow the existing architecture and naming patterns. Avoid unrelated refactors and dependencies.
|
||||
3. Do not change global opencode configuration, secrets, deployment, or remote services unless the assignment explicitly requires it.
|
||||
4. Run the relevant lint, build, unit tests, and focused checks. Fix errors caused by your changes. Do not claim a check passed unless it ran and you saw the result.
|
||||
5. When an API, product behavior, or external fact may have changed, verify it from primary documentation or the installed tool's help.
|
||||
6. Stop editing before reporting. The Orchestrator will send the result to independent review and QA. When feedback arrives, fix the stated issues and verify again.
|
||||
7. If blocked, give the Orchestrator a precise cause and the smallest decision needed.
|
||||
|
||||
## Final report format
|
||||
|
||||
- Implemented: concrete behavior.
|
||||
- Changed files: paths and why.
|
||||
- Checks: exact commands and pass/fail results.
|
||||
- Limits: remaining risks or unverified behavior.
|
||||
- End the final response with `CODER_DONE`.
|
||||
33
.opencode/agent/orchestrator.md
Normal file
33
.opencode/agent/orchestrator.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
description: Orchestrator / Tech Lead — coordinates coder, reviewer, and tester subagents and reports to the user.
|
||||
mode: primary
|
||||
---
|
||||
|
||||
# Orchestrator / Tech Lead
|
||||
|
||||
You are the only agent who normally speaks with the user. Coordinate the opencode subagents `coder`, `reviewer`, and `tester` through the Task tool; do not edit product code yourself. The subagents share this worktree. Only `coder` changes tracked project files.
|
||||
|
||||
## Start every task
|
||||
|
||||
1. Read the repository's `AGENTS.md` and any relevant specification before planning. Inspect the current worktree (`git status`, `git diff`) and identify pre-existing changes.
|
||||
2. Turn the user's request into a bounded task for Coder: scope, acceptance criteria, files or subsystems likely involved, constraints, and validation expected. Resolve routine choices yourself. Ask the user only for genuinely missing decisions.
|
||||
3. Give Coder ownership of implementation. Do not send implementation work to Reviewer or Tester.
|
||||
4. Submit the task to Coder with the Task tool (subagent_type `coder`). Keep one code writer at a time: never run two Coder tasks concurrently and do not start a new Coder task while another is working.
|
||||
5. Read Coder's report (ends with `CODER_DONE`) and inspect the diff yourself. Then run Reviewer and Tester on the result. Reviewer must not edit; Tester must not fix. They may run in parallel — their commands cannot interfere with each other's.
|
||||
6. Collect Critical, Major, and Minor findings with evidence. Send actionable findings back to Coder as a follow-up task. Repeat review and test on the changed result until Critical and Major findings are resolved, or report a concrete blocker to the user.
|
||||
6.5. When Critical and Major findings are closed and checks are green, do not ask for permission: deploy the changes to the test service right away with `docker compose up -d --build` (from the repository root; the container rebuilds the frontend from the working tree and applies migrations on startup). Then verify the container is up and `curl http://localhost:8080/api/health` responds OK.
|
||||
7. After the deploy, give the user a concise final report: what Coder implemented, what Reviewer reviewed, what Tester tested (commands and results), review findings resolved or remaining, known limits, and worktree/branch. Remind the user to refresh the page with cache cleared (Ctrl+Shift+R) and explicitly say you are waiting for their feedback to verify the deployed changes. Do not claim visual or integration checks that were not performed.
|
||||
|
||||
## Working rules
|
||||
|
||||
- Do not merge, publish, or commit unless the user requested it or existing authorization covers it. Deploying to the test service is authorized by this workflow (step 6.5); deploying elsewhere still requires the user's go-ahead.
|
||||
- For frontend work, include responsive behavior, accessibility, loading/error/empty states, and real browser verification when tooling exists in the acceptance criteria.
|
||||
- For backend work, include data integrity, security, edge cases, and relevant API checks.
|
||||
|
||||
## Expected worker reports
|
||||
|
||||
- Coder ends with `CODER_DONE` and lists changed files, implementation, verification, and limits.
|
||||
- Reviewer ends with `REVIEW_DONE` and lists findings by severity with file/line evidence, or states that no actionable findings were found.
|
||||
- Tester ends with `TEST_DONE` and lists commands, passes, failures, reproduction steps, expected and actual behavior, and severity.
|
||||
|
||||
This workflow adapts the narrow-role and read-only review patterns from the official OpenAI Docs on subagents.
|
||||
25
.opencode/agent/reviewer.md
Normal file
25
.opencode/agent/reviewer.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
description: Independent read-only reviewer. Analyzes the coder's diff, classifies findings by severity, and reports REVIEW_DONE. Never edits files.
|
||||
mode: subagent
|
||||
permission:
|
||||
edit: deny
|
||||
---
|
||||
|
||||
# Reviewer / Designer
|
||||
|
||||
You are an independent reviewer in an opencode agent team. Your session is read-only: do not edit code, tests, snapshots, or configuration. Analyze Coder's result and report to the Orchestrator.
|
||||
|
||||
## Review method
|
||||
|
||||
1. Read the assignment, acceptance criteria, `AGENTS.md`, and relevant specifications. Review the actual diff (`git diff`) and enough surrounding code to understand behavior.
|
||||
2. Trace changed execution paths. Prioritize defects with user impact. Cite exact files and lines, explain the consequence, and give a reproduction or concrete failure scenario where possible.
|
||||
3. For frontend/UI, inspect UX, visual hierarchy, consistency, responsive widths, touch targets, keyboard and screen-reader access, loading/error/empty states, component boundaries, and state handling. Use an available browser or screenshot tool for visual claims; if none exists, say what remains unverified.
|
||||
4. For backend/general code, inspect architecture, data flow, edge cases, security, validation, race conditions, error handling, tests, and violations of existing patterns.
|
||||
5. Classify findings: **Critical** blocks safe use or risks loss/security; **Major** breaks a supported workflow or causes a material regression; **Minor** is a bounded quality issue. Do not inflate speculative concerns.
|
||||
6. Separate confirmed findings from questions. If there are no actionable findings, state that plainly. Do not fill the report with style-only comments or restatements of Coder's work.
|
||||
|
||||
## Report format
|
||||
|
||||
- Findings in severity order, each with file/line, impact, and evidence.
|
||||
- Verification performed and any limits, especially missing visual checks.
|
||||
- End the final response with `REVIEW_DONE`.
|
||||
27
.opencode/agent/tester.md
Normal file
27
.opencode/agent/tester.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
description: Independent QA. Verifies the coder's finished result, runs checks, and reports TEST_DONE. Does not modify tracked files.
|
||||
mode: subagent
|
||||
permission:
|
||||
edit: deny
|
||||
---
|
||||
|
||||
# Tester / QA
|
||||
|
||||
You are independent QA in an opencode agent team. Verify Coder's finished result and report to the Orchestrator. Do not fix product code or tests; you must not modify tracked project files (build tools may create ignored artifacts).
|
||||
|
||||
## Test method
|
||||
|
||||
1. Read the assignment, acceptance criteria, `AGENTS.md`, and Coder's changed-file report. Inspect the diff enough to identify high-risk paths.
|
||||
2. Run relevant existing lint, build, and unit tests. Run integration or end-to-end tests when configured. Use Playwright/Cypress or another browser tool if present; verify responsive UI, console errors, network/API behavior, and core flows. Do not claim visual verification from code inspection alone.
|
||||
3. Exercise positive, negative, boundary, refresh/navigation, and recovery scenarios that matter to the change. Prefer focused tests before broad test suites when a failure needs isolation.
|
||||
4. Respect project safety rules: external Google/MeTube calls are mocked by default; do not perform real integration calls unless the task explicitly opts in. Never delete production data or change external state as a test without authorization.
|
||||
5. Capture exact commands and observations. If a check is blocked by environment, distinguish that from an application failure and explain the blocker.
|
||||
6. After verification, check `git status --short`. Report any tracked file that test commands changed; do not clean it up without coordination.
|
||||
|
||||
## Report format
|
||||
|
||||
- Scope and commands run.
|
||||
- Passed checks.
|
||||
- Failed checks with reproduction steps, expected versus actual behavior, evidence, and Critical/Major/Minor severity.
|
||||
- Untested areas and why.
|
||||
- End the final response with `TEST_DONE`.
|
||||
45
AGENTS.md
45
AGENTS.md
|
|
@ -1,44 +1,31 @@
|
|||
# AGENTS.md
|
||||
|
||||
Полное ТЗ: [`youtube_categories_metube_TZ.md`](./youtube_categories_metube_TZ.md). Перед любой существенной работой сверяйся с ним — этот файл лишь выжимка ограничений.
|
||||
Правила для персонального YouTube-клиента с категориями подписок и интеграцией с существующим MeTube. Основные этапы разработки (каркас, OAuth/подписки, категории, лента, воспроизведение и MeTube) уже реализованы; ориентируйся на фактический код и `README.md`.
|
||||
|
||||
## Жёсткие ограничения (раздел 34 ТЗ)
|
||||
## Архитектурные ограничения
|
||||
|
||||
1. Не форкать/копировать Tube Archivist.
|
||||
2. Не встраивать yt-dlp в этот сервис — скачивание только через существующий MeTube (`mediaVM`).
|
||||
2. Не встраивать yt-dlp в этот сервис — скачивание выполняет существующий MeTube на `mediaVM`.
|
||||
3. Не дублировать функции скачивания MeTube.
|
||||
4. Не монтировать media storage MeTube на `hermesVM` — видео не хранится локально, только проксируется/линкуется.
|
||||
4. Не монтировать media storage MeTube на `hermesVM`: видео не хранится локально, только проксируется или линкуется.
|
||||
5. Не реализовывать YouTube Recommendations/Home.
|
||||
6. Не делать multi-user — приложение строго single-user (`ALLOWED_GOOGLE_EMAIL`).
|
||||
7. Frontend не должен напрямую вызывать Google API.
|
||||
8. Frontend не должен напрямую вызывать MeTube write API (`POST /add` и т.п.) — только через наш backend.
|
||||
9. Не восстанавливать filename скачанного видео из YouTube title — использовать только точное имя, пришедшее от MeTube по событию `completed`.
|
||||
10. Не удалять существующие файлы MeTube.
|
||||
6. Приложение остаётся single-user (`ALLOWED_GOOGLE_EMAIL`).
|
||||
7. Frontend не вызывает Google API напрямую.
|
||||
8. Frontend не вызывает MeTube write API (`POST /add` и т.п.) напрямую — только через наш backend.
|
||||
9. Не восстанавливать имя скачанного файла из YouTube title: использовать точное имя из события MeTube `completed`.
|
||||
10. Не удалять существующие файлы MeTube при синхронизации или обслуживании; удаление по явному действию пользователя — отдельный поддерживаемый сценарий.
|
||||
11. Не менять исходный код MeTube.
|
||||
12. Все внешние base URL (MeTube, Google) — через env/config, не хардкодить.
|
||||
12. Все внешние base URL (MeTube, Google) задаются через env/config.
|
||||
13. REST API должен оставаться пригодным для будущего mobile/PWA клиента.
|
||||
|
||||
## Порядок фаз (раздел 33 ТЗ)
|
||||
|
||||
Не реализовывать следующую фазу, пока не работает предыдущая:
|
||||
|
||||
1. Skeleton (текущая фаза) — FastAPI, PostgreSQL, Alembic, React, Docker Compose, healthcheck.
|
||||
2. Google OAuth + subscriptions sync + channels UI.
|
||||
3. Categories CRUD + many-to-many + фильтр по категориям.
|
||||
4. Video sync/feed (uploads playlists, playlistItems, videos, background scheduler).
|
||||
5. YouTube playback (embed).
|
||||
6. MeTube integration (`/add`, download_jobs, Socket.IO consumer, media URL).
|
||||
7. Hardening (retry/recovery, logging, quota handling, tests, README).
|
||||
|
||||
## Структура и соглашения
|
||||
|
||||
- `migrations/` — Alembic, живёт в корне репозитория (не внутри `backend/`), импортирует модели из `backend/app`.
|
||||
- Схема БД добавляется миграциями инкрементально по фазам (см. раздел 11 ТЗ), а не одним махом в Phase 1.
|
||||
- MeTube-специфичные HTTP/Socket.IO вызовы должны быть инкапсулированы в отдельный класс `MeTubeClient` (раздел 36 ТЗ) — не размазывать по backend.
|
||||
- Секреты только через `.env` (см. `.env.example`), никогда не коммитить `.env`, refresh token, client secret.
|
||||
- Backend тесты — `pytest`, лежат в `tests/` в корне. Integration-тесты против Google/MeTube — mocked по умолчанию; реальные вызовы к `http://192.168.8.177:8081` — только opt-in, не в обычном CI.
|
||||
- **В unit-тестах не использовать `with TestClient(app) as client:`** — это запускает lifespan приложения, который с Phase 6 реально стучится в MeTube (Socket.IO) и в Postgres (`reconcile_on_startup`). Используй `TestClient(app)` без `with` (lifespan не запускается, дефолтное поведение starlette) — так и сделано во всех текущих тестах.
|
||||
- `migrations/` — Alembic в корне репозитория; миграции импортируют модели из `backend/app`. Схему БД изменять миграциями, не создавать и не менять ad hoc при старте приложения.
|
||||
- MeTube-специфичные HTTP/Socket.IO вызовы инкапсулировать в `MeTubeClient`, не размазывать по backend.
|
||||
- Секреты хранить только в `.env` (образец — `.env.example`); не коммитить `.env`, refresh token или client secret.
|
||||
- Backend тесты — `pytest` в корневом `tests/`. Интеграции с Google/MeTube по умолчанию mock; реальные вызовы к `http://192.168.8.177:8081` — только opt-in, не в обычном CI.
|
||||
- В unit-тестах использовать `TestClient(app)` без `with`: контекстный менеджер запускает lifespan с подключением к MeTube (Socket.IO) и Postgres (`reconcile_on_startup`).
|
||||
|
||||
## Запуск/проверка
|
||||
## Запуск и проверка
|
||||
|
||||
См. `README.md`. Быстрая проверка: `docker compose up -d --build` и `curl http://localhost:8080/api/health`.
|
||||
|
|
|
|||
29
AGENT_TEAM.md
Normal file
29
AGENT_TEAM.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Агентская команда opencode
|
||||
|
||||
Одна сессия opencode в роли **Orchestrator** и три субагента — **Coder**, **Reviewer**, **Tester**, которых Orchestrator вызывает через Task tool. Роли лежат в [`.opencode/agent/`](./.opencode/agent/), Orchestrator назначен агентом по умолчанию в [`opencode.json`](./opencode.json).
|
||||
|
||||
## Запуск и работа
|
||||
|
||||
В корне репозитория запусти `opencode`. Каждая новая сессия стартует Orchestrator'ом: задачу пиши обычным сообщением. Он сам:
|
||||
|
||||
- декомпозирует задачу и отправляет её Coder'у через Task tool;
|
||||
- после Coder'а запускает Reviewer (только чтение) и Tester (проверка без правок) параллельно;
|
||||
- возвращает Coder'у actionable findings и повторяет цикл, пока Critical/Major не закрыты;
|
||||
- когда Critical/Major закрыты и проверки зелёные — без вопроса деплоит на тестовый сервис (`docker compose up -d --build`, затем `curl http://localhost:8080/api/health`);
|
||||
- отдаёт финальный отчёт уже после деплоя: что написано, просмотрено и протестировано, оставшиеся риски и просьба проверить в браузере с очисткой кэша (Ctrl+Shift+R).
|
||||
|
||||
Вручную писать субагентам не нужно — их вызывает только Orchestrator.
|
||||
|
||||
## Роли
|
||||
|
||||
- **Orchestrator** (primary, агент по умолчанию) — единственный общается с пользователем; сам код не правит.
|
||||
- **Coder** (subagent) — единственный меняет отслеживаемые файлы; отчитывается `CODER_DONE`.
|
||||
- **Reviewer** (subagent, `edit: deny`) — read-only review; отчитывается `REVIEW_DONE`.
|
||||
- **Tester** (subagent, `edit: deny`) — прогоняет проверки; может создавать игнорируемые артефакты, но не меняет отслеживаемые файлы; отчитывается `TEST_DONE`.
|
||||
|
||||
## Файлы
|
||||
|
||||
- `.opencode/agent/*.md` — определения агентов команды;
|
||||
- `opencode.json` — `default_agent: orchestrator`.
|
||||
|
||||
Конфиг и агенты читаются при старте opencode: после изменений перезапусти сессию.
|
||||
|
|
@ -25,6 +25,11 @@ WORKDIR /app
|
|||
COPY entrypoint.sh ./entrypoint.sh
|
||||
RUN chmod +x ./entrypoint.sh
|
||||
|
||||
# Run as an unprivileged user: the app only reads the baked-in code/static
|
||||
# assets and talks to Postgres/MeTube over the network, it never needs root.
|
||||
RUN useradd --uid 10001 app
|
||||
USER app
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
|
|
|
|||
40
README.md
40
README.md
|
|
@ -1,9 +1,23 @@
|
|||
# MyYouTube
|
||||
|
||||
Персональный YouTube-клиент с категориями подписок и интеграцией с существующим MeTube.
|
||||
Полное техническое задание: [`youtube_categories_metube_TZ.md`](./youtube_categories_metube_TZ.md).
|
||||
Работа агентской команды (opencode) описана в [руководстве по агентской команде](./AGENT_TEAM.md).
|
||||
|
||||
Текущий статус: **Phase 1 — skeleton** (FastAPI backend, PostgreSQL, Alembic, React frontend, Docker Compose, healthcheck).
|
||||
Текущий статус: **функционально готово** (основные этапы 1–6 и доработки по итогам тестирования). Развёрнуто и вручную протестировано на тестовом окружении (`testmyyoutube.vrubel.xyz` → hermesVM `192.168.8.173` → MeTube на mediaVM `192.168.8.177`).
|
||||
|
||||
### Что сделано
|
||||
|
||||
- Skeleton, Google OAuth (single-user allow-list), sync подписок/видео (APScheduler), категории (CRUD + many-to-many), лента с курсорной пагинацией, YouTube-плеер, полная MeTube-интеграция (download/delete, Socket.IO live-статусы, recovery после рестарта).
|
||||
- Раздел **«На сервере»** — скачанные видео; `/saved` перенаправляет на `/local`.
|
||||
- Тёмный адаптивный интерфейс: общая навигация, категории с постоянными URL, поиск по названиям видео и каналов, мобильное меню.
|
||||
- Отписанные каналы скрываются из списка каналов.
|
||||
- Реальная отписка от канала на YouTube (`subscriptions.delete`) реализована по запросу пользователя сверх исходного MVP. Из-за этого OAuth scope расширен с `youtube.readonly` до полного `youtube` (read/write) — см. `backend/app/services/google_oauth.py`.
|
||||
- Удаление скачанного видео с сервера (`DELETE /api/videos/{id}/download`) — тоже сверх исходного MVP-скоупа, добавлено по запросу.
|
||||
|
||||
### Что осталось / сознательно отложено
|
||||
|
||||
- Дополнительные идеи после MVP пока не реализованы: PWA, watch later, Shorts-фильтр, SponsorBlock и т.д.
|
||||
- Hardening (retry/recovery, тесты) выполнялся по факту находок в live-тестировании, а не отдельным проходом — см. `git log` для конкретных багфиксов (flapping статусов загрузки, неверный id в MeTube `/delete`, OAuth scope mismatch).
|
||||
|
||||
## Стек
|
||||
|
||||
|
|
@ -31,6 +45,25 @@
|
|||
|
||||
## Локальная разработка
|
||||
|
||||
### Агентская команда (opencode)
|
||||
|
||||
Подробная инструкция: [AGENT_TEAM.md](./AGENT_TEAM.md).
|
||||
|
||||
Работа идёт в одной сессии opencode: она стартует агентом `orchestrator`
|
||||
(`default_agent` в `opencode.json`). Orchestrator сам декомпозирует задачу и
|
||||
вызывает субагентов через Task tool: `coder` (единственный меняет
|
||||
отслеживаемые файлы), `reviewer` (read-only review, `edit: deny`) и `tester`
|
||||
(проверки без правок, `edit: deny`). Роли описаны в
|
||||
[`.opencode/agent/`](./.opencode/agent/).
|
||||
|
||||
```bash
|
||||
opencode
|
||||
```
|
||||
|
||||
Задачу пиши обычным сообщением: Orchestrator передаст её Coder'у, соберёт
|
||||
review и QA, повторит цикл при находках и вернёт финальный отчёт. Вручную
|
||||
писать субагентам не нужно.
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
|
|
@ -81,7 +114,8 @@ myyoutube/
|
|||
├── Dockerfile
|
||||
├── compose.yml
|
||||
├── .env / .env.example
|
||||
└── youtube_categories_metube_TZ.md
|
||||
├── .opencode/ # агенты команды (orchestrator/coder/reviewer/tester)
|
||||
└── AGENT_TEAM.md # руководство по агентской команде opencode
|
||||
```
|
||||
|
||||
## Секреты
|
||||
|
|
|
|||
1018
UI_UX_spec.md
1018
UI_UX_spec.md
File diff suppressed because it is too large
Load diff
|
|
@ -17,11 +17,16 @@ router = APIRouter()
|
|||
|
||||
@router.get("/auth/status")
|
||||
def auth_status(request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
# This endpoint is public (the Connect page needs `authenticated` before
|
||||
# logging in), so the connected account's email must only be revealed to a
|
||||
# request holding a valid session.
|
||||
authenticated = bool(request.session.get("authenticated"))
|
||||
connected = google_oauth.is_connected(db)
|
||||
email = google_oauth.get_connected_email(db) if authenticated and connected else None
|
||||
return {
|
||||
"authenticated": bool(request.session.get("authenticated")),
|
||||
"authenticated": authenticated,
|
||||
"connected": connected,
|
||||
"email": google_oauth.get_connected_email(db) if connected else None,
|
||||
"email": email,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.core.auth_dependency import require_session
|
||||
from app.core.slugify import unique_slugify
|
||||
from app.db import get_db
|
||||
from app.models.category import Category
|
||||
from app.models.channel import Channel
|
||||
from app.models.channel_category import channel_categories
|
||||
from app.models.video import Video
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_session)])
|
||||
|
||||
|
|
@ -40,13 +45,15 @@ def _name_taken(db: Session, name: str, exclude_id: int | None = None) -> bool:
|
|||
return any(row[0].casefold() == target for row in query.all())
|
||||
|
||||
|
||||
def _serialize(db: Session, category: Category, counts: dict[int, int]) -> dict:
|
||||
def _serialize(db: Session, category: Category, counts: dict[int, int], new_videos: dict[int, int] | None = None) -> dict:
|
||||
new_videos = new_videos or {}
|
||||
return {
|
||||
"id": category.id,
|
||||
"name": category.name,
|
||||
"slug": category.slug,
|
||||
"sort_order": category.sort_order,
|
||||
"channel_count": counts.get(category.id, 0),
|
||||
"new_videos_count": new_videos.get(category.id, 0),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -59,7 +66,17 @@ def list_categories(db: Session = Depends(get_db)) -> list[dict]:
|
|||
.all()
|
||||
)
|
||||
counts = dict(count_rows)
|
||||
return [_serialize(db, c, counts) for c in categories]
|
||||
since = datetime.now(timezone.utc) - timedelta(days=settings.new_videos_window_days)
|
||||
new_videos_rows = (
|
||||
db.query(channel_categories.c.category_id, func.count(Video.id))
|
||||
.join(Channel, Channel.id == channel_categories.c.channel_id)
|
||||
.join(Video, Video.channel_id == Channel.id)
|
||||
.filter(Channel.subscribed.is_(True), Video.published_at >= since)
|
||||
.group_by(channel_categories.c.category_id)
|
||||
.all()
|
||||
)
|
||||
new_videos = dict(new_videos_rows)
|
||||
return [_serialize(db, c, counts, new_videos) for c in categories]
|
||||
|
||||
|
||||
@router.post("/categories", status_code=201)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.core.auth_dependency import require_session
|
||||
from app.db import get_db
|
||||
from app.models.category import Category
|
||||
from app.models.channel import Channel
|
||||
from app.models.channel_category import channel_categories
|
||||
from app.models.video import Video
|
||||
from app.services import sync
|
||||
from app.services.google_oauth import OAuthNotConnected
|
||||
from app.services.youtube_client import YouTubeAPIError, YouTubeInsufficientScope, YouTubeQuotaExceeded
|
||||
|
|
@ -37,7 +40,22 @@ def _category_ids_by_channel(db: Session, channel_ids: list[int]) -> dict[int, l
|
|||
return result
|
||||
|
||||
|
||||
def _serialize(channel: Channel, category_ids: list[int]) -> dict:
|
||||
def _new_videos_counts(db: Session, channel_ids: list[int]) -> dict[int, int]:
|
||||
"""Videos published within the new-videos window, per channel, in one
|
||||
aggregate query."""
|
||||
if not channel_ids:
|
||||
return {}
|
||||
since = datetime.now(timezone.utc) - timedelta(days=settings.new_videos_window_days)
|
||||
rows = (
|
||||
db.query(Video.channel_id, func.count(Video.id))
|
||||
.filter(Video.channel_id.in_(channel_ids), Video.published_at >= since)
|
||||
.group_by(Video.channel_id)
|
||||
.all()
|
||||
)
|
||||
return {channel_id: count for channel_id, count in rows}
|
||||
|
||||
|
||||
def _serialize(channel: Channel, category_ids: list[int], new_videos_count: int = 0) -> dict:
|
||||
return {
|
||||
"id": channel.id,
|
||||
"youtube_channel_id": channel.youtube_channel_id,
|
||||
|
|
@ -45,9 +63,11 @@ def _serialize(channel: Channel, category_ids: list[int]) -> dict:
|
|||
"description": channel.description,
|
||||
"thumbnail_url": channel.thumbnail_url,
|
||||
"uploads_playlist_id": channel.uploads_playlist_id,
|
||||
"subscriber_count": channel.subscriber_count,
|
||||
"subscribed": channel.subscribed,
|
||||
"last_synced_at": channel.last_synced_at,
|
||||
"category_ids": category_ids,
|
||||
"new_videos_count": new_videos_count,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -75,8 +95,10 @@ def list_channels(
|
|||
query = query.filter(Channel.id.in_(channel_ids_in_category))
|
||||
|
||||
channels = query.order_by(Channel.title.asc()).all()
|
||||
category_map = _category_ids_by_channel(db, [c.id for c in channels])
|
||||
return [_serialize(c, category_map.get(c.id, [])) for c in channels]
|
||||
channel_ids = [c.id for c in channels]
|
||||
category_map = _category_ids_by_channel(db, channel_ids)
|
||||
new_videos_map = _new_videos_counts(db, channel_ids)
|
||||
return [_serialize(c, category_map.get(c.id, []), new_videos_map.get(c.id, 0)) for c in channels]
|
||||
|
||||
|
||||
@router.get("/channels/{channel_id}")
|
||||
|
|
@ -85,7 +107,8 @@ def get_channel(channel_id: int, db: Session = Depends(get_db)) -> dict:
|
|||
if channel is None:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
category_map = _category_ids_by_channel(db, [channel_id])
|
||||
return _serialize(channel, category_map.get(channel_id, []))
|
||||
new_videos_map = _new_videos_counts(db, [channel_id])
|
||||
return _serialize(channel, category_map.get(channel_id, []), new_videos_map.get(channel_id, 0))
|
||||
|
||||
|
||||
@router.put("/channels/{channel_id}/categories")
|
||||
|
|
@ -110,7 +133,8 @@ def set_channel_categories(channel_id: int, payload: ChannelCategoriesUpdate, db
|
|||
)
|
||||
db.commit()
|
||||
|
||||
return _serialize(channel, sorted(unique_ids))
|
||||
new_videos_map = _new_videos_counts(db, [channel_id])
|
||||
return _serialize(channel, sorted(unique_ids), new_videos_map.get(channel_id, 0))
|
||||
|
||||
|
||||
@router.post("/channels/{channel_id}/unsubscribe")
|
||||
|
|
@ -139,4 +163,5 @@ def unsubscribe_channel(channel_id: int, db: Session = Depends(get_db)) -> dict:
|
|||
raise HTTPException(status_code=502, detail="YouTube is unavailable")
|
||||
|
||||
category_map = _category_ids_by_channel(db, [channel_id])
|
||||
return _serialize(channel, category_map.get(channel_id, []))
|
||||
new_videos_map = _new_videos_counts(db, [channel_id])
|
||||
return _serialize(channel, category_map.get(channel_id, []), new_videos_map.get(channel_id, 0))
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import base64
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.core.auth_dependency import require_session
|
||||
from app.db import get_db
|
||||
from app.models.channel import Channel
|
||||
from app.models.channel_category import channel_categories
|
||||
from app.models.download_job import DownloadJob
|
||||
from app.models.video import Video
|
||||
from app.services.download_jobs import latest_jobs_map
|
||||
from app.services.video_presentation import channel_categories_map, serialize_video
|
||||
|
|
@ -36,11 +38,50 @@ def _decode_cursor(cursor: str) -> tuple[datetime, int]:
|
|||
raise HTTPException(status_code=400, detail="Invalid cursor")
|
||||
|
||||
|
||||
def _completed_video_ids():
|
||||
# A video may have several download_jobs over time (retries,
|
||||
# redownload after delete); only its most recent one counts.
|
||||
latest_job_ids = select(func.max(DownloadJob.id)).group_by(DownloadJob.video_id)
|
||||
return select(DownloadJob.video_id).where(
|
||||
DownloadJob.id.in_(latest_job_ids), DownloadJob.status == "completed"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/feed/saved-counts")
|
||||
def get_saved_counts(db: Session = Depends(get_db)) -> dict:
|
||||
"""Per-category counts of saved (downloaded) videos, for the Saved
|
||||
page's sidebar -- distinct from /api/categories' channel_count."""
|
||||
completed_video_ids = _completed_video_ids()
|
||||
|
||||
all_count = db.query(Video).filter(Video.id.in_(completed_video_ids)).count()
|
||||
|
||||
categorized_channel_ids = select(channel_categories.c.channel_id)
|
||||
uncategorized_count = (
|
||||
db.query(Video)
|
||||
.filter(Video.id.in_(completed_video_ids), ~Video.channel_id.in_(categorized_channel_ids))
|
||||
.count()
|
||||
)
|
||||
|
||||
rows = (
|
||||
db.query(channel_categories.c.category_id, func.count(func.distinct(Video.id)))
|
||||
.join(Video, Video.channel_id == channel_categories.c.channel_id)
|
||||
.filter(Video.id.in_(completed_video_ids))
|
||||
.group_by(channel_categories.c.category_id)
|
||||
.all()
|
||||
)
|
||||
categories = {str(category_id): count for category_id, count in rows}
|
||||
|
||||
return {"all": all_count, "uncategorized": uncategorized_count, "categories": categories}
|
||||
|
||||
|
||||
@router.get("/feed")
|
||||
def get_feed(
|
||||
category_id: int | None = None,
|
||||
uncategorized: bool = False,
|
||||
channel_id: int | None = None,
|
||||
downloaded: bool = False,
|
||||
search: str | None = Query(None, max_length=200),
|
||||
new_only: bool = False,
|
||||
limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT),
|
||||
cursor: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
|
|
@ -58,6 +99,25 @@ def get_feed(
|
|||
)
|
||||
query = query.filter(Video.channel_id.in_(channel_ids_in_category))
|
||||
|
||||
if new_only:
|
||||
since = datetime.now(timezone.utc) - timedelta(days=settings.new_videos_window_days)
|
||||
query = query.filter(Video.published_at >= since)
|
||||
# Align with the sidebar badge count (categories.py), which only
|
||||
# counts subscribed channels: after unsubscribing, a channel's
|
||||
# fresh videos must disappear from "new only" too.
|
||||
subscribed_channel_ids = select(Channel.id).where(Channel.subscribed.is_(True))
|
||||
query = query.filter(Video.channel_id.in_(subscribed_channel_ids))
|
||||
|
||||
if downloaded:
|
||||
query = query.filter(Video.id.in_(_completed_video_ids()))
|
||||
|
||||
if search and search.strip():
|
||||
escaped = search.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
needle = f"%{escaped}%"
|
||||
query = query.join(Channel, Channel.id == Video.channel_id).filter(
|
||||
or_(Video.title.ilike(needle, escape="\\"), Channel.title.ilike(needle, escape="\\"))
|
||||
)
|
||||
|
||||
if cursor:
|
||||
cursor_published_at, cursor_id = _decode_cursor(cursor)
|
||||
query = query.filter(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from app.models.channel import Channel
|
|||
from app.models.download_job import DownloadJob
|
||||
from app.models.video import Video
|
||||
from app.services.download_jobs import (
|
||||
DeleteDidNotRemoveFile,
|
||||
DeleteNotAllowed,
|
||||
MeTubeRejected,
|
||||
delete_local_copy,
|
||||
|
|
@ -78,6 +79,14 @@ def delete_download(youtube_video_id: str, db: Session = Depends(get_db)) -> dic
|
|||
job = delete_local_copy(db, video)
|
||||
except DeleteNotAllowed as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except DeleteDidNotRemoveFile:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"MeTube убрал запись из своего списка, но файл остался на диске "
|
||||
"(на mediaVM выключена настройка DELETE_FILE_ON_TRASHCAN)"
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to delete local copy for %s", youtube_video_id)
|
||||
raise HTTPException(status_code=502, detail="MeTube is unavailable")
|
||||
|
|
|
|||
|
|
@ -17,14 +17,34 @@ class Settings(BaseSettings):
|
|||
google_redirect_uri: str = ""
|
||||
allowed_google_email: str = ""
|
||||
|
||||
# Google/YouTube endpoints (AGENTS.md rule 12): configurable via env,
|
||||
# defaults keep the current behaviour without touching .env.
|
||||
google_auth_uri: str = "https://accounts.google.com/o/oauth2/auth"
|
||||
google_token_uri: str = "https://oauth2.googleapis.com/token"
|
||||
google_userinfo_uri: str = "https://www.googleapis.com/oauth2/v3/userinfo"
|
||||
google_revoke_uri: str = "https://oauth2.googleapis.com/revoke"
|
||||
youtube_api_base_url: str = "https://www.googleapis.com/youtube/v3"
|
||||
youtube_watch_url_template: str = "https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
metube_api_base_url: str = "http://127.0.0.1:8081"
|
||||
metube_public_base_url: str = "http://127.0.0.1:8081"
|
||||
metube_container_download_dir: str = "/downloads"
|
||||
metube_request_timeout_seconds: int = 30
|
||||
|
||||
subscriptions_sync_interval_hours: int = 6
|
||||
videos_sync_interval_minutes: int = 60
|
||||
videos_per_channel_sync: int = 10
|
||||
# Videos sync is no longer scheduled: it is triggered by user activity
|
||||
# (any authenticated request) after this much idle time since the last
|
||||
# completed sync.
|
||||
videos_sync_idle_hours: int = 2
|
||||
# Hard history-depth limit per channel per videos sync: at most the
|
||||
# newest N playlist items are considered; anything older is never
|
||||
# backfilled by design (full channel history is out of scope).
|
||||
videos_backfill_cap: int = 200
|
||||
# Stop playlistItems pagination for a channel once this many already
|
||||
# known video ids are seen in a row (the rest of the playlist is history
|
||||
# we have already synced).
|
||||
videos_known_stop_threshold: int = 50
|
||||
new_videos_window_days: int = 2
|
||||
|
||||
log_level: str = "INFO"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.services.sync_trigger import maybe_trigger_videos_sync
|
||||
|
||||
|
||||
def require_session(request: Request) -> None:
|
||||
if not request.session.get("authenticated"):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
# Every authenticated request may trigger a background videos sync when it
|
||||
# is due (cheap check: lock + one app_settings row read).
|
||||
maybe_trigger_videos_sync()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, Text, func
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
|
@ -16,6 +16,7 @@ class Channel(Base):
|
|||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
thumbnail_url: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
uploads_playlist_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
subscriber_count: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
subscribed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ class OAuthCredentials(Base):
|
|||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
google_email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
encrypted_refresh_token: Mapped[str] = mapped_column(String, nullable=False)
|
||||
access_token_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
|
|
|
|||
|
|
@ -19,6 +19,15 @@ class DeleteNotAllowed(Exception):
|
|||
pass
|
||||
|
||||
|
||||
class DeleteDidNotRemoveFile(Exception):
|
||||
"""MeTube accepted the /delete request but the file is still reachable
|
||||
afterwards -- its DELETE_FILE_ON_TRASHCAN setting is very likely off on
|
||||
that instance, which is outside this app's control (see AGENTS.md
|
||||
constraint 11: never touch MeTube's own config/source)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def get_latest_job(db: Session, video_id: int) -> DownloadJob | None:
|
||||
return (
|
||||
db.query(DownloadJob)
|
||||
|
|
@ -66,10 +75,19 @@ def delete_local_copy(db: Session, video: Video) -> DownloadJob:
|
|||
job = get_latest_job(db, video.id)
|
||||
if job is None or job.status != "completed":
|
||||
raise DeleteNotAllowed("No completed local copy to delete")
|
||||
if not job.metube_job_id:
|
||||
raise DeleteNotAllowed("Missing MeTube job id, cannot request deletion")
|
||||
|
||||
MeTubeClient().delete_download(job.metube_job_id)
|
||||
client = MeTubeClient()
|
||||
client.delete_download(video.youtube_url)
|
||||
|
||||
# MeTube's /delete only unlinks the file if it's configured with
|
||||
# DELETE_FILE_ON_TRASHCAN=true -- otherwise it just drops the entry from
|
||||
# its own "done" list and the file stays put. We don't control that
|
||||
# instance's config, so verify rather than trust the "ok" response.
|
||||
if job.media_url and client.check_media(job.media_url):
|
||||
raise DeleteDidNotRemoveFile(
|
||||
"MeTube removed the entry but the file is still on disk "
|
||||
"(its DELETE_FILE_ON_TRASHCAN setting is likely off)"
|
||||
)
|
||||
|
||||
job.status = "deleted"
|
||||
job.media_url = None
|
||||
|
|
@ -99,6 +117,26 @@ def _find_job_by_payload(db: Session, payload: dict) -> DownloadJob | None:
|
|||
return None
|
||||
|
||||
|
||||
def _find_job_by_key(db: Session, key: str) -> DownloadJob | None:
|
||||
"""Match a bare MeTube store key (its 'canceled'/'cleared' events carry
|
||||
just one key, JSON-string-encoded) to our latest job for it. The key is
|
||||
the URL the download was enqueued under; matching the metube job id first
|
||||
keeps us tolerant of both shapes."""
|
||||
job = (
|
||||
db.query(DownloadJob)
|
||||
.filter(DownloadJob.metube_job_id == key)
|
||||
.order_by(DownloadJob.id.desc())
|
||||
.first()
|
||||
)
|
||||
if job is not None:
|
||||
return job
|
||||
|
||||
video = db.query(Video).filter(Video.youtube_url == key).one_or_none()
|
||||
if video is not None:
|
||||
return get_latest_job(db, video.id)
|
||||
return None
|
||||
|
||||
|
||||
async def handle_metube_event(db: Session, event_name: str, raw_payload) -> None:
|
||||
try:
|
||||
payload = json.loads(raw_payload) if isinstance(raw_payload, str) else raw_payload
|
||||
|
|
@ -106,21 +144,34 @@ async def handle_metube_event(db: Session, event_name: str, raw_payload) -> None
|
|||
logger.warning("Could not parse MeTube event payload for %s: %r", event_name, raw_payload)
|
||||
return
|
||||
|
||||
if event_name in ("canceled", "cleared"):
|
||||
if event_name == "canceled":
|
||||
if not isinstance(payload, str):
|
||||
return
|
||||
job = (
|
||||
db.query(DownloadJob)
|
||||
.filter(DownloadJob.metube_job_id == payload)
|
||||
.order_by(DownloadJob.id.desc())
|
||||
.first()
|
||||
)
|
||||
if job is not None and event_name == "canceled" and job.status in ACTIVE_STATUSES:
|
||||
job = _find_job_by_key(db, payload)
|
||||
if job is not None and job.status in ACTIVE_STATUSES:
|
||||
job.status = "failed"
|
||||
job.error_message = "Отменено в MeTube"
|
||||
db.commit()
|
||||
return
|
||||
|
||||
if event_name == "cleared":
|
||||
# MeTube emits 'cleared' ONLY when a *done* entry is deleted (trash
|
||||
# via /delete?where=done, or CLEAR_COMPLETED_AFTER): the payload is
|
||||
# that entry's key (its URL) as a JSON string, one event per item.
|
||||
# Clearing the queue emits 'canceled' instead. The removed entry is
|
||||
# usually a download MeTube had before we existed (AGENTS.md rule 10)
|
||||
# or one we already completed, so an unmatched/empty payload must not
|
||||
# touch anything: failing all active jobs here would wrongly mark
|
||||
# downloads MeTube is still running.
|
||||
if not isinstance(payload, str) or not payload:
|
||||
return
|
||||
job = _find_job_by_key(db, payload)
|
||||
if job is not None and job.status in ACTIVE_STATUSES:
|
||||
job.status = "failed"
|
||||
job.error_message = "Очищено в MeTube"
|
||||
db.commit()
|
||||
return
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# Google frequently echoes back a scope string that's a superset/reordering
|
||||
# of what we requested (e.g. we ask for "youtube", Google's token response
|
||||
|
|
@ -34,10 +35,15 @@ SCOPES = [
|
|||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
]
|
||||
|
||||
AUTH_URI = "https://accounts.google.com/o/oauth2/auth"
|
||||
TOKEN_URI = "https://oauth2.googleapis.com/token"
|
||||
USERINFO_URI = "https://www.googleapis.com/oauth2/v3/userinfo"
|
||||
REVOKE_URI = "https://oauth2.googleapis.com/revoke"
|
||||
# Refresh proactively: never hand out a token that could die mid-request.
|
||||
_ACCESS_TOKEN_REFRESH_BUFFER_SECONDS = 60
|
||||
|
||||
# Module-level access-token cache. Google access tokens live ~1h; previously
|
||||
# every get_credentials() call (sync, unsubscribe, ...) paid for a full token
|
||||
# exchange with Google. The lock keeps concurrent callers sharing one refresh
|
||||
# instead of racing each other to the token endpoint.
|
||||
_credentials_lock = threading.Lock()
|
||||
_cached_credentials: Credentials | None = None
|
||||
|
||||
|
||||
class OAuthNotConnected(Exception):
|
||||
|
|
@ -49,8 +55,8 @@ def _client_config() -> dict:
|
|||
"web": {
|
||||
"client_id": settings.google_client_id,
|
||||
"client_secret": settings.google_client_secret,
|
||||
"auth_uri": AUTH_URI,
|
||||
"token_uri": TOKEN_URI,
|
||||
"auth_uri": settings.google_auth_uri,
|
||||
"token_uri": settings.google_token_uri,
|
||||
"redirect_uris": [settings.google_redirect_uri],
|
||||
}
|
||||
}
|
||||
|
|
@ -83,7 +89,7 @@ def exchange_code(code: str, state: str) -> Credentials:
|
|||
|
||||
def fetch_userinfo(access_token: str) -> dict:
|
||||
response = httpx.get(
|
||||
USERINFO_URI,
|
||||
settings.google_userinfo_uri,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=settings.metube_request_timeout_seconds,
|
||||
)
|
||||
|
|
@ -93,16 +99,14 @@ def fetch_userinfo(access_token: str) -> dict:
|
|||
|
||||
def revoke_token(token: str) -> None:
|
||||
try:
|
||||
httpx.post(REVOKE_URI, params={"token": token}, timeout=10)
|
||||
httpx.post(settings.google_revoke_uri, params={"token": token}, timeout=10)
|
||||
except Exception:
|
||||
logger.warning("Failed to revoke Google token", exc_info=True)
|
||||
|
||||
|
||||
def store_credentials(db: Session, google_email: str, credentials: Credentials) -> None:
|
||||
global _cached_credentials
|
||||
encrypted = encrypt_token(credentials.refresh_token)
|
||||
expires_at = credentials.expiry
|
||||
if expires_at is not None and expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
row = db.get(OAuthCredentials, SINGLETON_ID)
|
||||
if row is None:
|
||||
|
|
@ -111,15 +115,22 @@ def store_credentials(db: Session, google_email: str, credentials: Credentials)
|
|||
else:
|
||||
row.google_email = google_email
|
||||
row.encrypted_refresh_token = encrypted
|
||||
row.access_token_expires_at = expires_at
|
||||
db.commit()
|
||||
|
||||
# The exchanged credentials carry a fresh access token -- reuse them so
|
||||
# the immediately following syncs don't pay for a second Google request.
|
||||
with _credentials_lock:
|
||||
_cached_credentials = credentials
|
||||
|
||||
|
||||
def clear_credentials(db: Session) -> None:
|
||||
global _cached_credentials
|
||||
row = db.get(OAuthCredentials, SINGLETON_ID)
|
||||
if row is not None:
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
with _credentials_lock:
|
||||
_cached_credentials = None
|
||||
|
||||
|
||||
def is_connected(db: Session) -> bool:
|
||||
|
|
@ -131,26 +142,48 @@ def get_connected_email(db: Session) -> str | None:
|
|||
return row.google_email if row else None
|
||||
|
||||
|
||||
def _token_missing_or_expiring(credentials: Credentials) -> bool:
|
||||
if not credentials.token:
|
||||
return True
|
||||
expiry = credentials.expiry
|
||||
if expiry is None:
|
||||
# Unknown expiry -- be conservative and refresh.
|
||||
return True
|
||||
if expiry.tzinfo is None:
|
||||
# google-auth reports expiry as a naive UTC datetime.
|
||||
expiry = expiry.replace(tzinfo=timezone.utc)
|
||||
return expiry <= datetime.now(timezone.utc) + timedelta(seconds=_ACCESS_TOKEN_REFRESH_BUFFER_SECONDS)
|
||||
|
||||
|
||||
def get_credentials(db: Session) -> Credentials:
|
||||
global _cached_credentials
|
||||
row = db.get(OAuthCredentials, SINGLETON_ID)
|
||||
if row is None:
|
||||
raise OAuthNotConnected("Google account is not connected")
|
||||
|
||||
refresh_token = decrypt_token(row.encrypted_refresh_token)
|
||||
|
||||
with _credentials_lock:
|
||||
credentials = _cached_credentials
|
||||
if credentials is None or credentials.refresh_token != refresh_token:
|
||||
# No cached token yet, or the account was reconnected with a new
|
||||
# refresh token. The refresh below does the initial exchange.
|
||||
credentials = Credentials(
|
||||
token=None,
|
||||
refresh_token=refresh_token,
|
||||
token_uri=TOKEN_URI,
|
||||
token_uri=settings.google_token_uri,
|
||||
client_id=settings.google_client_id,
|
||||
client_secret=settings.google_client_secret,
|
||||
scopes=SCOPES,
|
||||
)
|
||||
credentials.refresh(GoogleAuthRequest())
|
||||
_cached_credentials = credentials
|
||||
|
||||
expires_at = credentials.expiry
|
||||
if expires_at is not None and expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
row.access_token_expires_at = expires_at
|
||||
db.commit()
|
||||
if _token_missing_or_expiring(credentials):
|
||||
try:
|
||||
credentials.refresh(GoogleAuthRequest())
|
||||
except Exception:
|
||||
# Don't keep a broken cached object behind.
|
||||
_cached_credentials = None
|
||||
raise
|
||||
|
||||
return credentials
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ Verified against the real MeTube source (alexta69/metube, app/main.py + app/ytdl
|
|||
JSON-*string*-encoded (json.JSONEncoder().encode(...)), not a raw object — must
|
||||
json.loads() the payload. Relevant keys: id, title, url, status, msg, percent
|
||||
(float 0-100 or None), filename (already relative to DOWNLOAD_DIR), error.
|
||||
'canceled'/'cleared' carry just an id (also JSON-string-encoded).
|
||||
'canceled'/'cleared' carry the download's URL key (JSON-string-encoded),
|
||||
one event per item; 'cleared' fires only when a done entry is deleted
|
||||
(trash via /delete?where=done or CLEAR_COMPLETED_AFTER) — clearing the
|
||||
queue emits 'canceled'.
|
||||
- MeTube status vocabulary: pending/preparing/scheduled/downloading/postprocessing/
|
||||
finished/error — mapped to our own vocabulary in sync with download_jobs.
|
||||
- GET /history returns {"queue": [...], "pending": [...], "done": [...]} of the
|
||||
|
|
@ -77,16 +80,23 @@ class MeTubeClient:
|
|||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def delete_download(self, metube_job_id: str) -> dict:
|
||||
def delete_download(self, youtube_url: str) -> dict:
|
||||
"""Asks MeTube to remove a finished download from its 'done' list and
|
||||
delete the underlying file (actual file deletion additionally depends
|
||||
on MeTube's own DELETE_FILE_ON_TRASHCAN config, which we don't
|
||||
control). Only ever called with a metube_job_id our own app tracked
|
||||
from a download it started -- never touches files MeTube already had
|
||||
before we existed."""
|
||||
control). Only ever called for a URL our own app itself enqueued --
|
||||
never touches files MeTube already had before we existed.
|
||||
|
||||
MeTube's queue/pending/done stores are keyed by the download's URL
|
||||
(PersistentQueue.put: `key = value.info.url`), NOT by the id field a
|
||||
Download reports over Socket.IO -- passing that id here silently
|
||||
no-ops (MeTube logs "requested delete for non-existent download" and
|
||||
still returns {"status": "ok"} regardless), which is what happened
|
||||
before this was fixed: verified by curling this instance's /history
|
||||
and finding the "deleted" entry still present."""
|
||||
response = httpx.post(
|
||||
f"{self.api_base_url}/delete",
|
||||
json={"ids": [metube_job_id], "where": "done"},
|
||||
json={"ids": [youtube_url], "where": "done"},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
|
|
|||
|
|
@ -21,19 +21,9 @@ def _run_subscriptions_sync() -> None:
|
|||
db.close()
|
||||
|
||||
|
||||
def _run_videos_sync() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
sync.sync_videos(db)
|
||||
except sync.SyncInProgress:
|
||||
logger.info("Scheduled videos sync skipped: already running")
|
||||
except Exception:
|
||||
logger.exception("Scheduled videos sync failed")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def create_scheduler() -> BackgroundScheduler:
|
||||
# Videos sync is not scheduled anymore: it runs on user activity via
|
||||
# services.sync_trigger.maybe_trigger_videos_sync (see require_session).
|
||||
scheduler = BackgroundScheduler(timezone="UTC")
|
||||
scheduler.add_job(
|
||||
_run_subscriptions_sync,
|
||||
|
|
@ -41,10 +31,4 @@ def create_scheduler() -> BackgroundScheduler:
|
|||
hours=settings.subscriptions_sync_interval_hours,
|
||||
id="subscriptions_sync",
|
||||
)
|
||||
scheduler.add_job(
|
||||
_run_videos_sync,
|
||||
"interval",
|
||||
minutes=settings.videos_sync_interval_minutes,
|
||||
id="videos_sync",
|
||||
)
|
||||
return scheduler
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ def get_videos_sync_status(db: Session) -> dict:
|
|||
return _get_status(db, VIDEOS_SYNC_STATUS_KEY, _videos_lock)
|
||||
|
||||
|
||||
def is_videos_sync_running() -> bool:
|
||||
return _videos_lock.locked()
|
||||
|
||||
|
||||
def sync_subscriptions(db: Session) -> dict:
|
||||
if not _subscriptions_lock.acquire(blocking=False):
|
||||
raise SyncInProgress("Subscriptions sync already in progress")
|
||||
|
|
@ -108,10 +112,13 @@ def sync_subscriptions(db: Session) -> dict:
|
|||
subscribed_ids = [c.youtube_channel_id for c in existing.values() if c.subscribed]
|
||||
try:
|
||||
uploads = youtube_client.fetch_uploads_playlists(credentials, subscribed_ids)
|
||||
for channel_id, uploads_playlist_id in uploads.items():
|
||||
for channel_id, data in uploads.items():
|
||||
channel = existing.get(channel_id)
|
||||
if channel is not None:
|
||||
channel.uploads_playlist_id = uploads_playlist_id
|
||||
if data["uploads_playlist_id"] is not None:
|
||||
channel.uploads_playlist_id = data["uploads_playlist_id"]
|
||||
if data["subscriber_count"] is not None:
|
||||
channel.subscriber_count = data["subscriber_count"]
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch uploads playlists during subscriptions sync")
|
||||
|
|
@ -166,21 +173,38 @@ def sync_videos(db: Session) -> dict:
|
|||
)
|
||||
channel_by_youtube_id = {c.youtube_channel_id: c for c in channels}
|
||||
|
||||
# One query for all known video ids (grouped by channel) so there is
|
||||
# no N+1 at the DB level; the YouTube API is still queried per channel.
|
||||
known_ids_by_channel: dict[int, set[str]] = {}
|
||||
for youtube_video_id, channel_id in db.query(Video.youtube_video_id, Video.channel_id).all():
|
||||
known_ids_by_channel.setdefault(channel_id, set()).add(youtube_video_id)
|
||||
|
||||
candidate_video_ids: set[str] = set()
|
||||
for channel in channels:
|
||||
try:
|
||||
video_ids = youtube_client.fetch_playlist_video_ids(
|
||||
credentials, channel.uploads_playlist_id, settings.videos_per_channel_sync
|
||||
# videos_backfill_cap is a hard history-depth limit per
|
||||
# channel: only the newest N playlist items are ever
|
||||
# considered; anything older is not backfilled by design.
|
||||
# The early stop on consecutive known ids saves playlistItems
|
||||
# pages on repeated syncs inside that window.
|
||||
new_ids = youtube_client.fetch_playlist_video_ids_incremental(
|
||||
credentials,
|
||||
channel.uploads_playlist_id,
|
||||
known_ids_by_channel.get(channel.id, set()),
|
||||
settings.videos_backfill_cap,
|
||||
settings.videos_known_stop_threshold,
|
||||
)
|
||||
candidate_video_ids.update(video_ids)
|
||||
candidate_video_ids.update(new_ids)
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch playlist items for channel %s", channel.youtube_channel_id)
|
||||
|
||||
# Details are fetched only for ids that are not in the DB yet (the
|
||||
# incremental fetch above already filters out known ids), so every
|
||||
# returned item is a new video to insert. Existing videos' metadata is
|
||||
# deliberately not refreshed by the videos sync.
|
||||
details = youtube_client.fetch_videos_details(credentials, list(candidate_video_ids))
|
||||
|
||||
existing = {v.youtube_video_id: v for v in db.query(Video).all()}
|
||||
added = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
|
||||
for item in details:
|
||||
|
|
@ -195,11 +219,10 @@ def sync_videos(db: Session) -> dict:
|
|||
continue
|
||||
|
||||
duration_seconds = parse_iso8601_duration(item["duration_iso8601"])
|
||||
youtube_url = f"https://www.youtube.com/watch?v={item['youtube_video_id']}"
|
||||
youtube_url = settings.youtube_watch_url_template.format(video_id=item["youtube_video_id"])
|
||||
|
||||
video = existing.get(item["youtube_video_id"])
|
||||
if video is None:
|
||||
video = Video(
|
||||
db.add(
|
||||
Video(
|
||||
youtube_video_id=item["youtube_video_id"],
|
||||
channel_id=channel.id,
|
||||
title=item["title"],
|
||||
|
|
@ -209,18 +232,8 @@ def sync_videos(db: Session) -> dict:
|
|||
duration_seconds=duration_seconds,
|
||||
youtube_url=youtube_url,
|
||||
)
|
||||
db.add(video)
|
||||
existing[item["youtube_video_id"]] = video
|
||||
)
|
||||
added += 1
|
||||
else:
|
||||
video.channel_id = channel.id
|
||||
video.title = item["title"]
|
||||
video.description = item["description"]
|
||||
video.thumbnail_url = item["thumbnail_url"]
|
||||
video.published_at = published_at
|
||||
video.duration_seconds = duration_seconds
|
||||
video.youtube_url = youtube_url
|
||||
updated += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
|
|
@ -230,12 +243,14 @@ def sync_videos(db: Session) -> dict:
|
|||
"finished_at": _now_iso(),
|
||||
"error": None,
|
||||
"videos_added": added,
|
||||
"videos_updated": updated,
|
||||
# Kept for API compatibility: always 0, because the videos sync
|
||||
# never refreshes metadata of existing videos.
|
||||
"videos_updated": 0,
|
||||
"videos_skipped": skipped,
|
||||
"channels_checked": len(channels),
|
||||
}
|
||||
_save_status(db, VIDEOS_SYNC_STATUS_KEY, result)
|
||||
logger.info("Videos sync completed: added=%d updated=%d skipped=%d", added, updated, skipped)
|
||||
logger.info("Videos sync completed: added=%d skipped=%d", added, skipped)
|
||||
return result
|
||||
|
||||
except Exception as exc:
|
||||
|
|
|
|||
91
backend/app/services/sync_trigger.py
Normal file
91
backend/app/services/sync_trigger.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""Activity-triggered videos sync.
|
||||
|
||||
The hourly scheduled videos sync is gone; instead, every authenticated request
|
||||
(require_session) calls maybe_trigger_videos_sync(). The check must stay cheap:
|
||||
if the sync is not running and enough idle time has passed since the last
|
||||
completed sync, a background thread is started which calls sync.sync_videos
|
||||
with its own DB session.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.services import sync
|
||||
from app.services.state import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_videos_sync_due(finished_at_iso: str | None, now: datetime, idle_hours: int) -> bool:
|
||||
"""Pure predicate: should an automatic videos sync start now?
|
||||
|
||||
True when the last completed sync's finished_at is missing/unparseable or
|
||||
older than `idle_hours`."""
|
||||
if not finished_at_iso:
|
||||
return True
|
||||
if not isinstance(finished_at_iso, str):
|
||||
return True
|
||||
try:
|
||||
finished_at = datetime.fromisoformat(finished_at_iso.replace("Z", "+00:00"))
|
||||
except (ValueError, TypeError):
|
||||
return True
|
||||
if finished_at.tzinfo is None:
|
||||
finished_at = finished_at.replace(tzinfo=timezone.utc)
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
return now - finished_at >= timedelta(hours=idle_hours)
|
||||
|
||||
|
||||
def _extract_finished_at(raw: str | None) -> str | None:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
return payload.get("finished_at")
|
||||
|
||||
|
||||
def _run_videos_sync() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
sync.sync_videos(db)
|
||||
except sync.SyncInProgress:
|
||||
# Lost the race against a manual sync or another trigger: fine, the
|
||||
# other run will record the status.
|
||||
logger.info("Triggered videos sync skipped: already running")
|
||||
except Exception:
|
||||
logger.exception("Triggered videos sync failed")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def maybe_trigger_videos_sync() -> None:
|
||||
"""Cheap per-request check; never raises. Starts a background videos sync
|
||||
when due, or does nothing when a sync is already running."""
|
||||
try:
|
||||
if sync.is_videos_sync_running():
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
raw = get_setting(db, sync.VIDEOS_SYNC_STATUS_KEY)
|
||||
finished_at = _extract_finished_at(raw)
|
||||
if not is_videos_sync_due(finished_at, datetime.now(timezone.utc), settings.videos_sync_idle_hours):
|
||||
return
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
logger.info(
|
||||
"Videos sync due (last finished: %s), starting in background thread", finished_at
|
||||
)
|
||||
threading.Thread(target=_run_videos_sync, daemon=True).start()
|
||||
except Exception:
|
||||
# Never break the request because of the trigger itself.
|
||||
logger.exception("Failed to evaluate videos sync trigger")
|
||||
|
|
@ -7,9 +7,14 @@ from app.config import settings
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_BASE = "https://www.googleapis.com/youtube/v3"
|
||||
BATCH_SIZE = 50
|
||||
|
||||
# Hard safety cap for playlistItems pagination: at most this many pages
|
||||
# (BATCH_SIZE items each, i.e. 500 ids) per playlist, so the pageToken loop
|
||||
# can never run away. Raise both the cap and the caller's max_results
|
||||
# together if more is ever needed.
|
||||
MAX_PLAYLIST_PAGES = 10
|
||||
|
||||
|
||||
class YouTubeQuotaExceeded(Exception):
|
||||
pass
|
||||
|
|
@ -68,7 +73,7 @@ def fetch_subscriptions(credentials: Credentials) -> list[dict]:
|
|||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
|
||||
response = client.get(f"{API_BASE}/subscriptions", params=params, headers=_headers(credentials))
|
||||
response = client.get(f"{settings.youtube_api_base_url}/subscriptions", params=params, headers=_headers(credentials))
|
||||
_raise_for_status(response)
|
||||
data = response.json()
|
||||
|
||||
|
|
@ -103,26 +108,108 @@ def fetch_subscriptions(credentials: Credentials) -> list[dict]:
|
|||
|
||||
|
||||
def fetch_playlist_video_ids(credentials: Credentials, playlist_id: str, max_results: int) -> list[str]:
|
||||
"""Low-level primitive: fetch up to `max_results` playlist item ids,
|
||||
newest first, with no knowledge of what is already synced. The videos sync
|
||||
uses fetch_playlist_video_ids_incremental instead; this stays as the plain
|
||||
paginated helper (kept for tests and any future non-incremental callers)."""
|
||||
video_ids: list[str] = []
|
||||
page_token: str | None = None
|
||||
pages_fetched = 0
|
||||
|
||||
with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client:
|
||||
while pages_fetched < MAX_PLAYLIST_PAGES and len(video_ids) < max_results:
|
||||
params = {
|
||||
"part": "contentDetails",
|
||||
"playlistId": playlist_id,
|
||||
"maxResults": min(max_results, 50),
|
||||
"maxResults": min(max_results - len(video_ids), BATCH_SIZE),
|
||||
}
|
||||
response = client.get(f"{API_BASE}/playlistItems", params=params, headers=_headers(credentials))
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
|
||||
response = client.get(f"{settings.youtube_api_base_url}/playlistItems", params=params, headers=_headers(credentials))
|
||||
if response.status_code == 404:
|
||||
return []
|
||||
_raise_for_status(response)
|
||||
data = response.json()
|
||||
|
||||
video_ids = []
|
||||
for item in data.get("items", []):
|
||||
video_id = item.get("contentDetails", {}).get("videoId")
|
||||
if video_id:
|
||||
video_ids.append(video_id)
|
||||
|
||||
pages_fetched += 1
|
||||
page_token = data.get("nextPageToken")
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
return video_ids
|
||||
|
||||
|
||||
def fetch_playlist_video_ids_incremental(
|
||||
credentials: Credentials,
|
||||
playlist_id: str,
|
||||
known_ids: set[str],
|
||||
max_results: int,
|
||||
stop_threshold: int,
|
||||
) -> list[str]:
|
||||
"""Fetch up to `max_results` previously-unknown video ids from a playlist,
|
||||
stopping pagination early once `stop_threshold` consecutive ids that are
|
||||
already in `known_ids` are encountered. Uploads playlists are ordered
|
||||
newest-first, so a long run of known ids means we reached history that was
|
||||
already synced and there is nothing new further down.
|
||||
|
||||
`max_results` is a hard history-depth limit: at most the newest
|
||||
`max_results` videos of the channel are ever considered, and everything
|
||||
older than that is intentionally not backfilled (we do not mirror full
|
||||
channel history). The early stop on known ids only saves pages on repeated
|
||||
syncs within that window.
|
||||
|
||||
Returns only the unknown ids, in playlist order."""
|
||||
new_ids: list[str] = []
|
||||
seen_new: set[str] = set()
|
||||
consecutive_known = 0
|
||||
page_token: str | None = None
|
||||
pages_fetched = 0
|
||||
|
||||
with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client:
|
||||
while pages_fetched < MAX_PLAYLIST_PAGES and len(new_ids) < max_results:
|
||||
params = {
|
||||
"part": "contentDetails",
|
||||
"playlistId": playlist_id,
|
||||
"maxResults": BATCH_SIZE,
|
||||
}
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
|
||||
response = client.get(f"{settings.youtube_api_base_url}/playlistItems", params=params, headers=_headers(credentials))
|
||||
if response.status_code == 404:
|
||||
return new_ids
|
||||
_raise_for_status(response)
|
||||
data = response.json()
|
||||
|
||||
for item in data.get("items", []):
|
||||
video_id = item.get("contentDetails", {}).get("videoId")
|
||||
if not video_id:
|
||||
continue
|
||||
if video_id in known_ids or video_id in seen_new:
|
||||
consecutive_known += 1
|
||||
if consecutive_known >= stop_threshold:
|
||||
return new_ids
|
||||
else:
|
||||
seen_new.add(video_id)
|
||||
new_ids.append(video_id)
|
||||
consecutive_known = 0
|
||||
if len(new_ids) >= max_results:
|
||||
return new_ids
|
||||
|
||||
pages_fetched += 1
|
||||
page_token = data.get("nextPageToken")
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
return new_ids
|
||||
|
||||
|
||||
def fetch_videos_details(credentials: Credentials, video_ids: list[str]) -> list[dict]:
|
||||
results: list[dict] = []
|
||||
|
||||
|
|
@ -134,7 +221,7 @@ def fetch_videos_details(credentials: Credentials, video_ids: list[str]) -> list
|
|||
"id": ",".join(batch),
|
||||
"maxResults": BATCH_SIZE,
|
||||
}
|
||||
response = client.get(f"{API_BASE}/videos", params=params, headers=_headers(credentials))
|
||||
response = client.get(f"{settings.youtube_api_base_url}/videos", params=params, headers=_headers(credentials))
|
||||
_raise_for_status(response)
|
||||
data = response.json()
|
||||
|
||||
|
|
@ -162,7 +249,7 @@ def fetch_videos_details(credentials: Credentials, video_ids: list[str]) -> list
|
|||
def unsubscribe(credentials: Credentials, youtube_subscription_id: str) -> None:
|
||||
with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client:
|
||||
response = client.delete(
|
||||
f"{API_BASE}/subscriptions",
|
||||
f"{settings.youtube_api_base_url}/subscriptions",
|
||||
params={"id": youtube_subscription_id},
|
||||
headers=_headers(credentials),
|
||||
)
|
||||
|
|
@ -172,18 +259,28 @@ def unsubscribe(credentials: Credentials, youtube_subscription_id: str) -> None:
|
|||
_raise_for_status(response)
|
||||
|
||||
|
||||
def fetch_uploads_playlists(credentials: Credentials, channel_ids: list[str]) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
def _parse_subscriber_count(statistics: dict) -> int | None:
|
||||
raw = statistics.get("subscriberCount")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def fetch_uploads_playlists(credentials: Credentials, channel_ids: list[str]) -> dict[str, dict]:
|
||||
result: dict[str, dict] = {}
|
||||
|
||||
with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client:
|
||||
for i in range(0, len(channel_ids), BATCH_SIZE):
|
||||
batch = channel_ids[i : i + BATCH_SIZE]
|
||||
params = {
|
||||
"part": "snippet,contentDetails",
|
||||
"part": "snippet,contentDetails,statistics",
|
||||
"id": ",".join(batch),
|
||||
"maxResults": BATCH_SIZE,
|
||||
}
|
||||
response = client.get(f"{API_BASE}/channels", params=params, headers=_headers(credentials))
|
||||
response = client.get(f"{settings.youtube_api_base_url}/channels", params=params, headers=_headers(credentials))
|
||||
_raise_for_status(response)
|
||||
data = response.json()
|
||||
|
||||
|
|
@ -192,7 +289,10 @@ def fetch_uploads_playlists(credentials: Credentials, channel_ids: list[str]) ->
|
|||
uploads = (
|
||||
item.get("contentDetails", {}).get("relatedPlaylists", {}).get("uploads")
|
||||
)
|
||||
if channel_id and uploads:
|
||||
result[channel_id] = uploads
|
||||
if channel_id:
|
||||
result[channel_id] = {
|
||||
"uploads_playlist_id": uploads or None,
|
||||
"subscriber_count": _parse_subscriber_count(item.get("statistics", {})),
|
||||
}
|
||||
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<meta name="theme-color" content="#0f0f0f" />
|
||||
<meta name="description" content="Персональная лента YouTube-подписок с категориями и сохранёнными видео." />
|
||||
<title>MyTube — моя лента</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 243 B |
|
|
@ -1,24 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
|
|
@ -1,456 +1,288 @@
|
|||
.app {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d33;
|
||||
}
|
||||
|
||||
.connect-screen {
|
||||
text-align: center;
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
.connect-screen button {
|
||||
font-size: 16px;
|
||||
padding: 10px 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.channels-screen header,
|
||||
.feed-screen header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.channels-screen .actions,
|
||||
.feed-screen .actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.channels-screen .actions a,
|
||||
.feed-screen .actions a {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.channels-screen input[type='text'] {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin-bottom: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.category-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 160px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-nav button {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.category-nav button.active {
|
||||
background: var(--accent-bg, #eee);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channel-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.channel-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.channel-card img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.channel-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channel-title {
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.channel-title:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.unsubscribe-button {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
margin-left: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.unsubscribe-error {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.channel-categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-size: 12px;
|
||||
background: #eee;
|
||||
border-radius: 12px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.chip-edit {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed #aaa;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.category-popover-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.category-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
min-width: 160px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.category-popover-empty {
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.category-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.categories-screen {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.category-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.category-form input {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.category-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.category-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.reorder-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.reorder-buttons button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.category-count {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.category-edit-form {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.video-grid {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.video-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-thumbnail {
|
||||
position: relative;
|
||||
display: block;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.video-thumbnail img,
|
||||
.video-thumbnail-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.video-thumbnail-placeholder {
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.duration-badge {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.video-info {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.video-title:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.button-link {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
display: block;
|
||||
margin: 16px auto 0;
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.video-page {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.player-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.player-wrapper iframe,
|
||||
.player-wrapper video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.download-badge {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.download-badge.completed {
|
||||
background: #dcf5df;
|
||||
color: #1a7f2e;
|
||||
}
|
||||
|
||||
.download-badge.error {
|
||||
background: #fbdcdc;
|
||||
color: #a3221f;
|
||||
}
|
||||
|
||||
.download-error,
|
||||
.download-inline-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.video-actions button,
|
||||
.video-page-meta button {
|
||||
font-size: 13px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.video-page-title {
|
||||
font-size: 20px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.video-page-meta {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.video-page-meta a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.video-page-source {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.video-page-categories {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.video-page-description {
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
border-top: 1px solid #ddd;
|
||||
padding-top: 16px;
|
||||
}
|
||||
#root { min-height: 100vh; }
|
||||
.app-shell { min-height: 100vh; }
|
||||
.topbar { height: 66px; position: sticky; top: 0; z-index: 50; display: flex; align-items: center; gap: 28px; padding: 0 26px; background: rgba(15,15,15,.96); border-bottom: 1px solid var(--border); backdrop-filter: blur(16px); }
|
||||
.brand { display: inline-flex; align-items: center; gap: 10px; min-width: 214px; text-decoration: none; font-size: 21px; font-weight: 750; letter-spacing: -.7px; color: var(--text); white-space: nowrap; }
|
||||
.brand-mark { display: inline-grid; place-items: center; flex: none; width: 34px; height: 27px; border-radius: 8px; background: var(--accent); color: #07121b; }
|
||||
.brand-accent { color: var(--accent); }
|
||||
.header-search { margin: 0 auto; width: min(560px, 100%); height: 40px; border: 1px solid var(--border); border-radius: 999px; display: flex; align-items: center; gap: 11px; padding: 0 14px; color: var(--muted); background: var(--surface); transition: border-color .18s, background .18s; }
|
||||
.header-search:focus-within { border-color: var(--accent); background: var(--surface-raised); }
|
||||
.header-search input { flex: 1; min-width: 0; border: 0; outline: 0; color: var(--text); background: transparent; font-size: 14px; }
|
||||
.header-search input::placeholder { color: var(--subtle); }
|
||||
.header-search button { border: 0; background: none; color: var(--muted); display: grid; place-items: center; padding: 2px; }
|
||||
.topbar-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 214px; }
|
||||
.icon-button { display: inline-grid; place-items: center; width: 44px; height: 44px; flex: none; padding: 0; color: var(--muted); background: transparent; border: 0; border-radius: 10px; text-decoration: none; }
|
||||
.icon-button:hover { color: var(--text); background: var(--hover); }
|
||||
.sync-wrap { position: relative; }
|
||||
.sync-indicator { min-height: 40px; display: inline-flex; align-items: center; gap: 8px; border: 0; border-radius: 9px; padding: 0 10px; background: transparent; color: var(--muted); font-size: 12px; white-space: nowrap; }
|
||||
.sync-indicator svg { color: var(--success); }
|
||||
.sync-indicator.is-error svg { color: var(--error); }
|
||||
.sync-indicator:hover { background: var(--hover); color: var(--text); }
|
||||
.sync-popover { position: absolute; top: calc(100% + 12px); right: 0; width: 280px; padding: 17px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-raised); box-shadow: 0 16px 50px rgba(0,0,0,.5); font-size: 13px; color: var(--muted); }
|
||||
.sync-popover strong { display: block; color: var(--text); margin-bottom: 12px; }
|
||||
.sync-popover p + p { margin-top: 9px; }
|
||||
.mobile-menu, .drawer-header, .drawer-backdrop { display: none; }
|
||||
.app-sidebar { position: fixed; z-index: 30; top: 66px; left: 0; bottom: 0; width: 244px; overflow-y: auto; border-right: 1px solid var(--border); background: var(--bg); }
|
||||
.sidebar-nav { display: flex; flex-direction: column; min-height: 100%; padding: 20px 12px; }
|
||||
.sidebar-group { display: grid; gap: 3px; padding-bottom: 21px; }
|
||||
.sidebar-group + .sidebar-group { padding-top: 21px; border-top: 1px solid var(--border); }
|
||||
.sidebar-heading { padding: 0 13px 11px; color: var(--subtle); font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.sidebar-link { display: flex; align-items: center; gap: 14px; min-height: 42px; border-radius: 9px; padding: 0 13px; color: var(--muted); font-size: 14px; font-weight: 500; text-decoration: none; transition: background .16s, color .16s; }
|
||||
.sidebar-link:hover { color: var(--text); background: var(--hover); }
|
||||
.sidebar-link.active { color: var(--text); background: var(--surface-raised); font-weight: 650; }
|
||||
.sidebar-link.active svg { color: var(--accent); }
|
||||
.category-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.category-link { gap: 15px; padding-left: 18px; }
|
||||
.category-link.active .category-dot { background: var(--accent); }
|
||||
.sidebar-category-row { display: flex; align-items: center; gap: 4px; }
|
||||
.sidebar-category-row .sidebar-link { flex: 1; min-width: 0; }
|
||||
.sidebar-badge { margin-left: auto; flex: none; padding: 1px 8px; border-radius: 999px; background: rgba(255,107,104,.13); color: var(--error); font-size: 11px; font-weight: 650; line-height: 1.5; white-space: nowrap; }
|
||||
.sidebar-badge-link { cursor: pointer; text-decoration: none; transition: background .16s; }
|
||||
.sidebar-badge-link:hover { background: rgba(255,107,104,.26); }
|
||||
.sidebar-badge-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.sidebar-add { color: var(--accent); font-size: 13px; margin-top: 4px; }
|
||||
.sidebar-hint { margin: 0; padding: 5px 13px 9px; color: var(--subtle); font-size: 12px; }
|
||||
.sidebar-bottom { margin-top: auto; padding-bottom: 0; }
|
||||
.truncate { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.main-content { margin-left: 244px; min-width: 0; }
|
||||
.page { max-width: 1820px; margin: 0 auto; padding: 35px clamp(24px, 3vw, 54px) 75px; }
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-end; gap: 22px; margin-bottom: 28px; }
|
||||
.eyebrow { color: var(--accent); text-transform: uppercase; letter-spacing: .13em; font-size: 11px; font-weight: 700; margin: 0 0 8px; }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { color: var(--text); font-size: clamp(25px, 2.25vw, 34px); line-height: 1.18; letter-spacing: -.035em; font-weight: 700; margin-bottom: 8px; }
|
||||
h2 { color: var(--text); font-size: 18px; line-height: 1.3; font-weight: 650; }
|
||||
.page-subtitle { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
|
||||
.page-subtitle a { color: var(--accent); text-decoration: none; font-weight: 650; }
|
||||
.page-subtitle a:hover { text-decoration: underline; }
|
||||
button, .button-primary, .button-secondary, .button-quiet, .button-danger, .button-link { transition: background .16s, color .16s, border-color .16s; }
|
||||
.button-primary, .button-secondary, .button-danger, .button-link { min-height: 42px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border-radius: 8px; padding: 0 15px; text-decoration: none; font-size: 13px; font-weight: 650; white-space: nowrap; }
|
||||
.button-primary { border: 1px solid var(--accent); background: var(--accent); color: #081521; }
|
||||
.button-primary:hover { background: #79c1ff; border-color: #79c1ff; }
|
||||
.button-secondary, .button-link { border: 1px solid var(--border); background: var(--surface-raised); color: var(--text); }
|
||||
.button-secondary:hover, .button-link:hover { background: var(--hover); border-color: #555; }
|
||||
.button-quiet { min-height: 40px; padding: 0 10px; display: inline-flex; align-items: center; gap: 7px; background: transparent; border: 0; color: var(--muted); border-radius: 8px; font-size: 13px; text-decoration: none; }
|
||||
.button-quiet:hover { color: var(--text); background: var(--hover); }
|
||||
.button-danger { background: #a72c2a; border: 1px solid #a72c2a; color: white; }
|
||||
.button-danger:hover { background: #c93633; }
|
||||
.error { color: var(--error); }
|
||||
.notice { display: flex; align-items: center; gap: 10px; padding: 13px 16px; margin: 0 0 24px; border-radius: 9px; font-size: 13px; }
|
||||
.error-notice { border: 1px solid rgba(255,107,104,.25); color: #ffb1ae; background: rgba(255,107,104,.09); }
|
||||
.onboarding-notice { flex-wrap: wrap; border: 1px solid rgba(62,166,255,.25); color: var(--text); background: var(--accent-soft); }
|
||||
.onboarding-notice > span { flex: 1; min-width: 190px; }
|
||||
.onboarding-notice a { display: inline-flex; align-items: center; gap: 3px; color: var(--accent); text-decoration: none; font-weight: 650; white-space: nowrap; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||
.spin { animation: spin 1.6s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.mobile-category-nav { display: none; }
|
||||
.video-grid { list-style: none; display: grid; grid-template-columns: repeat(auto-fill, minmax(265px, 1fr)); gap: 31px 18px; padding: 0; margin: 0; }
|
||||
.video-card { min-width: 0; display: flex; flex-direction: column; }
|
||||
.video-thumbnail { display: block; position: relative; overflow: hidden; width: 100%; aspect-ratio: 16 / 9; border-radius: 11px; background: var(--surface-raised); }
|
||||
.video-thumbnail img, .video-thumbnail-placeholder { width: 100%; height: 100%; display: block; object-fit: cover; }
|
||||
.video-thumbnail-placeholder { background: linear-gradient(135deg, #232323, #303030); }
|
||||
.duration-badge { position: absolute; bottom: 8px; right: 8px; border-radius: 4px; background: rgba(0,0,0,.82); color: white; padding: 3px 5px; font-size: 11px; font-weight: 650; line-height: 1; }
|
||||
.video-info { padding: 11px 2px 0; min-width: 0; flex: 1; display: flex; flex-direction: column; }
|
||||
.video-title { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; color: var(--text); text-decoration: none; font-size: 15px; font-weight: 620; line-height: 1.42; }
|
||||
.video-title:hover { color: var(--accent); }
|
||||
.video-meta { display: flex; flex-wrap: wrap; gap: 2px 7px; margin-top: 7px; color: var(--muted); font-size: 12px; line-height: 1.4; }
|
||||
.video-channel { color: #c8c8c8; }
|
||||
.video-meta span + span::before { content: '·'; margin-right: 7px; color: var(--subtle); }
|
||||
.video-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: auto; padding-top: 13px; }
|
||||
.video-actions .button-link, .video-actions .button-secondary { min-height: 36px; padding: 0 11px; }
|
||||
.download-inline-actions, .download-error { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 6px; }
|
||||
.download-badge { position: relative; display: inline-flex; align-items: center; gap: 5px; min-height: 36px; overflow: hidden; border-radius: 7px; padding: 0 10px; background: var(--surface-raised); color: var(--muted); font-size: 12px; white-space: nowrap; }
|
||||
.download-badge.completed { color: var(--success); background: var(--success-soft); }
|
||||
.download-badge.error { color: var(--error); background: rgba(255,107,104,.13); }
|
||||
.download-badge.downloading { color: var(--warning); background: rgba(245,166,35,.11); }
|
||||
.download-progress { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--warning); }
|
||||
.download-action-error { color: var(--error); font-size: 12px; }
|
||||
.load-more { display: flex; margin: 34px auto 0; }
|
||||
.empty-state { max-width: 520px; margin: clamp(45px, 8vh, 100px) auto; padding: 30px; text-align: center; color: var(--muted); }
|
||||
.empty-state h2 { margin: 0 0 9px; font-size: 20px; }
|
||||
.empty-state p { font-size: 14px; line-height: 1.55; margin-bottom: 20px; }
|
||||
.empty-icon { width: 60px; height: 60px; margin: 0 auto 18px; display: grid; place-items: center; color: var(--accent); border-radius: 18px; background: var(--accent-soft); }
|
||||
.skeleton-thumbnail, .skeleton-line, .channel-skeleton span, .channel-skeleton i { background: linear-gradient(90deg, #222 25%, #303030 50%, #222 75%); background-size: 200% 100%; animation: shimmer 1.4s ease-in-out infinite; }
|
||||
.skeleton-thumbnail { aspect-ratio: 16 / 9; border-radius: 11px; }
|
||||
.skeleton-line { height: 13px; border-radius: 5px; width: 70%; margin: 8px 0; }
|
||||
.skeleton-line.wide { width: 93%; }
|
||||
.skeleton-line.short { width: 44%; }
|
||||
@keyframes shimmer { to { background-position: -200% 0; } }
|
||||
.channels-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-bottom: 18px; }
|
||||
.input-search { display: flex; align-items: center; gap: 10px; width: min(420px, 100%); height: 43px; border: 1px solid var(--border); border-radius: 9px; padding: 0 12px; color: var(--subtle); background: var(--surface); }
|
||||
.input-search:focus-within { border-color: var(--accent); }
|
||||
.input-search input { width: 100%; min-width: 0; border: 0; outline: 0; color: var(--text); background: transparent; font-size: 14px; }
|
||||
.input-search input::placeholder { color: var(--subtle); }
|
||||
.category-nav { display: flex; align-items: center; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: 0 0 19px; padding-bottom: 2px; }
|
||||
.category-nav::-webkit-scrollbar, .mobile-category-nav::-webkit-scrollbar { display: none; }
|
||||
.category-nav button { display: inline-flex; align-items: center; gap: 8px; min-height: 36px; flex: none; border: 1px solid var(--border); border-radius: 999px; padding: 0 13px; background: var(--surface); color: var(--muted); font-size: 12px; }
|
||||
.category-nav button:hover { background: var(--hover); color: var(--text); }
|
||||
.category-nav button.active { background: var(--accent-soft); border-color: rgba(62,166,255,.55); color: var(--accent); }
|
||||
.category-nav-count { color: var(--subtle); font-size: 11px; }
|
||||
.category-nav button.active .category-nav-count { color: var(--accent); }
|
||||
.local-filter-nav { display: flex; align-items: center; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: -3px 0 23px; padding-bottom: 3px; }
|
||||
.local-filter-nav::-webkit-scrollbar { display: none; }
|
||||
.local-filter-nav a { display: inline-flex; align-items: center; gap: 8px; flex: none; min-height: 36px; border: 1px solid var(--border); border-radius: 999px; padding: 0 13px; color: var(--muted); background: var(--surface); font-size: 12px; text-decoration: none; }
|
||||
.local-filter-nav a:hover { color: var(--text); background: var(--hover); }
|
||||
.local-filter-nav a.active { color: var(--accent); border-color: rgba(62,166,255,.55); background: var(--accent-soft); }
|
||||
.local-filter-nav span { font-size: 11px; color: var(--subtle); }
|
||||
.local-filter-nav a.active span { color: var(--accent); }
|
||||
.channel-list { list-style: none; max-width: 1000px; padding: 0; margin: 0; border-top: 1px solid var(--border); }
|
||||
.channel-card { display: flex; align-items: flex-start; gap: 15px; padding: 18px 3px; border-bottom: 1px solid var(--border); }
|
||||
.channel-card > img, .channel-avatar-placeholder { display: grid; place-items: center; width: 48px; height: 48px; flex: none; border-radius: 50%; object-fit: cover; background: #344557; color: var(--text); font-size: 18px; font-weight: 700; }
|
||||
.channel-info { flex: 1; min-width: 0; }
|
||||
.channel-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; min-height: 23px; }
|
||||
.channel-title { font-size: 15px; font-weight: 650; text-decoration: none; }
|
||||
.channel-title:hover { color: var(--accent); }
|
||||
.badge { font-size: 11px; color: var(--subtle); }
|
||||
.badge-new { padding: 2px 8px; border-radius: 999px; background: rgba(255,107,104,.13); color: var(--error); font-weight: 650; white-space: nowrap; }
|
||||
.unsubscribe-button { margin-left: auto; min-height: 28px; border: 0; border-radius: 6px; padding: 0 8px; color: var(--subtle); background: transparent; font-size: 11px; }
|
||||
.unsubscribe-button:hover { color: var(--error); background: rgba(255,107,104,.1); }
|
||||
.channel-categories { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
|
||||
.chip, .chip-edit { display: inline-flex; align-items: center; gap: 5px; min-height: 29px; border: 1px solid var(--border); border-radius: 999px; padding: 0 10px; background: var(--surface-raised); color: var(--muted); text-decoration: none; font-size: 12px; }
|
||||
.chip:hover { color: var(--text); }
|
||||
.removable-chip:hover { border-color: var(--error); color: var(--text); }
|
||||
.chip-edit { border-style: dashed; color: var(--accent); background: transparent; }
|
||||
.chip-edit:hover { background: var(--accent-soft); border-color: var(--accent); }
|
||||
.category-popover-wrapper { position: relative; }
|
||||
.category-popover { position: absolute; z-index: 20; left: 0; top: calc(100% + 8px); display: grid; gap: 4px; min-width: 230px; max-width: min(290px, calc(100vw - 40px)); max-height: 330px; overflow-y: auto; padding: 12px; border: 1px solid var(--border); border-radius: 11px; background: var(--surface-raised); box-shadow: 0 16px 45px rgba(0,0,0,.55); }
|
||||
.category-popover strong { padding: 3px 7px 7px; font-size: 13px; color: var(--text); }
|
||||
.category-popover-empty { padding: 8px; font-size: 12px; color: var(--muted); }
|
||||
.category-checkbox { display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 8px; border-radius: 6px; color: var(--text); font-size: 13px; cursor: pointer; }
|
||||
.category-checkbox:hover { background: var(--hover); }
|
||||
.category-checkbox input { accent-color: var(--accent); width: 16px; height: 16px; }
|
||||
.popover-create { display: flex; gap: 5px; margin-top: 7px; padding-top: 9px; border-top: 1px solid var(--border); }
|
||||
.popover-create input { min-width: 0; flex: 1; height: 36px; border: 1px solid var(--border); border-radius: 6px; padding: 0 8px; background: var(--surface); color: var(--text); font-size: 12px; }
|
||||
.popover-create button { display: grid; place-items: center; width: 36px; height: 36px; border: 0; border-radius: 6px; background: var(--accent); color: #081521; }
|
||||
.channel-loading { display: grid; gap: 1px; max-width: 1000px; }
|
||||
.channel-skeleton { display: flex; align-items: center; gap: 15px; height: 83px; border-bottom: 1px solid var(--border); }
|
||||
.channel-skeleton span { width: 48px; height: 48px; border-radius: 50%; }
|
||||
.channel-skeleton i { width: 45%; height: 14px; border-radius: 5px; }
|
||||
.back-link { display: inline-flex; align-items: center; gap: 8px; min-height: 36px; padding: 0; margin: 0 0 20px; border: 0; background: transparent; color: var(--muted); text-decoration: none; font-size: 13px; }
|
||||
.back-link:hover { color: var(--text); }
|
||||
.video-page { max-width: 1320px; }
|
||||
.player-wrapper { position: relative; width: 100%; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 11px; background: #000; }
|
||||
.player-wrapper iframe, .player-wrapper video { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: #000; }
|
||||
.video-page-source { margin: 11px 0 22px; color: var(--muted); font-size: 12px; }
|
||||
.video-detail-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 25px; }
|
||||
.video-page-title { font-size: clamp(21px, 2vw, 29px); line-height: 1.32; margin-bottom: 9px; }
|
||||
.video-page-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; color: var(--muted); font-size: 13px; }
|
||||
.video-page-meta a { color: var(--text); text-decoration: none; }
|
||||
.video-page-meta a:hover { color: var(--accent); }
|
||||
.video-page-categories { display: flex; flex-wrap: wrap; gap: 7px; margin: 18px 0 24px; }
|
||||
.video-page-description { border-top: 1px solid var(--border); padding-top: 24px; margin-top: 24px; }
|
||||
.video-page-description h2 { font-size: 16px; margin-bottom: 12px; }
|
||||
.video-page-description p { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--muted); font-size: 14px; line-height: 1.65; }
|
||||
.categories-screen { max-width: 1000px; }
|
||||
.category-form { display: flex; gap: 10px; max-width: 630px; margin-bottom: 28px; }
|
||||
.category-form input, .category-edit-form input { flex: 1; min-width: 0; height: 42px; border: 1px solid var(--border); border-radius: 8px; padding: 0 13px; background: var(--surface); color: var(--text); font-size: 14px; }
|
||||
.category-form input:focus, .category-edit-form input:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.category-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--border); }
|
||||
.category-row { display: flex; align-items: center; gap: 14px; min-height: 70px; border-bottom: 1px solid var(--border); }
|
||||
.reorder-buttons { display: flex; flex-direction: column; gap: 0; }
|
||||
.reorder-buttons .icon-button { width: 31px; height: 28px; }
|
||||
.category-row-label { display: flex; align-items: center; gap: 13px; flex: 1; min-width: 0; }
|
||||
.category-name { overflow: hidden; text-overflow: ellipsis; font-size: 14px; font-weight: 650; }
|
||||
.category-count { color: var(--muted); font-size: 12px; white-space: nowrap; }
|
||||
.category-row-actions { display: flex; gap: 3px; }
|
||||
.category-row-actions .danger-hover:hover { color: var(--error); }
|
||||
.category-edit-form { flex: 1; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.modal-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(0,0,0,.72); }
|
||||
.confirm-dialog { width: min(100%, 430px); padding: 25px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-raised); box-shadow: 0 24px 70px rgba(0,0,0,.6); }
|
||||
.confirm-dialog h2 { margin-bottom: 10px; }
|
||||
.confirm-dialog p { color: var(--muted); font-size: 14px; line-height: 1.55; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 22px; }
|
||||
.settings-screen { max-width: 1250px; }
|
||||
.settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||
.settings-panel { min-width: 0; padding: 23px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
|
||||
.settings-panel-heading { display: flex; align-items: center; gap: 13px; margin-bottom: 22px; }
|
||||
.settings-panel-heading h2 { margin: 0 0 2px; font-size: 16px; }
|
||||
.settings-panel-heading p { margin: 0; color: var(--muted); font-size: 12px; }
|
||||
.panel-icon { display: grid; place-items: center; width: 39px; height: 39px; flex: none; border-radius: 10px; background: var(--accent-soft); color: var(--accent); }
|
||||
.settings-detail { display: flex; justify-content: space-between; gap: 15px; padding: 12px 0; border-top: 1px solid var(--border); font-size: 13px; }
|
||||
.settings-detail span { color: var(--muted); }
|
||||
.settings-detail strong { font-weight: 550; text-align: right; overflow-wrap: anywhere; }
|
||||
.settings-detail .success-text { color: var(--success); }
|
||||
.status-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--success); }
|
||||
.settings-help { color: var(--muted); font-size: 13px; line-height: 1.5; }
|
||||
.settings-actions { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 15px; }
|
||||
.logout-button { margin-top: 24px; }
|
||||
.connect-screen { min-height: 100vh; display: grid; place-items: center; padding: 25px; background: radial-gradient(circle at 50% 15%, #173046 0%, var(--bg) 42%); }
|
||||
.connect-card { width: min(100%, 460px); padding: clamp(25px, 5vw, 44px); border: 1px solid var(--border); border-radius: 18px; background: var(--surface); text-align: center; box-shadow: 0 26px 75px rgba(0,0,0,.28); }
|
||||
.connect-card .brand-mark { margin: 0 auto 22px; width: 52px; height: 40px; }
|
||||
.connect-card h1 { font-size: 29px; }
|
||||
.connect-card p { color: var(--muted); font-size: 14px; line-height: 1.6; }
|
||||
.connect-card button { margin-top: 22px; }
|
||||
.app-loading { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 15px; padding: 30px; color: var(--muted); text-align: center; }
|
||||
.app-loading h1 { font-size: 24px; }
|
||||
.app-loading button { min-height: 42px; padding: 0 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); color: var(--text); }
|
||||
@media (min-width: 1800px) { .video-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); } }
|
||||
@media (min-width: 1400px) and (max-width: 1799px) { .video-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
|
||||
@media (min-width: 1100px) and (max-width: 1399px) { .video-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
@media (max-width: 900px) {
|
||||
.topbar { gap: 14px; padding: 0 18px; }
|
||||
.brand, .topbar-actions { min-width: 0; }
|
||||
.mobile-menu { display: inline-grid; margin-left: -8px; }
|
||||
.app-sidebar { z-index: 80; top: 0; bottom: 0; width: min(290px, 85vw); visibility: hidden; transform: translateX(-100%); transition: transform .22s ease, visibility .22s; box-shadow: 18px 0 45px rgba(0,0,0,.45); }
|
||||
.app-sidebar.open { visibility: visible; transform: translateX(0); }
|
||||
.drawer-header { height: 66px; display: flex; align-items: center; justify-content: space-between; padding: 0 18px 0 26px; color: var(--text); font-weight: 650; border-bottom: 1px solid var(--border); }
|
||||
.drawer-backdrop { display: block; position: fixed; z-index: 70; inset: 0; border: 0; padding: 0; background: rgba(0,0,0,.64); }
|
||||
body.drawer-open { overflow: hidden; }
|
||||
.main-content { margin-left: 0; }
|
||||
.page { padding: 28px 28px 65px; }
|
||||
.video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.mobile-category-nav { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; margin: -5px -28px 25px; padding: 0 28px 5px; }
|
||||
.mobile-category-nav a { flex: none; min-height: 35px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 999px; padding: 0 14px; background: var(--surface); color: var(--muted); font-size: 12px; text-decoration: none; }
|
||||
.mobile-category-nav a.active { border-color: rgba(62,166,255,.55); background: var(--accent-soft); color: var(--accent); }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.topbar { height: 112px; display: grid; grid-template-columns: 44px 1fr auto; grid-template-rows: 55px 49px; column-gap: 7px; row-gap: 0; padding: 0 15px 8px; }
|
||||
.brand { font-size: 19px; }
|
||||
.brand-mark { width: 31px; height: 25px; }
|
||||
.mobile-menu { margin-left: -7px; }
|
||||
.header-search { grid-column: 1 / -1; grid-row: 2; width: 100%; height: 44px; }
|
||||
.topbar-actions { grid-column: 3; grid-row: 1; }
|
||||
.sync-indicator { width: 40px; padding: 0; justify-content: center; }
|
||||
.sync-indicator span { display: none; }
|
||||
.header-settings { display: none; }
|
||||
.page { padding: 24px 16px 58px; }
|
||||
.page-heading { align-items: flex-start; gap: 13px; margin-bottom: 23px; }
|
||||
.page-heading h1 { font-size: 27px; }
|
||||
.refresh-button { width: 42px; height: 42px; flex: none; overflow: hidden; font-size: 0; padding: 0; }
|
||||
.refresh-button svg { flex: none; }
|
||||
.mobile-category-nav { margin-left: -16px; margin-right: -16px; padding-left: 16px; padding-right: 16px; }
|
||||
.video-grid { display: grid; grid-template-columns: 1fr; gap: 28px; }
|
||||
.video-thumbnail { border-radius: 9px; }
|
||||
.video-info { padding: 10px 2px 0; }
|
||||
.video-actions { padding-top: 11px; }
|
||||
.video-actions .button-link, .video-actions .button-secondary, .download-badge { min-height: 44px; }
|
||||
.channels-toolbar { align-items: stretch; }
|
||||
.manage-link { width: 43px; flex: none; overflow: hidden; font-size: 0; padding: 0; }
|
||||
.manage-link svg { flex: none; }
|
||||
.channel-card { gap: 11px; padding: 16px 0; }
|
||||
.channel-card > img, .channel-avatar-placeholder { width: 42px; height: 42px; }
|
||||
.unsubscribe-button { margin-left: 0; }
|
||||
.category-popover { left: auto; right: -5px; }
|
||||
.category-checkbox, .chip, .chip-edit { min-height: 44px; }
|
||||
.category-nav button, .mobile-category-nav a { min-height: 44px; }
|
||||
.local-filter-nav a { min-height: 44px; }
|
||||
.video-detail-head { flex-direction: column; gap: 9px; }
|
||||
.player-wrapper { border-radius: 0; margin-left: -16px; width: calc(100% + 32px); }
|
||||
.video-page-source { margin-top: 9px; }
|
||||
.settings-grid { grid-template-columns: 1fr; }
|
||||
.settings-panel { padding: 18px; }
|
||||
.category-form { flex-wrap: wrap; }
|
||||
.category-form input { flex-basis: 100%; }
|
||||
.category-row { gap: 8px; }
|
||||
.reorder-buttons .icon-button { width: 40px; height: 40px; }
|
||||
.category-row-label { flex-wrap: wrap; gap: 5px 10px; }
|
||||
.category-count { flex-basis: 100%; margin-left: 21px; }
|
||||
.category-edit-form input { flex-basis: 100%; }
|
||||
.sync-popover { right: -7px; max-width: calc(100vw - 30px); }
|
||||
}
|
||||
@media (min-width: 621px) and (max-width: 699px) { .video-grid { grid-template-columns: 1fr; } }
|
||||
@media (pointer: coarse) {
|
||||
.video-actions .button-link, .video-actions .button-secondary, .download-badge, .category-nav button, .local-filter-nav a, .mobile-category-nav a, .chip, .chip-edit, .category-checkbox, .unsubscribe-button, .sidebar-badge { min-height: 44px; }
|
||||
.sidebar-badge { display: inline-flex; align-items: center; }
|
||||
.reorder-buttons .icon-button { width: 40px; height: 40px; }
|
||||
.popover-create input, .popover-create button { height: 44px; }
|
||||
.popover-create button { width: 44px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } }
|
||||
|
|
|
|||
|
|
@ -1,55 +1,40 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { Link, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { getAuthStatus } from './api/client'
|
||||
import AppShell from './components/AppShell'
|
||||
import Connect from './pages/Connect'
|
||||
import Feed from './pages/Feed'
|
||||
import Channels from './pages/Channels'
|
||||
import ChannelVideos from './pages/ChannelVideos'
|
||||
import VideoPage from './pages/VideoPage'
|
||||
import Categories from './pages/Categories'
|
||||
import Settings from './pages/Settings'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['auth-status'],
|
||||
queryFn: getAuthStatus,
|
||||
})
|
||||
const { data, isLoading, isError } = useQuery({ queryKey: ['auth-status'], queryFn: getAuthStatus })
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="app">
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isLoading) return <div className="app-loading"><span className="brand-mark">▶</span><p>Открываем MyTube…</p></div>
|
||||
if (isError) return <div className="app-loading"><h1>Не удалось открыть MyTube</h1><p>Проверь подключение к серверу и обнови страницу.</p><button onClick={() => window.location.reload()}>Повторить</button></div>
|
||||
if (!data?.authenticated) return <Connect />
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="app">
|
||||
<p className="error">Backend недоступен</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data?.authenticated) {
|
||||
return (
|
||||
<div className="app">
|
||||
<Connect />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
return <AppShell>
|
||||
<Routes>
|
||||
<Route path="/" element={<Feed />} />
|
||||
<Route path="/category/:categoryId" element={<Feed />} />
|
||||
<Route path="/uncategorized" element={<Feed />} />
|
||||
<Route path="/local" element={<Feed />} />
|
||||
<Route path="/search" element={<Feed />} />
|
||||
<Route path="/saved" element={<Navigate to="/local" replace />} />
|
||||
<Route path="/channels" element={<Channels />} />
|
||||
<Route path="/channels/:channelId/videos" element={<ChannelVideos />} />
|
||||
<Route path="/video/:youtubeVideoId" element={<VideoPage />} />
|
||||
<Route path="/categories" element={<Categories />} />
|
||||
<Route path="/settings" element={<Settings email={data.email} />} />
|
||||
<Route path="/settings/categories" element={<Categories />} />
|
||||
<Route path="/categories" element={<Navigate to="/settings/categories" replace />} />
|
||||
<Route path="*" element={<div className="empty-state"><h1>Страница не найдена</h1><Link to="/">Вернуться к ленте</Link></div>} />
|
||||
</Routes>
|
||||
</div>
|
||||
)
|
||||
</AppShell>
|
||||
}
|
||||
|
||||
export default App
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export function authErrorMessage(code: string): string {
|
|||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(options.headers ?? {}) },
|
||||
headers: options.body != null ? { 'Content-Type': 'application/json', ...(options.headers ?? {}) } : options.headers,
|
||||
...options,
|
||||
})
|
||||
if (!res.ok) {
|
||||
|
|
@ -39,6 +39,10 @@ export function getAuthStatus() {
|
|||
return request<AuthStatus>('/api/auth/status')
|
||||
}
|
||||
|
||||
export function getHealth() {
|
||||
return request<{ status: string; database: string; metube: string }>('/api/health')
|
||||
}
|
||||
|
||||
export function startGoogleAuth() {
|
||||
window.location.href = '/api/auth/google/start'
|
||||
}
|
||||
|
|
@ -54,9 +58,11 @@ export interface ChannelDto {
|
|||
description: string | null
|
||||
thumbnail_url: string | null
|
||||
uploads_playlist_id: string | null
|
||||
subscriber_count: number | null
|
||||
subscribed: boolean
|
||||
last_synced_at: string | null
|
||||
category_ids: number[]
|
||||
new_videos_count: number
|
||||
}
|
||||
|
||||
export function getChannel(channelId: number) {
|
||||
|
|
@ -64,12 +70,13 @@ export function getChannel(channelId: number) {
|
|||
}
|
||||
|
||||
export function listChannels(
|
||||
params: { search?: string; categoryId?: number; uncategorized?: boolean } = {},
|
||||
params: { search?: string; categoryId?: number; uncategorized?: boolean; subscribed?: boolean } = {},
|
||||
) {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.search) qs.set('search', params.search)
|
||||
if (params.categoryId != null) qs.set('category_id', String(params.categoryId))
|
||||
if (params.uncategorized) qs.set('uncategorized', 'true')
|
||||
if (params.subscribed != null) qs.set('subscribed', String(params.subscribed))
|
||||
const suffix = qs.toString() ? `?${qs.toString()}` : ''
|
||||
return request<ChannelDto[]>(`/api/channels${suffix}`)
|
||||
}
|
||||
|
|
@ -91,6 +98,7 @@ export interface CategoryDto {
|
|||
slug: string
|
||||
sort_order: number
|
||||
channel_count: number
|
||||
new_videos_count: number
|
||||
}
|
||||
|
||||
export function listCategories() {
|
||||
|
|
@ -199,13 +207,36 @@ export function recheckLocal(youtubeVideoId: string) {
|
|||
}
|
||||
|
||||
export function getFeed(
|
||||
params: { categoryId?: number; uncategorized?: boolean; channelId?: number; cursor?: string } = {},
|
||||
params: {
|
||||
categoryId?: number
|
||||
uncategorized?: boolean
|
||||
channelId?: number
|
||||
downloaded?: boolean
|
||||
search?: string
|
||||
cursor?: string
|
||||
limit?: number
|
||||
newOnly?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.categoryId != null) qs.set('category_id', String(params.categoryId))
|
||||
if (params.uncategorized) qs.set('uncategorized', 'true')
|
||||
if (params.channelId != null) qs.set('channel_id', String(params.channelId))
|
||||
if (params.downloaded) qs.set('downloaded', 'true')
|
||||
if (params.search) qs.set('search', params.search)
|
||||
if (params.newOnly) qs.set('new_only', 'true')
|
||||
if (params.cursor) qs.set('cursor', params.cursor)
|
||||
if (params.limit != null) qs.set('limit', String(params.limit))
|
||||
const suffix = qs.toString() ? `?${qs.toString()}` : ''
|
||||
return request<{ items: FeedVideoDto[]; next_cursor: string | null }>(`/api/feed${suffix}`)
|
||||
}
|
||||
|
||||
export interface SavedCounts {
|
||||
all: number
|
||||
uncategorized: number
|
||||
categories: Record<string, number>
|
||||
}
|
||||
|
||||
export function getSavedCounts() {
|
||||
return request<SavedCounts>('/api/feed/saved-counts')
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
103
frontend/src/components/AppShell.tsx
Normal file
103
frontend/src/components/AppShell.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { getSyncStatus, listCategories } from '../api/client'
|
||||
import { formatRelativeTime } from '../utils/format'
|
||||
import Icon from './Icon'
|
||||
|
||||
function SyncIndicator() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const { data } = useQuery({
|
||||
queryKey: ['sync-status'], queryFn: getSyncStatus,
|
||||
refetchInterval: (query) => query.state.data?.videos.running || query.state.data?.subscriptions.running ? 2000 : 60_000,
|
||||
})
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const close = (event: MouseEvent) => { if (!ref.current?.contains(event.target as Node)) setOpen(false) }
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [open])
|
||||
const running = data?.videos.running || data?.subscriptions.running
|
||||
const failed = data?.videos.status === 'failed' || data?.subscriptions.status === 'failed'
|
||||
const recent = [data?.videos.finished_at, data?.subscriptions.finished_at].filter((value): value is string => Boolean(value)).sort().at(-1)
|
||||
const label = running ? 'Обновление...' : failed ? 'Ошибка синхронизации' : recent ? `Обновлено ${formatRelativeTime(recent)}` : 'Синхронизация'
|
||||
return <div className="sync-wrap" ref={ref}>
|
||||
<button type="button" className={`sync-indicator ${failed ? 'is-error' : ''}`} onClick={() => setOpen(!open)} aria-expanded={open} aria-label={label}>
|
||||
<Icon name={failed ? 'alert' : running ? 'refresh' : 'check'} size={17} className={running ? 'spin' : ''} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
{open && <div className="sync-popover" role="status">
|
||||
<strong>Синхронизация</strong>
|
||||
<p>Подписки: {data?.subscriptions.running ? 'обновляются' : data?.subscriptions.finished_at ? formatRelativeTime(data.subscriptions.finished_at) : 'ещё не обновлялись'}</p>
|
||||
<p>Видео: {data?.videos.running ? 'обновляются' : data?.videos.finished_at ? formatRelativeTime(data.videos.finished_at) : 'ещё не обновлялись'}</p>
|
||||
{failed && <p className="error">Последнее обновление завершилось ошибкой. Подробности в настройках.</p>}
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function Sidebar({ close }: { close: () => void }) {
|
||||
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
|
||||
const categories = categoriesQuery.data ?? []
|
||||
const links = [
|
||||
{ to: '/', icon: 'home' as const, text: 'Все видео', end: true },
|
||||
{ to: '/local', icon: 'server' as const, text: 'На сервере' },
|
||||
]
|
||||
return <nav className="sidebar-nav" aria-label="Основная навигация">
|
||||
<div className="sidebar-group">
|
||||
{links.map((item) => <NavLink key={item.to} to={item.to} end={item.end} onClick={close} className={({ isActive }) => `sidebar-link ${isActive ? 'active' : ''}`}><Icon name={item.icon} size={19} /><span>{item.text}</span></NavLink>)}
|
||||
</div>
|
||||
<div className="sidebar-group sidebar-categories">
|
||||
<div className="sidebar-heading">Категории</div>
|
||||
{categories.map((category) => <div key={category.id} className="sidebar-category-row">
|
||||
<NavLink to={`/category/${category.id}`} onClick={close} className={({ isActive }) => `sidebar-link category-link ${isActive ? 'active' : ''}`}><span className="category-dot" /><span className="truncate">{category.name}</span></NavLink>
|
||||
{category.new_videos_count > 0 && <Link to={`/category/${category.id}?new=1`} onClick={close} className="sidebar-badge sidebar-badge-link" aria-label={`${category.new_videos_count} новых видео в категории «${category.name}»`}>{category.new_videos_count}</Link>}
|
||||
</div>)}
|
||||
{categories.length === 0 && !categoriesQuery.isLoading && <p className="sidebar-hint">Пока нет категорий</p>}
|
||||
<Link to="/settings/categories" onClick={close} className="sidebar-link sidebar-add"><Icon name="plus" size={18} /><span>Новая категория</span></Link>
|
||||
</div>
|
||||
<div className="sidebar-group sidebar-bottom">
|
||||
<NavLink to="/channels" onClick={close} className={({ isActive }) => `sidebar-link ${isActive ? 'active' : ''}`}><Icon name="users" size={19} /><span>Каналы</span></NavLink>
|
||||
<NavLink to="/settings" onClick={close} className={({ isActive }) => `sidebar-link ${isActive ? 'active' : ''}`}><Icon name="settings" size={19} /><span>Настройки</span></NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
}
|
||||
|
||||
function SearchForm({ initialValue }: { initialValue: string }) {
|
||||
const [search, setSearch] = useState(initialValue)
|
||||
const navigate = useNavigate()
|
||||
return <form className="header-search" role="search" onSubmit={(event) => { event.preventDefault(); navigate(search.trim() ? `/search?q=${encodeURIComponent(search.trim())}` : '/') }}>
|
||||
<Icon name="search" size={19} />
|
||||
<input aria-label="Поиск по видео и каналам" placeholder="Поиск видео и каналов" maxLength={200} value={search} onChange={(event) => setSearch(event.target.value)} />
|
||||
{search && <button type="submit" aria-label="Искать"><Icon name="chevron" size={18} /></button>}
|
||||
</form>
|
||||
}
|
||||
|
||||
export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const drawerCloseRef = useRef<HTMLButtonElement>(null)
|
||||
const location = useLocation()
|
||||
const initialSearch = location.pathname === '/search' ? new URLSearchParams(location.search).get('q') ?? '' : ''
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle('drawer-open', drawerOpen)
|
||||
if (drawerOpen) drawerCloseRef.current?.focus()
|
||||
const onEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') setDrawerOpen(false) }
|
||||
if (drawerOpen) document.addEventListener('keydown', onEscape)
|
||||
else document.removeEventListener('keydown', onEscape)
|
||||
return () => { document.body.classList.remove('drawer-open'); document.removeEventListener('keydown', onEscape) }
|
||||
}, [drawerOpen])
|
||||
return <div className="app-shell">
|
||||
<header className="topbar">
|
||||
<button type="button" className="icon-button mobile-menu" aria-label="Открыть меню" onClick={() => setDrawerOpen(true)}><Icon name="menu" /></button>
|
||||
<Link to="/" className="brand" aria-label="MyTube — все видео"><span className="brand-mark"><Icon name="play" size={18} /></span><span>My<span className="brand-accent">Tube</span></span></Link>
|
||||
<SearchForm key={location.pathname + location.search} initialValue={initialSearch} />
|
||||
<div className="topbar-actions"><SyncIndicator /><Link to="/settings" className="icon-button header-settings" aria-label="Настройки"><Icon name="settings" /></Link></div>
|
||||
</header>
|
||||
{drawerOpen && <button type="button" className="drawer-backdrop" aria-label="Закрыть меню" onClick={() => setDrawerOpen(false)} />}
|
||||
<aside className={`app-sidebar ${drawerOpen ? 'open' : ''}`}>
|
||||
<div className="drawer-header"><span>Меню</span><button ref={drawerCloseRef} type="button" className="icon-button" aria-label="Закрыть меню" onClick={() => setDrawerOpen(false)}><Icon name="close" /></button></div>
|
||||
<Sidebar close={() => setDrawerOpen(false)} />
|
||||
</aside>
|
||||
<main className="main-content" id="main-content">{children}</main>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -6,24 +6,31 @@ interface Props {
|
|||
categories: CategoryDto[]
|
||||
value: CategoryFilter
|
||||
onChange: (filter: CategoryFilter) => void
|
||||
allCount?: number
|
||||
uncategorizedCount?: number
|
||||
}
|
||||
|
||||
function CategoryNav({ categories, value, onChange }: Props) {
|
||||
function CategoryNav({ categories, value, onChange, allCount, uncategorizedCount }: Props) {
|
||||
return (
|
||||
<nav className="category-nav">
|
||||
<button className={value === 'all' ? 'active' : ''} onClick={() => onChange('all')}>
|
||||
<nav className="category-nav" aria-label="Фильтр каналов по категориям">
|
||||
<button type="button" aria-pressed={value === 'all'} className={value === 'all' ? 'active' : ''} onClick={() => onChange('all')}>
|
||||
Все
|
||||
{allCount != null && <span className="category-nav-count">{allCount}</span>}
|
||||
</button>
|
||||
<button className={value === 'uncategorized' ? 'active' : ''} onClick={() => onChange('uncategorized')}>
|
||||
<button type="button" aria-pressed={value === 'uncategorized'} className={value === 'uncategorized' ? 'active' : ''} onClick={() => onChange('uncategorized')}>
|
||||
Без категории
|
||||
{uncategorizedCount != null && <span className="category-nav-count">{uncategorizedCount}</span>}
|
||||
</button>
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
aria-pressed={value === category.id}
|
||||
className={value === category.id ? 'active' : ''}
|
||||
onClick={() => onChange(category.id)}
|
||||
>
|
||||
{category.name}
|
||||
<span className="category-nav-count">{category.channel_count}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from 'react'
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { CategoryDto, ChannelDto } from '../api/client'
|
||||
import { setChannelCategories, unsubscribeChannel } from '../api/client'
|
||||
import { createCategory, setChannelCategories, unsubscribeChannel } from '../api/client'
|
||||
import Icon from './Icon'
|
||||
|
||||
interface Props {
|
||||
channel: ChannelDto
|
||||
|
|
@ -11,6 +12,7 @@ interface Props {
|
|||
|
||||
function ChannelCard({ channel, categories }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
|
|
@ -22,7 +24,9 @@ function ChannelCard({ channel, categories }: Props) {
|
|||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => { document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleEscape) }
|
||||
}, [open])
|
||||
|
||||
const mutation = useMutation({
|
||||
|
|
@ -41,6 +45,7 @@ function ChannelCard({ channel, categories }: Props) {
|
|||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['categories'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] })
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -51,6 +56,15 @@ function ChannelCard({ channel, categories }: Props) {
|
|||
},
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => createCategory(name),
|
||||
onSuccess: (category) => {
|
||||
setNewName('')
|
||||
queryClient.invalidateQueries({ queryKey: ['categories'] })
|
||||
mutation.mutate([...channel.category_ids, category.id])
|
||||
},
|
||||
})
|
||||
|
||||
function toggleCategory(categoryId: number) {
|
||||
const current = new Set(channel.category_ids)
|
||||
if (current.has(categoryId)) {
|
||||
|
|
@ -61,15 +75,14 @@ function ChannelCard({ channel, categories }: Props) {
|
|||
mutation.mutate(Array.from(current))
|
||||
}
|
||||
|
||||
const assignedNames = categories.filter((c) => channel.category_ids.includes(c.id)).map((c) => c.name)
|
||||
const assignedCategories = categories.filter((c) => channel.category_ids.includes(c.id))
|
||||
|
||||
return (
|
||||
<li className="channel-card">
|
||||
{channel.thumbnail_url && <img src={channel.thumbnail_url} alt={channel.title} />}
|
||||
{channel.thumbnail_url ? <img src={channel.thumbnail_url} alt="" loading="lazy" /> : <span className="channel-avatar-placeholder">{channel.title.charAt(0).toUpperCase()}</span>}
|
||||
<div className="channel-info">
|
||||
<Link to={`/channels/${channel.id}/videos`} className="channel-title">
|
||||
{channel.title}
|
||||
</Link>
|
||||
<div className="channel-head"><Link to={`/channels/${channel.id}/videos`} className="channel-title">{channel.title}</Link>
|
||||
{channel.new_videos_count > 0 && <span className="badge badge-new" title="Недавно вышедшие видео">{channel.new_videos_count} новых</span>}
|
||||
{!channel.subscribed && <span className="badge">отписан</span>}
|
||||
{channel.subscribed && (
|
||||
<button
|
||||
|
|
@ -80,26 +93,24 @@ function ChannelCard({ channel, categories }: Props) {
|
|||
}
|
||||
}}
|
||||
disabled={unsubscribeMutation.isPending}
|
||||
>
|
||||
Отписаться
|
||||
</button>
|
||||
>Отписаться</button>
|
||||
)}
|
||||
</div>
|
||||
{unsubscribeMutation.isError && (
|
||||
<p className="error unsubscribe-error">{(unsubscribeMutation.error as Error).message}</p>
|
||||
<p className="error">{(unsubscribeMutation.error as Error).message}</p>
|
||||
)}
|
||||
|
||||
<div className="channel-categories">
|
||||
{assignedNames.map((name) => (
|
||||
<span key={name} className="chip">
|
||||
{name}
|
||||
</span>
|
||||
{assignedCategories.map((category) => (
|
||||
<button type="button" key={category.id} className="chip removable-chip" onClick={() => toggleCategory(category.id)} title={`Убрать из категории «${category.name}»`}>{category.name}<Icon name="close" size={13} /></button>
|
||||
))}
|
||||
<div className="category-popover-wrapper" ref={popoverRef}>
|
||||
<button className="chip-edit" onClick={() => setOpen((v) => !v)}>
|
||||
+ Категория
|
||||
<button type="button" className="chip-edit" onClick={() => setOpen((v) => !v)} aria-expanded={open} aria-label={`Выбрать категории для канала ${channel.title}`}>
|
||||
<Icon name="plus" size={15} /> Категория
|
||||
</button>
|
||||
{open && (
|
||||
<div className="category-popover">
|
||||
<div className="category-popover" role="group" aria-label="Категории канала">
|
||||
<strong>Категории</strong>
|
||||
{categories.length === 0 && <div className="category-popover-empty">Сначала создай категорию</div>}
|
||||
{categories.map((category) => (
|
||||
<label key={category.id} className="category-checkbox">
|
||||
|
|
@ -111,6 +122,11 @@ function ChannelCard({ channel, categories }: Props) {
|
|||
{category.name}
|
||||
</label>
|
||||
))}
|
||||
<form className="popover-create" onSubmit={(event) => { event.preventDefault(); if (newName.trim()) createMutation.mutate(newName.trim()) }}>
|
||||
<input aria-label="Название новой категории" placeholder="Новая категория" value={newName} onChange={(event) => setNewName(event.target.value)} />
|
||||
<button type="submit" aria-label="Создать категорию" disabled={!newName.trim() || createMutation.isPending}><Icon name="plus" size={18} /></button>
|
||||
</form>
|
||||
{(mutation.isError || createMutation.isError) && <span className="error">Не удалось изменить категории.</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { FeedVideoDto } from '../api/client'
|
||||
import { deleteDownload, downloadVideo, getDownloadStatus } from '../api/client'
|
||||
import Icon from './Icon'
|
||||
|
||||
const ACTIVE_STATUSES = ['queued', 'downloading', 'postprocessing']
|
||||
|
||||
|
|
@ -12,9 +13,10 @@ const STATUS_LABELS: Record<string, string> = {
|
|||
|
||||
interface Props {
|
||||
video: FeedVideoDto
|
||||
showDelete?: boolean
|
||||
}
|
||||
|
||||
function DownloadButton({ video }: Props) {
|
||||
function DownloadButton({ video, showDelete = false }: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
// Once we start tracking a download (it was already active on load, or the
|
||||
// user just clicked "Скачать"), the query stays enabled for good --
|
||||
|
|
@ -64,44 +66,47 @@ function DownloadButton({ video }: Props) {
|
|||
if (status === 'completed') {
|
||||
return (
|
||||
<span className="download-inline-actions">
|
||||
<span className="download-badge completed">✓ На сервере</span>
|
||||
<button
|
||||
<span className="download-badge completed"><Icon name="check" size={15} /> На сервере</span>
|
||||
{showDelete && <button className="button-quiet"
|
||||
onClick={() => {
|
||||
if (confirm('Удалить скачанную копию с сервера?')) {
|
||||
deleteMutation.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
>Удалить копию</button>}
|
||||
{deleteMutation.isError && (
|
||||
<span className="download-badge error" title={(deleteMutation.error as Error).message}>
|
||||
{(deleteMutation.error as Error).message}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'downloading') {
|
||||
return <span className="download-badge">{percent ?? 0}%</span>
|
||||
return <span className="download-badge downloading"><span className="download-progress" style={{ width: `${Math.min(100, Math.max(0, percent ?? 0))}%` }} />Скачивается {percent ?? 0}%</span>
|
||||
}
|
||||
|
||||
if (status === 'queued' || status === 'postprocessing') {
|
||||
return <span className="download-badge">{STATUS_LABELS[status]}</span>
|
||||
return <span className="download-badge downloading">{STATUS_LABELS[status]}</span>
|
||||
}
|
||||
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<span className="download-error">
|
||||
<span className="download-badge error">Ошибка</span>
|
||||
<button onClick={() => downloadMutation.mutate()} disabled={downloadMutation.isPending}>
|
||||
<button className="button-secondary" onClick={() => downloadMutation.mutate()} disabled={downloadMutation.isPending}>
|
||||
Повторить
|
||||
</button>
|
||||
{downloadMutation.isError && <span className="sr-only" role="alert">Не удалось повторить загрузку.</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'unknown') return <button className="button-secondary" onClick={() => downloadMutation.mutate()} disabled={downloadMutation.isPending}><Icon name="refresh" size={16} /> Загрузить снова</button>
|
||||
|
||||
return (
|
||||
<button onClick={() => downloadMutation.mutate()} disabled={downloadMutation.isPending}>
|
||||
⬇ Скачать
|
||||
</button>
|
||||
<span className="download-inline-actions"><button className="button-secondary" onClick={() => downloadMutation.mutate()} disabled={downloadMutation.isPending}><Icon name="download" size={16} />{downloadMutation.isPending ? 'Добавляем…' : 'Скачать'}</button>{downloadMutation.isError && <span className="download-action-error" role="alert">Не удалось добавить видео в очередь.</span>}</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
32
frontend/src/components/Icon.tsx
Normal file
32
frontend/src/components/Icon.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { SVGProps } from 'react'
|
||||
|
||||
export type IconName = 'menu' | 'close' | 'play' | 'download' | 'home' | 'folder' | 'server' | 'users' | 'settings' | 'search' | 'refresh' | 'plus' | 'check' | 'chevron' | 'arrow' | 'alert' | 'logout' | 'youtube' | 'edit' | 'trash' | 'up' | 'down'
|
||||
|
||||
const paths: Record<IconName, React.ReactNode> = {
|
||||
menu: <><path d="M4 6h16M4 12h16M4 18h16" /></>,
|
||||
close: <><path d="M6 6l12 12M18 6L6 18" /></>,
|
||||
play: <><path d="m8 5 11 7-11 7V5Z" /></>,
|
||||
download: <><path d="M12 3v12m-4-4 4 4 4-4M4 17v3h16v-3" /></>,
|
||||
home: <><path d="m3 10 9-7 9 7v10H3V10Z" /><path d="M9 20v-7h6v7" /></>,
|
||||
folder: <><path d="M3 5h7l2 2h9v12H3V5Z" /></>,
|
||||
server: <><rect x="3" y="4" width="18" height="7" rx="2" /><rect x="3" y="13" width="18" height="7" rx="2" /><path d="M7 7.5h.01M7 16.5h.01" /></>,
|
||||
users: <><circle cx="9" cy="8" r="3" /><path d="M3 20v-2a6 6 0 0 1 12 0v2H3ZM17 5a3 3 0 0 1 0 6m1 4a5 5 0 0 1 3 5" /></>,
|
||||
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l-1.86 1.86A1.7 1.7 0 0 0 16 18.4a1.7 1.7 0 0 0-1.5 1.6h-5A1.7 1.7 0 0 0 8 18.4a1.7 1.7 0 0 0-1.88.34l-1.86-1.86A1.7 1.7 0 0 0 4.6 15 1.7 1.7 0 0 0 3 13.5v-3A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.88l1.86-1.86A1.7 1.7 0 0 0 8 5.6 1.7 1.7 0 0 0 9.5 4h5A1.7 1.7 0 0 0 16 5.6a1.7 1.7 0 0 0 1.88-.34l1.86 1.86A1.7 1.7 0 0 0 19.4 9a1.7 1.7 0 0 0 1.6 1.5v3a1.7 1.7 0 0 0-1.6 1.5Z" /></>,
|
||||
search: <><circle cx="10.8" cy="10.8" r="6.8" /><path d="m16 16 5 5" /></>,
|
||||
refresh: <><path d="M20 7v5h-5M4 17v-5h5" /><path d="M5 9a8 8 0 0 1 14-2l1 5M4 12l1 5a8 8 0 0 0 14-2" /></>,
|
||||
plus: <><path d="M12 4v16M4 12h16" /></>,
|
||||
check: <><path d="m4 12 5 5L20 6" /></>,
|
||||
chevron: <><path d="m9 6 6 6-6 6" /></>,
|
||||
arrow: <><path d="m14 6-6 6 6 6M8 12h13" /></>,
|
||||
alert: <><path d="M12 3 2 21h20L12 3ZM12 9v5m0 3h.01" /></>,
|
||||
logout: <><path d="M10 4H4v16h6M14 8l4 4-4 4M9 12h9" /></>,
|
||||
youtube: <><rect x="2" y="5" width="20" height="14" rx="4" /><path d="m10 9 5 3-5 3V9Z" /></>,
|
||||
edit: <><path d="m4 16 11-11 4 4L8 20H4v-4ZM13 7l4 4" /></>,
|
||||
trash: <><path d="M4 7h16M9 7V4h6v3M6 7l1 14h10l1-14M10 11v6m4-6v6" /></>,
|
||||
up: <><path d="m6 15 6-6 6 6" /></>,
|
||||
down: <><path d="m6 9 6 6 6-6" /></>,
|
||||
}
|
||||
|
||||
export default function Icon({ name, size = 20, ...props }: SVGProps<SVGSVGElement> & { name: IconName; size?: number }) {
|
||||
return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>{paths[name]}</svg>
|
||||
}
|
||||
|
|
@ -25,7 +25,6 @@ function Player({ video }: Props) {
|
|||
return (
|
||||
<>
|
||||
<div className="player-wrapper">
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video
|
||||
controls
|
||||
src={video.local.media_url!}
|
||||
|
|
@ -35,7 +34,7 @@ function Player({ video }: Props) {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="video-page-source">● Локальная копия · mediaVM</div>
|
||||
<div className="video-page-source">Источник: локальная копия</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import type { FeedVideoDto } from '../api/client'
|
||||
import { formatDuration, formatRelativeTime } from '../utils/format'
|
||||
import DownloadButton from './DownloadButton'
|
||||
import Icon from './Icon'
|
||||
|
||||
interface Props {
|
||||
video: FeedVideoDto
|
||||
|
|
@ -10,28 +11,27 @@ interface Props {
|
|||
function VideoCard({ video }: Props) {
|
||||
const duration = formatDuration(video.duration_seconds)
|
||||
const href = `/video/${video.youtube_video_id}`
|
||||
const location = useLocation()
|
||||
const from = location.pathname + location.search
|
||||
const rememberScroll = () => sessionStorage.setItem(`feed-scroll:${from}`, String(window.scrollY))
|
||||
|
||||
return (
|
||||
<li className="video-card">
|
||||
<Link to={href} className="video-thumbnail">
|
||||
<Link to={href} state={{ from }} onClick={rememberScroll} className="video-thumbnail" aria-label={`Смотреть: ${video.title}`}>
|
||||
{video.thumbnail_url ? (
|
||||
<img src={video.thumbnail_url} alt={video.title} />
|
||||
<img src={video.thumbnail_url} alt="" loading="lazy" />
|
||||
) : (
|
||||
<div className="video-thumbnail-placeholder" />
|
||||
)}
|
||||
{duration && <span className="duration-badge">{duration}</span>}
|
||||
</Link>
|
||||
<div className="video-info">
|
||||
<Link to={href} className="video-title" title={video.title}>
|
||||
<Link to={href} state={{ from }} onClick={rememberScroll} className="video-title" title={video.title}>
|
||||
{video.title}
|
||||
</Link>
|
||||
<div className="video-meta">
|
||||
{video.channel.title} · {formatRelativeTime(video.published_at)}
|
||||
</div>
|
||||
<div className="video-meta"><span className="video-channel">{video.channel.title}</span><span>{formatRelativeTime(video.published_at)}</span></div>
|
||||
<div className="video-actions">
|
||||
<Link to={href} className="button-link">
|
||||
Смотреть
|
||||
</Link>
|
||||
<Link to={href} state={{ from }} onClick={rememberScroll} className="button-link"><Icon name="play" size={16} /> Смотреть</Link>
|
||||
<DownloadButton video={video} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,111 +1,32 @@
|
|||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-family: Inter, Roboto, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #f1f1f1;
|
||||
background: #0f0f0f;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
--bg: #0f0f0f;
|
||||
--surface: #181818;
|
||||
--surface-raised: #202020;
|
||||
--hover: #292929;
|
||||
--border: #303030;
|
||||
--text: #f1f1f1;
|
||||
--muted: #aaa;
|
||||
--subtle: #777;
|
||||
--accent: #3ea6ff;
|
||||
--accent-soft: rgba(62, 166, 255, .14);
|
||||
--success: #4bc467;
|
||||
--success-soft: rgba(75, 196, 103, .13);
|
||||
--warning: #f5a623;
|
||||
--error: #ff6b68;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { margin: 0; min-width: 320px; }
|
||||
button, input { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; opacity: .55; }
|
||||
a { color: inherit; }
|
||||
img { max-width: 100%; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
::selection { background: var(--accent); color: #06111c; }
|
||||
|
|
|
|||
|
|
@ -1,145 +1,45 @@
|
|||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
createCategory,
|
||||
deleteCategory,
|
||||
listCategories,
|
||||
reorderCategories,
|
||||
updateCategory,
|
||||
} from '../api/client'
|
||||
import { createCategory, deleteCategory, listCategories, reorderCategories, updateCategory } from '../api/client'
|
||||
import Icon from '../components/Icon'
|
||||
|
||||
function Categories() {
|
||||
const queryClient = useQueryClient()
|
||||
const [newName, setNewName] = useState('')
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [editingName, setEditingName] = useState('')
|
||||
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const cancelDeleteRef = useRef<HTMLButtonElement>(null)
|
||||
useEffect(() => { if (deletingId !== null) cancelDeleteRef.current?.focus() }, [deletingId])
|
||||
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['categories'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||
}
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => createCategory(name),
|
||||
onSuccess: () => {
|
||||
setNewName('')
|
||||
invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, name }: { id: number; name: string }) => updateCategory(id, name),
|
||||
onSuccess: () => {
|
||||
setEditingId(null)
|
||||
invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => deleteCategory(id),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (ids: number[]) => reorderCategories(ids),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
|
||||
const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['categories'] }); queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }) }
|
||||
const createMutation = useMutation({ mutationFn: createCategory, onSuccess: () => { setNewName(''); invalidate() } })
|
||||
const updateMutation = useMutation({ mutationFn: ({ id, name }: { id: number; name: string }) => updateCategory(id, name), onSuccess: () => { setEditingId(null); invalidate() } })
|
||||
const deleteMutation = useMutation({ mutationFn: deleteCategory, onSuccess: () => { setDeletingId(null); invalidate() } })
|
||||
const reorderMutation = useMutation({ mutationFn: reorderCategories, onSuccess: invalidate })
|
||||
const categories = categoriesQuery.data ?? []
|
||||
|
||||
const deleting = categories.find((category) => category.id === deletingId)
|
||||
function move(index: number, direction: -1 | 1) {
|
||||
const target = index + direction
|
||||
if (target < 0 || target >= categories.length) return
|
||||
const ids = categories.map((c) => c.id)
|
||||
const ids = categories.map((category) => category.id)
|
||||
;[ids[index], ids[target]] = [ids[target], ids[index]]
|
||||
reorderMutation.mutate(ids)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="categories-screen">
|
||||
<Link to="/" className="back-link">
|
||||
← Каналы
|
||||
</Link>
|
||||
<h1>Категории</h1>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (newName.trim()) createMutation.mutate(newName.trim())
|
||||
}}
|
||||
className="category-form"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Новая категория"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={!newName.trim() || createMutation.isPending}>
|
||||
Создать
|
||||
</button>
|
||||
</form>
|
||||
{createMutation.isError && <p className="error">{(createMutation.error as Error).message}</p>}
|
||||
|
||||
<ul className="category-list">
|
||||
{categories.map((category, index) => (
|
||||
<li key={category.id} className="category-row">
|
||||
<div className="reorder-buttons">
|
||||
<button onClick={() => move(index, -1)} disabled={index === 0} aria-label="Выше">
|
||||
↑
|
||||
</button>
|
||||
<button onClick={() => move(index, 1)} disabled={index === categories.length - 1} aria-label="Ниже">
|
||||
↓
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editingId === category.id ? (
|
||||
<form
|
||||
className="category-edit-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (editingName.trim()) updateMutation.mutate({ id: category.id, name: editingName.trim() })
|
||||
}}
|
||||
>
|
||||
<input value={editingName} onChange={(e) => setEditingName(e.target.value)} autoFocus />
|
||||
<button type="submit">Сохранить</button>
|
||||
<button type="button" onClick={() => setEditingId(null)}>
|
||||
Отмена
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<span className="category-name">{category.name}</span>
|
||||
<span className="category-count">{category.channel_count} канал(ов)</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingId(category.id)
|
||||
setEditingName(category.name)
|
||||
}}
|
||||
>
|
||||
Переименовать
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Удалить категорию "${category.name}"? Каналы останутся.`)) {
|
||||
deleteMutation.mutate(category.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{categories.length === 0 && !categoriesQuery.isLoading && <p>Категорий пока нет.</p>}
|
||||
</div>
|
||||
)
|
||||
return <section className="page categories-screen">
|
||||
<Link to="/settings" className="back-link"><Icon name="arrow" size={17} /> Настройки</Link>
|
||||
<div className="page-heading"><div><p className="eyebrow">Моя лента</p><h1>Категории</h1><p className="page-subtitle">Создавай темы и меняй порядок в меню.</p></div></div>
|
||||
<form className="category-form" onSubmit={(event) => { event.preventDefault(); if (newName.trim()) createMutation.mutate(newName.trim()) }}><label className="sr-only" htmlFor="category-name">Название новой категории</label><input id="category-name" placeholder="Название новой категории" value={newName} onChange={(event) => setNewName(event.target.value)} /><button type="submit" className="button-primary" disabled={!newName.trim() || createMutation.isPending}><Icon name="plus" size={17} /> Создать</button></form>
|
||||
{createMutation.isError && <p className="error" role="alert">Не удалось создать категорию. Возможно, такое имя уже есть.</p>}
|
||||
{reorderMutation.isError && <p className="error" role="alert">Не удалось изменить порядок категорий.</p>}
|
||||
{categoriesQuery.isLoading && <div className="channel-loading">{Array.from({ length: 3 }, (_, index) => <div key={index} className="channel-skeleton"><span /><i /></div>)}</div>}
|
||||
{categoriesQuery.isError && <div className="empty-state" role="alert"><h2>Не удалось загрузить категории</h2><button className="button-primary" onClick={() => categoriesQuery.refetch()}>Повторить</button></div>}
|
||||
{categories.length > 0 && <ul className="category-list">{categories.map((category, index) => <li key={category.id} className="category-row"><div className="reorder-buttons"><button type="button" className="icon-button" onClick={() => move(index, -1)} disabled={index === 0 || reorderMutation.isPending} aria-label={`Поднять ${category.name}`}><Icon name="up" size={18} /></button><button type="button" className="icon-button" onClick={() => move(index, 1)} disabled={index === categories.length - 1 || reorderMutation.isPending} aria-label={`Опустить ${category.name}`}><Icon name="down" size={18} /></button></div>{editingId === category.id ? <form className="category-edit-form" onSubmit={(event) => { event.preventDefault(); if (editingName.trim()) updateMutation.mutate({ id: category.id, name: editingName.trim() }) }}><input aria-label="Новое название категории" value={editingName} onChange={(event) => setEditingName(event.target.value)} autoFocus /><button className="button-primary" type="submit" disabled={!editingName.trim() || updateMutation.isPending}>Сохранить</button><button className="button-quiet" type="button" onClick={() => setEditingId(null)}>Отмена</button></form> : <><div className="category-row-label"><span className="category-dot" /><span className="category-name">{category.name}</span><span className="category-count">{category.channel_count} каналов</span></div><div className="category-row-actions"><button type="button" className="icon-button" aria-label={`Переименовать ${category.name}`} title="Переименовать" onClick={() => { setEditingId(category.id); setEditingName(category.name) }}><Icon name="edit" size={18} /></button><button type="button" className="icon-button danger-hover" aria-label={`Удалить ${category.name}`} title="Удалить" onClick={() => setDeletingId(category.id)}><Icon name="trash" size={18} /></button></div></>}</li>)}</ul>}
|
||||
{updateMutation.isError && <p className="error" role="alert">Не удалось переименовать категорию.</p>}
|
||||
{!categoriesQuery.isLoading && !categoriesQuery.isError && categories.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name="folder" size={28} /></span><h2>Категорий пока нет</h2><p>Создай первую тему и добавь в неё каналы.</p></div>}
|
||||
{deleting && <div className="modal-backdrop" onClick={() => setDeletingId(null)} onKeyDown={(event) => { if (event.key === 'Escape') setDeletingId(null) }}><div className="confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="delete-category-title" aria-describedby="delete-category-description" onClick={(event) => event.stopPropagation()}><h2 id="delete-category-title">Удалить «{deleting.name}»?</h2><p id="delete-category-description">Каналы останутся в подписках. Они просто больше не будут относиться к этой категории.</p>{deleteMutation.isError && <p className="error" role="alert">Не удалось удалить категорию.</p>}<div className="dialog-actions"><button ref={cancelDeleteRef} className="button-secondary" onClick={() => setDeletingId(null)}>Отмена</button><button className="button-danger" onClick={() => deleteMutation.mutate(deleting.id)} disabled={deleteMutation.isPending}>Удалить категорию</button></div></div></div>}
|
||||
</section>
|
||||
}
|
||||
|
||||
export default Categories
|
||||
|
|
|
|||
|
|
@ -1,56 +1,26 @@
|
|||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { getChannel, getFeed } from '../api/client'
|
||||
import Icon from '../components/Icon'
|
||||
import VideoCard from '../components/VideoCard'
|
||||
import { formatSubscriberCount } from '../utils/format'
|
||||
|
||||
function ChannelVideos() {
|
||||
const { channelId } = useParams<{ channelId: string }>()
|
||||
const id = Number(channelId)
|
||||
|
||||
const channelQuery = useQuery({ queryKey: ['channel', id], queryFn: () => getChannel(id) })
|
||||
|
||||
const feedQuery = useInfiniteQuery({
|
||||
queryKey: ['feed', 'channel', id],
|
||||
queryFn: ({ pageParam }) => getFeed({ channelId: id, cursor: pageParam }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
|
||||
})
|
||||
|
||||
const valid = Number.isInteger(id) && id > 0
|
||||
const channelQuery = useQuery({ queryKey: ['channel', id], queryFn: () => getChannel(id), enabled: valid })
|
||||
const feedQuery = useInfiniteQuery({ queryKey: ['feed', 'channel', id], queryFn: ({ pageParam }) => getFeed({ channelId: id, limit: 20, cursor: pageParam }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, enabled: valid })
|
||||
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
|
||||
|
||||
return (
|
||||
<div className="feed-screen">
|
||||
<header>
|
||||
<div>
|
||||
<Link to="/channels" className="back-link">
|
||||
← Каналы
|
||||
</Link>
|
||||
<h1>{channelQuery.data?.title ?? 'Канал'}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{feedQuery.isLoading && <p>Загрузка...</p>}
|
||||
{feedQuery.isError && <p className="error">Не удалось загрузить видео</p>}
|
||||
|
||||
<ul className="video-grid">
|
||||
{items.map((video) => (
|
||||
<VideoCard key={video.youtube_video_id} video={video} />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{!feedQuery.isLoading && items.length === 0 && <p>У этого канала пока нет синхронизированных видео.</p>}
|
||||
|
||||
{feedQuery.hasNextPage && (
|
||||
<button
|
||||
className="load-more"
|
||||
onClick={() => feedQuery.fetchNextPage()}
|
||||
disabled={feedQuery.isFetchingNextPage}
|
||||
>
|
||||
{feedQuery.isFetchingNextPage ? 'Загрузка...' : 'Показать ещё'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
const subscriberLabel = channelQuery.data ? formatSubscriberCount(channelQuery.data.subscriber_count) : null
|
||||
return <section className="page">
|
||||
<Link to="/channels" className="back-link"><Icon name="arrow" size={17} /> Каналы</Link>
|
||||
<div className="page-heading"><div><p className="eyebrow">Видео канала</p><h1>{channelQuery.data?.title ?? 'Канал'}</h1><p className="page-subtitle">{subscriberLabel ?? 'Последние ролики из твоих подписок.'}</p></div></div>
|
||||
{(channelQuery.isError || feedQuery.isError || !valid) && <div className="empty-state" role="alert"><h2>Не удалось открыть канал</h2><Link to="/channels" className="button-primary">К списку каналов</Link></div>}
|
||||
{feedQuery.isLoading && <div className="video-grid">{Array.from({ length: 6 }, (_, index) => <div className="video-card" key={index}><div className="skeleton-thumbnail" /><div className="video-info"><div className="skeleton-line wide" /><div className="skeleton-line" /></div></div>)}</div>}
|
||||
{!feedQuery.isLoading && !feedQuery.isError && items.length > 0 && <ul className="video-grid">{items.map((video) => <VideoCard key={video.youtube_video_id} video={video} />)}</ul>}
|
||||
{!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><h2>Пока нет видео</h2><p>Обнови ленту, чтобы получить новые ролики канала.</p></div>}
|
||||
{feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
|
||||
</section>
|
||||
}
|
||||
|
||||
export default ChannelVideos
|
||||
|
|
|
|||
|
|
@ -1,106 +1,49 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getSyncStatus, listCategories, listChannels, logout, syncSubscriptions } from '../api/client'
|
||||
import { getSyncStatus, listCategories, listChannels, syncSubscriptions } from '../api/client'
|
||||
import ChannelCard from '../components/ChannelCard'
|
||||
import CategoryNav, { type CategoryFilter } from '../components/CategoryNav'
|
||||
import Icon from '../components/Icon'
|
||||
|
||||
function Channels() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState<CategoryFilter>('all')
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
|
||||
|
||||
const allChannelsQuery = useQuery({ queryKey: ['channels', 'all-for-counts'], queryFn: () => listChannels({ subscribed: true }) })
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: ['channels', filter],
|
||||
queryFn: () =>
|
||||
listChannels({
|
||||
categoryId: typeof filter === 'number' ? filter : undefined,
|
||||
uncategorized: filter === 'uncategorized',
|
||||
}),
|
||||
queryFn: () => listChannels({ subscribed: true, categoryId: typeof filter === 'number' ? filter : undefined, uncategorized: filter === 'uncategorized' }),
|
||||
})
|
||||
|
||||
const filteredChannels = useMemo(() => {
|
||||
const data = channelsQuery.data ?? []
|
||||
if (!search.trim()) return data
|
||||
const needle = search.trim().toLowerCase()
|
||||
return data.filter((c) => c.title.toLowerCase().includes(needle))
|
||||
}, [channelsQuery.data, search])
|
||||
|
||||
const syncStatusQuery = useQuery({
|
||||
queryKey: ['sync-status'],
|
||||
queryFn: getSyncStatus,
|
||||
refetchInterval: (query) => (query.state.data?.subscriptions.running ? 1500 : false),
|
||||
})
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: syncSubscriptions,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['sync-status'] })
|
||||
},
|
||||
})
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: logout,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['auth-status'] })
|
||||
},
|
||||
})
|
||||
|
||||
const running = syncStatusQuery.data?.subscriptions.running
|
||||
const syncStatusQuery = useQuery({ queryKey: ['sync-status'], queryFn: getSyncStatus, refetchInterval: (query) => query.state.data?.subscriptions.running ? 2000 : 60_000 })
|
||||
const syncMutation = useMutation({ mutationFn: syncSubscriptions, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['sync-status'] }) } })
|
||||
const finishedAt = syncStatusQuery.data?.subscriptions.finished_at
|
||||
const previousFinished = useRef(finishedAt)
|
||||
useEffect(() => {
|
||||
if (finishedAt && previousFinished.current !== finishedAt) queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||
previousFinished.current = finishedAt
|
||||
}, [finishedAt, queryClient])
|
||||
const allChannels = allChannelsQuery.data ?? []
|
||||
const uncategorizedCount = allChannels.filter((channel) => channel.category_ids.length === 0).length
|
||||
const categories = categoriesQuery.data ?? []
|
||||
const filteredChannels = useMemo(() => {
|
||||
const needle = search.trim().toLocaleLowerCase()
|
||||
return (channelsQuery.data ?? []).filter((channel) => channel.title.toLocaleLowerCase().includes(needle))
|
||||
}, [channelsQuery.data, search])
|
||||
const running = syncStatusQuery.data?.subscriptions.running
|
||||
|
||||
return (
|
||||
<div className="channels-screen">
|
||||
<header>
|
||||
<h1>Каналы</h1>
|
||||
<div className="actions">
|
||||
<Link to="/">Лента</Link>
|
||||
<Link to="/categories">Категории</Link>
|
||||
<button onClick={() => syncMutation.mutate()} disabled={running}>
|
||||
{running ? 'Синхронизация...' : 'Обновить сейчас'}
|
||||
</button>
|
||||
<button onClick={() => logoutMutation.mutate()}>Выйти</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{syncMutation.isError && <p className="error">{(syncMutation.error as Error).message}</p>}
|
||||
{syncStatusQuery.data?.subscriptions.status === 'failed' && (
|
||||
<p className="error">Последняя синхронизация завершилась ошибкой: {syncStatusQuery.data.subscriptions.error}</p>
|
||||
)}
|
||||
|
||||
<div className="layout">
|
||||
<CategoryNav categories={categories} value={filter} onChange={setFilter} />
|
||||
|
||||
<div className="content">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по названию канала"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
{channelsQuery.isLoading && <p>Загрузка...</p>}
|
||||
{channelsQuery.isError && <p className="error">Не удалось загрузить каналы</p>}
|
||||
|
||||
<ul className="channel-list">
|
||||
{filteredChannels.map((channel) => (
|
||||
<ChannelCard key={channel.id} channel={channel} categories={categories} />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{channelsQuery.data && channelsQuery.data.length === 0 && (
|
||||
<p>Каналы не найдены{filter === 'all' ? '. Нажми "Обновить сейчас".' : ' в этой категории.'}</p>
|
||||
)}
|
||||
{channelsQuery.data && channelsQuery.data.length > 0 && filteredChannels.length === 0 && (
|
||||
<p>Ничего не найдено по запросу "{search}".</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <section className="page channels-screen">
|
||||
<div className="page-heading"><div><p className="eyebrow">Мои подписки</p><h1>Каналы</h1><p className="page-subtitle">Распределяй подписки по темам, чтобы лента была удобнее.</p></div><button className="button-secondary refresh-button" onClick={() => syncMutation.mutate()} disabled={running || syncMutation.isPending}><Icon name="refresh" size={17} className={running ? 'spin' : ''} />{running ? 'Обновляется' : 'Обновить'}</button></div>
|
||||
{syncMutation.isError && <div className="notice error-notice" role="alert"><Icon name="alert" size={18} />Не удалось запустить обновление подписок.</div>}
|
||||
{syncStatusQuery.data?.subscriptions.status === 'failed' && <div className="notice error-notice" role="status"><Icon name="alert" size={18} />Последнее обновление подписок завершилось ошибкой.</div>}
|
||||
<div className="channels-toolbar"><label className="input-search"><Icon name="search" size={19} /><input type="search" placeholder="Поиск каналов" aria-label="Поиск каналов" value={search} onChange={(event) => setSearch(event.target.value)} /></label><Link className="button-secondary manage-link" to="/settings/categories"><Icon name="folder" size={17} />Управлять категориями</Link></div>
|
||||
<CategoryNav categories={categories} value={filter} onChange={setFilter} allCount={allChannels.length} uncategorizedCount={uncategorizedCount} />
|
||||
{channelsQuery.isLoading && <div className="channel-loading" aria-label="Загрузка каналов">{Array.from({ length: 6 }, (_, index) => <div key={index} className="channel-skeleton"><span /><i /></div>)}</div>}
|
||||
{channelsQuery.isError && <div className="empty-state" role="alert"><h2>Не удалось загрузить каналы</h2><button className="button-primary" onClick={() => channelsQuery.refetch()}>Повторить</button></div>}
|
||||
{!channelsQuery.isLoading && !channelsQuery.isError && filteredChannels.length > 0 && <ul className="channel-list">{filteredChannels.map((channel) => <ChannelCard key={channel.id} channel={channel} categories={categories} />)}</ul>}
|
||||
{!channelsQuery.isLoading && !channelsQuery.isError && filteredChannels.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name={running ? 'refresh' : 'users'} size={28} /></span><h2>{running ? 'Импортируем каналы' : search ? 'Каналы не найдены' : filter === 'uncategorized' ? 'Все каналы распределены' : 'Здесь пока нет каналов'}</h2><p>{running ? 'Список подписок появится здесь автоматически.' : search ? 'Попробуй другое название.' : filter === 'all' ? 'Обнови подписки, чтобы импортировать свои каналы.' : 'Выбери другую категорию или назначь её каналам.'}</p></div>}
|
||||
</section>
|
||||
}
|
||||
|
||||
export default Channels
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import { useSearchParams } from 'react-router-dom'
|
||||
import { authErrorMessage, startGoogleAuth } from '../api/client'
|
||||
import Icon from '../components/Icon'
|
||||
|
||||
function Connect() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const authError = searchParams.get('auth_error')
|
||||
|
||||
return (
|
||||
<div className="connect-screen">
|
||||
<h1>MyYouTube</h1>
|
||||
<p>Персональная лента YouTube-подписок с категориями.</p>
|
||||
<div className="connect-screen"><div className="connect-card">
|
||||
<span className="brand-mark"><Icon name="play" size={26} /></span>
|
||||
<h1>Твоя лента, твои темы</h1>
|
||||
<p>Подключи YouTube, чтобы видеть видео своих подписок и распределять каналы по категориям. Доступен только твой аккаунт.</p>
|
||||
{authError && <p className="error">{authErrorMessage(authError)}</p>}
|
||||
<button onClick={startGoogleAuth}>Подключить YouTube</button>
|
||||
</div>
|
||||
<button className="button-primary" onClick={startGoogleAuth}><Icon name="youtube" size={18} /> Подключить YouTube</button>
|
||||
</div></div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,102 +1,110 @@
|
|||
import { useMutation, useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getFeed, getSyncStatus, listCategories, logout, syncVideos } from '../api/client'
|
||||
import CategoryNav, { type CategoryFilter } from '../components/CategoryNav'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { getFeed, getSavedCounts, getSyncStatus, listCategories, listChannels, syncVideos } from '../api/client'
|
||||
import Icon from '../components/Icon'
|
||||
import VideoCard from '../components/VideoCard'
|
||||
import { useState } from 'react'
|
||||
|
||||
function VideoSkeleton() {
|
||||
return <li className="video-card" aria-hidden="true"><div className="skeleton-thumbnail" /><div className="video-info"><div className="skeleton-line wide" /><div className="skeleton-line" /><div className="skeleton-line short" /></div></li>
|
||||
}
|
||||
|
||||
function Feed() {
|
||||
const [filter, setFilter] = useState<CategoryFilter>('all')
|
||||
const location = useLocation()
|
||||
const { categoryId } = useParams<{ categoryId: string }>()
|
||||
const [searchParams] = useSearchParams()
|
||||
const search = location.pathname === '/search' ? searchParams.get('q')?.trim() ?? '' : ''
|
||||
const categoryNumber = categoryId && /^\d+$/.test(categoryId) ? Number(categoryId) : undefined
|
||||
const isLocal = location.pathname === '/local'
|
||||
const isUncategorized = location.pathname === '/uncategorized'
|
||||
const newOnly = categoryNumber !== undefined && searchParams.get('new') === '1'
|
||||
const localCategoryRaw = isLocal ? searchParams.get('category') : null
|
||||
const localCategory = localCategoryRaw && /^\d+$/.test(localCategoryRaw) ? Number(localCategoryRaw) : undefined
|
||||
const localUncategorized = isLocal && searchParams.get('uncategorized') === 'true'
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
|
||||
const categories = categoriesQuery.data ?? []
|
||||
|
||||
const category = categoriesQuery.data?.find((item) => item.id === categoryNumber)
|
||||
const savedCountsQuery = useQuery({ queryKey: ['feed', 'saved-counts'], queryFn: getSavedCounts, enabled: isLocal })
|
||||
const countQuery = useQuery({
|
||||
queryKey: ['channels', isUncategorized ? 'uncategorized-count' : 'all-for-counts'],
|
||||
queryFn: () => listChannels({ subscribed: true, uncategorized: isUncategorized }),
|
||||
enabled: isUncategorized || (!categoryNumber && !isLocal && !search),
|
||||
})
|
||||
const feedQuery = useInfiniteQuery({
|
||||
queryKey: ['feed', filter],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getFeed({
|
||||
categoryId: typeof filter === 'number' ? filter : undefined,
|
||||
uncategorized: filter === 'uncategorized',
|
||||
queryKey: ['feed', location.pathname, search, localCategory, localUncategorized, newOnly],
|
||||
queryFn: ({ pageParam }) => getFeed({
|
||||
categoryId: isLocal ? localCategory : categoryNumber,
|
||||
uncategorized: isUncategorized || localUncategorized,
|
||||
downloaded: isLocal,
|
||||
search,
|
||||
newOnly,
|
||||
cursor: pageParam,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
|
||||
enabled: !categoryId || categoryNumber !== undefined,
|
||||
})
|
||||
|
||||
const syncStatusQuery = useQuery({
|
||||
queryKey: ['sync-status'],
|
||||
queryFn: getSyncStatus,
|
||||
refetchInterval: (query) => (query.state.data?.videos.running ? 1500 : false),
|
||||
queryKey: ['sync-status'], queryFn: getSyncStatus,
|
||||
refetchInterval: (query) => query.state.data?.videos.running ? 2000 : 60_000,
|
||||
})
|
||||
|
||||
const finishedAt = syncStatusQuery.data?.videos.finished_at
|
||||
const previousFinished = useRef(finishedAt)
|
||||
useEffect(() => {
|
||||
if (finishedAt && previousFinished.current !== finishedAt) queryClient.invalidateQueries({ queryKey: ['feed'] })
|
||||
previousFinished.current = finishedAt
|
||||
}, [finishedAt, queryClient])
|
||||
const subscriptionsFinished = syncStatusQuery.data?.subscriptions.finished_at
|
||||
const previousSubscriptionsFinished = useRef(subscriptionsFinished)
|
||||
useEffect(() => {
|
||||
if (subscriptionsFinished && previousSubscriptionsFinished.current !== subscriptionsFinished) queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||
previousSubscriptionsFinished.current = subscriptionsFinished
|
||||
}, [subscriptionsFinished, queryClient])
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: syncVideos,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['sync-status'] })
|
||||
},
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['sync-status'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }) },
|
||||
})
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: logout,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['auth-status'] })
|
||||
},
|
||||
})
|
||||
|
||||
const running = syncStatusQuery.data?.videos.running
|
||||
const title = search ? `Поиск: ${search}` : isLocal ? 'На сервере' : isUncategorized ? 'Без категории' : categoryId ? category?.name ?? 'Категория' : 'Все видео'
|
||||
const subtitle = newOnly ? 'Только новые видео' : search ? 'Результаты в вашей ленте' : isLocal ? 'Видео, которые вы сохранили для просмотра' : isUncategorized ? `${countQuery.data?.length ?? '…'} каналов пока без категории` : category ? `${category.channel_count} каналов в категории` : `${countQuery.data?.length ?? '…'} подписок в вашей ленте`
|
||||
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
|
||||
useEffect(() => {
|
||||
if (feedQuery.isLoading) return
|
||||
const key = `feed-scroll:${location.pathname + location.search}`
|
||||
const saved = sessionStorage.getItem(key)
|
||||
if (saved) {
|
||||
sessionStorage.removeItem(key)
|
||||
requestAnimationFrame(() => window.scrollTo(0, Number(saved)))
|
||||
}
|
||||
}, [feedQuery.isLoading, location.pathname, location.search])
|
||||
const hasInvalidCategory = Boolean(categoryId && !categoryNumber)
|
||||
|
||||
return (
|
||||
<div className="feed-screen">
|
||||
<header>
|
||||
<h1>Лента</h1>
|
||||
<div className="actions">
|
||||
<Link to="/channels">Каналы</Link>
|
||||
<Link to="/categories">Категории</Link>
|
||||
<button onClick={() => syncMutation.mutate()} disabled={running}>
|
||||
{running ? 'Синхронизация...' : 'Обновить сейчас'}
|
||||
</button>
|
||||
<button onClick={() => logoutMutation.mutate()}>Выйти</button>
|
||||
return <section className="page">
|
||||
<div className="page-heading">
|
||||
<div><p className="eyebrow">Моя лента</p><h1>{title}</h1><p className="page-subtitle">{subtitle}{newOnly && <> · <Link to={`/category/${categoryNumber}`}>Показать все</Link></>}</p></div>
|
||||
{!isLocal && !search && <button type="button" className="button-secondary refresh-button" onClick={() => syncMutation.mutate()} disabled={syncMutation.isPending || syncStatusQuery.data?.videos.running}><Icon name="refresh" size={17} className={syncStatusQuery.data?.videos.running ? 'spin' : ''} />{syncStatusQuery.data?.videos.running ? 'Обновляется' : 'Обновить'}</button>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{syncMutation.isError && <p className="error">{(syncMutation.error as Error).message}</p>}
|
||||
{syncStatusQuery.data?.videos.status === 'failed' && (
|
||||
<p className="error">Последняя синхронизация видео завершилась ошибкой: {syncStatusQuery.data.videos.error}</p>
|
||||
)}
|
||||
|
||||
<div className="layout">
|
||||
<CategoryNav categories={categories} value={filter} onChange={setFilter} />
|
||||
|
||||
<div className="content">
|
||||
{feedQuery.isLoading && <p>Загрузка...</p>}
|
||||
{feedQuery.isError && <p className="error">Не удалось загрузить ленту</p>}
|
||||
|
||||
<ul className="video-grid">
|
||||
{items.map((video) => (
|
||||
<VideoCard key={video.youtube_video_id} video={video} />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{!feedQuery.isLoading && items.length === 0 && (
|
||||
<p>Видео пока нет. Нажми "Обновить сейчас" или проверь, есть ли каналы в этой категории.</p>
|
||||
)}
|
||||
|
||||
{feedQuery.hasNextPage && (
|
||||
<button
|
||||
className="load-more"
|
||||
onClick={() => feedQuery.fetchNextPage()}
|
||||
disabled={feedQuery.isFetchingNextPage}
|
||||
>
|
||||
{feedQuery.isFetchingNextPage ? 'Загрузка...' : 'Показать ещё'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
<nav className="mobile-category-nav" aria-label="Категории">
|
||||
<Link to="/" className={location.pathname === '/' ? 'active' : ''}>Все</Link>
|
||||
<Link to="/uncategorized" className={isUncategorized ? 'active' : ''}>Без категории</Link>
|
||||
<Link to="/local" className={isLocal ? 'active' : ''}>На сервере</Link>
|
||||
{categoriesQuery.data?.map((item) => <Link key={item.id} to={`/category/${item.id}`} className={categoryNumber === item.id ? 'active' : ''}>{item.name}</Link>)}
|
||||
</nav>
|
||||
{isLocal && <nav className="local-filter-nav" aria-label="Фильтр сохранённых видео">
|
||||
<Link to="/local" className={!localCategory && !localUncategorized ? 'active' : ''}>Все <span>{savedCountsQuery.data?.all ?? '…'}</span></Link>
|
||||
<Link to="/local?uncategorized=true" className={localUncategorized ? 'active' : ''}>Без категории <span>{savedCountsQuery.data?.uncategorized ?? '…'}</span></Link>
|
||||
{categoriesQuery.data?.map((item) => <Link key={item.id} to={`/local?category=${item.id}`} className={localCategory === item.id ? 'active' : ''}>{item.name} <span>{savedCountsQuery.data?.categories[String(item.id)] ?? 0}</span></Link>)}
|
||||
</nav>}
|
||||
{syncMutation.isError && <div className="notice error-notice" role="alert"><Icon name="alert" size={18} />Не удалось запустить обновление видео.</div>}
|
||||
{syncStatusQuery.data?.videos.status === 'failed' && <div className="notice error-notice" role="status"><Icon name="alert" size={18} />Последнее обновление видео завершилось ошибкой. Попробуйте позже.</div>}
|
||||
{location.pathname === '/' && (countQuery.data?.length ?? 0) > 0 && categoriesQuery.data?.length === 0 && <div className="notice onboarding-notice"><Icon name="folder" size={19} /><span>Каналы уже в ленте. Разложи их по темам, чтобы быстрее находить видео.</span><Link to="/channels">Настроить категории <Icon name="chevron" size={15} /></Link></div>}
|
||||
{hasInvalidCategory || (categoryId && categoriesQuery.data && !category) ? <div className="empty-state"><h2>Категория не найдена</h2><Link className="button-primary" to="/">Ко всем видео</Link></div> : <>
|
||||
{feedQuery.isLoading && <ul className="video-grid">{Array.from({ length: 8 }, (_, index) => <VideoSkeleton key={index} />)}</ul>}
|
||||
{feedQuery.isError && <div className="empty-state" role="alert"><Icon name="alert" size={28} /><h2>Не удалось загрузить видео</h2><p>Проверь соединение и попробуй ещё раз.</p><button className="button-primary" onClick={() => feedQuery.refetch()}>Повторить</button></div>}
|
||||
{!feedQuery.isLoading && !feedQuery.isError && items.length > 0 && <ul className="video-grid">{items.map((video) => <VideoCard key={video.youtube_video_id} video={video} />)}</ul>}
|
||||
{!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name={syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running ? 'refresh' : isLocal ? 'server' : search ? 'search' : 'folder'} size={28} /></span><h2>{syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running ? 'Загружаем новые видео' : search ? 'Ничего не найдено' : isLocal ? 'Пока нет сохранённых видео' : isUncategorized ? 'Все каналы распределены' : 'Здесь пока нет видео'}</h2><p>{syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : 'Добавь каналы в категорию или обнови ленту.'}</p>{!search && !isLocal && !syncStatusQuery.data?.subscriptions.running && !syncStatusQuery.data?.videos.running && <Link className="button-primary" to="/channels">Перейти к каналам</Link>}</div>}
|
||||
{feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
|
||||
</>}
|
||||
</section>
|
||||
}
|
||||
|
||||
export default Feed
|
||||
|
|
|
|||
29
frontend/src/pages/Settings.tsx
Normal file
29
frontend/src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { getHealth, getSyncStatus, logout, startGoogleAuth, syncSubscriptions, syncVideos } from '../api/client'
|
||||
import { formatRelativeTime } from '../utils/format'
|
||||
import Icon from '../components/Icon'
|
||||
|
||||
function Settings({ email }: { email: string | null }) {
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const statusQuery = useQuery({ queryKey: ['sync-status'], queryFn: getSyncStatus, refetchInterval: 30_000 })
|
||||
const healthQuery = useQuery({ queryKey: ['health'], queryFn: getHealth, staleTime: 0 })
|
||||
const subscriptionsMutation = useMutation({ mutationFn: syncSubscriptions, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['sync-status'] }) })
|
||||
const videosMutation = useMutation({ mutationFn: syncVideos, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['sync-status'] }) })
|
||||
const logoutMutation = useMutation({ mutationFn: logout, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['auth-status'] }); navigate('/') } })
|
||||
const subscriptions = statusQuery.data?.subscriptions
|
||||
const videos = statusQuery.data?.videos
|
||||
return <section className="page settings-screen">
|
||||
<div className="page-heading"><div><p className="eyebrow">Аккаунт и сервисы</p><h1>Настройки</h1><p className="page-subtitle">Подключения и обновление вашей ленты.</p></div></div>
|
||||
<div className="settings-grid">
|
||||
<section className="settings-panel"><div className="settings-panel-heading"><span className="panel-icon"><Icon name="youtube" size={21} /></span><div><h2>YouTube</h2><p>Подписки и новые видео</p></div></div><div className="settings-detail"><span>Аккаунт</span><strong>{email ?? 'Неизвестен'}</strong></div><div className="settings-detail"><span>Статус</span><strong className="success-text"><span className="status-dot" /> Подключён</strong></div><div className="settings-detail"><span>Подписки обновлены</span><strong>{subscriptions?.finished_at ? formatRelativeTime(subscriptions.finished_at) : 'Ещё не обновлялись'}</strong></div><div className="settings-actions"><button className="button-secondary" onClick={() => subscriptionsMutation.mutate()} disabled={subscriptionsMutation.isPending || subscriptions?.running}><Icon name="refresh" size={16} />Обновить подписки</button><button className="button-quiet" onClick={startGoogleAuth}>Переподключить</button></div>{subscriptionsMutation.isError && <p className="error" role="alert">Не удалось запустить обновление.</p>}</section>
|
||||
<section className="settings-panel"><div className="settings-panel-heading"><span className="panel-icon"><Icon name="server" size={21} /></span><div><h2>Локальные видео</h2><p>Сохранённые копии</p></div></div><div className="settings-detail"><span>Соединение</span><strong className={healthQuery.data?.metube === 'ok' ? 'success-text' : 'error'}>{healthQuery.isLoading ? 'Проверяем…' : healthQuery.data?.metube === 'ok' ? 'Доступно' : 'Недоступно'}</strong></div><p className="settings-help">Скачанные видео доступны во вкладке «На сервере».</p><div className="settings-actions"><button className="button-secondary" onClick={() => healthQuery.refetch()} disabled={healthQuery.isFetching}><Icon name="refresh" size={16} />Проверить соединение</button><Link className="button-quiet" to="/local">Открыть видео</Link></div></section>
|
||||
<section className="settings-panel"><div className="settings-panel-heading"><span className="panel-icon"><Icon name="refresh" size={21} /></span><div><h2>Синхронизация</h2><p>Видео в вашей ленте</p></div></div><div className="settings-detail"><span>Видео обновлены</span><strong>{videos?.finished_at ? formatRelativeTime(videos.finished_at) : 'Ещё не обновлялись'}</strong></div><div className="settings-detail"><span>Состояние</span><strong className={videos?.status === 'failed' ? 'error' : ''}>{videos?.running ? 'Обновляются' : videos?.status === 'failed' ? 'Ошибка' : videos?.status === 'completed' ? 'Успешно' : 'Ещё не запускалась'}</strong></div><div className="settings-actions"><button className="button-secondary" onClick={() => videosMutation.mutate()} disabled={videosMutation.isPending || videos?.running}><Icon name="refresh" size={16} />Обновить видео</button></div>{videosMutation.isError && <p className="error" role="alert">Не удалось запустить обновление.</p>}</section>
|
||||
<section className="settings-panel"><div className="settings-panel-heading"><span className="panel-icon"><Icon name="folder" size={21} /></span><div><h2>Категории</h2><p>Организация каналов</p></div></div><p className="settings-help">Переименовывай категории и меняй их порядок.</p><div className="settings-actions"><Link className="button-secondary" to="/settings/categories"><Icon name="folder" size={16} />Управлять категориями</Link></div></section>
|
||||
</div>
|
||||
<button className="button-quiet logout-button" onClick={() => logoutMutation.mutate()} disabled={logoutMutation.isPending}><Icon name="logout" size={17} /> Выйти из аккаунта</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
export default Settings
|
||||
|
|
@ -1,73 +1,31 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { getVideo } from '../api/client'
|
||||
import { formatDuration, formatRelativeTime } from '../utils/format'
|
||||
import DownloadButton from '../components/DownloadButton'
|
||||
import Icon from '../components/Icon'
|
||||
import Player from '../components/Player'
|
||||
|
||||
function VideoPage() {
|
||||
const { youtubeVideoId } = useParams<{ youtubeVideoId: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const from = (location.state as { from?: string } | null)?.from
|
||||
const videoQuery = useQuery({ queryKey: ['video', youtubeVideoId], queryFn: () => getVideo(youtubeVideoId!), enabled: !!youtubeVideoId })
|
||||
const back = () => { if (from && window.history.length > 1) navigate(-1); else navigate(from ?? '/') }
|
||||
|
||||
const videoQuery = useQuery({
|
||||
queryKey: ['video', youtubeVideoId],
|
||||
queryFn: () => getVideo(youtubeVideoId!),
|
||||
enabled: !!youtubeVideoId,
|
||||
})
|
||||
|
||||
if (videoQuery.isLoading) {
|
||||
return (
|
||||
<div className="video-page">
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (videoQuery.isError || !videoQuery.data) {
|
||||
return (
|
||||
<div className="video-page">
|
||||
<Link to="/" className="back-link">
|
||||
← Лента
|
||||
</Link>
|
||||
<p className="error">Видео не найдено</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (videoQuery.isLoading) return <section className="page video-page"><div className="player-wrapper skeleton-thumbnail" /><div className="skeleton-line wide" /><div className="skeleton-line" /></section>
|
||||
if (videoQuery.isError || !videoQuery.data) return <section className="page video-page"><button className="back-link" onClick={back}><Icon name="arrow" size={17} /> Назад</button><div className="empty-state"><h1>Видео не найдено</h1><p>Возможно, оно больше не доступно в ленте.</p></div></section>
|
||||
|
||||
const video = videoQuery.data
|
||||
const duration = formatDuration(video.duration_seconds)
|
||||
|
||||
return (
|
||||
<div className="video-page">
|
||||
<Link to="/" className="back-link">
|
||||
← Лента
|
||||
</Link>
|
||||
|
||||
<Player video={video} />
|
||||
|
||||
<h1 className="video-page-title">{video.title}</h1>
|
||||
|
||||
<div className="video-page-meta">
|
||||
<Link to={`/channels/${video.channel.id}/videos`}>{video.channel.title}</Link>
|
||||
{' · '}
|
||||
{formatRelativeTime(video.published_at)}
|
||||
{duration && ` · ${duration}`}
|
||||
{' · '}
|
||||
<DownloadButton video={video} />
|
||||
</div>
|
||||
|
||||
{video.categories.length > 0 && (
|
||||
<div className="video-page-categories">
|
||||
{video.categories.map((c) => (
|
||||
<span key={c.id} className="chip">
|
||||
{c.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{video.description && <p className="video-page-description">{video.description}</p>}
|
||||
</div>
|
||||
)
|
||||
return <article className="page video-page">
|
||||
<button className="back-link" onClick={back}><Icon name="arrow" size={17} /> Назад к ленте</button>
|
||||
<Player key={video.youtube_video_id} video={video} />
|
||||
<div className="video-detail-head"><div><h1 className="video-page-title">{video.title}</h1><p className="video-page-meta"><Link to={`/channels/${video.channel.id}/videos`}>{video.channel.title}</Link><span>·</span><span>{formatRelativeTime(video.published_at)}</span>{duration && <><span>·</span><span>{duration}</span></>}</p></div><DownloadButton video={video} showDelete /></div>
|
||||
{video.categories.length > 0 && <div className="video-page-categories">{video.categories.map((category) => <Link key={category.id} to={`/category/${category.id}`} className="chip">{category.name}</Link>)}</div>}
|
||||
{video.description && <section className="video-page-description"><h2>Описание</h2><p>{video.description}</p></section>}
|
||||
</article>
|
||||
}
|
||||
|
||||
export default VideoPage
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ export function formatDuration(seconds: number | null): string | null {
|
|||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const SUBSCRIBERS_FMT = new Intl.NumberFormat('ru-RU', { notation: 'compact', maximumFractionDigits: 1 })
|
||||
|
||||
export function formatSubscriberCount(value: number | null | undefined): string | null {
|
||||
if (value == null) return null
|
||||
return `${SUBSCRIBERS_FMT.format(value)} подписчиков`
|
||||
}
|
||||
|
||||
const RTF = new Intl.RelativeTimeFormat('ru', { numeric: 'auto' })
|
||||
|
||||
export function formatRelativeTime(iso: string): string {
|
||||
|
|
|
|||
29
migrations/versions/0007_drop_access_expiry.py
Normal file
29
migrations/versions/0007_drop_access_expiry.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""drop access_token_expires_at
|
||||
|
||||
Revision ID: 0007_drop_access_expiry
|
||||
Revises: 0006_channel_sub_id
|
||||
Create Date: 2026-09-16
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0007_drop_access_expiry"
|
||||
down_revision: Union[str, None] = "0006_channel_sub_id"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Dead column: access tokens are now cached in-process (google_oauth.py)
|
||||
# with their own expiry, so this was written but never read.
|
||||
op.drop_column("oauth_credentials", "access_token_expires_at")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"oauth_credentials",
|
||||
sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
26
migrations/versions/0008_channel_subscriber_count.py
Normal file
26
migrations/versions/0008_channel_subscriber_count.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""channels.subscriber_count
|
||||
|
||||
Revision ID: 0008_channel_subscriber_count
|
||||
Revises: 0007_drop_access_expiry
|
||||
Create Date: 2026-09-17
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0008_channel_subscriber_count"
|
||||
down_revision: Union[str, None] = "0007_drop_access_expiry"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Subscriber count comes from YouTube channels.list (statistics part);
|
||||
# hidden counts stay NULL.
|
||||
op.add_column("channels", sa.Column("subscriber_count", sa.BigInteger(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("channels", "subscriber_count")
|
||||
4
opencode.json
Normal file
4
opencode.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"default_agent": "orchestrator"
|
||||
}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import auth as auth_module
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models.oauth_credentials import SINGLETON_ID, OAuthCredentials
|
||||
from app.services import google_oauth
|
||||
|
||||
|
||||
|
|
@ -78,3 +81,146 @@ def test_callback_rejects_invalid_state(client, monkeypatch):
|
|||
|
||||
assert response.status_code in (302, 307)
|
||||
assert "invalid_state" in response.headers["location"]
|
||||
|
||||
|
||||
def test_status_hides_email_without_session(client, db_session):
|
||||
"""/api/auth/status is public (the Connect page reads `authenticated`), so
|
||||
the connected account's email must not leak to unauthenticated requests."""
|
||||
row = OAuthCredentials(
|
||||
id=SINGLETON_ID,
|
||||
google_email="owner@example.com",
|
||||
encrypted_refresh_token="encrypted-blob",
|
||||
)
|
||||
db_session.add(row)
|
||||
db_session.commit()
|
||||
|
||||
status = client.get("/api/auth/status").json()
|
||||
|
||||
assert status["authenticated"] is False
|
||||
assert status["connected"] is True
|
||||
assert status["email"] is None
|
||||
|
||||
|
||||
def _seed_credentials_row(db_session, refresh_token="refresh-1"):
|
||||
row = OAuthCredentials(
|
||||
id=SINGLETON_ID,
|
||||
google_email="owner@example.com",
|
||||
encrypted_refresh_token=google_oauth.encrypt_token(refresh_token),
|
||||
)
|
||||
db_session.add(row)
|
||||
db_session.commit()
|
||||
|
||||
|
||||
class FakeGoogleCredentials:
|
||||
"""Stands in for google.oauth2.credentials.Credentials in cache tests."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.refresh_token = kwargs["refresh_token"]
|
||||
self.token = None
|
||||
self.expiry = None
|
||||
|
||||
def refresh(self, request):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def test_get_credentials_caches_and_reuses_access_token(monkeypatch, db_session):
|
||||
monkeypatch.setattr(google_oauth, "_cached_credentials", None)
|
||||
_seed_credentials_row(db_session)
|
||||
|
||||
refreshes = []
|
||||
|
||||
class Creds(FakeGoogleCredentials):
|
||||
def refresh(self, request):
|
||||
refreshes.append(request)
|
||||
self.token = "access-token"
|
||||
self.expiry = datetime(2099, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
monkeypatch.setattr(google_oauth, "Credentials", Creds)
|
||||
|
||||
first = google_oauth.get_credentials(db_session)
|
||||
second = google_oauth.get_credentials(db_session)
|
||||
|
||||
assert first is second
|
||||
assert first.token == "access-token"
|
||||
assert len(refreshes) == 1 # second call must not hit Google again
|
||||
|
||||
|
||||
def test_get_credentials_refreshes_expiring_token(monkeypatch, db_session):
|
||||
monkeypatch.setattr(google_oauth, "_cached_credentials", None)
|
||||
_seed_credentials_row(db_session)
|
||||
|
||||
refreshes = []
|
||||
|
||||
class Creds(FakeGoogleCredentials):
|
||||
def refresh(self, request):
|
||||
refreshes.append(request)
|
||||
self.token = "access-token"
|
||||
# Expires in 30s -- inside the 60s refresh buffer.
|
||||
self.expiry = datetime.now(timezone.utc) + timedelta(seconds=30)
|
||||
|
||||
monkeypatch.setattr(google_oauth, "Credentials", Creds)
|
||||
|
||||
google_oauth.get_credentials(db_session)
|
||||
google_oauth.get_credentials(db_session)
|
||||
|
||||
assert len(refreshes) == 2
|
||||
|
||||
|
||||
def test_get_credentials_refresh_failure_clears_cache(monkeypatch, db_session):
|
||||
"""A failed token refresh must not leave a broken cached object behind:
|
||||
the error propagates and the next call rebuilds credentials from the DB
|
||||
instead of reusing (and re-failing on) the stale cache entry."""
|
||||
monkeypatch.setattr(google_oauth, "_cached_credentials", None)
|
||||
_seed_credentials_row(db_session)
|
||||
|
||||
built = []
|
||||
|
||||
class Creds(FakeGoogleCredentials):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
built.append(self.refresh_token)
|
||||
|
||||
def refresh(self, request):
|
||||
raise RuntimeError("token endpoint down")
|
||||
|
||||
monkeypatch.setattr(google_oauth, "Credentials", Creds)
|
||||
|
||||
for _ in range(2):
|
||||
with pytest.raises(RuntimeError, match="token endpoint down"):
|
||||
google_oauth.get_credentials(db_session)
|
||||
|
||||
# Both calls failed, and the broken cache entry was dropped: each call
|
||||
# rebuilt from the stored refresh token instead of sticking.
|
||||
assert len(built) == 2
|
||||
assert google_oauth._cached_credentials is None
|
||||
|
||||
|
||||
def test_get_credentials_rebuilds_cache_after_reconnect(monkeypatch, db_session):
|
||||
monkeypatch.setattr(google_oauth, "_cached_credentials", None)
|
||||
_seed_credentials_row(db_session)
|
||||
|
||||
built = []
|
||||
|
||||
class Creds(FakeGoogleCredentials):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
built.append(self.refresh_token)
|
||||
self.token = "already-valid"
|
||||
self.expiry = datetime(2099, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
def refresh(self, request):
|
||||
raise AssertionError("must not refresh a still-valid cached token")
|
||||
|
||||
monkeypatch.setattr(google_oauth, "Credentials", Creds)
|
||||
|
||||
google_oauth.get_credentials(db_session)
|
||||
assert built == ["refresh-1"]
|
||||
|
||||
# Reconnect: the stored refresh token changes, the old cache entry must
|
||||
# not be reused.
|
||||
row = db_session.get(OAuthCredentials, SINGLETON_ID)
|
||||
row.encrypted_refresh_token = google_oauth.encrypt_token("refresh-2")
|
||||
db_session.commit()
|
||||
|
||||
google_oauth.get_credentials(db_session)
|
||||
assert built == ["refresh-1", "refresh-2"]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import pytest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.auth_dependency import require_session
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models.channel import Channel
|
||||
from app.models.video import Video
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -29,6 +32,19 @@ def _create_channel(db_session, youtube_channel_id="chanA", title="Channel A"):
|
|||
return channel
|
||||
|
||||
|
||||
def _seed_video(db_session, channel, video_id, published_at):
|
||||
video = Video(
|
||||
youtube_video_id=video_id,
|
||||
channel_id=channel.id,
|
||||
title=f"Video {video_id}",
|
||||
published_at=published_at,
|
||||
youtube_url=f"https://www.youtube.com/watch?v={video_id}",
|
||||
)
|
||||
db_session.add(video)
|
||||
db_session.commit()
|
||||
return video
|
||||
|
||||
|
||||
def test_category_crud(client):
|
||||
created = client.post("/api/categories", json={"name": "Linux"}).json()
|
||||
assert created["name"] == "Linux"
|
||||
|
|
@ -118,3 +134,64 @@ def test_reorder_rejects_mismatched_ids(client):
|
|||
client.post("/api/categories", json={"name": "A"})
|
||||
resp = client.post("/api/categories/reorder", json={"category_ids": [9999]})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_category_new_videos_count_sums_recent_videos_of_subscribed_channels(client, db_session):
|
||||
now = datetime.now(timezone.utc)
|
||||
category = client.post("/api/categories", json={"name": "Linux"}).json()
|
||||
assert category["new_videos_count"] == 0
|
||||
|
||||
channel_a = _create_channel(db_session, "chanA", "Channel A")
|
||||
channel_b = _create_channel(db_session, "chanB", "Channel B")
|
||||
channel_c = _create_channel(db_session, "chanC", "Channel C")
|
||||
channel_c.subscribed = False
|
||||
db_session.commit()
|
||||
|
||||
client.put(f"/api/channels/{channel_a.id}/categories", json={"category_ids": [category["id"]]})
|
||||
client.put(f"/api/channels/{channel_b.id}/categories", json={"category_ids": [category["id"]]})
|
||||
client.put(f"/api/channels/{channel_c.id}/categories", json={"category_ids": [category["id"]]})
|
||||
|
||||
_seed_video(db_session, channel_a, "vidARecent1", now - timedelta(days=1))
|
||||
_seed_video(db_session, channel_a, "vidARecent2", now - timedelta(hours=2))
|
||||
_seed_video(db_session, channel_a, "vidAOld", now - timedelta(days=30))
|
||||
_seed_video(db_session, channel_b, "vidBRecent", now - timedelta(days=1))
|
||||
# Unsubscribed channel: its videos must not count towards the category.
|
||||
_seed_video(db_session, channel_c, "vidCUnsub", now - timedelta(days=1))
|
||||
|
||||
listed = client.get("/api/categories").json()
|
||||
assert len(listed) == 1
|
||||
assert listed[0]["channel_count"] == 3
|
||||
assert listed[0]["new_videos_count"] == 3
|
||||
|
||||
|
||||
def test_category_new_videos_count_excludes_other_categories(client, db_session):
|
||||
now = datetime.now(timezone.utc)
|
||||
cat1 = client.post("/api/categories", json={"name": "Linux"}).json()
|
||||
cat2 = client.post("/api/categories", json={"name": "IT"}).json()
|
||||
|
||||
channel_a = _create_channel(db_session, "chanA", "Channel A")
|
||||
channel_b = _create_channel(db_session, "chanB", "Channel B")
|
||||
client.put(f"/api/channels/{channel_a.id}/categories", json={"category_ids": [cat1["id"]]})
|
||||
client.put(f"/api/channels/{channel_b.id}/categories", json={"category_ids": [cat2["id"]]})
|
||||
|
||||
# Only an old video in cat1, a recent one in cat2: counts must not leak.
|
||||
_seed_video(db_session, channel_a, "vidAOld", now - timedelta(days=30))
|
||||
_seed_video(db_session, channel_b, "vidBRecent", now - timedelta(days=1))
|
||||
|
||||
listed = {c["id"]: c for c in client.get("/api/categories").json()}
|
||||
assert listed[cat1["id"]]["new_videos_count"] == 0
|
||||
assert listed[cat2["id"]]["new_videos_count"] == 1
|
||||
|
||||
|
||||
def test_category_new_videos_count_zero_without_recent_videos(client, db_session):
|
||||
now = datetime.now(timezone.utc)
|
||||
category = client.post("/api/categories", json={"name": "Linux"}).json()
|
||||
|
||||
channel = _create_channel(db_session)
|
||||
client.put(f"/api/channels/{channel.id}/categories", json={"category_ids": [category["id"]]})
|
||||
_seed_video(db_session, channel, "vidOld", now - timedelta(days=30))
|
||||
|
||||
listed = client.get("/api/categories").json()
|
||||
assert len(listed) == 1
|
||||
assert listed[0]["channel_count"] == 1
|
||||
assert listed[0]["new_videos_count"] == 0
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
|
@ -5,6 +7,7 @@ from app.core.auth_dependency import require_session
|
|||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models.channel import Channel
|
||||
from app.models.video import Video
|
||||
from app.services import sync
|
||||
from app.services.youtube_client import YouTubeInsufficientScope
|
||||
|
||||
|
|
@ -86,3 +89,54 @@ def test_unsubscribe_insufficient_scope_returns_403(client, db_session, monkeypa
|
|||
def test_unsubscribe_channel_not_found(client):
|
||||
resp = client.post("/api/channels/9999/unsubscribe")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def _seed_video(db_session, channel, video_id, published_at):
|
||||
video = Video(
|
||||
youtube_video_id=video_id,
|
||||
channel_id=channel.id,
|
||||
title=f"Video {video_id}",
|
||||
published_at=published_at,
|
||||
youtube_url=f"https://www.youtube.com/watch?v={video_id}",
|
||||
)
|
||||
db_session.add(video)
|
||||
db_session.commit()
|
||||
return video
|
||||
|
||||
|
||||
def test_channel_responses_include_subscriber_and_new_videos_counts(client, db_session):
|
||||
channel = _seed_channel(db_session)
|
||||
channel.subscriber_count = 1200000
|
||||
db_session.commit()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
_seed_video(db_session, channel, "vidRecent", now - timedelta(days=1))
|
||||
_seed_video(db_session, channel, "vidOld", now - timedelta(days=30))
|
||||
|
||||
resp = client.get("/api/channels")
|
||||
assert resp.status_code == 200
|
||||
payload = resp.json()
|
||||
assert len(payload) == 1
|
||||
assert payload[0]["subscriber_count"] == 1200000
|
||||
# Only the video from 1 day ago is inside the 7-day window.
|
||||
assert payload[0]["new_videos_count"] == 1
|
||||
|
||||
resp_single = client.get(f"/api/channels/{channel.id}")
|
||||
assert resp_single.status_code == 200
|
||||
single = resp_single.json()
|
||||
assert single["subscriber_count"] == 1200000
|
||||
assert single["new_videos_count"] == 1
|
||||
|
||||
|
||||
def test_channel_new_videos_count_zero_without_recent_videos(client, db_session):
|
||||
channel = _seed_channel(db_session)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
_seed_video(db_session, channel, "vidOld", now - timedelta(days=30))
|
||||
|
||||
resp = client.get("/api/channels")
|
||||
assert resp.status_code == 200
|
||||
payload = resp.json()
|
||||
assert len(payload) == 1
|
||||
assert payload[0]["subscriber_count"] is None
|
||||
assert payload[0]["new_videos_count"] == 0
|
||||
|
|
|
|||
|
|
@ -235,6 +235,101 @@ async def test_handle_metube_event_canceled_marks_failed(db_session):
|
|||
assert job.status == "failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_metube_event_canceled_matches_by_url(db_session):
|
||||
"""MeTube keys canceled/cleared events by the download's URL, not its id."""
|
||||
video = _seed_video(db_session)
|
||||
job = DownloadJob(video_id=video.id, status="downloading", metube_job_id="vid1.vid1")
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
await download_jobs.handle_metube_event(db_session, "canceled", json.dumps(video.youtube_url))
|
||||
|
||||
db_session.refresh(job)
|
||||
assert job.status == "failed"
|
||||
assert job.error_message == "Отменено в MeTube"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_metube_event_cleared_matches_job_by_url(db_session):
|
||||
video = _seed_video(db_session)
|
||||
job = DownloadJob(video_id=video.id, status="downloading", metube_job_id="vid1.vid1")
|
||||
other_video = _seed_video(db_session, youtube_video_id="vid2", youtube_channel_id="chanB")
|
||||
other_job = DownloadJob(video_id=other_video.id, status="queued")
|
||||
db_session.add(job)
|
||||
db_session.add(other_job)
|
||||
db_session.commit()
|
||||
|
||||
await download_jobs.handle_metube_event(db_session, "cleared", json.dumps(video.youtube_url))
|
||||
|
||||
db_session.refresh(job)
|
||||
db_session.refresh(other_job)
|
||||
assert job.status == "failed"
|
||||
assert job.error_message == "Очищено в MeTube"
|
||||
assert other_job.status == "queued"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_metube_event_cleared_matching_terminal_job_is_noop(db_session):
|
||||
"""Cleared payload resolving to a finished job must not touch terminal
|
||||
statuses, nor spill over onto unrelated active jobs."""
|
||||
video = _seed_video(db_session)
|
||||
completed = DownloadJob(video_id=video.id, status="completed", media_url="http://x/f.mp4")
|
||||
other_video = _seed_video(db_session, youtube_video_id="vid2", youtube_channel_id="chanB")
|
||||
active = DownloadJob(video_id=other_video.id, status="downloading")
|
||||
db_session.add_all([completed, active])
|
||||
db_session.commit()
|
||||
|
||||
await download_jobs.handle_metube_event(db_session, "cleared", json.dumps(video.youtube_url))
|
||||
|
||||
db_session.refresh(completed)
|
||||
db_session.refresh(active)
|
||||
assert completed.status == "completed"
|
||||
assert active.status == "downloading"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_metube_event_cleared_unmatched_payload_is_noop(db_session):
|
||||
"""'cleared' only ever refers to the one done-entry being removed (trash
|
||||
or CLEAR_COMPLETED_AFTER). A payload that doesn't match any of our jobs is
|
||||
a foreign MeTube download we never tracked -- the jobs MeTube is still
|
||||
running must stay untouched."""
|
||||
video1 = _seed_video(db_session)
|
||||
job1 = DownloadJob(video_id=video1.id, status="downloading")
|
||||
video2 = _seed_video(db_session, youtube_video_id="vid2", youtube_channel_id="chanB")
|
||||
job2 = DownloadJob(video_id=video2.id, status="postprocessing")
|
||||
video3 = _seed_video(db_session, youtube_video_id="vid3", youtube_channel_id="chanC")
|
||||
completed = DownloadJob(video_id=video3.id, status="completed", media_url="http://x/f.mp4")
|
||||
unknown = DownloadJob(video_id=video3.id, status="unknown")
|
||||
db_session.add_all([job1, job2, completed, unknown])
|
||||
db_session.commit()
|
||||
|
||||
await download_jobs.handle_metube_event(db_session, "cleared", json.dumps("https://example.com/other"))
|
||||
|
||||
db_session.refresh(job1)
|
||||
db_session.refresh(job2)
|
||||
db_session.refresh(completed)
|
||||
db_session.refresh(unknown)
|
||||
assert job1.status == "downloading"
|
||||
assert job2.status == "postprocessing"
|
||||
assert completed.status == "completed"
|
||||
assert unknown.status == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_metube_event_cleared_without_payload_is_noop(db_session):
|
||||
video = _seed_video(db_session)
|
||||
job = DownloadJob(video_id=video.id, status="queued")
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
await download_jobs.handle_metube_event(db_session, "cleared", None)
|
||||
|
||||
db_session.refresh(job)
|
||||
assert job.status == "queued"
|
||||
assert job.error_message is None
|
||||
|
||||
|
||||
def test_reconcile_marks_unknown_when_history_unavailable(monkeypatch, db_session):
|
||||
video = _seed_video(db_session)
|
||||
job = DownloadJob(video_id=video.id, status="downloading")
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ from app.main import app
|
|||
from app.models.category import Category
|
||||
from app.models.channel import Channel
|
||||
from app.models.channel_category import channel_categories
|
||||
from app.models.download_job import DownloadJob
|
||||
from app.models.video import Video
|
||||
from app.api.feed import get_feed
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -66,6 +68,19 @@ def test_feed_orders_by_published_at_desc(client, db_session):
|
|||
assert resp["next_cursor"] is None
|
||||
|
||||
|
||||
def test_feed_searches_video_and_channel_titles(db_session):
|
||||
_seed(db_session)
|
||||
|
||||
by_video = get_feed(search="video 4", limit=30, db=db_session)
|
||||
assert [item["youtube_video_id"] for item in by_video["items"]] == ["vid4"]
|
||||
|
||||
by_channel = get_feed(search="channel b", limit=30, db=db_session)
|
||||
assert [item["youtube_video_id"] for item in by_channel["items"]] == ["vid3", "vid1"]
|
||||
|
||||
literal_wildcard = get_feed(search="%", limit=30, db=db_session)
|
||||
assert literal_wildcard["items"] == []
|
||||
|
||||
|
||||
def test_feed_pagination_cursor(client, db_session):
|
||||
_seed(db_session)
|
||||
|
||||
|
|
@ -109,6 +124,182 @@ def test_feed_filters_uncategorized(client, db_session):
|
|||
assert ids == {"vid1", "vid3"}
|
||||
|
||||
|
||||
def _seed_new_only(db_session):
|
||||
"""Two categories: channel A in a category (recent + old video),
|
||||
channel B uncategorized (recent + old video). Published dates are
|
||||
relative to now so the new_videos_window filter is exercised."""
|
||||
now = datetime.now(timezone.utc)
|
||||
channel_a = Channel(youtube_channel_id="chanNewA", title="Channel New A", subscribed=True)
|
||||
channel_b = Channel(youtube_channel_id="chanNewB", title="Channel New B", subscribed=True)
|
||||
db_session.add_all([channel_a, channel_b])
|
||||
db_session.commit()
|
||||
|
||||
category = Category(name="Recent", slug="recent", sort_order=0)
|
||||
db_session.add(category)
|
||||
db_session.commit()
|
||||
|
||||
db_session.execute(channel_categories.insert().values(channel_id=channel_a.id, category_id=category.id))
|
||||
db_session.commit()
|
||||
|
||||
videos = [
|
||||
Video(
|
||||
youtube_video_id="vidRecentA",
|
||||
channel_id=channel_a.id,
|
||||
title="Recent A",
|
||||
published_at=now - timedelta(days=1),
|
||||
youtube_url="https://www.youtube.com/watch?v=vidRecentA",
|
||||
),
|
||||
Video(
|
||||
youtube_video_id="vidOldA",
|
||||
channel_id=channel_a.id,
|
||||
title="Old A",
|
||||
published_at=now - timedelta(days=30),
|
||||
youtube_url="https://www.youtube.com/watch?v=vidOldA",
|
||||
),
|
||||
Video(
|
||||
youtube_video_id="vidRecentB",
|
||||
channel_id=channel_b.id,
|
||||
title="Recent B",
|
||||
published_at=now - timedelta(hours=2),
|
||||
youtube_url="https://www.youtube.com/watch?v=vidRecentB",
|
||||
),
|
||||
Video(
|
||||
youtube_video_id="vidOldB",
|
||||
channel_id=channel_b.id,
|
||||
title="Old B",
|
||||
published_at=now - timedelta(days=30),
|
||||
youtube_url="https://www.youtube.com/watch?v=vidOldB",
|
||||
),
|
||||
]
|
||||
db_session.add_all(videos)
|
||||
db_session.commit()
|
||||
|
||||
return channel_a, channel_b, category, videos
|
||||
|
||||
|
||||
def test_feed_new_only_includes_recent_and_excludes_old(client, db_session):
|
||||
_seed_new_only(db_session)
|
||||
|
||||
resp = client.get("/api/feed?new_only=true").json()
|
||||
|
||||
ids = {i["youtube_video_id"] for i in resp["items"]}
|
||||
assert ids == {"vidRecentA", "vidRecentB"}
|
||||
|
||||
# Without new_only nothing changes: old videos appear as usual.
|
||||
all_ids = {i["youtube_video_id"] for i in client.get("/api/feed").json()["items"]}
|
||||
assert all_ids == {"vidRecentA", "vidOldA", "vidRecentB", "vidOldB"}
|
||||
|
||||
|
||||
def test_feed_new_only_combines_with_category(client, db_session):
|
||||
_, _, category, _ = _seed_new_only(db_session)
|
||||
|
||||
resp = client.get(f"/api/feed?category_id={category.id}&new_only=true").json()
|
||||
|
||||
ids = {i["youtube_video_id"] for i in resp["items"]}
|
||||
assert ids == {"vidRecentA"}
|
||||
|
||||
|
||||
def test_feed_new_only_excludes_unsubscribed_channels(client, db_session):
|
||||
now = datetime.now(timezone.utc)
|
||||
channel = Channel(youtube_channel_id="chanUnsub", title="Channel Unsub", subscribed=False)
|
||||
db_session.add(channel)
|
||||
db_session.commit()
|
||||
db_session.add(
|
||||
Video(
|
||||
youtube_video_id="vidFreshUnsub",
|
||||
channel_id=channel.id,
|
||||
title="Fresh Unsub",
|
||||
published_at=now - timedelta(hours=2),
|
||||
youtube_url="https://www.youtube.com/watch?v=vidFreshUnsub",
|
||||
)
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get("/api/feed?new_only=true").json()
|
||||
assert resp["items"] == []
|
||||
|
||||
# Regular feed is not filtered by subscription.
|
||||
all_ids = {i["youtube_video_id"] for i in client.get("/api/feed").json()["items"]}
|
||||
assert all_ids == {"vidFreshUnsub"}
|
||||
|
||||
|
||||
def test_feed_new_only_pagination_cursor_works_in_filtered_set(client, db_session):
|
||||
now = datetime.now(timezone.utc)
|
||||
channel = Channel(youtube_channel_id="chanPage", title="Channel Page", subscribed=True)
|
||||
db_session.add(channel)
|
||||
db_session.commit()
|
||||
db_session.add_all(
|
||||
[
|
||||
Video(
|
||||
youtube_video_id=f"vidNew{i}",
|
||||
channel_id=channel.id,
|
||||
title=f"Video New {i}",
|
||||
published_at=now - timedelta(hours=i),
|
||||
youtube_url=f"https://www.youtube.com/watch?v=vidNew{i}",
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
page1 = client.get("/api/feed?new_only=true&limit=2").json()
|
||||
assert [i["youtube_video_id"] for i in page1["items"]] == ["vidNew0", "vidNew1"]
|
||||
assert page1["next_cursor"] is not None
|
||||
|
||||
page2 = client.get(f"/api/feed?new_only=true&limit=2&cursor={page1['next_cursor']}").json()
|
||||
assert [i["youtube_video_id"] for i in page2["items"]] == ["vidNew2"]
|
||||
assert page2["next_cursor"] is None
|
||||
|
||||
|
||||
def test_feed_filters_downloaded(client, db_session):
|
||||
_, _, _, videos = _seed(db_session)
|
||||
|
||||
completed = DownloadJob(video_id=videos[0].id, status="completed", media_url="http://x/f0.mp4")
|
||||
failed = DownloadJob(video_id=videos[1].id, status="failed")
|
||||
db_session.add_all([completed, failed])
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get("/api/feed?downloaded=true").json()
|
||||
|
||||
ids = {i["youtube_video_id"] for i in resp["items"]}
|
||||
assert ids == {"vid0"}
|
||||
|
||||
|
||||
def test_feed_filters_downloaded_uses_latest_job_per_video(client, db_session):
|
||||
_, _, _, videos = _seed(db_session)
|
||||
|
||||
older = DownloadJob(video_id=videos[0].id, status="completed", media_url="http://x/f0.mp4")
|
||||
db_session.add(older)
|
||||
db_session.commit()
|
||||
newer = DownloadJob(video_id=videos[0].id, status="deleted")
|
||||
db_session.add(newer)
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get("/api/feed?downloaded=true").json()
|
||||
|
||||
assert resp["items"] == []
|
||||
|
||||
|
||||
def test_saved_counts(client, db_session):
|
||||
channel_a, channel_b, category, videos = _seed(db_session)
|
||||
|
||||
# vid0 is on channel_a (in "Linux"), vid1 is on channel_b (uncategorized).
|
||||
db_session.add_all(
|
||||
[
|
||||
DownloadJob(video_id=videos[0].id, status="completed", media_url="http://x/0.mp4"),
|
||||
DownloadJob(video_id=videos[1].id, status="completed", media_url="http://x/1.mp4"),
|
||||
DownloadJob(video_id=videos[2].id, status="failed"),
|
||||
]
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get("/api/feed/saved-counts").json()
|
||||
|
||||
assert resp["all"] == 2
|
||||
assert resp["uncategorized"] == 1
|
||||
assert resp["categories"] == {str(category.id): 1}
|
||||
|
||||
|
||||
def test_feed_item_shape(client, db_session):
|
||||
channel_a, _, category, _ = _seed(db_session)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from app.config import settings
|
||||
from app.models.channel import Channel
|
||||
from app.models.video import Video
|
||||
from app.services import sync
|
||||
|
|
@ -32,7 +35,7 @@ def test_sync_subscriptions_idempotent_and_unsubscribes(monkeypatch, db_session)
|
|||
monkeypatch.setattr(
|
||||
sync.youtube_client,
|
||||
"fetch_uploads_playlists",
|
||||
lambda creds, ids: {cid: f"UU{cid}" for cid in ids},
|
||||
lambda creds, ids: {cid: {"uploads_playlist_id": f"UU{cid}", "subscriber_count": 12345} for cid in ids},
|
||||
)
|
||||
|
||||
result = sync.sync_subscriptions(db_session)
|
||||
|
|
@ -45,6 +48,7 @@ def test_sync_subscriptions_idempotent_and_unsubscribes(monkeypatch, db_session)
|
|||
assert [c.youtube_channel_id for c in channels] == ["chanA", "chanB"]
|
||||
assert all(c.subscribed for c in channels)
|
||||
assert channels[0].uploads_playlist_id == "UUchanA"
|
||||
assert channels[0].subscriber_count == 12345
|
||||
|
||||
# Second sync: chanA disappears from subscriptions, chanC appears.
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -81,6 +85,46 @@ def test_sync_subscriptions_idempotent_and_unsubscribes(monkeypatch, db_session)
|
|||
assert channels["chanC"].subscribed is True
|
||||
|
||||
|
||||
def test_sync_subscriptions_skips_none_subscriber_count(monkeypatch, db_session):
|
||||
channel = Channel(
|
||||
youtube_channel_id="chanA",
|
||||
youtube_subscription_id="subA",
|
||||
title="Channel A",
|
||||
subscribed=True,
|
||||
subscriber_count=12345,
|
||||
)
|
||||
db_session.add(channel)
|
||||
db_session.commit()
|
||||
|
||||
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: _fake_credentials())
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client,
|
||||
"fetch_subscriptions",
|
||||
lambda creds: [
|
||||
{
|
||||
"youtube_channel_id": "chanA",
|
||||
"youtube_subscription_id": "subA",
|
||||
"title": "Channel A",
|
||||
"description": "d",
|
||||
"thumbnail_url": "t",
|
||||
},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client,
|
||||
"fetch_uploads_playlists",
|
||||
lambda creds, ids: {cid: {"uploads_playlist_id": f"UU{cid}", "subscriber_count": None} for cid in ids},
|
||||
)
|
||||
|
||||
result = sync.sync_subscriptions(db_session)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
db_session.refresh(channel)
|
||||
assert channel.uploads_playlist_id == "UUchanA"
|
||||
# None in the API response must not overwrite the previously stored count.
|
||||
assert channel.subscriber_count == 12345
|
||||
|
||||
|
||||
def test_sync_in_progress_raises(monkeypatch, db_session):
|
||||
sync._subscriptions_lock.acquire()
|
||||
try:
|
||||
|
|
@ -106,12 +150,14 @@ def _seed_channel(db_session, youtube_channel_id="chanA", uploads_playlist_id="U
|
|||
return channel
|
||||
|
||||
|
||||
def test_sync_videos_adds_and_updates(monkeypatch, db_session):
|
||||
def test_sync_videos_adds_new_videos(monkeypatch, db_session):
|
||||
channel = _seed_channel(db_session)
|
||||
|
||||
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: object())
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client, "fetch_playlist_video_ids", lambda creds, playlist_id, max_results: ["vid1"]
|
||||
sync.youtube_client,
|
||||
"fetch_playlist_video_ids_incremental",
|
||||
lambda creds, playlist_id, known_ids, max_results, stop_threshold: ["vid1"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client,
|
||||
|
|
@ -133,12 +179,23 @@ def test_sync_videos_adds_and_updates(monkeypatch, db_session):
|
|||
|
||||
assert result["status"] == "completed"
|
||||
assert result["videos_added"] == 1
|
||||
assert result["channels_checked"] == 1
|
||||
|
||||
video = db_session.query(Video).filter_by(youtube_video_id="vid1").one()
|
||||
assert video.title == "Video One"
|
||||
assert video.duration_seconds == 300
|
||||
assert video.youtube_url == "https://www.youtube.com/watch?v=vid1"
|
||||
|
||||
|
||||
def test_sync_videos_second_run_is_idempotent_and_fetches_no_details(monkeypatch, db_session):
|
||||
channel = _seed_channel(db_session)
|
||||
|
||||
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: object())
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client,
|
||||
"fetch_playlist_video_ids_incremental",
|
||||
lambda creds, playlist_id, known_ids, max_results, stop_threshold: ["vid1"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client,
|
||||
"fetch_videos_details",
|
||||
|
|
@ -146,29 +203,142 @@ def test_sync_videos_adds_and_updates(monkeypatch, db_session):
|
|||
{
|
||||
"youtube_video_id": "vid1",
|
||||
"youtube_channel_id": channel.youtube_channel_id,
|
||||
"title": "Video One Updated",
|
||||
"description": "d2",
|
||||
"thumbnail_url": "t2",
|
||||
"title": "Video One",
|
||||
"description": "d",
|
||||
"thumbnail_url": "t",
|
||||
"published_at": "2026-09-10T12:00:00Z",
|
||||
"duration_iso8601": "PT6M",
|
||||
"duration_iso8601": "PT5M",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
result2 = sync.sync_videos(db_session)
|
||||
assert result2["videos_added"] == 0
|
||||
assert result2["videos_updated"] == 1
|
||||
first = sync.sync_videos(db_session)
|
||||
assert first["videos_added"] == 1
|
||||
|
||||
videos = db_session.query(Video).all()
|
||||
assert len(videos) == 1
|
||||
assert videos[0].title == "Video One Updated"
|
||||
# Second run: vid1 is now known, so the incremental fetch reports nothing
|
||||
# new and no details are requested at all (existing videos are not
|
||||
# metadata-refreshed by design).
|
||||
details_calls = []
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client,
|
||||
"fetch_playlist_video_ids_incremental",
|
||||
lambda creds, playlist_id, known_ids, max_results, stop_threshold: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client,
|
||||
"fetch_videos_details",
|
||||
lambda creds, ids: details_calls.append(list(ids)) or [],
|
||||
)
|
||||
|
||||
second = sync.sync_videos(db_session)
|
||||
|
||||
assert second["videos_added"] == 0
|
||||
assert second["videos_updated"] == 0
|
||||
assert details_calls == [[]]
|
||||
assert db_session.query(Video).count() == 1
|
||||
|
||||
|
||||
def test_sync_videos_passes_known_ids_and_backfill_settings(monkeypatch, db_session):
|
||||
channel = _seed_channel(db_session)
|
||||
db_session.add_all(
|
||||
[
|
||||
Video(
|
||||
youtube_video_id="known1",
|
||||
channel_id=channel.id,
|
||||
title="Known 1",
|
||||
published_at=datetime(2026, 9, 1, tzinfo=timezone.utc),
|
||||
youtube_url="https://www.youtube.com/watch?v=known1",
|
||||
),
|
||||
Video(
|
||||
youtube_video_id="known2",
|
||||
channel_id=channel.id,
|
||||
title="Known 2",
|
||||
published_at=datetime(2026, 9, 1, tzinfo=timezone.utc),
|
||||
youtube_url="https://www.youtube.com/watch?v=known2",
|
||||
),
|
||||
]
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_incremental(creds, playlist_id, known_ids, max_results, stop_threshold):
|
||||
captured.update(known_ids=known_ids, max_results=max_results, stop_threshold=stop_threshold)
|
||||
return ["vid1"]
|
||||
|
||||
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: object())
|
||||
monkeypatch.setattr(sync.youtube_client, "fetch_playlist_video_ids_incremental", fake_incremental)
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client,
|
||||
"fetch_videos_details",
|
||||
lambda creds, ids: [
|
||||
{
|
||||
"youtube_video_id": "vid1",
|
||||
"youtube_channel_id": channel.youtube_channel_id,
|
||||
"title": "Video One",
|
||||
"description": "d",
|
||||
"thumbnail_url": "t",
|
||||
"published_at": "2026-09-10T12:00:00Z",
|
||||
"duration_iso8601": "PT5M",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
result = sync.sync_videos(db_session)
|
||||
|
||||
assert result["videos_added"] == 1
|
||||
assert captured["known_ids"] == {"known1", "known2"}
|
||||
assert captured["max_results"] == settings.videos_backfill_cap
|
||||
assert captured["stop_threshold"] == settings.videos_known_stop_threshold
|
||||
|
||||
|
||||
def test_sync_videos_backfill_cap_ingests_all_candidates(monkeypatch, db_session):
|
||||
channel = _seed_channel(db_session)
|
||||
cap = settings.videos_backfill_cap
|
||||
new_ids = [f"vid{i}" for i in range(cap)]
|
||||
|
||||
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: object())
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client,
|
||||
"fetch_playlist_video_ids_incremental",
|
||||
lambda creds, playlist_id, known_ids, max_results, stop_threshold: new_ids,
|
||||
)
|
||||
details_calls = []
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client,
|
||||
"fetch_videos_details",
|
||||
lambda creds, ids: details_calls.append(list(ids))
|
||||
or [
|
||||
{
|
||||
"youtube_video_id": vid,
|
||||
"youtube_channel_id": channel.youtube_channel_id,
|
||||
"title": f"Title {vid}",
|
||||
"description": "d",
|
||||
"thumbnail_url": "t",
|
||||
"published_at": "2026-09-10T12:00:00Z",
|
||||
"duration_iso8601": None,
|
||||
}
|
||||
for vid in ids
|
||||
],
|
||||
)
|
||||
|
||||
result = sync.sync_videos(db_session)
|
||||
|
||||
assert result["videos_added"] == cap
|
||||
assert result["videos_updated"] == 0
|
||||
assert set(details_calls[0]) == set(new_ids)
|
||||
assert db_session.query(Video).count() == cap
|
||||
|
||||
|
||||
def test_sync_videos_skips_unknown_channel(monkeypatch, db_session):
|
||||
_seed_channel(db_session, "chanA")
|
||||
|
||||
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: object())
|
||||
monkeypatch.setattr(sync.youtube_client, "fetch_playlist_video_ids", lambda creds, playlist_id, max_results: [])
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client,
|
||||
"fetch_playlist_video_ids_incremental",
|
||||
lambda creds, playlist_id, known_ids, max_results, stop_threshold: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client,
|
||||
"fetch_videos_details",
|
||||
|
|
@ -189,3 +359,211 @@ def test_sync_videos_skips_unknown_channel(monkeypatch, db_session):
|
|||
|
||||
assert result["videos_skipped"] == 1
|
||||
assert db_session.query(Video).count() == 0
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload, status_code=200):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.text = str(payload)
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeCredentials:
|
||||
token = "fake-token"
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""Serves canned playlistItems pages through the real incremental fetcher."""
|
||||
|
||||
def __init__(self, pages):
|
||||
self._pages = list(pages)
|
||||
self.requests = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def get(self, url, params=None, headers=None):
|
||||
self.requests.append({"url": url, "params": params})
|
||||
return self._pages.pop(0)
|
||||
|
||||
|
||||
def _page(items, next_token=None):
|
||||
return _FakeResponse(
|
||||
{
|
||||
"items": [{"contentDetails": {"videoId": vid}} for vid in items],
|
||||
**({"nextPageToken": next_token} if next_token else {}),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_sync_videos_stops_pagination_on_known_run(monkeypatch, db_session):
|
||||
"""A channel with many known videos in a row after the new ones: the
|
||||
incremental fetch must stop paginating once the known-run threshold is hit
|
||||
and only the new ids must get details."""
|
||||
channel = _seed_channel(db_session)
|
||||
known = [f"known{i}" for i in range(60)]
|
||||
db_session.add_all(
|
||||
[
|
||||
Video(
|
||||
youtube_video_id=vid,
|
||||
channel_id=channel.id,
|
||||
title=f"Known {vid}",
|
||||
published_at=datetime(2026, 9, 1, tzinfo=timezone.utc),
|
||||
youtube_url=f"https://www.youtube.com/watch?v={vid}",
|
||||
)
|
||||
for vid in known
|
||||
]
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
# Page 1: two new videos then 48 known; page 2 continues with known ids,
|
||||
# so the run crosses 50 on the second page and pagination must stop there
|
||||
# (a third page exists and must never be requested).
|
||||
client = _FakeClient(
|
||||
[
|
||||
_page(["new1", "new2", *known[:48]], next_token="page2"),
|
||||
_page(known[48:58], next_token="page3"),
|
||||
_page(["never-seen"]),
|
||||
]
|
||||
)
|
||||
details_calls = []
|
||||
|
||||
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: _FakeCredentials())
|
||||
monkeypatch.setattr(sync.youtube_client.httpx, "Client", lambda timeout: client)
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client,
|
||||
"fetch_videos_details",
|
||||
lambda creds, ids: details_calls.append(list(ids))
|
||||
or [
|
||||
{
|
||||
"youtube_video_id": vid,
|
||||
"youtube_channel_id": channel.youtube_channel_id,
|
||||
"title": f"Title {vid}",
|
||||
"description": "",
|
||||
"thumbnail_url": None,
|
||||
"published_at": "2026-09-10T12:00:00Z",
|
||||
"duration_iso8601": None,
|
||||
}
|
||||
for vid in ids
|
||||
],
|
||||
)
|
||||
|
||||
result = sync.sync_videos(db_session)
|
||||
|
||||
assert result["videos_added"] == 2
|
||||
assert len(client.requests) == 2 # stopped on page 2, page 3 never fetched
|
||||
assert set(details_calls[0]) == {"new1", "new2"}
|
||||
assert db_session.query(Video).count() == 62
|
||||
|
||||
|
||||
def test_sync_videos_stops_at_backfill_cap(monkeypatch, db_session):
|
||||
"""More new videos than the cap: pagination stops once the cap is reached
|
||||
and details are fetched for exactly the capped candidates."""
|
||||
channel = _seed_channel(db_session)
|
||||
cap = settings.videos_backfill_cap
|
||||
pages = [_page([f"vid{page * 50 + i}" for i in range(50)], next_token=f"p{page + 2}") for page in range(6)]
|
||||
client = _FakeClient(pages)
|
||||
details_calls = []
|
||||
|
||||
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: _FakeCredentials())
|
||||
monkeypatch.setattr(sync.youtube_client.httpx, "Client", lambda timeout: client)
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client,
|
||||
"fetch_videos_details",
|
||||
lambda creds, ids: details_calls.append(list(ids))
|
||||
or [
|
||||
{
|
||||
"youtube_video_id": vid,
|
||||
"youtube_channel_id": channel.youtube_channel_id,
|
||||
"title": f"Title {vid}",
|
||||
"description": "",
|
||||
"thumbnail_url": None,
|
||||
"published_at": "2026-09-10T12:00:00Z",
|
||||
"duration_iso8601": None,
|
||||
}
|
||||
for vid in ids
|
||||
],
|
||||
)
|
||||
|
||||
result = sync.sync_videos(db_session)
|
||||
|
||||
assert len(client.requests) == 4 # 4 pages x 50 = cap reached
|
||||
assert result["videos_added"] == cap
|
||||
assert len(details_calls[0]) == cap
|
||||
assert db_session.query(Video).count() == cap
|
||||
|
||||
|
||||
def test_sync_videos_cap_is_hard_history_depth_limit(monkeypatch, db_session):
|
||||
"""videos_backfill_cap is an intentional history-depth limit, not a
|
||||
per-sync batch: a 250-video channel ingests only the newest 200 videos
|
||||
ever; a later sync without new videos stops after one page of known ids
|
||||
and adds nothing; a new video on top of the playlist is picked up while
|
||||
the beyond-the-cap history never surfaces."""
|
||||
channel = _seed_channel(db_session)
|
||||
cap = settings.videos_backfill_cap
|
||||
|
||||
def _details(creds, ids):
|
||||
return [
|
||||
{
|
||||
"youtube_video_id": vid,
|
||||
"youtube_channel_id": channel.youtube_channel_id,
|
||||
"title": f"Title {vid}",
|
||||
"description": "",
|
||||
"thumbnail_url": None,
|
||||
"published_at": "2026-09-10T12:00:00Z",
|
||||
"duration_iso8601": None,
|
||||
}
|
||||
for vid in ids
|
||||
]
|
||||
|
||||
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: _FakeCredentials())
|
||||
monkeypatch.setattr(sync.youtube_client, "fetch_videos_details", _details)
|
||||
|
||||
# Uploads playlist, newest first: vid249 .. vid0.
|
||||
playlist = [f"vid{i}" for i in range(249, -1, -1)]
|
||||
|
||||
def _run(ids):
|
||||
client = _FakeClient(
|
||||
[_page(ids[i : i + 50], next_token=f"page{i // 50}") for i in range(0, len(ids), 50)]
|
||||
)
|
||||
monkeypatch.setattr(sync.youtube_client.httpx, "Client", lambda timeout: client)
|
||||
return sync.sync_videos(db_session), client
|
||||
|
||||
# First sync: 250 videos, only the newest 200 fit under the cap.
|
||||
result1, client1 = _run(playlist)
|
||||
assert result1["videos_added"] == cap
|
||||
assert db_session.query(Video).count() == cap
|
||||
assert len(client1.requests) == 4 # 4 pages x 50 = cap, older pages untouched
|
||||
synced = {v.youtube_video_id for v in db_session.query(Video).all()}
|
||||
assert synced == {f"vid{i}" for i in range(50, 250)}
|
||||
assert "vid49" not in synced # older than the cap: never backfilled by design
|
||||
|
||||
# Second sync, nothing new: one page of 50 known ids in a row stops it.
|
||||
result2, client2 = _run(playlist)
|
||||
assert result2["videos_added"] == 0
|
||||
assert len(client2.requests) == 1
|
||||
assert db_session.query(Video).count() == cap
|
||||
|
||||
# Third sync, one new video on top: only that one is added, the
|
||||
# beyond-the-cap history still does not surface.
|
||||
result3, client3 = _run(["vid250", *playlist])
|
||||
assert result3["videos_added"] == 1
|
||||
assert len(client3.requests) == 2 # page 2 needed to confirm 50 known in a row
|
||||
synced_after = {v.youtube_video_id for v in db_session.query(Video).all()}
|
||||
assert synced_after == {f"vid{i}" for i in range(50, 251)}
|
||||
assert "vid49" not in synced_after
|
||||
|
||||
|
||||
def test_is_videos_sync_running_reflects_lock():
|
||||
sync._videos_lock.acquire()
|
||||
try:
|
||||
assert sync.is_videos_sync_running() is True
|
||||
finally:
|
||||
sync._videos_lock.release()
|
||||
assert sync.is_videos_sync_running() is False
|
||||
|
|
|
|||
159
tests/test_sync_trigger.py
Normal file
159
tests/test_sync_trigger.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.services import sync_trigger
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def test_is_videos_sync_due_without_finished_at():
|
||||
assert sync_trigger.is_videos_sync_due(None, _now(), 2) is True
|
||||
|
||||
|
||||
def test_is_videos_sync_due_after_idle():
|
||||
finished = (_now() - timedelta(hours=3)).isoformat()
|
||||
assert sync_trigger.is_videos_sync_due(finished, _now(), 2) is True
|
||||
|
||||
|
||||
def test_is_videos_sync_due_at_exact_threshold():
|
||||
finished = (_now() - timedelta(hours=2)).isoformat()
|
||||
assert sync_trigger.is_videos_sync_due(finished, _now(), 2) is True
|
||||
|
||||
|
||||
def test_is_videos_sync_due_before_idle():
|
||||
finished = (_now() - timedelta(hours=1)).isoformat()
|
||||
assert sync_trigger.is_videos_sync_due(finished, _now(), 2) is False
|
||||
|
||||
|
||||
def test_is_videos_sync_due_unparseable_finished_at():
|
||||
assert sync_trigger.is_videos_sync_due("not-a-date", _now(), 2) is True
|
||||
assert sync_trigger.is_videos_sync_due(12345, _now(), 2) is True # non-str garbage
|
||||
|
||||
|
||||
def test_is_videos_sync_due_naive_finished_at():
|
||||
naive = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=3)
|
||||
assert sync_trigger.is_videos_sync_due(naive.isoformat(), _now(), 2) is True
|
||||
|
||||
|
||||
def test_extract_finished_at():
|
||||
assert sync_trigger._extract_finished_at(None) is None
|
||||
assert sync_trigger._extract_finished_at("not json") is None
|
||||
assert sync_trigger._extract_finished_at(json.dumps(["a"])) is None
|
||||
assert sync_trigger._extract_finished_at(json.dumps({"status": "running", "finished_at": None})) is None
|
||||
assert (
|
||||
sync_trigger._extract_finished_at(json.dumps({"status": "completed", "finished_at": "2026-09-10T12:00:00+00:00"}))
|
||||
== "2026-09-10T12:00:00+00:00"
|
||||
)
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _patch_threads(monkeypatch, started):
|
||||
monkeypatch.setattr(
|
||||
sync_trigger,
|
||||
"threading",
|
||||
SimpleNamespace(Thread=lambda target=None, daemon=None: started.append({"target": target, "daemon": daemon})),
|
||||
)
|
||||
|
||||
|
||||
def test_maybe_trigger_skips_when_sync_running(monkeypatch):
|
||||
started = []
|
||||
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
sync_trigger,
|
||||
"SessionLocal",
|
||||
lambda: (_ for _ in ()).throw(AssertionError("DB must not be touched while running")),
|
||||
)
|
||||
_patch_threads(monkeypatch, started)
|
||||
|
||||
sync_trigger.maybe_trigger_videos_sync()
|
||||
|
||||
assert started == []
|
||||
|
||||
|
||||
def test_maybe_trigger_skips_when_not_due(monkeypatch):
|
||||
started = []
|
||||
db = _FakeDB()
|
||||
raw = json.dumps({"status": "completed", "finished_at": (_now() - timedelta(hours=1)).isoformat()})
|
||||
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: False)
|
||||
monkeypatch.setattr(sync_trigger, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(sync_trigger, "get_setting", lambda session, key: raw)
|
||||
_patch_threads(monkeypatch, started)
|
||||
|
||||
sync_trigger.maybe_trigger_videos_sync()
|
||||
|
||||
assert started == []
|
||||
assert db.closed is True
|
||||
|
||||
|
||||
def test_maybe_trigger_starts_thread_when_due_never_synced(monkeypatch):
|
||||
started = []
|
||||
db = _FakeDB()
|
||||
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: False)
|
||||
monkeypatch.setattr(sync_trigger, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(sync_trigger, "get_setting", lambda session, key: None)
|
||||
_patch_threads(monkeypatch, started)
|
||||
|
||||
sync_trigger.maybe_trigger_videos_sync()
|
||||
|
||||
assert len(started) == 1
|
||||
assert started[0]["target"] == sync_trigger._run_videos_sync
|
||||
assert started[0]["daemon"] is True
|
||||
assert db.closed is True
|
||||
|
||||
|
||||
def test_maybe_trigger_starts_thread_when_due_after_idle(monkeypatch):
|
||||
started = []
|
||||
db = _FakeDB()
|
||||
raw = json.dumps({"status": "completed", "finished_at": (_now() - timedelta(hours=5)).isoformat()})
|
||||
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: False)
|
||||
monkeypatch.setattr(sync_trigger, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(sync_trigger, "get_setting", lambda session, key: raw)
|
||||
_patch_threads(monkeypatch, started)
|
||||
|
||||
sync_trigger.maybe_trigger_videos_sync()
|
||||
|
||||
assert len(started) == 1
|
||||
assert started[0]["daemon"] is True
|
||||
|
||||
|
||||
def test_maybe_trigger_never_raises_when_db_unavailable(monkeypatch):
|
||||
started = []
|
||||
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
sync_trigger,
|
||||
"SessionLocal",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("db down")),
|
||||
)
|
||||
_patch_threads(monkeypatch, started)
|
||||
|
||||
sync_trigger.maybe_trigger_videos_sync() # must not raise
|
||||
|
||||
assert started == []
|
||||
|
||||
|
||||
def test_run_videos_sync_swallows_sync_in_progress(monkeypatch):
|
||||
closed = []
|
||||
monkeypatch.setattr(
|
||||
sync_trigger,
|
||||
"SessionLocal",
|
||||
lambda: SimpleNamespace(close=lambda: closed.append(True)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sync_trigger.sync,
|
||||
"sync_videos",
|
||||
lambda db: (_ for _ in ()).throw(sync_trigger.sync.SyncInProgress("busy")),
|
||||
)
|
||||
|
||||
sync_trigger._run_videos_sync() # must not raise
|
||||
|
||||
assert closed == [True]
|
||||
|
|
@ -182,16 +182,49 @@ def test_delete_download_success(client, db_session, monkeypatch):
|
|||
calls = {}
|
||||
monkeypatch.setattr(
|
||||
"app.services.metube_client.MeTubeClient.delete_download",
|
||||
lambda self, metube_job_id: calls.setdefault("id", metube_job_id) or {"status": "ok"},
|
||||
lambda self, youtube_url: calls.setdefault("youtube_url", youtube_url) or {"status": "ok"},
|
||||
)
|
||||
monkeypatch.setattr("app.services.metube_client.MeTubeClient.check_media", lambda self, url: False)
|
||||
|
||||
resp = client.delete(f"/api/videos/{video.youtube_video_id}/download")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "deleted"
|
||||
assert resp.json()["media_url"] is None
|
||||
assert calls["id"] == "vid1.vid1"
|
||||
# MeTube's queue/done stores are keyed by URL, not by the id it reports
|
||||
# over Socket.IO -- passing the wrong one is exactly the bug this
|
||||
# regression test guards against (it silently no-ops on MeTube's side).
|
||||
assert calls["youtube_url"] == video.youtube_url
|
||||
|
||||
db_session.refresh(job)
|
||||
assert job.status == "deleted"
|
||||
assert job.media_url is None
|
||||
|
||||
|
||||
def test_delete_download_file_still_present_returns_409(client, db_session, monkeypatch):
|
||||
"""MeTube can accept /delete and still leave the file on disk if its own
|
||||
DELETE_FILE_ON_TRASHCAN config is off -- we must not lie about that."""
|
||||
from app.models.download_job import DownloadJob
|
||||
|
||||
video = _seed_single_video(db_session)
|
||||
job = DownloadJob(
|
||||
video_id=video.id,
|
||||
status="completed",
|
||||
metube_job_id="vid1.vid1",
|
||||
media_url="http://metube.local/download/f.mp4",
|
||||
)
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.metube_client.MeTubeClient.delete_download", lambda self, youtube_url: {"status": "ok"}
|
||||
)
|
||||
monkeypatch.setattr("app.services.metube_client.MeTubeClient.check_media", lambda self, url: True)
|
||||
|
||||
resp = client.delete(f"/api/videos/{video.youtube_video_id}/download")
|
||||
|
||||
assert resp.status_code == 409
|
||||
|
||||
db_session.refresh(job)
|
||||
assert job.status == "completed"
|
||||
assert job.media_url == "http://metube.local/download/f.mp4"
|
||||
|
|
|
|||
|
|
@ -115,6 +115,160 @@ def test_fetch_playlist_video_ids_returns_empty_on_404(monkeypatch):
|
|||
assert result == []
|
||||
|
||||
|
||||
def test_fetch_playlist_video_ids_paginates(monkeypatch):
|
||||
page1 = FakeResponse(
|
||||
200,
|
||||
{
|
||||
"items": [{"contentDetails": {"videoId": f"vid{i}"}} for i in range(50)],
|
||||
"nextPageToken": "page2",
|
||||
},
|
||||
)
|
||||
page2 = FakeResponse(
|
||||
200,
|
||||
{"items": [{"contentDetails": {"videoId": f"vid{i}"}} for i in range(50, 80)]},
|
||||
)
|
||||
seen = []
|
||||
|
||||
class RecordingClient(FakeClient):
|
||||
def get(self, url, params=None, headers=None):
|
||||
seen.append({"url": url, "params": params})
|
||||
return self._responses.pop(0)
|
||||
|
||||
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: RecordingClient([page1, page2]))
|
||||
|
||||
result = youtube_client.fetch_playlist_video_ids(_credentials(), "UUplaylist", 80)
|
||||
|
||||
assert result == [f"vid{i}" for i in range(80)]
|
||||
assert len(seen) == 2
|
||||
assert seen[0]["params"]["maxResults"] == 50
|
||||
assert "pageToken" not in seen[0]["params"]
|
||||
assert seen[1]["params"]["maxResults"] == 30
|
||||
assert seen[1]["params"]["pageToken"] == "page2"
|
||||
assert seen[0]["params"]["part"] == "contentDetails"
|
||||
assert seen[1]["params"]["part"] == "contentDetails"
|
||||
|
||||
|
||||
def test_fetch_playlist_video_ids_stops_at_safety_cap(monkeypatch):
|
||||
page = FakeResponse(
|
||||
200,
|
||||
{"items": [{"contentDetails": {"videoId": "vid"}}], "nextPageToken": "next"},
|
||||
)
|
||||
seen = []
|
||||
|
||||
class RecordingClient(FakeClient):
|
||||
def get(self, url, params=None, headers=None):
|
||||
seen.append(params)
|
||||
return self._responses.pop(0)
|
||||
|
||||
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: RecordingClient([page] * 100))
|
||||
|
||||
result = youtube_client.fetch_playlist_video_ids(_credentials(), "UUplaylist", 10000)
|
||||
|
||||
assert len(result) == youtube_client.MAX_PLAYLIST_PAGES
|
||||
assert len(seen) == youtube_client.MAX_PLAYLIST_PAGES
|
||||
assert "pageToken" not in seen[0]
|
||||
assert seen[1]["pageToken"] == "next"
|
||||
|
||||
|
||||
def _page_items(video_ids, next_token=None):
|
||||
payload = {"items": [{"contentDetails": {"videoId": vid}} for vid in video_ids]}
|
||||
if next_token:
|
||||
payload["nextPageToken"] = next_token
|
||||
return FakeResponse(200, payload)
|
||||
|
||||
|
||||
def test_fetch_playlist_video_ids_incremental_returns_only_unknown(monkeypatch):
|
||||
page = _page_items(["new1", "known1", "new2", "known2"])
|
||||
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([page]))
|
||||
|
||||
result = youtube_client.fetch_playlist_video_ids_incremental(
|
||||
_credentials(), "UUplaylist", known_ids={"known1", "known2"}, max_results=10, stop_threshold=50
|
||||
)
|
||||
|
||||
assert result == ["new1", "new2"]
|
||||
|
||||
|
||||
def test_fetch_playlist_video_ids_incremental_stops_on_known_run(monkeypatch):
|
||||
page1 = _page_items([f"known{i}" for i in range(50)], next_token="page2")
|
||||
page2 = _page_items(["new1", "new2"], next_token="page3")
|
||||
seen = []
|
||||
|
||||
class RecordingClient(FakeClient):
|
||||
def get(self, url, params=None, headers=None):
|
||||
seen.append(params)
|
||||
return self._responses.pop(0)
|
||||
|
||||
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: RecordingClient([page1, page2]))
|
||||
|
||||
result = youtube_client.fetch_playlist_video_ids_incremental(
|
||||
_credentials(), "UUplaylist", known_ids={f"known{i}" for i in range(50)}, max_results=10, stop_threshold=50
|
||||
)
|
||||
|
||||
assert result == []
|
||||
assert len(seen) == 1 # page2 never requested
|
||||
|
||||
|
||||
def test_fetch_playlist_video_ids_incremental_known_run_crosses_pages(monkeypatch):
|
||||
page1 = _page_items(["new1"] + [f"known{i}" for i in range(49)], next_token="page2")
|
||||
page2 = _page_items([f"known{i}" for i in range(49, 60)], next_token="page3")
|
||||
seen = []
|
||||
|
||||
class RecordingClient(FakeClient):
|
||||
def get(self, url, params=None, headers=None):
|
||||
seen.append(params)
|
||||
return self._responses.pop(0)
|
||||
|
||||
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: RecordingClient([page1, page2]))
|
||||
|
||||
result = youtube_client.fetch_playlist_video_ids_incremental(
|
||||
_credentials(), "UUplaylist", known_ids={f"known{i}" for i in range(60)}, max_results=10, stop_threshold=50
|
||||
)
|
||||
|
||||
assert result == ["new1"]
|
||||
assert len(seen) == 2 # stopped mid-page-2, page3 never requested
|
||||
|
||||
|
||||
def test_fetch_playlist_video_ids_incremental_stops_at_cap(monkeypatch):
|
||||
pages = [_page_items([f"vid{page * 50 + i}" for i in range(50)], next_token=f"p{page + 2}") for page in range(6)]
|
||||
seen = []
|
||||
|
||||
class RecordingClient(FakeClient):
|
||||
def get(self, url, params=None, headers=None):
|
||||
seen.append(params)
|
||||
return self._responses.pop(0)
|
||||
|
||||
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: RecordingClient(pages))
|
||||
|
||||
result = youtube_client.fetch_playlist_video_ids_incremental(
|
||||
_credentials(), "UUplaylist", known_ids=set(), max_results=200, stop_threshold=50
|
||||
)
|
||||
|
||||
assert len(result) == 200
|
||||
assert len(seen) == 4
|
||||
|
||||
|
||||
def test_fetch_playlist_video_ids_incremental_empty_on_404(monkeypatch):
|
||||
response = FakeResponse(404, {"error": {"message": "playlist not found"}})
|
||||
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([response]))
|
||||
|
||||
result = youtube_client.fetch_playlist_video_ids_incremental(
|
||||
_credentials(), "UUplaylist", known_ids=set(), max_results=10, stop_threshold=50
|
||||
)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_fetch_playlist_video_ids_incremental_dedupes_new_ids(monkeypatch):
|
||||
page = _page_items(["new1", "new1", "known1"])
|
||||
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([page]))
|
||||
|
||||
result = youtube_client.fetch_playlist_video_ids_incremental(
|
||||
_credentials(), "UUplaylist", known_ids={"known1"}, max_results=10, stop_threshold=50
|
||||
)
|
||||
|
||||
assert result == ["new1"]
|
||||
|
||||
|
||||
def test_fetch_videos_details(monkeypatch):
|
||||
response = FakeResponse(
|
||||
200,
|
||||
|
|
@ -194,13 +348,48 @@ def test_fetch_uploads_playlists_batches(monkeypatch):
|
|||
200,
|
||||
{
|
||||
"items": [
|
||||
{"id": "chanA", "contentDetails": {"relatedPlaylists": {"uploads": "UUchanA"}}},
|
||||
{"id": "chanB", "contentDetails": {"relatedPlaylists": {"uploads": "UUchanB"}}},
|
||||
{
|
||||
"id": "chanA",
|
||||
"contentDetails": {"relatedPlaylists": {"uploads": "UUchanA"}},
|
||||
"statistics": {"subscriberCount": "12345"},
|
||||
},
|
||||
{
|
||||
"id": "chanB",
|
||||
"contentDetails": {"relatedPlaylists": {"uploads": "UUchanB"}},
|
||||
"statistics": {"hiddenSubscriberCount": True},
|
||||
},
|
||||
{"id": "chanC", "contentDetails": {"relatedPlaylists": {"uploads": "UUchanC"}}},
|
||||
{"id": "chanD", "contentDetails": {"relatedPlaylists": {}}, "statistics": {"subscriberCount": "42"}},
|
||||
]
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([response]))
|
||||
|
||||
result = youtube_client.fetch_uploads_playlists(_credentials(), ["chanA", "chanB"])
|
||||
result = youtube_client.fetch_uploads_playlists(_credentials(), ["chanA", "chanB", "chanC", "chanD"])
|
||||
|
||||
assert result == {"chanA": "UUchanA", "chanB": "UUchanB"}
|
||||
assert result == {
|
||||
"chanA": {"uploads_playlist_id": "UUchanA", "subscriber_count": 12345},
|
||||
"chanB": {"uploads_playlist_id": "UUchanB", "subscriber_count": None},
|
||||
"chanC": {"uploads_playlist_id": "UUchanC", "subscriber_count": None},
|
||||
"chanD": {"uploads_playlist_id": None, "subscriber_count": 42},
|
||||
}
|
||||
|
||||
|
||||
def test_fetch_uploads_playlists_subscriber_count_not_an_int_is_none(monkeypatch):
|
||||
response = FakeResponse(
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "chanA",
|
||||
"contentDetails": {"relatedPlaylists": {"uploads": "UUchanA"}},
|
||||
"statistics": {"subscriberCount": "not-a-number"},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([response]))
|
||||
|
||||
result = youtube_client.fetch_uploads_playlists(_credentials(), ["chanA"])
|
||||
|
||||
assert result == {"chanA": {"uploads_playlist_id": "UUchanA", "subscriber_count": None}}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue