Compare commits
No commits in common. "e10df8dcbd18ef43ce089238dfa92703922cfd1f" and "8ddea8762dfecefde01963310a75cd821b92691e" have entirely different histories.
e10df8dcbd
...
8ddea8762d
64 changed files with 3837 additions and 2984 deletions
21
.env.example
21
.env.example
|
|
@ -12,30 +12,13 @@ GOOGLE_CLIENT_SECRET=
|
||||||
GOOGLE_REDIRECT_URI=https://testmyyoutube.vrubel.xyz/api/auth/google/callback
|
GOOGLE_REDIRECT_URI=https://testmyyoutube.vrubel.xyz/api/auth/google/callback
|
||||||
ALLOWED_GOOGLE_EMAIL=
|
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_API_BASE_URL=http://192.168.8.177:8081
|
||||||
METUBE_PUBLIC_BASE_URL=http://192.168.8.177:8081
|
METUBE_PUBLIC_BASE_URL=http://192.168.8.177:8081
|
||||||
METUBE_CONTAINER_DOWNLOAD_DIR=/downloads
|
METUBE_CONTAINER_DOWNLOAD_DIR=/downloads
|
||||||
METUBE_REQUEST_TIMEOUT_SECONDS=30
|
METUBE_REQUEST_TIMEOUT_SECONDS=30
|
||||||
|
|
||||||
SUBSCRIPTIONS_SYNC_INTERVAL_HOURS=6
|
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
|
LOG_LEVEL=INFO
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
---
|
|
||||||
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`.
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
---
|
|
||||||
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`.
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
---
|
|
||||||
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,31 +1,44 @@
|
||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
Правила для персонального YouTube-клиента с категориями подписок и интеграцией с существующим MeTube. Основные этапы разработки (каркас, OAuth/подписки, категории, лента, воспроизведение и MeTube) уже реализованы; ориентируйся на фактический код и `README.md`.
|
Полное ТЗ: [`youtube_categories_metube_TZ.md`](./youtube_categories_metube_TZ.md). Перед любой существенной работой сверяйся с ним — этот файл лишь выжимка ограничений.
|
||||||
|
|
||||||
## Архитектурные ограничения
|
## Жёсткие ограничения (раздел 34 ТЗ)
|
||||||
|
|
||||||
1. Не форкать/копировать Tube Archivist.
|
1. Не форкать/копировать Tube Archivist.
|
||||||
2. Не встраивать yt-dlp в этот сервис — скачивание выполняет существующий MeTube на `mediaVM`.
|
2. Не встраивать yt-dlp в этот сервис — скачивание только через существующий MeTube (`mediaVM`).
|
||||||
3. Не дублировать функции скачивания MeTube.
|
3. Не дублировать функции скачивания MeTube.
|
||||||
4. Не монтировать media storage MeTube на `hermesVM`: видео не хранится локально, только проксируется или линкуется.
|
4. Не монтировать media storage MeTube на `hermesVM` — видео не хранится локально, только проксируется/линкуется.
|
||||||
5. Не реализовывать YouTube Recommendations/Home.
|
5. Не реализовывать YouTube Recommendations/Home.
|
||||||
6. Приложение остаётся single-user (`ALLOWED_GOOGLE_EMAIL`).
|
6. Не делать multi-user — приложение строго single-user (`ALLOWED_GOOGLE_EMAIL`).
|
||||||
7. Frontend не вызывает Google API напрямую.
|
7. Frontend не должен напрямую вызывать Google API.
|
||||||
8. Frontend не вызывает MeTube write API (`POST /add` и т.п.) напрямую — только через наш backend.
|
8. Frontend не должен напрямую вызывать MeTube write API (`POST /add` и т.п.) — только через наш backend.
|
||||||
9. Не восстанавливать имя скачанного файла из YouTube title: использовать точное имя из события MeTube `completed`.
|
9. Не восстанавливать filename скачанного видео из YouTube title — использовать только точное имя, пришедшее от MeTube по событию `completed`.
|
||||||
10. Не удалять существующие файлы MeTube при синхронизации или обслуживании; удаление по явному действию пользователя — отдельный поддерживаемый сценарий.
|
10. Не удалять существующие файлы MeTube.
|
||||||
11. Не менять исходный код MeTube.
|
11. Не менять исходный код MeTube.
|
||||||
12. Все внешние base URL (MeTube, Google) задаются через env/config.
|
12. Все внешние base URL (MeTube, Google) — через env/config, не хардкодить.
|
||||||
13. REST API должен оставаться пригодным для будущего mobile/PWA клиента.
|
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/app`. Схему БД изменять миграциями, не создавать и не менять ad hoc при старте приложения.
|
- `migrations/` — Alembic, живёт в корне репозитория (не внутри `backend/`), импортирует модели из `backend/app`.
|
||||||
- MeTube-специфичные HTTP/Socket.IO вызовы инкапсулировать в `MeTubeClient`, не размазывать по backend.
|
- Схема БД добавляется миграциями инкрементально по фазам (см. раздел 11 ТЗ), а не одним махом в Phase 1.
|
||||||
- Секреты хранить только в `.env` (образец — `.env.example`); не коммитить `.env`, refresh token или client secret.
|
- MeTube-специфичные HTTP/Socket.IO вызовы должны быть инкапсулированы в отдельный класс `MeTubeClient` (раздел 36 ТЗ) — не размазывать по backend.
|
||||||
- Backend тесты — `pytest` в корневом `tests/`. Интеграции с Google/MeTube по умолчанию mock; реальные вызовы к `http://192.168.8.177:8081` — только opt-in, не в обычном CI.
|
- Секреты только через `.env` (см. `.env.example`), никогда не коммитить `.env`, refresh token, client secret.
|
||||||
- В unit-тестах использовать `TestClient(app)` без `with`: контекстный менеджер запускает lifespan с подключением к MeTube (Socket.IO) и Postgres (`reconcile_on_startup`).
|
- 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) — так и сделано во всех текущих тестах.
|
||||||
|
|
||||||
## Запуск и проверка
|
## Запуск/проверка
|
||||||
|
|
||||||
См. `README.md`. Быстрая проверка: `docker compose up -d --build` и `curl http://localhost:8080/api/health`.
|
См. `README.md`. Быстрая проверка: `docker compose up -d --build` и `curl http://localhost:8080/api/health`.
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
# Агентская команда 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,11 +25,6 @@ WORKDIR /app
|
||||||
COPY entrypoint.sh ./entrypoint.sh
|
COPY entrypoint.sh ./entrypoint.sh
|
||||||
RUN chmod +x ./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
|
EXPOSE 8080
|
||||||
|
|
||||||
ENTRYPOINT ["./entrypoint.sh"]
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
|
|
|
||||||
40
README.md
40
README.md
|
|
@ -1,23 +1,9 @@
|
||||||
# MyYouTube
|
# MyYouTube
|
||||||
|
|
||||||
Персональный YouTube-клиент с категориями подписок и интеграцией с существующим MeTube.
|
Персональный YouTube-клиент с категориями подписок и интеграцией с существующим MeTube.
|
||||||
Работа агентской команды (opencode) описана в [руководстве по агентской команде](./AGENT_TEAM.md).
|
Полное техническое задание: [`youtube_categories_metube_TZ.md`](./youtube_categories_metube_TZ.md).
|
||||||
|
|
||||||
Текущий статус: **функционально готово** (основные этапы 1–6 и доработки по итогам тестирования). Развёрнуто и вручную протестировано на тестовом окружении (`testmyyoutube.vrubel.xyz` → hermesVM `192.168.8.173` → MeTube на mediaVM `192.168.8.177`).
|
Текущий статус: **Phase 1 — skeleton** (FastAPI backend, PostgreSQL, Alembic, React frontend, Docker Compose, healthcheck).
|
||||||
|
|
||||||
### Что сделано
|
|
||||||
|
|
||||||
- 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).
|
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
|
|
@ -45,25 +31,6 @@
|
||||||
|
|
||||||
## Локальная разработка
|
## Локальная разработка
|
||||||
|
|
||||||
### Агентская команда (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
|
### Backend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -114,8 +81,7 @@ myyoutube/
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── compose.yml
|
├── compose.yml
|
||||||
├── .env / .env.example
|
├── .env / .env.example
|
||||||
├── .opencode/ # агенты команды (orchestrator/coder/reviewer/tester)
|
└── youtube_categories_metube_TZ.md
|
||||||
└── AGENT_TEAM.md # руководство по агентской команде opencode
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Секреты
|
## Секреты
|
||||||
|
|
|
||||||
1018
UI_UX_spec.md
Normal file
1018
UI_UX_spec.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -17,16 +17,11 @@ router = APIRouter()
|
||||||
|
|
||||||
@router.get("/auth/status")
|
@router.get("/auth/status")
|
||||||
def auth_status(request: Request, db: Session = Depends(get_db)) -> dict:
|
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)
|
connected = google_oauth.is_connected(db)
|
||||||
email = google_oauth.get_connected_email(db) if authenticated and connected else None
|
|
||||||
return {
|
return {
|
||||||
"authenticated": authenticated,
|
"authenticated": bool(request.session.get("authenticated")),
|
||||||
"connected": connected,
|
"connected": connected,
|
||||||
"email": email,
|
"email": google_oauth.get_connected_email(db) if connected else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,14 @@
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.core.auth_dependency import require_session
|
from app.core.auth_dependency import require_session
|
||||||
from app.core.slugify import unique_slugify
|
from app.core.slugify import unique_slugify
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from app.models.channel import Channel
|
|
||||||
from app.models.channel_category import channel_categories
|
from app.models.channel_category import channel_categories
|
||||||
from app.models.video import Video
|
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(require_session)])
|
router = APIRouter(dependencies=[Depends(require_session)])
|
||||||
|
|
||||||
|
|
@ -45,15 +40,13 @@ def _name_taken(db: Session, name: str, exclude_id: int | None = None) -> bool:
|
||||||
return any(row[0].casefold() == target for row in query.all())
|
return any(row[0].casefold() == target for row in query.all())
|
||||||
|
|
||||||
|
|
||||||
def _serialize(db: Session, category: Category, counts: dict[int, int], new_videos: dict[int, int] | None = None) -> dict:
|
def _serialize(db: Session, category: Category, counts: dict[int, int]) -> dict:
|
||||||
new_videos = new_videos or {}
|
|
||||||
return {
|
return {
|
||||||
"id": category.id,
|
"id": category.id,
|
||||||
"name": category.name,
|
"name": category.name,
|
||||||
"slug": category.slug,
|
"slug": category.slug,
|
||||||
"sort_order": category.sort_order,
|
"sort_order": category.sort_order,
|
||||||
"channel_count": counts.get(category.id, 0),
|
"channel_count": counts.get(category.id, 0),
|
||||||
"new_videos_count": new_videos.get(category.id, 0),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -66,17 +59,7 @@ def list_categories(db: Session = Depends(get_db)) -> list[dict]:
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
counts = dict(count_rows)
|
counts = dict(count_rows)
|
||||||
since = datetime.now(timezone.utc) - timedelta(days=settings.new_videos_window_days)
|
return [_serialize(db, c, counts) for c in categories]
|
||||||
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)
|
@router.post("/categories", status_code=201)
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,15 @@
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.core.auth_dependency import require_session
|
from app.core.auth_dependency import require_session
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from app.models.channel import Channel
|
from app.models.channel import Channel
|
||||||
from app.models.channel_category import channel_categories
|
from app.models.channel_category import channel_categories
|
||||||
from app.models.video import Video
|
|
||||||
from app.services import sync
|
from app.services import sync
|
||||||
from app.services.google_oauth import OAuthNotConnected
|
from app.services.google_oauth import OAuthNotConnected
|
||||||
from app.services.youtube_client import YouTubeAPIError, YouTubeInsufficientScope, YouTubeQuotaExceeded
|
from app.services.youtube_client import YouTubeAPIError, YouTubeInsufficientScope, YouTubeQuotaExceeded
|
||||||
|
|
@ -40,22 +37,7 @@ def _category_ids_by_channel(db: Session, channel_ids: list[int]) -> dict[int, l
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _new_videos_counts(db: Session, channel_ids: list[int]) -> dict[int, int]:
|
def _serialize(channel: Channel, category_ids: list[int]) -> dict:
|
||||||
"""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 {
|
return {
|
||||||
"id": channel.id,
|
"id": channel.id,
|
||||||
"youtube_channel_id": channel.youtube_channel_id,
|
"youtube_channel_id": channel.youtube_channel_id,
|
||||||
|
|
@ -63,11 +45,9 @@ def _serialize(channel: Channel, category_ids: list[int], new_videos_count: int
|
||||||
"description": channel.description,
|
"description": channel.description,
|
||||||
"thumbnail_url": channel.thumbnail_url,
|
"thumbnail_url": channel.thumbnail_url,
|
||||||
"uploads_playlist_id": channel.uploads_playlist_id,
|
"uploads_playlist_id": channel.uploads_playlist_id,
|
||||||
"subscriber_count": channel.subscriber_count,
|
|
||||||
"subscribed": channel.subscribed,
|
"subscribed": channel.subscribed,
|
||||||
"last_synced_at": channel.last_synced_at,
|
"last_synced_at": channel.last_synced_at,
|
||||||
"category_ids": category_ids,
|
"category_ids": category_ids,
|
||||||
"new_videos_count": new_videos_count,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -95,10 +75,8 @@ def list_channels(
|
||||||
query = query.filter(Channel.id.in_(channel_ids_in_category))
|
query = query.filter(Channel.id.in_(channel_ids_in_category))
|
||||||
|
|
||||||
channels = query.order_by(Channel.title.asc()).all()
|
channels = query.order_by(Channel.title.asc()).all()
|
||||||
channel_ids = [c.id for c in channels]
|
category_map = _category_ids_by_channel(db, [c.id for c in channels])
|
||||||
category_map = _category_ids_by_channel(db, channel_ids)
|
return [_serialize(c, category_map.get(c.id, [])) for c in channels]
|
||||||
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}")
|
@router.get("/channels/{channel_id}")
|
||||||
|
|
@ -107,8 +85,7 @@ def get_channel(channel_id: int, db: Session = Depends(get_db)) -> dict:
|
||||||
if channel is None:
|
if channel is None:
|
||||||
raise HTTPException(status_code=404, detail="Channel not found")
|
raise HTTPException(status_code=404, detail="Channel not found")
|
||||||
category_map = _category_ids_by_channel(db, [channel_id])
|
category_map = _category_ids_by_channel(db, [channel_id])
|
||||||
new_videos_map = _new_videos_counts(db, [channel_id])
|
return _serialize(channel, category_map.get(channel_id, []))
|
||||||
return _serialize(channel, category_map.get(channel_id, []), new_videos_map.get(channel_id, 0))
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/channels/{channel_id}/categories")
|
@router.put("/channels/{channel_id}/categories")
|
||||||
|
|
@ -133,8 +110,7 @@ def set_channel_categories(channel_id: int, payload: ChannelCategoriesUpdate, db
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
new_videos_map = _new_videos_counts(db, [channel_id])
|
return _serialize(channel, sorted(unique_ids))
|
||||||
return _serialize(channel, sorted(unique_ids), new_videos_map.get(channel_id, 0))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/channels/{channel_id}/unsubscribe")
|
@router.post("/channels/{channel_id}/unsubscribe")
|
||||||
|
|
@ -163,5 +139,4 @@ def unsubscribe_channel(channel_id: int, db: Session = Depends(get_db)) -> dict:
|
||||||
raise HTTPException(status_code=502, detail="YouTube is unavailable")
|
raise HTTPException(status_code=502, detail="YouTube is unavailable")
|
||||||
|
|
||||||
category_map = _category_ids_by_channel(db, [channel_id])
|
category_map = _category_ids_by_channel(db, [channel_id])
|
||||||
new_videos_map = _new_videos_counts(db, [channel_id])
|
return _serialize(channel, category_map.get(channel_id, []))
|
||||||
return _serialize(channel, category_map.get(channel_id, []), new_videos_map.get(channel_id, 0))
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
import base64
|
import base64
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.core.auth_dependency import require_session
|
from app.core.auth_dependency import require_session
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models.channel import Channel
|
from app.models.channel import Channel
|
||||||
from app.models.channel_category import channel_categories
|
from app.models.channel_category import channel_categories
|
||||||
from app.models.download_job import DownloadJob
|
|
||||||
from app.models.video import Video
|
from app.models.video import Video
|
||||||
from app.services.download_jobs import latest_jobs_map
|
from app.services.download_jobs import latest_jobs_map
|
||||||
from app.services.video_presentation import channel_categories_map, serialize_video
|
from app.services.video_presentation import channel_categories_map, serialize_video
|
||||||
|
|
@ -38,50 +36,11 @@ def _decode_cursor(cursor: str) -> tuple[datetime, int]:
|
||||||
raise HTTPException(status_code=400, detail="Invalid cursor")
|
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")
|
@router.get("/feed")
|
||||||
def get_feed(
|
def get_feed(
|
||||||
category_id: int | None = None,
|
category_id: int | None = None,
|
||||||
uncategorized: bool = False,
|
uncategorized: bool = False,
|
||||||
channel_id: int | None = None,
|
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),
|
limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT),
|
||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|
@ -99,25 +58,6 @@ def get_feed(
|
||||||
)
|
)
|
||||||
query = query.filter(Video.channel_id.in_(channel_ids_in_category))
|
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:
|
if cursor:
|
||||||
cursor_published_at, cursor_id = _decode_cursor(cursor)
|
cursor_published_at, cursor_id = _decode_cursor(cursor)
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ from app.models.channel import Channel
|
||||||
from app.models.download_job import DownloadJob
|
from app.models.download_job import DownloadJob
|
||||||
from app.models.video import Video
|
from app.models.video import Video
|
||||||
from app.services.download_jobs import (
|
from app.services.download_jobs import (
|
||||||
DeleteDidNotRemoveFile,
|
|
||||||
DeleteNotAllowed,
|
DeleteNotAllowed,
|
||||||
MeTubeRejected,
|
MeTubeRejected,
|
||||||
delete_local_copy,
|
delete_local_copy,
|
||||||
|
|
@ -79,14 +78,6 @@ def delete_download(youtube_video_id: str, db: Session = Depends(get_db)) -> dic
|
||||||
job = delete_local_copy(db, video)
|
job = delete_local_copy(db, video)
|
||||||
except DeleteNotAllowed as exc:
|
except DeleteNotAllowed as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(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:
|
except Exception:
|
||||||
logger.exception("Failed to delete local copy for %s", youtube_video_id)
|
logger.exception("Failed to delete local copy for %s", youtube_video_id)
|
||||||
raise HTTPException(status_code=502, detail="MeTube is unavailable")
|
raise HTTPException(status_code=502, detail="MeTube is unavailable")
|
||||||
|
|
|
||||||
|
|
@ -17,34 +17,14 @@ class Settings(BaseSettings):
|
||||||
google_redirect_uri: str = ""
|
google_redirect_uri: str = ""
|
||||||
allowed_google_email: 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_api_base_url: str = "http://127.0.0.1:8081"
|
||||||
metube_public_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_container_download_dir: str = "/downloads"
|
||||||
metube_request_timeout_seconds: int = 30
|
metube_request_timeout_seconds: int = 30
|
||||||
|
|
||||||
subscriptions_sync_interval_hours: int = 6
|
subscriptions_sync_interval_hours: int = 6
|
||||||
# Videos sync is no longer scheduled: it is triggered by user activity
|
videos_sync_interval_minutes: int = 60
|
||||||
# (any authenticated request) after this much idle time since the last
|
videos_per_channel_sync: int = 10
|
||||||
# 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"
|
log_level: str = "INFO"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
from app.services.sync_trigger import maybe_trigger_videos_sync
|
|
||||||
|
|
||||||
|
|
||||||
def require_session(request: Request) -> None:
|
def require_session(request: Request) -> None:
|
||||||
if not request.session.get("authenticated"):
|
if not request.session.get("authenticated"):
|
||||||
raise HTTPException(status_code=401, detail="Not 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 datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, Boolean, DateTime, String, Text, func
|
from sqlalchemy import Boolean, DateTime, String, Text, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
|
|
@ -16,7 +16,6 @@ class Channel(Base):
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
thumbnail_url: Mapped[str | None] = mapped_column(String, nullable=True)
|
thumbnail_url: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
uploads_playlist_id: Mapped[str | None] = mapped_column(String(64), 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)
|
subscribed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=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)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ class OAuthCredentials(Base):
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
google_email: Mapped[str] = mapped_column(String(255), nullable=False)
|
google_email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
encrypted_refresh_token: Mapped[str] = mapped_column(String, 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)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||||
|
|
|
||||||
|
|
@ -19,15 +19,6 @@ class DeleteNotAllowed(Exception):
|
||||||
pass
|
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:
|
def get_latest_job(db: Session, video_id: int) -> DownloadJob | None:
|
||||||
return (
|
return (
|
||||||
db.query(DownloadJob)
|
db.query(DownloadJob)
|
||||||
|
|
@ -75,19 +66,10 @@ def delete_local_copy(db: Session, video: Video) -> DownloadJob:
|
||||||
job = get_latest_job(db, video.id)
|
job = get_latest_job(db, video.id)
|
||||||
if job is None or job.status != "completed":
|
if job is None or job.status != "completed":
|
||||||
raise DeleteNotAllowed("No completed local copy to delete")
|
raise DeleteNotAllowed("No completed local copy to delete")
|
||||||
|
if not job.metube_job_id:
|
||||||
|
raise DeleteNotAllowed("Missing MeTube job id, cannot request deletion")
|
||||||
|
|
||||||
client = MeTubeClient()
|
MeTubeClient().delete_download(job.metube_job_id)
|
||||||
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.status = "deleted"
|
||||||
job.media_url = None
|
job.media_url = None
|
||||||
|
|
@ -117,26 +99,6 @@ def _find_job_by_payload(db: Session, payload: dict) -> DownloadJob | None:
|
||||||
return 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:
|
async def handle_metube_event(db: Session, event_name: str, raw_payload) -> None:
|
||||||
try:
|
try:
|
||||||
payload = json.loads(raw_payload) if isinstance(raw_payload, str) else raw_payload
|
payload = json.loads(raw_payload) if isinstance(raw_payload, str) else raw_payload
|
||||||
|
|
@ -144,34 +106,21 @@ 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)
|
logger.warning("Could not parse MeTube event payload for %s: %r", event_name, raw_payload)
|
||||||
return
|
return
|
||||||
|
|
||||||
if event_name == "canceled":
|
if event_name in ("canceled", "cleared"):
|
||||||
if not isinstance(payload, str):
|
if not isinstance(payload, str):
|
||||||
return
|
return
|
||||||
job = _find_job_by_key(db, payload)
|
job = (
|
||||||
if job is not None and job.status in ACTIVE_STATUSES:
|
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.status = "failed"
|
job.status = "failed"
|
||||||
job.error_message = "Отменено в MeTube"
|
job.error_message = "Отменено в MeTube"
|
||||||
db.commit()
|
db.commit()
|
||||||
return
|
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):
|
if not isinstance(payload, dict):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
from datetime import datetime, timezone
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
# Google frequently echoes back a scope string that's a superset/reordering
|
# 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
|
# of what we requested (e.g. we ask for "youtube", Google's token response
|
||||||
|
|
@ -35,15 +34,10 @@ SCOPES = [
|
||||||
"https://www.googleapis.com/auth/userinfo.profile",
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Refresh proactively: never hand out a token that could die mid-request.
|
AUTH_URI = "https://accounts.google.com/o/oauth2/auth"
|
||||||
_ACCESS_TOKEN_REFRESH_BUFFER_SECONDS = 60
|
TOKEN_URI = "https://oauth2.googleapis.com/token"
|
||||||
|
USERINFO_URI = "https://www.googleapis.com/oauth2/v3/userinfo"
|
||||||
# Module-level access-token cache. Google access tokens live ~1h; previously
|
REVOKE_URI = "https://oauth2.googleapis.com/revoke"
|
||||||
# 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):
|
class OAuthNotConnected(Exception):
|
||||||
|
|
@ -55,8 +49,8 @@ def _client_config() -> dict:
|
||||||
"web": {
|
"web": {
|
||||||
"client_id": settings.google_client_id,
|
"client_id": settings.google_client_id,
|
||||||
"client_secret": settings.google_client_secret,
|
"client_secret": settings.google_client_secret,
|
||||||
"auth_uri": settings.google_auth_uri,
|
"auth_uri": AUTH_URI,
|
||||||
"token_uri": settings.google_token_uri,
|
"token_uri": TOKEN_URI,
|
||||||
"redirect_uris": [settings.google_redirect_uri],
|
"redirect_uris": [settings.google_redirect_uri],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +83,7 @@ def exchange_code(code: str, state: str) -> Credentials:
|
||||||
|
|
||||||
def fetch_userinfo(access_token: str) -> dict:
|
def fetch_userinfo(access_token: str) -> dict:
|
||||||
response = httpx.get(
|
response = httpx.get(
|
||||||
settings.google_userinfo_uri,
|
USERINFO_URI,
|
||||||
headers={"Authorization": f"Bearer {access_token}"},
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
timeout=settings.metube_request_timeout_seconds,
|
timeout=settings.metube_request_timeout_seconds,
|
||||||
)
|
)
|
||||||
|
|
@ -99,14 +93,16 @@ def fetch_userinfo(access_token: str) -> dict:
|
||||||
|
|
||||||
def revoke_token(token: str) -> None:
|
def revoke_token(token: str) -> None:
|
||||||
try:
|
try:
|
||||||
httpx.post(settings.google_revoke_uri, params={"token": token}, timeout=10)
|
httpx.post(REVOKE_URI, params={"token": token}, timeout=10)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to revoke Google token", exc_info=True)
|
logger.warning("Failed to revoke Google token", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def store_credentials(db: Session, google_email: str, credentials: Credentials) -> None:
|
def store_credentials(db: Session, google_email: str, credentials: Credentials) -> None:
|
||||||
global _cached_credentials
|
|
||||||
encrypted = encrypt_token(credentials.refresh_token)
|
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)
|
row = db.get(OAuthCredentials, SINGLETON_ID)
|
||||||
if row is None:
|
if row is None:
|
||||||
|
|
@ -115,22 +111,15 @@ def store_credentials(db: Session, google_email: str, credentials: Credentials)
|
||||||
else:
|
else:
|
||||||
row.google_email = google_email
|
row.google_email = google_email
|
||||||
row.encrypted_refresh_token = encrypted
|
row.encrypted_refresh_token = encrypted
|
||||||
|
row.access_token_expires_at = expires_at
|
||||||
db.commit()
|
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:
|
def clear_credentials(db: Session) -> None:
|
||||||
global _cached_credentials
|
|
||||||
row = db.get(OAuthCredentials, SINGLETON_ID)
|
row = db.get(OAuthCredentials, SINGLETON_ID)
|
||||||
if row is not None:
|
if row is not None:
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
with _credentials_lock:
|
|
||||||
_cached_credentials = None
|
|
||||||
|
|
||||||
|
|
||||||
def is_connected(db: Session) -> bool:
|
def is_connected(db: Session) -> bool:
|
||||||
|
|
@ -142,48 +131,26 @@ def get_connected_email(db: Session) -> str | None:
|
||||||
return row.google_email if row else 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:
|
def get_credentials(db: Session) -> Credentials:
|
||||||
global _cached_credentials
|
|
||||||
row = db.get(OAuthCredentials, SINGLETON_ID)
|
row = db.get(OAuthCredentials, SINGLETON_ID)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise OAuthNotConnected("Google account is not connected")
|
raise OAuthNotConnected("Google account is not connected")
|
||||||
|
|
||||||
refresh_token = decrypt_token(row.encrypted_refresh_token)
|
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(
|
credentials = Credentials(
|
||||||
token=None,
|
token=None,
|
||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
token_uri=settings.google_token_uri,
|
token_uri=TOKEN_URI,
|
||||||
client_id=settings.google_client_id,
|
client_id=settings.google_client_id,
|
||||||
client_secret=settings.google_client_secret,
|
client_secret=settings.google_client_secret,
|
||||||
scopes=SCOPES,
|
scopes=SCOPES,
|
||||||
)
|
)
|
||||||
_cached_credentials = credentials
|
|
||||||
|
|
||||||
if _token_missing_or_expiring(credentials):
|
|
||||||
try:
|
|
||||||
credentials.refresh(GoogleAuthRequest())
|
credentials.refresh(GoogleAuthRequest())
|
||||||
except Exception:
|
|
||||||
# Don't keep a broken cached object behind.
|
expires_at = credentials.expiry
|
||||||
_cached_credentials = None
|
if expires_at is not None and expires_at.tzinfo is None:
|
||||||
raise
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
row.access_token_expires_at = expires_at
|
||||||
|
db.commit()
|
||||||
|
|
||||||
return credentials
|
return credentials
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,7 @@ 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-*string*-encoded (json.JSONEncoder().encode(...)), not a raw object — must
|
||||||
json.loads() the payload. Relevant keys: id, title, url, status, msg, percent
|
json.loads() the payload. Relevant keys: id, title, url, status, msg, percent
|
||||||
(float 0-100 or None), filename (already relative to DOWNLOAD_DIR), error.
|
(float 0-100 or None), filename (already relative to DOWNLOAD_DIR), error.
|
||||||
'canceled'/'cleared' carry the download's URL key (JSON-string-encoded),
|
'canceled'/'cleared' carry just an id (also 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/
|
- MeTube status vocabulary: pending/preparing/scheduled/downloading/postprocessing/
|
||||||
finished/error — mapped to our own vocabulary in sync with download_jobs.
|
finished/error — mapped to our own vocabulary in sync with download_jobs.
|
||||||
- GET /history returns {"queue": [...], "pending": [...], "done": [...]} of the
|
- GET /history returns {"queue": [...], "pending": [...], "done": [...]} of the
|
||||||
|
|
@ -80,23 +77,16 @@ class MeTubeClient:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
def delete_download(self, youtube_url: str) -> dict:
|
def delete_download(self, metube_job_id: str) -> dict:
|
||||||
"""Asks MeTube to remove a finished download from its 'done' list and
|
"""Asks MeTube to remove a finished download from its 'done' list and
|
||||||
delete the underlying file (actual file deletion additionally depends
|
delete the underlying file (actual file deletion additionally depends
|
||||||
on MeTube's own DELETE_FILE_ON_TRASHCAN config, which we don't
|
on MeTube's own DELETE_FILE_ON_TRASHCAN config, which we don't
|
||||||
control). Only ever called for a URL our own app itself enqueued --
|
control). Only ever called with a metube_job_id our own app tracked
|
||||||
never touches files MeTube already had before we existed.
|
from a download it started -- 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(
|
response = httpx.post(
|
||||||
f"{self.api_base_url}/delete",
|
f"{self.api_base_url}/delete",
|
||||||
json={"ids": [youtube_url], "where": "done"},
|
json={"ids": [metube_job_id], "where": "done"},
|
||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,19 @@ def _run_subscriptions_sync() -> None:
|
||||||
db.close()
|
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:
|
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 = BackgroundScheduler(timezone="UTC")
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
_run_subscriptions_sync,
|
_run_subscriptions_sync,
|
||||||
|
|
@ -31,4 +41,10 @@ def create_scheduler() -> BackgroundScheduler:
|
||||||
hours=settings.subscriptions_sync_interval_hours,
|
hours=settings.subscriptions_sync_interval_hours,
|
||||||
id="subscriptions_sync",
|
id="subscriptions_sync",
|
||||||
)
|
)
|
||||||
|
scheduler.add_job(
|
||||||
|
_run_videos_sync,
|
||||||
|
"interval",
|
||||||
|
minutes=settings.videos_sync_interval_minutes,
|
||||||
|
id="videos_sync",
|
||||||
|
)
|
||||||
return scheduler
|
return scheduler
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,6 @@ def get_videos_sync_status(db: Session) -> dict:
|
||||||
return _get_status(db, VIDEOS_SYNC_STATUS_KEY, _videos_lock)
|
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:
|
def sync_subscriptions(db: Session) -> dict:
|
||||||
if not _subscriptions_lock.acquire(blocking=False):
|
if not _subscriptions_lock.acquire(blocking=False):
|
||||||
raise SyncInProgress("Subscriptions sync already in progress")
|
raise SyncInProgress("Subscriptions sync already in progress")
|
||||||
|
|
@ -112,13 +108,10 @@ def sync_subscriptions(db: Session) -> dict:
|
||||||
subscribed_ids = [c.youtube_channel_id for c in existing.values() if c.subscribed]
|
subscribed_ids = [c.youtube_channel_id for c in existing.values() if c.subscribed]
|
||||||
try:
|
try:
|
||||||
uploads = youtube_client.fetch_uploads_playlists(credentials, subscribed_ids)
|
uploads = youtube_client.fetch_uploads_playlists(credentials, subscribed_ids)
|
||||||
for channel_id, data in uploads.items():
|
for channel_id, uploads_playlist_id in uploads.items():
|
||||||
channel = existing.get(channel_id)
|
channel = existing.get(channel_id)
|
||||||
if channel is not None:
|
if channel is not None:
|
||||||
if data["uploads_playlist_id"] is not None:
|
channel.uploads_playlist_id = uploads_playlist_id
|
||||||
channel.uploads_playlist_id = data["uploads_playlist_id"]
|
|
||||||
if data["subscriber_count"] is not None:
|
|
||||||
channel.subscriber_count = data["subscriber_count"]
|
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to fetch uploads playlists during subscriptions sync")
|
logger.exception("Failed to fetch uploads playlists during subscriptions sync")
|
||||||
|
|
@ -173,38 +166,21 @@ def sync_videos(db: Session) -> dict:
|
||||||
)
|
)
|
||||||
channel_by_youtube_id = {c.youtube_channel_id: c for c in channels}
|
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()
|
candidate_video_ids: set[str] = set()
|
||||||
for channel in channels:
|
for channel in channels:
|
||||||
try:
|
try:
|
||||||
# videos_backfill_cap is a hard history-depth limit per
|
video_ids = youtube_client.fetch_playlist_video_ids(
|
||||||
# channel: only the newest N playlist items are ever
|
credentials, channel.uploads_playlist_id, settings.videos_per_channel_sync
|
||||||
# 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(new_ids)
|
candidate_video_ids.update(video_ids)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to fetch playlist items for channel %s", channel.youtube_channel_id)
|
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))
|
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
|
added = 0
|
||||||
|
updated = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
|
||||||
for item in details:
|
for item in details:
|
||||||
|
|
@ -219,10 +195,11 @@ def sync_videos(db: Session) -> dict:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
duration_seconds = parse_iso8601_duration(item["duration_iso8601"])
|
duration_seconds = parse_iso8601_duration(item["duration_iso8601"])
|
||||||
youtube_url = settings.youtube_watch_url_template.format(video_id=item["youtube_video_id"])
|
youtube_url = f"https://www.youtube.com/watch?v={item['youtube_video_id']}"
|
||||||
|
|
||||||
db.add(
|
video = existing.get(item["youtube_video_id"])
|
||||||
Video(
|
if video is None:
|
||||||
|
video = Video(
|
||||||
youtube_video_id=item["youtube_video_id"],
|
youtube_video_id=item["youtube_video_id"],
|
||||||
channel_id=channel.id,
|
channel_id=channel.id,
|
||||||
title=item["title"],
|
title=item["title"],
|
||||||
|
|
@ -232,8 +209,18 @@ def sync_videos(db: Session) -> dict:
|
||||||
duration_seconds=duration_seconds,
|
duration_seconds=duration_seconds,
|
||||||
youtube_url=youtube_url,
|
youtube_url=youtube_url,
|
||||||
)
|
)
|
||||||
)
|
db.add(video)
|
||||||
|
existing[item["youtube_video_id"]] = video
|
||||||
added += 1
|
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()
|
db.commit()
|
||||||
|
|
||||||
|
|
@ -243,14 +230,12 @@ def sync_videos(db: Session) -> dict:
|
||||||
"finished_at": _now_iso(),
|
"finished_at": _now_iso(),
|
||||||
"error": None,
|
"error": None,
|
||||||
"videos_added": added,
|
"videos_added": added,
|
||||||
# Kept for API compatibility: always 0, because the videos sync
|
"videos_updated": updated,
|
||||||
# never refreshes metadata of existing videos.
|
|
||||||
"videos_updated": 0,
|
|
||||||
"videos_skipped": skipped,
|
"videos_skipped": skipped,
|
||||||
"channels_checked": len(channels),
|
"channels_checked": len(channels),
|
||||||
}
|
}
|
||||||
_save_status(db, VIDEOS_SYNC_STATUS_KEY, result)
|
_save_status(db, VIDEOS_SYNC_STATUS_KEY, result)
|
||||||
logger.info("Videos sync completed: added=%d skipped=%d", added, skipped)
|
logger.info("Videos sync completed: added=%d updated=%d skipped=%d", added, updated, skipped)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
"""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,14 +7,9 @@ from app.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
API_BASE = "https://www.googleapis.com/youtube/v3"
|
||||||
BATCH_SIZE = 50
|
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):
|
class YouTubeQuotaExceeded(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
@ -73,7 +68,7 @@ def fetch_subscriptions(credentials: Credentials) -> list[dict]:
|
||||||
if page_token:
|
if page_token:
|
||||||
params["pageToken"] = page_token
|
params["pageToken"] = page_token
|
||||||
|
|
||||||
response = client.get(f"{settings.youtube_api_base_url}/subscriptions", params=params, headers=_headers(credentials))
|
response = client.get(f"{API_BASE}/subscriptions", params=params, headers=_headers(credentials))
|
||||||
_raise_for_status(response)
|
_raise_for_status(response)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -108,108 +103,26 @@ def fetch_subscriptions(credentials: Credentials) -> list[dict]:
|
||||||
|
|
||||||
|
|
||||||
def fetch_playlist_video_ids(credentials: Credentials, playlist_id: str, max_results: int) -> list[str]:
|
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:
|
with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client:
|
||||||
while pages_fetched < MAX_PLAYLIST_PAGES and len(video_ids) < max_results:
|
|
||||||
params = {
|
params = {
|
||||||
"part": "contentDetails",
|
"part": "contentDetails",
|
||||||
"playlistId": playlist_id,
|
"playlistId": playlist_id,
|
||||||
"maxResults": min(max_results - len(video_ids), BATCH_SIZE),
|
"maxResults": min(max_results, 50),
|
||||||
}
|
}
|
||||||
if page_token:
|
response = client.get(f"{API_BASE}/playlistItems", params=params, headers=_headers(credentials))
|
||||||
params["pageToken"] = page_token
|
|
||||||
|
|
||||||
response = client.get(f"{settings.youtube_api_base_url}/playlistItems", params=params, headers=_headers(credentials))
|
|
||||||
if response.status_code == 404:
|
if response.status_code == 404:
|
||||||
return []
|
return []
|
||||||
_raise_for_status(response)
|
_raise_for_status(response)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
video_ids = []
|
||||||
for item in data.get("items", []):
|
for item in data.get("items", []):
|
||||||
video_id = item.get("contentDetails", {}).get("videoId")
|
video_id = item.get("contentDetails", {}).get("videoId")
|
||||||
if video_id:
|
if video_id:
|
||||||
video_ids.append(video_id)
|
video_ids.append(video_id)
|
||||||
|
|
||||||
pages_fetched += 1
|
|
||||||
page_token = data.get("nextPageToken")
|
|
||||||
if not page_token:
|
|
||||||
break
|
|
||||||
|
|
||||||
return video_ids
|
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]:
|
def fetch_videos_details(credentials: Credentials, video_ids: list[str]) -> list[dict]:
|
||||||
results: list[dict] = []
|
results: list[dict] = []
|
||||||
|
|
||||||
|
|
@ -221,7 +134,7 @@ def fetch_videos_details(credentials: Credentials, video_ids: list[str]) -> list
|
||||||
"id": ",".join(batch),
|
"id": ",".join(batch),
|
||||||
"maxResults": BATCH_SIZE,
|
"maxResults": BATCH_SIZE,
|
||||||
}
|
}
|
||||||
response = client.get(f"{settings.youtube_api_base_url}/videos", params=params, headers=_headers(credentials))
|
response = client.get(f"{API_BASE}/videos", params=params, headers=_headers(credentials))
|
||||||
_raise_for_status(response)
|
_raise_for_status(response)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -249,7 +162,7 @@ def fetch_videos_details(credentials: Credentials, video_ids: list[str]) -> list
|
||||||
def unsubscribe(credentials: Credentials, youtube_subscription_id: str) -> None:
|
def unsubscribe(credentials: Credentials, youtube_subscription_id: str) -> None:
|
||||||
with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client:
|
with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client:
|
||||||
response = client.delete(
|
response = client.delete(
|
||||||
f"{settings.youtube_api_base_url}/subscriptions",
|
f"{API_BASE}/subscriptions",
|
||||||
params={"id": youtube_subscription_id},
|
params={"id": youtube_subscription_id},
|
||||||
headers=_headers(credentials),
|
headers=_headers(credentials),
|
||||||
)
|
)
|
||||||
|
|
@ -259,28 +172,18 @@ def unsubscribe(credentials: Credentials, youtube_subscription_id: str) -> None:
|
||||||
_raise_for_status(response)
|
_raise_for_status(response)
|
||||||
|
|
||||||
|
|
||||||
def _parse_subscriber_count(statistics: dict) -> int | None:
|
def fetch_uploads_playlists(credentials: Credentials, channel_ids: list[str]) -> dict[str, str]:
|
||||||
raw = statistics.get("subscriberCount")
|
result: dict[str, str] = {}
|
||||||
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:
|
with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client:
|
||||||
for i in range(0, len(channel_ids), BATCH_SIZE):
|
for i in range(0, len(channel_ids), BATCH_SIZE):
|
||||||
batch = channel_ids[i : i + BATCH_SIZE]
|
batch = channel_ids[i : i + BATCH_SIZE]
|
||||||
params = {
|
params = {
|
||||||
"part": "snippet,contentDetails,statistics",
|
"part": "snippet,contentDetails",
|
||||||
"id": ",".join(batch),
|
"id": ",".join(batch),
|
||||||
"maxResults": BATCH_SIZE,
|
"maxResults": BATCH_SIZE,
|
||||||
}
|
}
|
||||||
response = client.get(f"{settings.youtube_api_base_url}/channels", params=params, headers=_headers(credentials))
|
response = client.get(f"{API_BASE}/channels", params=params, headers=_headers(credentials))
|
||||||
_raise_for_status(response)
|
_raise_for_status(response)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|
@ -289,10 +192,7 @@ def fetch_uploads_playlists(credentials: Credentials, channel_ids: list[str]) ->
|
||||||
uploads = (
|
uploads = (
|
||||||
item.get("contentDetails", {}).get("relatedPlaylists", {}).get("uploads")
|
item.get("contentDetails", {}).get("relatedPlaylists", {}).get("uploads")
|
||||||
)
|
)
|
||||||
if channel_id:
|
if channel_id and uploads:
|
||||||
result[channel_id] = {
|
result[channel_id] = uploads
|
||||||
"uploads_playlist_id": uploads or None,
|
|
||||||
"subscriber_count": _parse_subscriber_count(item.get("statistics", {})),
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0f0f0f" />
|
<title>frontend</title>
|
||||||
<meta name="description" content="Персональная лента YouTube-подписок с категориями и сохранёнными видео." />
|
|
||||||
<title>MyTube — моя лента</title>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 243 B After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
|
|
@ -1,288 +1,456 @@
|
||||||
#root { min-height: 100vh; }
|
.app {
|
||||||
.app-shell { min-height: 100vh; }
|
max-width: 960px;
|
||||||
.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); }
|
margin: 0 auto;
|
||||||
.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; }
|
padding: 24px;
|
||||||
.brand-mark { display: inline-grid; place-items: center; flex: none; width: 34px; height: 27px; border-radius: 8px; background: var(--accent); color: #07121b; }
|
text-align: left;
|
||||||
.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; }
|
.error {
|
||||||
.brand { font-size: 19px; }
|
color: #d33;
|
||||||
.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) {
|
.connect-screen {
|
||||||
.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; }
|
text-align: center;
|
||||||
.sidebar-badge { display: inline-flex; align-items: center; }
|
padding-top: 80px;
|
||||||
.reorder-buttons .icon-button { width: 40px; height: 40px; }
|
}
|
||||||
.popover-create input, .popover-create button { height: 44px; }
|
|
||||||
.popover-create button { width: 44px; }
|
.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;
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } }
|
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,55 @@
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Link, Navigate, Route, Routes } from 'react-router-dom'
|
import { Route, Routes } from 'react-router-dom'
|
||||||
import { getAuthStatus } from './api/client'
|
import { getAuthStatus } from './api/client'
|
||||||
import AppShell from './components/AppShell'
|
|
||||||
import Connect from './pages/Connect'
|
import Connect from './pages/Connect'
|
||||||
import Feed from './pages/Feed'
|
import Feed from './pages/Feed'
|
||||||
import Channels from './pages/Channels'
|
import Channels from './pages/Channels'
|
||||||
import ChannelVideos from './pages/ChannelVideos'
|
import ChannelVideos from './pages/ChannelVideos'
|
||||||
import VideoPage from './pages/VideoPage'
|
import VideoPage from './pages/VideoPage'
|
||||||
import Categories from './pages/Categories'
|
import Categories from './pages/Categories'
|
||||||
import Settings from './pages/Settings'
|
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
function App() {
|
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-loading"><span className="brand-mark">▶</span><p>Открываем MyTube…</p></div>
|
if (isLoading) {
|
||||||
if (isError) return <div className="app-loading"><h1>Не удалось открыть MyTube</h1><p>Проверь подключение к серверу и обнови страницу.</p><button onClick={() => window.location.reload()}>Повторить</button></div>
|
return (
|
||||||
if (!data?.authenticated) return <Connect />
|
<div className="app">
|
||||||
|
<p>Загрузка...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return <AppShell>
|
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">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Feed />} />
|
<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" element={<Channels />} />
|
||||||
<Route path="/channels/:channelId/videos" element={<ChannelVideos />} />
|
<Route path="/channels/:channelId/videos" element={<ChannelVideos />} />
|
||||||
<Route path="/video/:youtubeVideoId" element={<VideoPage />} />
|
<Route path="/video/:youtubeVideoId" element={<VideoPage />} />
|
||||||
<Route path="/settings" element={<Settings email={data.email} />} />
|
<Route path="/categories" element={<Categories />} />
|
||||||
<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>
|
</Routes>
|
||||||
</AppShell>
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export function authErrorMessage(code: string): string {
|
||||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: options.body != null ? { 'Content-Type': 'application/json', ...(options.headers ?? {}) } : options.headers,
|
headers: { 'Content-Type': 'application/json', ...(options.headers ?? {}) },
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -39,10 +39,6 @@ export function getAuthStatus() {
|
||||||
return request<AuthStatus>('/api/auth/status')
|
return request<AuthStatus>('/api/auth/status')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHealth() {
|
|
||||||
return request<{ status: string; database: string; metube: string }>('/api/health')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startGoogleAuth() {
|
export function startGoogleAuth() {
|
||||||
window.location.href = '/api/auth/google/start'
|
window.location.href = '/api/auth/google/start'
|
||||||
}
|
}
|
||||||
|
|
@ -58,11 +54,9 @@ export interface ChannelDto {
|
||||||
description: string | null
|
description: string | null
|
||||||
thumbnail_url: string | null
|
thumbnail_url: string | null
|
||||||
uploads_playlist_id: string | null
|
uploads_playlist_id: string | null
|
||||||
subscriber_count: number | null
|
|
||||||
subscribed: boolean
|
subscribed: boolean
|
||||||
last_synced_at: string | null
|
last_synced_at: string | null
|
||||||
category_ids: number[]
|
category_ids: number[]
|
||||||
new_videos_count: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChannel(channelId: number) {
|
export function getChannel(channelId: number) {
|
||||||
|
|
@ -70,13 +64,12 @@ export function getChannel(channelId: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listChannels(
|
export function listChannels(
|
||||||
params: { search?: string; categoryId?: number; uncategorized?: boolean; subscribed?: boolean } = {},
|
params: { search?: string; categoryId?: number; uncategorized?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
const qs = new URLSearchParams()
|
const qs = new URLSearchParams()
|
||||||
if (params.search) qs.set('search', params.search)
|
if (params.search) qs.set('search', params.search)
|
||||||
if (params.categoryId != null) qs.set('category_id', String(params.categoryId))
|
if (params.categoryId != null) qs.set('category_id', String(params.categoryId))
|
||||||
if (params.uncategorized) qs.set('uncategorized', 'true')
|
if (params.uncategorized) qs.set('uncategorized', 'true')
|
||||||
if (params.subscribed != null) qs.set('subscribed', String(params.subscribed))
|
|
||||||
const suffix = qs.toString() ? `?${qs.toString()}` : ''
|
const suffix = qs.toString() ? `?${qs.toString()}` : ''
|
||||||
return request<ChannelDto[]>(`/api/channels${suffix}`)
|
return request<ChannelDto[]>(`/api/channels${suffix}`)
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +91,6 @@ export interface CategoryDto {
|
||||||
slug: string
|
slug: string
|
||||||
sort_order: number
|
sort_order: number
|
||||||
channel_count: number
|
channel_count: number
|
||||||
new_videos_count: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listCategories() {
|
export function listCategories() {
|
||||||
|
|
@ -207,36 +199,13 @@ export function recheckLocal(youtubeVideoId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFeed(
|
export function getFeed(
|
||||||
params: {
|
params: { categoryId?: number; uncategorized?: boolean; channelId?: number; cursor?: string } = {},
|
||||||
categoryId?: number
|
|
||||||
uncategorized?: boolean
|
|
||||||
channelId?: number
|
|
||||||
downloaded?: boolean
|
|
||||||
search?: string
|
|
||||||
cursor?: string
|
|
||||||
limit?: number
|
|
||||||
newOnly?: boolean
|
|
||||||
} = {},
|
|
||||||
) {
|
) {
|
||||||
const qs = new URLSearchParams()
|
const qs = new URLSearchParams()
|
||||||
if (params.categoryId != null) qs.set('category_id', String(params.categoryId))
|
if (params.categoryId != null) qs.set('category_id', String(params.categoryId))
|
||||||
if (params.uncategorized) qs.set('uncategorized', 'true')
|
if (params.uncategorized) qs.set('uncategorized', 'true')
|
||||||
if (params.channelId != null) qs.set('channel_id', String(params.channelId))
|
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.cursor) qs.set('cursor', params.cursor)
|
||||||
if (params.limit != null) qs.set('limit', String(params.limit))
|
|
||||||
const suffix = qs.toString() ? `?${qs.toString()}` : ''
|
const suffix = qs.toString() ? `?${qs.toString()}` : ''
|
||||||
return request<{ items: FeedVideoDto[]; next_cursor: string | null }>(`/api/feed${suffix}`)
|
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')
|
|
||||||
}
|
|
||||||
|
|
|
||||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 4 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
|
|
@ -1,103 +0,0 @@
|
||||||
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,31 +6,24 @@ interface Props {
|
||||||
categories: CategoryDto[]
|
categories: CategoryDto[]
|
||||||
value: CategoryFilter
|
value: CategoryFilter
|
||||||
onChange: (filter: CategoryFilter) => void
|
onChange: (filter: CategoryFilter) => void
|
||||||
allCount?: number
|
|
||||||
uncategorizedCount?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryNav({ categories, value, onChange, allCount, uncategorizedCount }: Props) {
|
function CategoryNav({ categories, value, onChange }: Props) {
|
||||||
return (
|
return (
|
||||||
<nav className="category-nav" aria-label="Фильтр каналов по категориям">
|
<nav className="category-nav">
|
||||||
<button type="button" aria-pressed={value === 'all'} className={value === 'all' ? 'active' : ''} onClick={() => onChange('all')}>
|
<button className={value === 'all' ? 'active' : ''} onClick={() => onChange('all')}>
|
||||||
Все
|
Все
|
||||||
{allCount != null && <span className="category-nav-count">{allCount}</span>}
|
|
||||||
</button>
|
</button>
|
||||||
<button type="button" aria-pressed={value === 'uncategorized'} className={value === 'uncategorized' ? 'active' : ''} onClick={() => onChange('uncategorized')}>
|
<button className={value === 'uncategorized' ? 'active' : ''} onClick={() => onChange('uncategorized')}>
|
||||||
Без категории
|
Без категории
|
||||||
{uncategorizedCount != null && <span className="category-nav-count">{uncategorizedCount}</span>}
|
|
||||||
</button>
|
</button>
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<button
|
<button
|
||||||
key={category.id}
|
key={category.id}
|
||||||
type="button"
|
|
||||||
aria-pressed={value === category.id}
|
|
||||||
className={value === category.id ? 'active' : ''}
|
className={value === category.id ? 'active' : ''}
|
||||||
onClick={() => onChange(category.id)}
|
onClick={() => onChange(category.id)}
|
||||||
>
|
>
|
||||||
{category.name}
|
{category.name}
|
||||||
<span className="category-nav-count">{category.channel_count}</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import type { CategoryDto, ChannelDto } from '../api/client'
|
import type { CategoryDto, ChannelDto } from '../api/client'
|
||||||
import { createCategory, setChannelCategories, unsubscribeChannel } from '../api/client'
|
import { setChannelCategories, unsubscribeChannel } from '../api/client'
|
||||||
import Icon from './Icon'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
channel: ChannelDto
|
channel: ChannelDto
|
||||||
|
|
@ -12,7 +11,6 @@ interface Props {
|
||||||
|
|
||||||
function ChannelCard({ channel, categories }: Props) {
|
function ChannelCard({ channel, categories }: Props) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [newName, setNewName] = useState('')
|
|
||||||
const popoverRef = useRef<HTMLDivElement>(null)
|
const popoverRef = useRef<HTMLDivElement>(null)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
|
@ -24,9 +22,7 @@ function ChannelCard({ channel, categories }: Props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
document.addEventListener('keydown', handleEscape)
|
|
||||||
return () => { document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleEscape) }
|
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
|
|
@ -45,7 +41,6 @@ function ChannelCard({ channel, categories }: Props) {
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['categories'] })
|
queryClient.invalidateQueries({ queryKey: ['categories'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['feed'] })
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -56,15 +51,6 @@ 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) {
|
function toggleCategory(categoryId: number) {
|
||||||
const current = new Set(channel.category_ids)
|
const current = new Set(channel.category_ids)
|
||||||
if (current.has(categoryId)) {
|
if (current.has(categoryId)) {
|
||||||
|
|
@ -75,14 +61,15 @@ function ChannelCard({ channel, categories }: Props) {
|
||||||
mutation.mutate(Array.from(current))
|
mutation.mutate(Array.from(current))
|
||||||
}
|
}
|
||||||
|
|
||||||
const assignedCategories = categories.filter((c) => channel.category_ids.includes(c.id))
|
const assignedNames = categories.filter((c) => channel.category_ids.includes(c.id)).map((c) => c.name)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="channel-card">
|
<li className="channel-card">
|
||||||
{channel.thumbnail_url ? <img src={channel.thumbnail_url} alt="" loading="lazy" /> : <span className="channel-avatar-placeholder">{channel.title.charAt(0).toUpperCase()}</span>}
|
{channel.thumbnail_url && <img src={channel.thumbnail_url} alt={channel.title} />}
|
||||||
<div className="channel-info">
|
<div className="channel-info">
|
||||||
<div className="channel-head"><Link to={`/channels/${channel.id}/videos`} className="channel-title">{channel.title}</Link>
|
<Link to={`/channels/${channel.id}/videos`} className="channel-title">
|
||||||
{channel.new_videos_count > 0 && <span className="badge badge-new" title="Недавно вышедшие видео">{channel.new_videos_count} новых</span>}
|
{channel.title}
|
||||||
|
</Link>
|
||||||
{!channel.subscribed && <span className="badge">отписан</span>}
|
{!channel.subscribed && <span className="badge">отписан</span>}
|
||||||
{channel.subscribed && (
|
{channel.subscribed && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -93,24 +80,26 @@ function ChannelCard({ channel, categories }: Props) {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={unsubscribeMutation.isPending}
|
disabled={unsubscribeMutation.isPending}
|
||||||
>Отписаться</button>
|
>
|
||||||
|
Отписаться
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
{unsubscribeMutation.isError && (
|
{unsubscribeMutation.isError && (
|
||||||
<p className="error">{(unsubscribeMutation.error as Error).message}</p>
|
<p className="error unsubscribe-error">{(unsubscribeMutation.error as Error).message}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="channel-categories">
|
<div className="channel-categories">
|
||||||
{assignedCategories.map((category) => (
|
{assignedNames.map((name) => (
|
||||||
<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>
|
<span key={name} className="chip">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
<div className="category-popover-wrapper" ref={popoverRef}>
|
<div className="category-popover-wrapper" ref={popoverRef}>
|
||||||
<button type="button" className="chip-edit" onClick={() => setOpen((v) => !v)} aria-expanded={open} aria-label={`Выбрать категории для канала ${channel.title}`}>
|
<button className="chip-edit" onClick={() => setOpen((v) => !v)}>
|
||||||
<Icon name="plus" size={15} /> Категория
|
+ Категория
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="category-popover" role="group" aria-label="Категории канала">
|
<div className="category-popover">
|
||||||
<strong>Категории</strong>
|
|
||||||
{categories.length === 0 && <div className="category-popover-empty">Сначала создай категорию</div>}
|
{categories.length === 0 && <div className="category-popover-empty">Сначала создай категорию</div>}
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<label key={category.id} className="category-checkbox">
|
<label key={category.id} className="category-checkbox">
|
||||||
|
|
@ -122,11 +111,6 @@ function ChannelCard({ channel, categories }: Props) {
|
||||||
{category.name}
|
{category.name}
|
||||||
</label>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import type { FeedVideoDto } from '../api/client'
|
import type { FeedVideoDto } from '../api/client'
|
||||||
import { deleteDownload, downloadVideo, getDownloadStatus } from '../api/client'
|
import { deleteDownload, downloadVideo, getDownloadStatus } from '../api/client'
|
||||||
import Icon from './Icon'
|
|
||||||
|
|
||||||
const ACTIVE_STATUSES = ['queued', 'downloading', 'postprocessing']
|
const ACTIVE_STATUSES = ['queued', 'downloading', 'postprocessing']
|
||||||
|
|
||||||
|
|
@ -13,10 +12,9 @@ const STATUS_LABELS: Record<string, string> = {
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
video: FeedVideoDto
|
video: FeedVideoDto
|
||||||
showDelete?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DownloadButton({ video, showDelete = false }: Props) {
|
function DownloadButton({ video }: Props) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
// Once we start tracking a download (it was already active on load, or the
|
// Once we start tracking a download (it was already active on load, or the
|
||||||
// user just clicked "Скачать"), the query stays enabled for good --
|
// user just clicked "Скачать"), the query stays enabled for good --
|
||||||
|
|
@ -66,47 +64,44 @@ function DownloadButton({ video, showDelete = false }: Props) {
|
||||||
if (status === 'completed') {
|
if (status === 'completed') {
|
||||||
return (
|
return (
|
||||||
<span className="download-inline-actions">
|
<span className="download-inline-actions">
|
||||||
<span className="download-badge completed"><Icon name="check" size={15} /> На сервере</span>
|
<span className="download-badge completed">✓ На сервере</span>
|
||||||
{showDelete && <button className="button-quiet"
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm('Удалить скачанную копию с сервера?')) {
|
if (confirm('Удалить скачанную копию с сервера?')) {
|
||||||
deleteMutation.mutate()
|
deleteMutation.mutate()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
>Удалить копию</button>}
|
>
|
||||||
{deleteMutation.isError && (
|
Удалить
|
||||||
<span className="download-badge error" title={(deleteMutation.error as Error).message}>
|
</button>
|
||||||
{(deleteMutation.error as Error).message}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'downloading') {
|
if (status === 'downloading') {
|
||||||
return <span className="download-badge downloading"><span className="download-progress" style={{ width: `${Math.min(100, Math.max(0, percent ?? 0))}%` }} />Скачивается {percent ?? 0}%</span>
|
return <span className="download-badge">{percent ?? 0}%</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'queued' || status === 'postprocessing') {
|
if (status === 'queued' || status === 'postprocessing') {
|
||||||
return <span className="download-badge downloading">{STATUS_LABELS[status]}</span>
|
return <span className="download-badge">{STATUS_LABELS[status]}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'failed') {
|
if (status === 'failed') {
|
||||||
return (
|
return (
|
||||||
<span className="download-error">
|
<span className="download-error">
|
||||||
<button className="button-secondary" onClick={() => downloadMutation.mutate()} disabled={downloadMutation.isPending}>
|
<span className="download-badge error">Ошибка</span>
|
||||||
|
<button onClick={() => downloadMutation.mutate()} disabled={downloadMutation.isPending}>
|
||||||
Повторить
|
Повторить
|
||||||
</button>
|
</button>
|
||||||
{downloadMutation.isError && <span className="sr-only" role="alert">Не удалось повторить загрузку.</span>}
|
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'unknown') return <button className="button-secondary" onClick={() => downloadMutation.mutate()} disabled={downloadMutation.isPending}><Icon name="refresh" size={16} /> Загрузить снова</button>
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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>
|
<button onClick={() => downloadMutation.mutate()} disabled={downloadMutation.isPending}>
|
||||||
|
⬇ Скачать
|
||||||
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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,6 +25,7 @@ function Player({ video }: Props) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="player-wrapper">
|
<div className="player-wrapper">
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||||
<video
|
<video
|
||||||
controls
|
controls
|
||||||
src={video.local.media_url!}
|
src={video.local.media_url!}
|
||||||
|
|
@ -34,7 +35,7 @@ function Player({ video }: Props) {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="video-page-source">Источник: локальная копия</div>
|
<div className="video-page-source">● Локальная копия · mediaVM</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import type { FeedVideoDto } from '../api/client'
|
import type { FeedVideoDto } from '../api/client'
|
||||||
import { formatDuration, formatRelativeTime } from '../utils/format'
|
import { formatDuration, formatRelativeTime } from '../utils/format'
|
||||||
import DownloadButton from './DownloadButton'
|
import DownloadButton from './DownloadButton'
|
||||||
import Icon from './Icon'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
video: FeedVideoDto
|
video: FeedVideoDto
|
||||||
|
|
@ -11,27 +10,28 @@ interface Props {
|
||||||
function VideoCard({ video }: Props) {
|
function VideoCard({ video }: Props) {
|
||||||
const duration = formatDuration(video.duration_seconds)
|
const duration = formatDuration(video.duration_seconds)
|
||||||
const href = `/video/${video.youtube_video_id}`
|
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 (
|
return (
|
||||||
<li className="video-card">
|
<li className="video-card">
|
||||||
<Link to={href} state={{ from }} onClick={rememberScroll} className="video-thumbnail" aria-label={`Смотреть: ${video.title}`}>
|
<Link to={href} className="video-thumbnail">
|
||||||
{video.thumbnail_url ? (
|
{video.thumbnail_url ? (
|
||||||
<img src={video.thumbnail_url} alt="" loading="lazy" />
|
<img src={video.thumbnail_url} alt={video.title} />
|
||||||
) : (
|
) : (
|
||||||
<div className="video-thumbnail-placeholder" />
|
<div className="video-thumbnail-placeholder" />
|
||||||
)}
|
)}
|
||||||
{duration && <span className="duration-badge">{duration}</span>}
|
{duration && <span className="duration-badge">{duration}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="video-info">
|
<div className="video-info">
|
||||||
<Link to={href} state={{ from }} onClick={rememberScroll} className="video-title" title={video.title}>
|
<Link to={href} className="video-title" title={video.title}>
|
||||||
{video.title}
|
{video.title}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="video-meta"><span className="video-channel">{video.channel.title}</span><span>{formatRelativeTime(video.published_at)}</span></div>
|
<div className="video-meta">
|
||||||
|
{video.channel.title} · {formatRelativeTime(video.published_at)}
|
||||||
|
</div>
|
||||||
<div className="video-actions">
|
<div className="video-actions">
|
||||||
<Link to={href} state={{ from }} onClick={rememberScroll} className="button-link"><Icon name="play" size={16} /> Смотреть</Link>
|
<Link to={href} className="button-link">
|
||||||
|
Смотреть
|
||||||
|
</Link>
|
||||||
<DownloadButton video={video} />
|
<DownloadButton video={video} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,111 @@
|
||||||
:root {
|
:root {
|
||||||
font-family: Inter, Roboto, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
--text: #6b6375;
|
||||||
color: #f1f1f1;
|
--text-h: #08060d;
|
||||||
background: #0f0f0f;
|
--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-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
--bg: #0f0f0f;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
--surface: #181818;
|
|
||||||
--surface-raised: #202020;
|
@media (max-width: 1024px) {
|
||||||
--hover: #292929;
|
font-size: 16px;
|
||||||
--border: #303030;
|
}
|
||||||
--text: #f1f1f1;
|
}
|
||||||
--muted: #aaa;
|
|
||||||
--subtle: #777;
|
@media (prefers-color-scheme: dark) {
|
||||||
--accent: #3ea6ff;
|
:root {
|
||||||
--accent-soft: rgba(62, 166, 255, .14);
|
--text: #9ca3af;
|
||||||
--success: #4bc467;
|
--text-h: #f3f4f6;
|
||||||
--success-soft: rgba(75, 196, 103, .13);
|
--bg: #16171d;
|
||||||
--warning: #f5a623;
|
--border: #2e303a;
|
||||||
--error: #ff6b68;
|
--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);
|
||||||
}
|
}
|
||||||
* { 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,45 +1,145 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { createCategory, deleteCategory, listCategories, reorderCategories, updateCategory } from '../api/client'
|
import {
|
||||||
import Icon from '../components/Icon'
|
createCategory,
|
||||||
|
deleteCategory,
|
||||||
|
listCategories,
|
||||||
|
reorderCategories,
|
||||||
|
updateCategory,
|
||||||
|
} from '../api/client'
|
||||||
|
|
||||||
function Categories() {
|
function Categories() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
const [editingId, setEditingId] = useState<number | null>(null)
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
const [editingName, setEditingName] = useState('')
|
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 categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
|
||||||
const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['categories'] }); queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }) }
|
|
||||||
const createMutation = useMutation({ mutationFn: createCategory, onSuccess: () => { setNewName(''); invalidate() } })
|
const invalidate = () => {
|
||||||
const updateMutation = useMutation({ mutationFn: ({ id, name }: { id: number; name: string }) => updateCategory(id, name), onSuccess: () => { setEditingId(null); invalidate() } })
|
queryClient.invalidateQueries({ queryKey: ['categories'] })
|
||||||
const deleteMutation = useMutation({ mutationFn: deleteCategory, onSuccess: () => { setDeletingId(null); invalidate() } })
|
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||||
const reorderMutation = useMutation({ mutationFn: reorderCategories, onSuccess: invalidate })
|
}
|
||||||
|
|
||||||
|
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 categories = categoriesQuery.data ?? []
|
const categories = categoriesQuery.data ?? []
|
||||||
const deleting = categories.find((category) => category.id === deletingId)
|
|
||||||
function move(index: number, direction: -1 | 1) {
|
function move(index: number, direction: -1 | 1) {
|
||||||
const target = index + direction
|
const target = index + direction
|
||||||
if (target < 0 || target >= categories.length) return
|
if (target < 0 || target >= categories.length) return
|
||||||
const ids = categories.map((category) => category.id)
|
const ids = categories.map((c) => c.id)
|
||||||
;[ids[index], ids[target]] = [ids[target], ids[index]]
|
;[ids[index], ids[target]] = [ids[target], ids[index]]
|
||||||
reorderMutation.mutate(ids)
|
reorderMutation.mutate(ids)
|
||||||
}
|
}
|
||||||
return <section className="page categories-screen">
|
|
||||||
<Link to="/settings" className="back-link"><Icon name="arrow" size={17} /> Настройки</Link>
|
return (
|
||||||
<div className="page-heading"><div><p className="eyebrow">Моя лента</p><h1>Категории</h1><p className="page-subtitle">Создавай темы и меняй порядок в меню.</p></div></div>
|
<div className="categories-screen">
|
||||||
<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>
|
<Link to="/" className="back-link">
|
||||||
{createMutation.isError && <p className="error" role="alert">Не удалось создать категорию. Возможно, такое имя уже есть.</p>}
|
← Каналы
|
||||||
{reorderMutation.isError && <p className="error" role="alert">Не удалось изменить порядок категорий.</p>}
|
</Link>
|
||||||
{categoriesQuery.isLoading && <div className="channel-loading">{Array.from({ length: 3 }, (_, index) => <div key={index} className="channel-skeleton"><span /><i /></div>)}</div>}
|
<h1>Категории</h1>
|
||||||
{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>}
|
<form
|
||||||
{updateMutation.isError && <p className="error" role="alert">Не удалось переименовать категорию.</p>}
|
onSubmit={(e) => {
|
||||||
{!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>}
|
e.preventDefault()
|
||||||
{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>}
|
if (newName.trim()) createMutation.mutate(newName.trim())
|
||||||
</section>
|
}}
|
||||||
|
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Categories
|
export default Categories
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,56 @@
|
||||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { getChannel, getFeed } from '../api/client'
|
import { getChannel, getFeed } from '../api/client'
|
||||||
import Icon from '../components/Icon'
|
|
||||||
import VideoCard from '../components/VideoCard'
|
import VideoCard from '../components/VideoCard'
|
||||||
import { formatSubscriberCount } from '../utils/format'
|
|
||||||
|
|
||||||
function ChannelVideos() {
|
function ChannelVideos() {
|
||||||
const { channelId } = useParams<{ channelId: string }>()
|
const { channelId } = useParams<{ channelId: string }>()
|
||||||
const id = Number(channelId)
|
const id = Number(channelId)
|
||||||
const valid = Number.isInteger(id) && id > 0
|
|
||||||
const channelQuery = useQuery({ queryKey: ['channel', id], queryFn: () => getChannel(id), enabled: valid })
|
const channelQuery = useQuery({ queryKey: ['channel', id], queryFn: () => getChannel(id) })
|
||||||
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 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 items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
|
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
|
||||||
const subscriberLabel = channelQuery.data ? formatSubscriberCount(channelQuery.data.subscriber_count) : null
|
|
||||||
return <section className="page">
|
return (
|
||||||
<Link to="/channels" className="back-link"><Icon name="arrow" size={17} /> Каналы</Link>
|
<div className="feed-screen">
|
||||||
<div className="page-heading"><div><p className="eyebrow">Видео канала</p><h1>{channelQuery.data?.title ?? 'Канал'}</h1><p className="page-subtitle">{subscriberLabel ?? 'Последние ролики из твоих подписок.'}</p></div></div>
|
<header>
|
||||||
{(channelQuery.isError || feedQuery.isError || !valid) && <div className="empty-state" role="alert"><h2>Не удалось открыть канал</h2><Link to="/channels" className="button-primary">К списку каналов</Link></div>}
|
<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>}
|
<Link to="/channels" className="back-link">
|
||||||
{!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>}
|
</Link>
|
||||||
{feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
|
<h1>{channelQuery.data?.title ?? 'Канал'}</h1>
|
||||||
</section>
|
</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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ChannelVideos
|
export default ChannelVideos
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,106 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { getSyncStatus, listCategories, listChannels, syncSubscriptions } from '../api/client'
|
import { getSyncStatus, listCategories, listChannels, logout, syncSubscriptions } from '../api/client'
|
||||||
import ChannelCard from '../components/ChannelCard'
|
import ChannelCard from '../components/ChannelCard'
|
||||||
import CategoryNav, { type CategoryFilter } from '../components/CategoryNav'
|
import CategoryNav, { type CategoryFilter } from '../components/CategoryNav'
|
||||||
import Icon from '../components/Icon'
|
|
||||||
|
|
||||||
function Channels() {
|
function Channels() {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [filter, setFilter] = useState<CategoryFilter>('all')
|
const [filter, setFilter] = useState<CategoryFilter>('all')
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
|
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
|
||||||
const allChannelsQuery = useQuery({ queryKey: ['channels', 'all-for-counts'], queryFn: () => listChannels({ subscribed: true }) })
|
|
||||||
const channelsQuery = useQuery({
|
const channelsQuery = useQuery({
|
||||||
queryKey: ['channels', filter],
|
queryKey: ['channels', filter],
|
||||||
queryFn: () => listChannels({ subscribed: true, categoryId: typeof filter === 'number' ? filter : undefined, uncategorized: filter === 'uncategorized' }),
|
queryFn: () =>
|
||||||
|
listChannels({
|
||||||
|
categoryId: typeof filter === 'number' ? filter : undefined,
|
||||||
|
uncategorized: filter === 'uncategorized',
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
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 <section className="page channels-screen">
|
const filteredChannels = useMemo(() => {
|
||||||
<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>
|
const data = channelsQuery.data ?? []
|
||||||
{syncMutation.isError && <div className="notice error-notice" role="alert"><Icon name="alert" size={18} />Не удалось запустить обновление подписок.</div>}
|
if (!search.trim()) return data
|
||||||
{syncStatusQuery.data?.subscriptions.status === 'failed' && <div className="notice error-notice" role="status"><Icon name="alert" size={18} />Последнее обновление подписок завершилось ошибкой.</div>}
|
const needle = search.trim().toLowerCase()
|
||||||
<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>
|
return data.filter((c) => c.title.toLowerCase().includes(needle))
|
||||||
<CategoryNav categories={categories} value={filter} onChange={setFilter} allCount={allChannels.length} uncategorizedCount={uncategorizedCount} />
|
}, [channelsQuery.data, search])
|
||||||
{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>}
|
const syncStatusQuery = useQuery({
|
||||||
{!channelsQuery.isLoading && !channelsQuery.isError && filteredChannels.length > 0 && <ul className="channel-list">{filteredChannels.map((channel) => <ChannelCard key={channel.id} channel={channel} categories={categories} />)}</ul>}
|
queryKey: ['sync-status'],
|
||||||
{!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>}
|
queryFn: getSyncStatus,
|
||||||
</section>
|
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 categories = categoriesQuery.data ?? []
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Channels
|
export default Channels
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { authErrorMessage, startGoogleAuth } from '../api/client'
|
import { authErrorMessage, startGoogleAuth } from '../api/client'
|
||||||
import Icon from '../components/Icon'
|
|
||||||
|
|
||||||
function Connect() {
|
function Connect() {
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const authError = searchParams.get('auth_error')
|
const authError = searchParams.get('auth_error')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="connect-screen"><div className="connect-card">
|
<div className="connect-screen">
|
||||||
<span className="brand-mark"><Icon name="play" size={26} /></span>
|
<h1>MyYouTube</h1>
|
||||||
<h1>Твоя лента, твои темы</h1>
|
<p>Персональная лента YouTube-подписок с категориями.</p>
|
||||||
<p>Подключи YouTube, чтобы видеть видео своих подписок и распределять каналы по категориям. Доступен только твой аккаунт.</p>
|
|
||||||
{authError && <p className="error">{authErrorMessage(authError)}</p>}
|
{authError && <p className="error">{authErrorMessage(authError)}</p>}
|
||||||
<button className="button-primary" onClick={startGoogleAuth}><Icon name="youtube" size={18} /> Подключить YouTube</button>
|
<button onClick={startGoogleAuth}>Подключить YouTube</button>
|
||||||
</div></div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,110 +1,102 @@
|
||||||
import { useEffect, useRef } from 'react'
|
import { useMutation, useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { Link } from 'react-router-dom'
|
||||||
import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom'
|
import { getFeed, getSyncStatus, listCategories, logout, syncVideos } from '../api/client'
|
||||||
import { getFeed, getSavedCounts, getSyncStatus, listCategories, listChannels, syncVideos } from '../api/client'
|
import CategoryNav, { type CategoryFilter } from '../components/CategoryNav'
|
||||||
import Icon from '../components/Icon'
|
|
||||||
import VideoCard from '../components/VideoCard'
|
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() {
|
function Feed() {
|
||||||
const location = useLocation()
|
const [filter, setFilter] = useState<CategoryFilter>('all')
|
||||||
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 queryClient = useQueryClient()
|
||||||
|
|
||||||
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
|
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
|
||||||
const category = categoriesQuery.data?.find((item) => item.id === categoryNumber)
|
const categories = categoriesQuery.data ?? []
|
||||||
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({
|
const feedQuery = useInfiniteQuery({
|
||||||
queryKey: ['feed', location.pathname, search, localCategory, localUncategorized, newOnly],
|
queryKey: ['feed', filter],
|
||||||
queryFn: ({ pageParam }) => getFeed({
|
queryFn: ({ pageParam }) =>
|
||||||
categoryId: isLocal ? localCategory : categoryNumber,
|
getFeed({
|
||||||
uncategorized: isUncategorized || localUncategorized,
|
categoryId: typeof filter === 'number' ? filter : undefined,
|
||||||
downloaded: isLocal,
|
uncategorized: filter === 'uncategorized',
|
||||||
search,
|
|
||||||
newOnly,
|
|
||||||
cursor: pageParam,
|
cursor: pageParam,
|
||||||
}),
|
}),
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
|
getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined,
|
||||||
enabled: !categoryId || categoryNumber !== undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const syncStatusQuery = useQuery({
|
const syncStatusQuery = useQuery({
|
||||||
queryKey: ['sync-status'], queryFn: getSyncStatus,
|
queryKey: ['sync-status'],
|
||||||
refetchInterval: (query) => query.state.data?.videos.running ? 2000 : 60_000,
|
queryFn: getSyncStatus,
|
||||||
|
refetchInterval: (query) => (query.state.data?.videos.running ? 1500 : false),
|
||||||
})
|
})
|
||||||
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({
|
const syncMutation = useMutation({
|
||||||
mutationFn: syncVideos,
|
mutationFn: syncVideos,
|
||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['sync-status'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }) },
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['feed'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['sync-status'] })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
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 <section className="page">
|
const logoutMutation = useMutation({
|
||||||
<div className="page-heading">
|
mutationFn: logout,
|
||||||
<div><p className="eyebrow">Моя лента</p><h1>{title}</h1><p className="page-subtitle">{subtitle}{newOnly && <> · <Link to={`/category/${categoryNumber}`}>Показать все</Link></>}</p></div>
|
onSuccess: () => {
|
||||||
{!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>}
|
queryClient.invalidateQueries({ queryKey: ['auth-status'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const running = syncStatusQuery.data?.videos.running
|
||||||
|
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? []
|
||||||
|
|
||||||
|
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>
|
||||||
</div>
|
</div>
|
||||||
<nav className="mobile-category-nav" aria-label="Категории">
|
</header>
|
||||||
<Link to="/" className={location.pathname === '/' ? 'active' : ''}>Все</Link>
|
|
||||||
<Link to="/uncategorized" className={isUncategorized ? 'active' : ''}>Без категории</Link>
|
{syncMutation.isError && <p className="error">{(syncMutation.error as Error).message}</p>}
|
||||||
<Link to="/local" className={isLocal ? 'active' : ''}>На сервере</Link>
|
{syncStatusQuery.data?.videos.status === 'failed' && (
|
||||||
{categoriesQuery.data?.map((item) => <Link key={item.id} to={`/category/${item.id}`} className={categoryNumber === item.id ? 'active' : ''}>{item.name}</Link>)}
|
<p className="error">Последняя синхронизация видео завершилась ошибкой: {syncStatusQuery.data.videos.error}</p>
|
||||||
</nav>
|
)}
|
||||||
{isLocal && <nav className="local-filter-nav" aria-label="Фильтр сохранённых видео">
|
|
||||||
<Link to="/local" className={!localCategory && !localUncategorized ? 'active' : ''}>Все <span>{savedCountsQuery.data?.all ?? '…'}</span></Link>
|
<div className="layout">
|
||||||
<Link to="/local?uncategorized=true" className={localUncategorized ? 'active' : ''}>Без категории <span>{savedCountsQuery.data?.uncategorized ?? '…'}</span></Link>
|
<CategoryNav categories={categories} value={filter} onChange={setFilter} />
|
||||||
{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>}
|
<div className="content">
|
||||||
{syncMutation.isError && <div className="notice error-notice" role="alert"><Icon name="alert" size={18} />Не удалось запустить обновление видео.</div>}
|
{feedQuery.isLoading && <p>Загрузка...</p>}
|
||||||
{syncStatusQuery.data?.videos.status === 'failed' && <div className="notice error-notice" role="status"><Icon name="alert" size={18} />Последнее обновление видео завершилось ошибкой. Попробуйте позже.</div>}
|
{feedQuery.isError && <p className="error">Не удалось загрузить ленту</p>}
|
||||||
{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> : <>
|
<ul className="video-grid">
|
||||||
{feedQuery.isLoading && <ul className="video-grid">{Array.from({ length: 8 }, (_, index) => <VideoSkeleton key={index} />)}</ul>}
|
{items.map((video) => (
|
||||||
{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>}
|
<VideoCard key={video.youtube_video_id} video={video} />
|
||||||
{!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>}
|
</ul>
|
||||||
{feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
|
|
||||||
</>}
|
{!feedQuery.isLoading && items.length === 0 && (
|
||||||
</section>
|
<p>Видео пока нет. Нажми "Обновить сейчас" или проверь, есть ли каналы в этой категории.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{feedQuery.hasNextPage && (
|
||||||
|
<button
|
||||||
|
className="load-more"
|
||||||
|
onClick={() => feedQuery.fetchNextPage()}
|
||||||
|
disabled={feedQuery.isFetchingNextPage}
|
||||||
|
>
|
||||||
|
{feedQuery.isFetchingNextPage ? 'Загрузка...' : 'Показать ещё'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Feed
|
export default Feed
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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,31 +1,73 @@
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { getVideo } from '../api/client'
|
import { getVideo } from '../api/client'
|
||||||
import { formatDuration, formatRelativeTime } from '../utils/format'
|
import { formatDuration, formatRelativeTime } from '../utils/format'
|
||||||
import DownloadButton from '../components/DownloadButton'
|
import DownloadButton from '../components/DownloadButton'
|
||||||
import Icon from '../components/Icon'
|
|
||||||
import Player from '../components/Player'
|
import Player from '../components/Player'
|
||||||
|
|
||||||
function VideoPage() {
|
function VideoPage() {
|
||||||
const { youtubeVideoId } = useParams<{ youtubeVideoId: string }>()
|
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 ?? '/') }
|
|
||||||
|
|
||||||
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>
|
const videoQuery = useQuery({
|
||||||
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>
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const video = videoQuery.data
|
const video = videoQuery.data
|
||||||
const duration = formatDuration(video.duration_seconds)
|
const duration = formatDuration(video.duration_seconds)
|
||||||
return <article className="page video-page">
|
|
||||||
<button className="back-link" onClick={back}><Icon name="arrow" size={17} /> Назад к ленте</button>
|
return (
|
||||||
<Player key={video.youtube_video_id} video={video} />
|
<div className="video-page">
|
||||||
<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>
|
<Link to="/" className="back-link">
|
||||||
{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>}
|
</Link>
|
||||||
</article>
|
|
||||||
|
<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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default VideoPage
|
export default VideoPage
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,6 @@ export function formatDuration(seconds: number | null): string | null {
|
||||||
return `${m}:${String(s).padStart(2, '0')}`
|
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' })
|
const RTF = new Intl.RelativeTimeFormat('ru', { numeric: 'auto' })
|
||||||
|
|
||||||
export function formatRelativeTime(iso: string): string {
|
export function formatRelativeTime(iso: string): string {
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
"""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),
|
|
||||||
)
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
"""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")
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://opencode.ai/config.json",
|
|
||||||
"default_agent": "orchestrator"
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.api import auth as auth_module
|
from app.api import auth as auth_module
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models.oauth_credentials import SINGLETON_ID, OAuthCredentials
|
|
||||||
from app.services import google_oauth
|
from app.services import google_oauth
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -81,146 +78,3 @@ def test_callback_rejects_invalid_state(client, monkeypatch):
|
||||||
|
|
||||||
assert response.status_code in (302, 307)
|
assert response.status_code in (302, 307)
|
||||||
assert "invalid_state" in response.headers["location"]
|
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,13 +1,10 @@
|
||||||
import pytest
|
import pytest
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.core.auth_dependency import require_session
|
from app.core.auth_dependency import require_session
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models.channel import Channel
|
from app.models.channel import Channel
|
||||||
from app.models.video import Video
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -32,19 +29,6 @@ def _create_channel(db_session, youtube_channel_id="chanA", title="Channel A"):
|
||||||
return channel
|
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):
|
def test_category_crud(client):
|
||||||
created = client.post("/api/categories", json={"name": "Linux"}).json()
|
created = client.post("/api/categories", json={"name": "Linux"}).json()
|
||||||
assert created["name"] == "Linux"
|
assert created["name"] == "Linux"
|
||||||
|
|
@ -134,64 +118,3 @@ def test_reorder_rejects_mismatched_ids(client):
|
||||||
client.post("/api/categories", json={"name": "A"})
|
client.post("/api/categories", json={"name": "A"})
|
||||||
resp = client.post("/api/categories/reorder", json={"category_ids": [9999]})
|
resp = client.post("/api/categories/reorder", json={"category_ids": [9999]})
|
||||||
assert resp.status_code == 400
|
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,5 +1,3 @@
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
@ -7,7 +5,6 @@ from app.core.auth_dependency import require_session
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models.channel import Channel
|
from app.models.channel import Channel
|
||||||
from app.models.video import Video
|
|
||||||
from app.services import sync
|
from app.services import sync
|
||||||
from app.services.youtube_client import YouTubeInsufficientScope
|
from app.services.youtube_client import YouTubeInsufficientScope
|
||||||
|
|
||||||
|
|
@ -89,54 +86,3 @@ def test_unsubscribe_insufficient_scope_returns_403(client, db_session, monkeypa
|
||||||
def test_unsubscribe_channel_not_found(client):
|
def test_unsubscribe_channel_not_found(client):
|
||||||
resp = client.post("/api/channels/9999/unsubscribe")
|
resp = client.post("/api/channels/9999/unsubscribe")
|
||||||
assert resp.status_code == 404
|
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,101 +235,6 @@ async def test_handle_metube_event_canceled_marks_failed(db_session):
|
||||||
assert job.status == "failed"
|
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):
|
def test_reconcile_marks_unknown_when_history_unavailable(monkeypatch, db_session):
|
||||||
video = _seed_video(db_session)
|
video = _seed_video(db_session)
|
||||||
job = DownloadJob(video_id=video.id, status="downloading")
|
job = DownloadJob(video_id=video.id, status="downloading")
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,7 @@ from app.main import app
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from app.models.channel import Channel
|
from app.models.channel import Channel
|
||||||
from app.models.channel_category import channel_categories
|
from app.models.channel_category import channel_categories
|
||||||
from app.models.download_job import DownloadJob
|
|
||||||
from app.models.video import Video
|
from app.models.video import Video
|
||||||
from app.api.feed import get_feed
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -68,19 +66,6 @@ def test_feed_orders_by_published_at_desc(client, db_session):
|
||||||
assert resp["next_cursor"] is None
|
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):
|
def test_feed_pagination_cursor(client, db_session):
|
||||||
_seed(db_session)
|
_seed(db_session)
|
||||||
|
|
||||||
|
|
@ -124,182 +109,6 @@ def test_feed_filters_uncategorized(client, db_session):
|
||||||
assert ids == {"vid1", "vid3"}
|
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):
|
def test_feed_item_shape(client, db_session):
|
||||||
channel_a, _, category, _ = _seed(db_session)
|
channel_a, _, category, _ = _seed(db_session)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.models.channel import Channel
|
from app.models.channel import Channel
|
||||||
from app.models.video import Video
|
from app.models.video import Video
|
||||||
from app.services import sync
|
from app.services import sync
|
||||||
|
|
@ -35,7 +32,7 @@ def test_sync_subscriptions_idempotent_and_unsubscribes(monkeypatch, db_session)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
sync.youtube_client,
|
sync.youtube_client,
|
||||||
"fetch_uploads_playlists",
|
"fetch_uploads_playlists",
|
||||||
lambda creds, ids: {cid: {"uploads_playlist_id": f"UU{cid}", "subscriber_count": 12345} for cid in ids},
|
lambda creds, ids: {cid: f"UU{cid}" for cid in ids},
|
||||||
)
|
)
|
||||||
|
|
||||||
result = sync.sync_subscriptions(db_session)
|
result = sync.sync_subscriptions(db_session)
|
||||||
|
|
@ -48,7 +45,6 @@ def test_sync_subscriptions_idempotent_and_unsubscribes(monkeypatch, db_session)
|
||||||
assert [c.youtube_channel_id for c in channels] == ["chanA", "chanB"]
|
assert [c.youtube_channel_id for c in channels] == ["chanA", "chanB"]
|
||||||
assert all(c.subscribed for c in channels)
|
assert all(c.subscribed for c in channels)
|
||||||
assert channels[0].uploads_playlist_id == "UUchanA"
|
assert channels[0].uploads_playlist_id == "UUchanA"
|
||||||
assert channels[0].subscriber_count == 12345
|
|
||||||
|
|
||||||
# Second sync: chanA disappears from subscriptions, chanC appears.
|
# Second sync: chanA disappears from subscriptions, chanC appears.
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -85,46 +81,6 @@ def test_sync_subscriptions_idempotent_and_unsubscribes(monkeypatch, db_session)
|
||||||
assert channels["chanC"].subscribed is True
|
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):
|
def test_sync_in_progress_raises(monkeypatch, db_session):
|
||||||
sync._subscriptions_lock.acquire()
|
sync._subscriptions_lock.acquire()
|
||||||
try:
|
try:
|
||||||
|
|
@ -150,14 +106,12 @@ def _seed_channel(db_session, youtube_channel_id="chanA", uploads_playlist_id="U
|
||||||
return channel
|
return channel
|
||||||
|
|
||||||
|
|
||||||
def test_sync_videos_adds_new_videos(monkeypatch, db_session):
|
def test_sync_videos_adds_and_updates(monkeypatch, db_session):
|
||||||
channel = _seed_channel(db_session)
|
channel = _seed_channel(db_session)
|
||||||
|
|
||||||
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: object())
|
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: object())
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
sync.youtube_client,
|
sync.youtube_client, "fetch_playlist_video_ids", lambda creds, playlist_id, max_results: ["vid1"]
|
||||||
"fetch_playlist_video_ids_incremental",
|
|
||||||
lambda creds, playlist_id, known_ids, max_results, stop_threshold: ["vid1"],
|
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
sync.youtube_client,
|
sync.youtube_client,
|
||||||
|
|
@ -179,23 +133,12 @@ def test_sync_videos_adds_new_videos(monkeypatch, db_session):
|
||||||
|
|
||||||
assert result["status"] == "completed"
|
assert result["status"] == "completed"
|
||||||
assert result["videos_added"] == 1
|
assert result["videos_added"] == 1
|
||||||
assert result["channels_checked"] == 1
|
|
||||||
|
|
||||||
video = db_session.query(Video).filter_by(youtube_video_id="vid1").one()
|
video = db_session.query(Video).filter_by(youtube_video_id="vid1").one()
|
||||||
assert video.title == "Video One"
|
assert video.title == "Video One"
|
||||||
assert video.duration_seconds == 300
|
assert video.duration_seconds == 300
|
||||||
assert video.youtube_url == "https://www.youtube.com/watch?v=vid1"
|
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(
|
monkeypatch.setattr(
|
||||||
sync.youtube_client,
|
sync.youtube_client,
|
||||||
"fetch_videos_details",
|
"fetch_videos_details",
|
||||||
|
|
@ -203,142 +146,29 @@ def test_sync_videos_second_run_is_idempotent_and_fetches_no_details(monkeypatch
|
||||||
{
|
{
|
||||||
"youtube_video_id": "vid1",
|
"youtube_video_id": "vid1",
|
||||||
"youtube_channel_id": channel.youtube_channel_id,
|
"youtube_channel_id": channel.youtube_channel_id,
|
||||||
"title": "Video One",
|
"title": "Video One Updated",
|
||||||
"description": "d",
|
"description": "d2",
|
||||||
"thumbnail_url": "t",
|
"thumbnail_url": "t2",
|
||||||
"published_at": "2026-09-10T12:00:00Z",
|
"published_at": "2026-09-10T12:00:00Z",
|
||||||
"duration_iso8601": "PT5M",
|
"duration_iso8601": "PT6M",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
first = sync.sync_videos(db_session)
|
result2 = sync.sync_videos(db_session)
|
||||||
assert first["videos_added"] == 1
|
assert result2["videos_added"] == 0
|
||||||
|
assert result2["videos_updated"] == 1
|
||||||
|
|
||||||
# Second run: vid1 is now known, so the incremental fetch reports nothing
|
videos = db_session.query(Video).all()
|
||||||
# new and no details are requested at all (existing videos are not
|
assert len(videos) == 1
|
||||||
# metadata-refreshed by design).
|
assert videos[0].title == "Video One Updated"
|
||||||
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):
|
def test_sync_videos_skips_unknown_channel(monkeypatch, db_session):
|
||||||
_seed_channel(db_session, "chanA")
|
_seed_channel(db_session, "chanA")
|
||||||
|
|
||||||
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: object())
|
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: object())
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(sync.youtube_client, "fetch_playlist_video_ids", lambda creds, playlist_id, max_results: [])
|
||||||
sync.youtube_client,
|
|
||||||
"fetch_playlist_video_ids_incremental",
|
|
||||||
lambda creds, playlist_id, known_ids, max_results, stop_threshold: [],
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
sync.youtube_client,
|
sync.youtube_client,
|
||||||
"fetch_videos_details",
|
"fetch_videos_details",
|
||||||
|
|
@ -359,211 +189,3 @@ def test_sync_videos_skips_unknown_channel(monkeypatch, db_session):
|
||||||
|
|
||||||
assert result["videos_skipped"] == 1
|
assert result["videos_skipped"] == 1
|
||||||
assert db_session.query(Video).count() == 0
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
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,49 +182,16 @@ def test_delete_download_success(client, db_session, monkeypatch):
|
||||||
calls = {}
|
calls = {}
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"app.services.metube_client.MeTubeClient.delete_download",
|
"app.services.metube_client.MeTubeClient.delete_download",
|
||||||
lambda self, youtube_url: calls.setdefault("youtube_url", youtube_url) or {"status": "ok"},
|
lambda self, metube_job_id: calls.setdefault("id", metube_job_id) 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")
|
resp = client.delete(f"/api/videos/{video.youtube_video_id}/download")
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["status"] == "deleted"
|
assert resp.json()["status"] == "deleted"
|
||||||
assert resp.json()["media_url"] is None
|
assert resp.json()["media_url"] is None
|
||||||
# MeTube's queue/done stores are keyed by URL, not by the id it reports
|
assert calls["id"] == "vid1.vid1"
|
||||||
# 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)
|
db_session.refresh(job)
|
||||||
assert job.status == "deleted"
|
assert job.status == "deleted"
|
||||||
assert job.media_url is None
|
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,160 +115,6 @@ def test_fetch_playlist_video_ids_returns_empty_on_404(monkeypatch):
|
||||||
assert result == []
|
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):
|
def test_fetch_videos_details(monkeypatch):
|
||||||
response = FakeResponse(
|
response = FakeResponse(
|
||||||
200,
|
200,
|
||||||
|
|
@ -348,48 +194,13 @@ def test_fetch_uploads_playlists_batches(monkeypatch):
|
||||||
200,
|
200,
|
||||||
{
|
{
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{"id": "chanA", "contentDetails": {"relatedPlaylists": {"uploads": "UUchanA"}}},
|
||||||
"id": "chanA",
|
{"id": "chanB", "contentDetails": {"relatedPlaylists": {"uploads": "UUchanB"}}},
|
||||||
"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]))
|
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([response]))
|
||||||
|
|
||||||
result = youtube_client.fetch_uploads_playlists(_credentials(), ["chanA", "chanB", "chanC", "chanD"])
|
result = youtube_client.fetch_uploads_playlists(_credentials(), ["chanA", "chanB"])
|
||||||
|
|
||||||
assert result == {
|
assert result == {"chanA": "UUchanA", "chanB": "UUchanB"}
|
||||||
"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}}
|
|
||||||
|
|
|
||||||
1552
youtube_categories_metube_TZ.md
Normal file
1552
youtube_categories_metube_TZ.md
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue