Compare commits

..

10 commits

Author SHA1 Message Date
vrubelroman
e10df8dcbd Channel features: subscribers, new-videos badges, activity-driven sync
- Store subscriber count from YouTube statistics and show it on channel page
- Sync 50 videos per channel with playlistItems pagination support
- Show per-channel and per-category new-videos counters (2-day window)
- Replace hourly videos sync with activity trigger (2h idle) and
  incremental backfill (hard cap 200 per channel)
- Clicking the sidebar new-videos count filters the category feed to
  recent videos only (new_only)
- Update agent-team docs: deploy after green checks
2026-09-17 21:07:33 +00:00
vrubelroman
fde9a439df Fix review findings and remove Uncategorized from sidebar
Backend: cache Google access tokens (drop dead access_token_expires_at,
migration 0007), handle MeTube cleared/canceled events by URL, return
email from /auth/status only when authenticated, move Google base URLs
into settings, run container as non-root.

Frontend: include local feed filters in the query key, remove dead
Saved page and unused assets, drop stale CategoryNav props and classes,
send Content-Type only with a body, remove Uncategorized from sidebar.
2026-09-17 17:22:22 +00:00
vrubelroman
6c704cac97 Switch agent team to opencode, drop Codex/Herdr multi-agent setup 2026-09-16 23:08:21 +00:00
vrubelroman
c4a772b1e3 Redesign responsive MyTube interface 2026-09-16 21:40:32 +00:00
vrubelroman
90eb012e0d Show saved-video counts (not channel counts) in the Saved page sidebar
GET /api/feed/saved-counts aggregates, per category, how many videos have
a completed download (using each video's latest job only, same rule as
the existing ?downloaded=true feed filter). CategoryNav gains an optional
categoryCounts override so Feed/Channels keep showing channel_count while
Saved shows this instead.

Query key ['feed', 'saved-counts'] deliberately nests under 'feed' so
DownloadButton's existing invalidateQueries({queryKey: ['feed']}) on
download/delete refreshes these counts too, with no extra wiring.

1 new backend test (77 total).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:41:20 +00:00
vrubelroman
44f81f0f0d Show channel counts in the left CategoryNav sidebar (Feed/Channels/Saved)
Same "channels per category" counts as the categories management page,
now next to each button in the sidebar used across all three pages.
Categories already carry channel_count; "Все"/"Без категории" counts are
derived client-side from one extra unfiltered channels query, reused
across the three pages via a shared ['channels', 'all-for-counts'] key.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:38:06 +00:00
vrubelroman
cdb1c2ded2 Show channel count for "Без категории" on the categories management page
Reuses the existing GET /api/channels?uncategorized=true endpoint client-side
rather than changing the /api/categories response shape, which stays a
plain array (kept several existing call sites simple).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:35:39 +00:00
vrubelroman
b29bcfb8d7 Hide unsubscribed channels from the channels list, add a Saved tab
- GET /api/channels called with subscribed=true from the Channels page, so
  a channel disappears from the list as soon as it's unsubscribed instead
  of lingering with a badge
- GET /api/feed gains a `downloaded` filter (this was already anticipated
  in the original TZ's feed query params but left unimplemented until
  download_jobs existed) -- matches on each video's *latest* job only, so
  a redownload/delete history doesn't leave stale matches
- New /saved route: same category filtering, video grid and download
  actions as the main feed, scoped to downloaded videos only

2 new backend tests (76 total).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:23:33 +00:00
vrubelroman
62f8fb746b Fix delete not finding the download: wrong id sent to MeTube's /delete
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 (which is what we stored as metube_job_id and were
sending). Sending the wrong key made MeTube's clear()/cancel() silently
no-op ("requested delete for non-existent download" in its own logs) while
still returning {"status": "ok"} regardless -- confirmed live by curling
/history on the real instance and finding the "deleted" entry still
present, unrelated to the DELETE_FILE_ON_TRASHCAN config fix that came
right before this.

delete_download() now takes the video's canonical youtube_url instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:14:43 +00:00
vrubelroman
441a3ef9df Verify deletion actually removed the file before reporting success
MeTube's /delete only unlinks the file when it's configured with
DELETE_FILE_ON_TRASHCAN=true; otherwise it just drops the entry from its
own "done" list and returns {"status": "ok"} regardless -- we were trusting
that response and marking the job "deleted" (offering a re-download) while
the file was still sitting on mediaVM's disk the whole time.

Now HEAD-check the media_url right after the delete call. If the file is
still reachable, leave the job's status untouched (still "completed", still
playable) and surface a clear 409 explaining MeTube's own config is why,
rather than lying about local state.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:01:41 +00:00
64 changed files with 2986 additions and 3839 deletions

View file

@ -12,13 +12,30 @@ 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

26
.opencode/agent/coder.md Normal file
View file

@ -0,0 +1,26 @@
---
description: Implementation owner. Makes the smallest coherent change for an assigned bounded task, runs the relevant checks, and reports CODER_DONE.
mode: subagent
---
# Coder
You are the implementation owner in an opencode agent team. The Orchestrator (main session) gives you bounded tasks. You are the only agent allowed to change tracked project files in this worktree.
## Working rules
1. Read `AGENTS.md` and the relevant project specification before changing code. Inspect existing changes (`git status`, `git diff`) and preserve them.
2. Implement the assigned acceptance criteria with the smallest coherent change. Follow the existing architecture and naming patterns. Avoid unrelated refactors and dependencies.
3. Do not change global opencode configuration, secrets, deployment, or remote services unless the assignment explicitly requires it.
4. Run the relevant lint, build, unit tests, and focused checks. Fix errors caused by your changes. Do not claim a check passed unless it ran and you saw the result.
5. When an API, product behavior, or external fact may have changed, verify it from primary documentation or the installed tool's help.
6. Stop editing before reporting. The Orchestrator will send the result to independent review and QA. When feedback arrives, fix the stated issues and verify again.
7. If blocked, give the Orchestrator a precise cause and the smallest decision needed.
## Final report format
- Implemented: concrete behavior.
- Changed files: paths and why.
- Checks: exact commands and pass/fail results.
- Limits: remaining risks or unverified behavior.
- End the final response with `CODER_DONE`.

View file

@ -0,0 +1,33 @@
---
description: Orchestrator / Tech Lead — coordinates coder, reviewer, and tester subagents and reports to the user.
mode: primary
---
# Orchestrator / Tech Lead
You are the only agent who normally speaks with the user. Coordinate the opencode subagents `coder`, `reviewer`, and `tester` through the Task tool; do not edit product code yourself. The subagents share this worktree. Only `coder` changes tracked project files.
## Start every task
1. Read the repository's `AGENTS.md` and any relevant specification before planning. Inspect the current worktree (`git status`, `git diff`) and identify pre-existing changes.
2. Turn the user's request into a bounded task for Coder: scope, acceptance criteria, files or subsystems likely involved, constraints, and validation expected. Resolve routine choices yourself. Ask the user only for genuinely missing decisions.
3. Give Coder ownership of implementation. Do not send implementation work to Reviewer or Tester.
4. Submit the task to Coder with the Task tool (subagent_type `coder`). Keep one code writer at a time: never run two Coder tasks concurrently and do not start a new Coder task while another is working.
5. Read Coder's report (ends with `CODER_DONE`) and inspect the diff yourself. Then run Reviewer and Tester on the result. Reviewer must not edit; Tester must not fix. They may run in parallel — their commands cannot interfere with each other's.
6. Collect Critical, Major, and Minor findings with evidence. Send actionable findings back to Coder as a follow-up task. Repeat review and test on the changed result until Critical and Major findings are resolved, or report a concrete blocker to the user.
6.5. When Critical and Major findings are closed and checks are green, do not ask for permission: deploy the changes to the test service right away with `docker compose up -d --build` (from the repository root; the container rebuilds the frontend from the working tree and applies migrations on startup). Then verify the container is up and `curl http://localhost:8080/api/health` responds OK.
7. After the deploy, give the user a concise final report: what Coder implemented, what Reviewer reviewed, what Tester tested (commands and results), review findings resolved or remaining, known limits, and worktree/branch. Remind the user to refresh the page with cache cleared (Ctrl+Shift+R) and explicitly say you are waiting for their feedback to verify the deployed changes. Do not claim visual or integration checks that were not performed.
## Working rules
- Do not merge, publish, or commit unless the user requested it or existing authorization covers it. Deploying to the test service is authorized by this workflow (step 6.5); deploying elsewhere still requires the user's go-ahead.
- For frontend work, include responsive behavior, accessibility, loading/error/empty states, and real browser verification when tooling exists in the acceptance criteria.
- For backend work, include data integrity, security, edge cases, and relevant API checks.
## Expected worker reports
- Coder ends with `CODER_DONE` and lists changed files, implementation, verification, and limits.
- Reviewer ends with `REVIEW_DONE` and lists findings by severity with file/line evidence, or states that no actionable findings were found.
- Tester ends with `TEST_DONE` and lists commands, passes, failures, reproduction steps, expected and actual behavior, and severity.
This workflow adapts the narrow-role and read-only review patterns from the official OpenAI Docs on subagents.

View file

@ -0,0 +1,25 @@
---
description: Independent read-only reviewer. Analyzes the coder's diff, classifies findings by severity, and reports REVIEW_DONE. Never edits files.
mode: subagent
permission:
edit: deny
---
# Reviewer / Designer
You are an independent reviewer in an opencode agent team. Your session is read-only: do not edit code, tests, snapshots, or configuration. Analyze Coder's result and report to the Orchestrator.
## Review method
1. Read the assignment, acceptance criteria, `AGENTS.md`, and relevant specifications. Review the actual diff (`git diff`) and enough surrounding code to understand behavior.
2. Trace changed execution paths. Prioritize defects with user impact. Cite exact files and lines, explain the consequence, and give a reproduction or concrete failure scenario where possible.
3. For frontend/UI, inspect UX, visual hierarchy, consistency, responsive widths, touch targets, keyboard and screen-reader access, loading/error/empty states, component boundaries, and state handling. Use an available browser or screenshot tool for visual claims; if none exists, say what remains unverified.
4. For backend/general code, inspect architecture, data flow, edge cases, security, validation, race conditions, error handling, tests, and violations of existing patterns.
5. Classify findings: **Critical** blocks safe use or risks loss/security; **Major** breaks a supported workflow or causes a material regression; **Minor** is a bounded quality issue. Do not inflate speculative concerns.
6. Separate confirmed findings from questions. If there are no actionable findings, state that plainly. Do not fill the report with style-only comments or restatements of Coder's work.
## Report format
- Findings in severity order, each with file/line, impact, and evidence.
- Verification performed and any limits, especially missing visual checks.
- End the final response with `REVIEW_DONE`.

27
.opencode/agent/tester.md Normal file
View file

@ -0,0 +1,27 @@
---
description: Independent QA. Verifies the coder's finished result, runs checks, and reports TEST_DONE. Does not modify tracked files.
mode: subagent
permission:
edit: deny
---
# Tester / QA
You are independent QA in an opencode agent team. Verify Coder's finished result and report to the Orchestrator. Do not fix product code or tests; you must not modify tracked project files (build tools may create ignored artifacts).
## Test method
1. Read the assignment, acceptance criteria, `AGENTS.md`, and Coder's changed-file report. Inspect the diff enough to identify high-risk paths.
2. Run relevant existing lint, build, and unit tests. Run integration or end-to-end tests when configured. Use Playwright/Cypress or another browser tool if present; verify responsive UI, console errors, network/API behavior, and core flows. Do not claim visual verification from code inspection alone.
3. Exercise positive, negative, boundary, refresh/navigation, and recovery scenarios that matter to the change. Prefer focused tests before broad test suites when a failure needs isolation.
4. Respect project safety rules: external Google/MeTube calls are mocked by default; do not perform real integration calls unless the task explicitly opts in. Never delete production data or change external state as a test without authorization.
5. Capture exact commands and observations. If a check is blocked by environment, distinguish that from an application failure and explain the blocker.
6. After verification, check `git status --short`. Report any tracked file that test commands changed; do not clean it up without coordination.
## Report format
- Scope and commands run.
- Passed checks.
- Failed checks with reproduction steps, expected versus actual behavior, evidence, and Critical/Major/Minor severity.
- Untested areas and why.
- End the final response with `TEST_DONE`.

View file

@ -1,44 +1,31 @@
# AGENTS.md # AGENTS.md
Полное ТЗ: [`youtube_categories_metube_TZ.md`](./youtube_categories_metube_TZ.md). Перед любой существенной работой сверяйся с ним — этот файл лишь выжимка ограничений. Правила для персонального YouTube-клиента с категориями подписок и интеграцией с существующим MeTube. Основные этапы разработки (каркас, OAuth/подписки, категории, лента, воспроизведение и MeTube) уже реализованы; ориентируйся на фактический код и `README.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. Не делать multi-user — приложение строго single-user (`ALLOWED_GOOGLE_EMAIL`). 6. Приложение остаётся 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. Не восстанавливать filename скачанного видео из YouTube title — использовать только точное имя, пришедшее от MeTube по событию `completed`. 9. Не восстанавливать имя скачанного файла из 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/`), импортирует модели из `backend/app`. - `migrations/` — Alembic в корне репозитория; миграции импортируют модели из `backend/app`. Схему БД изменять миграциями, не создавать и не менять ad hoc при старте приложения.
- Схема БД добавляется миграциями инкрементально по фазам (см. раздел 11 ТЗ), а не одним махом в Phase 1. - MeTube-специфичные HTTP/Socket.IO вызовы инкапсулировать в `MeTubeClient`, не размазывать по backend.
- MeTube-специфичные HTTP/Socket.IO вызовы должны быть инкапсулированы в отдельный класс `MeTubeClient` (раздел 36 ТЗ) — не размазывать по backend. - Секреты хранить только в `.env` (образец — `.env.example`); не коммитить `.env`, refresh token или client secret.
- Секреты только через `.env` (см. `.env.example`), никогда не коммитить `.env`, refresh token, client secret. - Backend тесты — `pytest` в корневом `tests/`. Интеграции с Google/MeTube по умолчанию mock; реальные вызовы к `http://192.168.8.177:8081` — только opt-in, не в обычном CI.
- Backend тесты — `pytest`, лежат в `tests/` в корне. Integration-тесты против Google/MeTube — mocked по умолчанию; реальные вызовы к `http://192.168.8.177:8081` — только opt-in, не в обычном CI. - В unit-тестах использовать `TestClient(app)` без `with`: контекстный менеджер запускает lifespan с подключением к MeTube (Socket.IO) и Postgres (`reconcile_on_startup`).
- **В 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`.

29
AGENT_TEAM.md Normal file
View file

@ -0,0 +1,29 @@
# Агентская команда opencode
Одна сессия opencode в роли **Orchestrator** и три субагента — **Coder**, **Reviewer**, **Tester**, которых Orchestrator вызывает через Task tool. Роли лежат в [`.opencode/agent/`](./.opencode/agent/), Orchestrator назначен агентом по умолчанию в [`opencode.json`](./opencode.json).
## Запуск и работа
В корне репозитория запусти `opencode`. Каждая новая сессия стартует Orchestrator'ом: задачу пиши обычным сообщением. Он сам:
- декомпозирует задачу и отправляет её Coder'у через Task tool;
- после Coder'а запускает Reviewer (только чтение) и Tester (проверка без правок) параллельно;
- возвращает Coder'у actionable findings и повторяет цикл, пока Critical/Major не закрыты;
- когда Critical/Major закрыты и проверки зелёные — без вопроса деплоит на тестовый сервис (`docker compose up -d --build`, затем `curl http://localhost:8080/api/health`);
- отдаёт финальный отчёт уже после деплоя: что написано, просмотрено и протестировано, оставшиеся риски и просьба проверить в браузере с очисткой кэша (Ctrl+Shift+R).
Вручную писать субагентам не нужно — их вызывает только Orchestrator.
## Роли
- **Orchestrator** (primary, агент по умолчанию) — единственный общается с пользователем; сам код не правит.
- **Coder** (subagent) — единственный меняет отслеживаемые файлы; отчитывается `CODER_DONE`.
- **Reviewer** (subagent, `edit: deny`) — read-only review; отчитывается `REVIEW_DONE`.
- **Tester** (subagent, `edit: deny`) — прогоняет проверки; может создавать игнорируемые артефакты, но не меняет отслеживаемые файлы; отчитывается `TEST_DONE`.
## Файлы
- `.opencode/agent/*.md` — определения агентов команды;
- `opencode.json` — `default_agent: orchestrator`.
Конфиг и агенты читаются при старте opencode: после изменений перезапусти сессию.

View file

@ -25,6 +25,11 @@ 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"]

View file

@ -1,9 +1,23 @@
# MyYouTube # MyYouTube
Персональный YouTube-клиент с категориями подписок и интеграцией с существующим MeTube. Персональный YouTube-клиент с категориями подписок и интеграцией с существующим MeTube.
Полное техническое задание: [`youtube_categories_metube_TZ.md`](./youtube_categories_metube_TZ.md). Работа агентской команды (opencode) описана в [руководстве по агентской команде](./AGENT_TEAM.md).
Текущий статус: **Phase 1 — skeleton** (FastAPI backend, PostgreSQL, Alembic, React frontend, Docker Compose, healthcheck). Текущий статус: **функционально готово** (основные этапы 1–6 и доработки по итогам тестирования). Развёрнуто и вручную протестировано на тестовом окружении (`testmyyoutube.vrubel.xyz` → hermesVM `192.168.8.173` → MeTube на mediaVM `192.168.8.177`).
### Что сделано
- Skeleton, Google OAuth (single-user allow-list), sync подписок/видео (APScheduler), категории (CRUD + many-to-many), лента с курсорной пагинацией, YouTube-плеер, полная MeTube-интеграция (download/delete, Socket.IO live-статусы, recovery после рестарта).
- Раздел **«На сервере»** — скачанные видео; `/saved` перенаправляет на `/local`.
- Тёмный адаптивный интерфейс: общая навигация, категории с постоянными URL, поиск по названиям видео и каналов, мобильное меню.
- Отписанные каналы скрываются из списка каналов.
- Реальная отписка от канала на YouTube (`subscriptions.delete`) реализована по запросу пользователя сверх исходного MVP. Из-за этого OAuth scope расширен с `youtube.readonly` до полного `youtube` (read/write) — см. `backend/app/services/google_oauth.py`.
- Удаление скачанного видео с сервера (`DELETE /api/videos/{id}/download`) — тоже сверх исходного MVP-скоупа, добавлено по запросу.
### Что осталось / сознательно отложено
- Дополнительные идеи после MVP пока не реализованы: PWA, watch later, Shorts-фильтр, SponsorBlock и т.д.
- Hardening (retry/recovery, тесты) выполнялся по факту находок в live-тестировании, а не отдельным проходом — см. `git log` для конкретных багфиксов (flapping статусов загрузки, неверный id в MeTube `/delete`, OAuth scope mismatch).
## Стек ## Стек
@ -31,6 +45,25 @@
## Локальная разработка ## Локальная разработка
### Агентская команда (opencode)
Подробная инструкция: [AGENT_TEAM.md](./AGENT_TEAM.md).
Работа идёт в одной сессии opencode: она стартует агентом `orchestrator`
(`default_agent` в `opencode.json`). Orchestrator сам декомпозирует задачу и
вызывает субагентов через Task tool: `coder` (единственный меняет
отслеживаемые файлы), `reviewer` (read-only review, `edit: deny`) и `tester`
(проверки без правок, `edit: deny`). Роли описаны в
[`.opencode/agent/`](./.opencode/agent/).
```bash
opencode
```
Задачу пиши обычным сообщением: Orchestrator передаст её Coder'у, соберёт
review и QA, повторит цикл при находках и вернёт финальный отчёт. Вручную
писать субагентам не нужно.
### Backend ### Backend
```bash ```bash
@ -81,7 +114,8 @@ myyoutube/
├── Dockerfile ├── Dockerfile
├── compose.yml ├── compose.yml
├── .env / .env.example ├── .env / .env.example
└── youtube_categories_metube_TZ.md ├── .opencode/ # агенты команды (orchestrator/coder/reviewer/tester)
└── AGENT_TEAM.md # руководство по агентской команде opencode
``` ```
## Секреты ## Секреты

File diff suppressed because it is too large Load diff

View file

@ -17,11 +17,16 @@ 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": bool(request.session.get("authenticated")), "authenticated": authenticated,
"connected": connected, "connected": connected,
"email": google_oauth.get_connected_email(db) if connected else None, "email": email,
} }

View file

@ -1,14 +1,19 @@
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)])
@ -40,13 +45,15 @@ def _name_taken(db: Session, name: str, exclude_id: int | None = None) -> bool:
return any(row[0].casefold() == target for row in query.all()) return any(row[0].casefold() == target for row in query.all())
def _serialize(db: Session, category: Category, counts: dict[int, int]) -> dict: def _serialize(db: Session, category: Category, counts: dict[int, int], new_videos: dict[int, int] | None = None) -> dict:
new_videos = new_videos or {}
return { 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),
} }
@ -59,7 +66,17 @@ def list_categories(db: Session = Depends(get_db)) -> list[dict]:
.all() .all()
) )
counts = dict(count_rows) counts = dict(count_rows)
return [_serialize(db, c, counts) for c in categories] since = datetime.now(timezone.utc) - timedelta(days=settings.new_videos_window_days)
new_videos_rows = (
db.query(channel_categories.c.category_id, func.count(Video.id))
.join(Channel, Channel.id == channel_categories.c.channel_id)
.join(Video, Video.channel_id == Channel.id)
.filter(Channel.subscribed.is_(True), Video.published_at >= since)
.group_by(channel_categories.c.category_id)
.all()
)
new_videos = dict(new_videos_rows)
return [_serialize(db, c, counts, new_videos) for c in categories]
@router.post("/categories", status_code=201) @router.post("/categories", status_code=201)

View file

@ -1,15 +1,18 @@
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 select from sqlalchemy import func, 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
@ -37,7 +40,22 @@ def _category_ids_by_channel(db: Session, channel_ids: list[int]) -> dict[int, l
return result return result
def _serialize(channel: Channel, category_ids: list[int]) -> dict: def _new_videos_counts(db: Session, channel_ids: list[int]) -> dict[int, int]:
"""Videos published within the new-videos window, per channel, in one
aggregate query."""
if not channel_ids:
return {}
since = datetime.now(timezone.utc) - timedelta(days=settings.new_videos_window_days)
rows = (
db.query(Video.channel_id, func.count(Video.id))
.filter(Video.channel_id.in_(channel_ids), Video.published_at >= since)
.group_by(Video.channel_id)
.all()
)
return {channel_id: count for channel_id, count in rows}
def _serialize(channel: Channel, category_ids: list[int], new_videos_count: int = 0) -> dict:
return { return {
"id": channel.id, "id": channel.id,
"youtube_channel_id": channel.youtube_channel_id, "youtube_channel_id": channel.youtube_channel_id,
@ -45,9 +63,11 @@ def _serialize(channel: Channel, category_ids: list[int]) -> dict:
"description": channel.description, "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,
} }
@ -75,8 +95,10 @@ 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()
category_map = _category_ids_by_channel(db, [c.id for c in channels]) channel_ids = [c.id for c in channels]
return [_serialize(c, category_map.get(c.id, [])) for c in channels] category_map = _category_ids_by_channel(db, channel_ids)
new_videos_map = _new_videos_counts(db, channel_ids)
return [_serialize(c, category_map.get(c.id, []), new_videos_map.get(c.id, 0)) for c in channels]
@router.get("/channels/{channel_id}") @router.get("/channels/{channel_id}")
@ -85,7 +107,8 @@ def get_channel(channel_id: int, db: Session = Depends(get_db)) -> dict:
if channel is None: 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])
return _serialize(channel, category_map.get(channel_id, [])) new_videos_map = _new_videos_counts(db, [channel_id])
return _serialize(channel, category_map.get(channel_id, []), new_videos_map.get(channel_id, 0))
@router.put("/channels/{channel_id}/categories") @router.put("/channels/{channel_id}/categories")
@ -110,7 +133,8 @@ def set_channel_categories(channel_id: int, payload: ChannelCategoriesUpdate, db
) )
db.commit() db.commit()
return _serialize(channel, sorted(unique_ids)) new_videos_map = _new_videos_counts(db, [channel_id])
return _serialize(channel, sorted(unique_ids), new_videos_map.get(channel_id, 0))
@router.post("/channels/{channel_id}/unsubscribe") @router.post("/channels/{channel_id}/unsubscribe")
@ -139,4 +163,5 @@ def unsubscribe_channel(channel_id: int, db: Session = Depends(get_db)) -> dict:
raise HTTPException(status_code=502, detail="YouTube is unavailable") 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])
return _serialize(channel, category_map.get(channel_id, [])) new_videos_map = _new_videos_counts(db, [channel_id])
return _serialize(channel, category_map.get(channel_id, []), new_videos_map.get(channel_id, 0))

View file

@ -1,14 +1,16 @@
import base64 import base64
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select from sqlalchemy import func, or_, 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
@ -36,11 +38,50 @@ 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),
@ -58,6 +99,25 @@ 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(

View file

@ -9,6 +9,7 @@ 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,
@ -78,6 +79,14 @@ 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")

View file

@ -17,14 +17,34 @@ 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_interval_minutes: int = 60 # Videos sync is no longer scheduled: it is triggered by user activity
videos_per_channel_sync: int = 10 # (any authenticated request) after this much idle time since the last
# completed sync.
videos_sync_idle_hours: int = 2
# Hard history-depth limit per channel per videos sync: at most the
# newest N playlist items are considered; anything older is never
# backfilled by design (full channel history is out of scope).
videos_backfill_cap: int = 200
# Stop playlistItems pagination for a channel once this many already
# known video ids are seen in a row (the rest of the playlist is history
# we have already synced).
videos_known_stop_threshold: int = 50
new_videos_window_days: int = 2
log_level: str = "INFO" log_level: str = "INFO"

View file

@ -1,6 +1,11 @@
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()

View file

@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, Text, func from sqlalchemy import BigInteger, Boolean, DateTime, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base from app.db import Base
@ -16,6 +16,7 @@ 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)

View file

@ -14,7 +14,6 @@ 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

View file

@ -19,6 +19,15 @@ 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)
@ -66,10 +75,19 @@ 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")
MeTubeClient().delete_download(job.metube_job_id) client = MeTubeClient()
client.delete_download(video.youtube_url)
# MeTube's /delete only unlinks the file if it's configured with
# DELETE_FILE_ON_TRASHCAN=true -- otherwise it just drops the entry from
# its own "done" list and the file stays put. We don't control that
# instance's config, so verify rather than trust the "ok" response.
if job.media_url and client.check_media(job.media_url):
raise DeleteDidNotRemoveFile(
"MeTube removed the entry but the file is still on disk "
"(its DELETE_FILE_ON_TRASHCAN setting is likely off)"
)
job.status = "deleted" job.status = "deleted"
job.media_url = None job.media_url = None
@ -99,6 +117,26 @@ 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
@ -106,21 +144,34 @@ async def handle_metube_event(db: Session, event_name: str, raw_payload) -> None
logger.warning("Could not parse MeTube event payload for %s: %r", event_name, raw_payload) logger.warning("Could not parse MeTube event payload for %s: %r", event_name, raw_payload)
return return
if event_name in ("canceled", "cleared"): if event_name == "canceled":
if not isinstance(payload, str): if not isinstance(payload, str):
return return
job = ( job = _find_job_by_key(db, payload)
db.query(DownloadJob) if job is not None and job.status in ACTIVE_STATUSES:
.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

View file

@ -1,6 +1,7 @@
import logging import logging
import os import os
from datetime import datetime, timezone import threading
from datetime import datetime, timedelta, timezone
# Google frequently echoes back a scope string that's a superset/reordering # 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
@ -34,10 +35,15 @@ SCOPES = [
"https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.profile",
] ]
AUTH_URI = "https://accounts.google.com/o/oauth2/auth" # Refresh proactively: never hand out a token that could die mid-request.
TOKEN_URI = "https://oauth2.googleapis.com/token" _ACCESS_TOKEN_REFRESH_BUFFER_SECONDS = 60
USERINFO_URI = "https://www.googleapis.com/oauth2/v3/userinfo"
REVOKE_URI = "https://oauth2.googleapis.com/revoke" # Module-level access-token cache. Google access tokens live ~1h; previously
# every get_credentials() call (sync, unsubscribe, ...) paid for a full token
# exchange with Google. The lock keeps concurrent callers sharing one refresh
# instead of racing each other to the token endpoint.
_credentials_lock = threading.Lock()
_cached_credentials: Credentials | None = None
class OAuthNotConnected(Exception): class OAuthNotConnected(Exception):
@ -49,8 +55,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": AUTH_URI, "auth_uri": settings.google_auth_uri,
"token_uri": TOKEN_URI, "token_uri": settings.google_token_uri,
"redirect_uris": [settings.google_redirect_uri], "redirect_uris": [settings.google_redirect_uri],
} }
} }
@ -83,7 +89,7 @@ def exchange_code(code: str, state: str) -> Credentials:
def fetch_userinfo(access_token: str) -> dict: def fetch_userinfo(access_token: str) -> dict:
response = httpx.get( response = httpx.get(
USERINFO_URI, settings.google_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,
) )
@ -93,16 +99,14 @@ def fetch_userinfo(access_token: str) -> dict:
def revoke_token(token: str) -> None: def revoke_token(token: str) -> None:
try: try:
httpx.post(REVOKE_URI, params={"token": token}, timeout=10) httpx.post(settings.google_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:
@ -111,15 +115,22 @@ 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:
@ -131,26 +142,48 @@ 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=TOKEN_URI, token_uri=settings.google_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,
) )
credentials.refresh(GoogleAuthRequest()) _cached_credentials = credentials
expires_at = credentials.expiry if _token_missing_or_expiring(credentials):
if expires_at is not None and expires_at.tzinfo is None: try:
expires_at = expires_at.replace(tzinfo=timezone.utc) credentials.refresh(GoogleAuthRequest())
row.access_token_expires_at = expires_at except Exception:
db.commit() # Don't keep a broken cached object behind.
_cached_credentials = None
raise
return credentials return credentials

View file

@ -10,7 +10,10 @@ Verified against the real MeTube source (alexta69/metube, app/main.py + app/ytdl
JSON-*string*-encoded (json.JSONEncoder().encode(...)), not a raw object — must JSON-*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 just an id (also JSON-string-encoded). 'canceled'/'cleared' carry the download's URL key (JSON-string-encoded),
one event per item; 'cleared' fires only when a done entry is deleted
(trash via /delete?where=done or CLEAR_COMPLETED_AFTER) — clearing the
queue emits 'canceled'.
- MeTube status vocabulary: pending/preparing/scheduled/downloading/postprocessing/ - 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
@ -77,16 +80,23 @@ class MeTubeClient:
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
def delete_download(self, metube_job_id: str) -> dict: def delete_download(self, youtube_url: str) -> dict:
"""Asks MeTube to remove a finished download from its 'done' list and """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 with a metube_job_id our own app tracked control). Only ever called for a URL our own app itself enqueued --
from a download it started -- never touches files MeTube already had never touches files MeTube already had before we existed.
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": [metube_job_id], "where": "done"}, json={"ids": [youtube_url], "where": "done"},
timeout=self.timeout, timeout=self.timeout,
) )
response.raise_for_status() response.raise_for_status()

View file

@ -21,19 +21,9 @@ 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,
@ -41,10 +31,4 @@ 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

View file

@ -52,6 +52,10 @@ 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")
@ -108,10 +112,13 @@ 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, uploads_playlist_id in uploads.items(): for channel_id, data in uploads.items():
channel = existing.get(channel_id) channel = existing.get(channel_id)
if channel is not None: if channel is not None:
channel.uploads_playlist_id = uploads_playlist_id if data["uploads_playlist_id"] is not None:
channel.uploads_playlist_id = data["uploads_playlist_id"]
if data["subscriber_count"] is not None:
channel.subscriber_count = data["subscriber_count"]
db.commit() 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")
@ -166,21 +173,38 @@ 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:
video_ids = youtube_client.fetch_playlist_video_ids( # videos_backfill_cap is a hard history-depth limit per
credentials, channel.uploads_playlist_id, settings.videos_per_channel_sync # channel: only the newest N playlist items are ever
# considered; anything older is not backfilled by design.
# The early stop on consecutive known ids saves playlistItems
# pages on repeated syncs inside that window.
new_ids = youtube_client.fetch_playlist_video_ids_incremental(
credentials,
channel.uploads_playlist_id,
known_ids_by_channel.get(channel.id, set()),
settings.videos_backfill_cap,
settings.videos_known_stop_threshold,
) )
candidate_video_ids.update(video_ids) candidate_video_ids.update(new_ids)
except Exception: 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:
@ -195,11 +219,10 @@ 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 = f"https://www.youtube.com/watch?v={item['youtube_video_id']}" youtube_url = settings.youtube_watch_url_template.format(video_id=item["youtube_video_id"])
video = existing.get(item["youtube_video_id"]) db.add(
if video is None: Video(
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"],
@ -209,18 +232,8 @@ 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()
@ -230,12 +243,14 @@ def sync_videos(db: Session) -> dict:
"finished_at": _now_iso(), "finished_at": _now_iso(),
"error": None, "error": None,
"videos_added": added, "videos_added": added,
"videos_updated": updated, # Kept for API compatibility: always 0, because the videos sync
# never refreshes metadata of existing videos.
"videos_updated": 0,
"videos_skipped": skipped, "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 updated=%d skipped=%d", added, updated, skipped) logger.info("Videos sync completed: added=%d skipped=%d", added, skipped)
return result return result
except Exception as exc: except Exception as exc:

View file

@ -0,0 +1,91 @@
"""Activity-triggered videos sync.
The hourly scheduled videos sync is gone; instead, every authenticated request
(require_session) calls maybe_trigger_videos_sync(). The check must stay cheap:
if the sync is not running and enough idle time has passed since the last
completed sync, a background thread is started which calls sync.sync_videos
with its own DB session.
"""
import json
import logging
import threading
from datetime import datetime, timedelta, timezone
from app.config import settings
from app.db import SessionLocal
from app.services import sync
from app.services.state import get_setting
logger = logging.getLogger(__name__)
def is_videos_sync_due(finished_at_iso: str | None, now: datetime, idle_hours: int) -> bool:
"""Pure predicate: should an automatic videos sync start now?
True when the last completed sync's finished_at is missing/unparseable or
older than `idle_hours`."""
if not finished_at_iso:
return True
if not isinstance(finished_at_iso, str):
return True
try:
finished_at = datetime.fromisoformat(finished_at_iso.replace("Z", "+00:00"))
except (ValueError, TypeError):
return True
if finished_at.tzinfo is None:
finished_at = finished_at.replace(tzinfo=timezone.utc)
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
return now - finished_at >= timedelta(hours=idle_hours)
def _extract_finished_at(raw: str | None) -> str | None:
if not raw:
return None
try:
payload = json.loads(raw)
except (ValueError, TypeError):
return None
if not isinstance(payload, dict):
return None
return payload.get("finished_at")
def _run_videos_sync() -> None:
db = SessionLocal()
try:
sync.sync_videos(db)
except sync.SyncInProgress:
# Lost the race against a manual sync or another trigger: fine, the
# other run will record the status.
logger.info("Triggered videos sync skipped: already running")
except Exception:
logger.exception("Triggered videos sync failed")
finally:
db.close()
def maybe_trigger_videos_sync() -> None:
"""Cheap per-request check; never raises. Starts a background videos sync
when due, or does nothing when a sync is already running."""
try:
if sync.is_videos_sync_running():
return
db = SessionLocal()
try:
raw = get_setting(db, sync.VIDEOS_SYNC_STATUS_KEY)
finished_at = _extract_finished_at(raw)
if not is_videos_sync_due(finished_at, datetime.now(timezone.utc), settings.videos_sync_idle_hours):
return
finally:
db.close()
logger.info(
"Videos sync due (last finished: %s), starting in background thread", finished_at
)
threading.Thread(target=_run_videos_sync, daemon=True).start()
except Exception:
# Never break the request because of the trigger itself.
logger.exception("Failed to evaluate videos sync trigger")

View file

@ -7,9 +7,14 @@ 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
@ -68,7 +73,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"{API_BASE}/subscriptions", params=params, headers=_headers(credentials)) response = client.get(f"{settings.youtube_api_base_url}/subscriptions", params=params, headers=_headers(credentials))
_raise_for_status(response) _raise_for_status(response)
data = response.json() data = response.json()
@ -103,26 +108,108 @@ def fetch_subscriptions(credentials: Credentials) -> list[dict]:
def fetch_playlist_video_ids(credentials: Credentials, playlist_id: str, max_results: int) -> list[str]: 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, 50), "maxResults": min(max_results - len(video_ids), BATCH_SIZE),
} }
response = client.get(f"{API_BASE}/playlistItems", params=params, headers=_headers(credentials)) if page_token:
params["pageToken"] = page_token
response = client.get(f"{settings.youtube_api_base_url}/playlistItems", params=params, headers=_headers(credentials))
if response.status_code == 404: 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] = []
@ -134,7 +221,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"{API_BASE}/videos", params=params, headers=_headers(credentials)) response = client.get(f"{settings.youtube_api_base_url}/videos", params=params, headers=_headers(credentials))
_raise_for_status(response) _raise_for_status(response)
data = response.json() data = response.json()
@ -162,7 +249,7 @@ def fetch_videos_details(credentials: Credentials, video_ids: list[str]) -> list
def unsubscribe(credentials: Credentials, youtube_subscription_id: str) -> None: 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"{API_BASE}/subscriptions", f"{settings.youtube_api_base_url}/subscriptions",
params={"id": youtube_subscription_id}, params={"id": youtube_subscription_id},
headers=_headers(credentials), headers=_headers(credentials),
) )
@ -172,18 +259,28 @@ def unsubscribe(credentials: Credentials, youtube_subscription_id: str) -> None:
_raise_for_status(response) _raise_for_status(response)
def fetch_uploads_playlists(credentials: Credentials, channel_ids: list[str]) -> dict[str, str]: def _parse_subscriber_count(statistics: dict) -> int | None:
result: dict[str, str] = {} raw = statistics.get("subscriberCount")
if raw is None:
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def fetch_uploads_playlists(credentials: Credentials, channel_ids: list[str]) -> dict[str, dict]:
result: dict[str, dict] = {}
with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client: 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", "part": "snippet,contentDetails,statistics",
"id": ",".join(batch), "id": ",".join(batch),
"maxResults": BATCH_SIZE, "maxResults": BATCH_SIZE,
} }
response = client.get(f"{API_BASE}/channels", params=params, headers=_headers(credentials)) response = client.get(f"{settings.youtube_api_base_url}/channels", params=params, headers=_headers(credentials))
_raise_for_status(response) _raise_for_status(response)
data = response.json() data = response.json()
@ -192,7 +289,10 @@ 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 and uploads: if channel_id:
result[channel_id] = uploads result[channel_id] = {
"uploads_playlist_id": uploads or None,
"subscriber_count": _parse_subscriber_count(item.get("statistics", {})),
}
return result return result

View file

@ -1,10 +1,12 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="ru">
<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" />
<title>frontend</title> <meta name="theme-color" content="#0f0f0f" />
<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: 9.3 KiB

After

Width:  |  Height:  |  Size: 243 B

Before After
Before After

View file

@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

View file

@ -1,456 +1,288 @@
.app { #root { min-height: 100vh; }
max-width: 960px; .app-shell { min-height: 100vh; }
margin: 0 auto; .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); }
padding: 24px; .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; }
text-align: left; .brand-mark { display: inline-grid; place-items: center; flex: none; width: 34px; height: 27px; border-radius: 8px; background: var(--accent); color: #07121b; }
} .brand-accent { color: var(--accent); }
.header-search { margin: 0 auto; width: min(560px, 100%); height: 40px; border: 1px solid var(--border); border-radius: 999px; display: flex; align-items: center; gap: 11px; padding: 0 14px; color: var(--muted); background: var(--surface); transition: border-color .18s, background .18s; }
.error { .header-search:focus-within { border-color: var(--accent); background: var(--surface-raised); }
color: #d33; .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; }
.connect-screen { .topbar-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 214px; }
text-align: center; .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; }
padding-top: 80px; .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; }
.connect-screen button { .sync-indicator svg { color: var(--success); }
font-size: 16px; .sync-indicator.is-error svg { color: var(--error); }
padding: 10px 24px; .sync-indicator:hover { background: var(--hover); color: var(--text); }
cursor: pointer; .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; }
.channels-screen header, .mobile-menu, .drawer-header, .drawer-backdrop { display: none; }
.feed-screen header { .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); }
display: flex; .sidebar-nav { display: flex; flex-direction: column; min-height: 100%; padding: 20px 12px; }
align-items: center; .sidebar-group { display: grid; gap: 3px; padding-bottom: 21px; }
justify-content: space-between; .sidebar-group + .sidebar-group { padding-top: 21px; border-top: 1px solid var(--border); }
gap: 12px; .sidebar-heading { padding: 0 13px 11px; color: var(--subtle); font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
margin-bottom: 16px; .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; }
.channels-screen .actions, .sidebar-link.active svg { color: var(--accent); }
.feed-screen .actions { .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); }
display: flex; .category-link { gap: 15px; padding-left: 18px; }
gap: 8px; .category-link.active .category-dot { background: var(--accent); }
align-items: center; .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; }
.channels-screen .actions a, .sidebar-badge-link { cursor: pointer; text-decoration: none; transition: background .16s; }
.feed-screen .actions a { .sidebar-badge-link:hover { background: rgba(255,107,104,.26); }
align-self: center; .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; }
.channels-screen input[type='text'] { .sidebar-bottom { margin-top: auto; padding-bottom: 0; }
width: 100%; .truncate { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
padding: 8px; .main-content { margin-left: 244px; min-width: 0; }
margin-bottom: 16px; .page { max-width: 1820px; margin: 0 auto; padding: 35px clamp(24px, 3vw, 54px) 75px; }
box-sizing: border-box; .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; }
.layout { h1 { color: var(--text); font-size: clamp(25px, 2.25vw, 34px); line-height: 1.18; letter-spacing: -.035em; font-weight: 700; margin-bottom: 8px; }
display: flex; h2 { color: var(--text); font-size: 18px; line-height: 1.3; font-weight: 650; }
gap: 20px; .page-subtitle { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
align-items: flex-start; .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; }
.category-nav { .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; }
display: flex; .button-primary { border: 1px solid var(--accent); background: var(--accent); color: #081521; }
flex-direction: column; .button-primary:hover { background: #79c1ff; border-color: #79c1ff; }
gap: 4px; .button-secondary, .button-link { border: 1px solid var(--border); background: var(--surface-raised); color: var(--text); }
min-width: 160px; .button-secondary:hover, .button-link:hover { background: var(--hover); border-color: #555; }
flex-shrink: 0; .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; }
.category-nav button { .button-danger:hover { background: #c93633; }
text-align: left; .error { color: var(--error); }
padding: 6px 10px; .notice { display: flex; align-items: center; gap: 10px; padding: 13px 16px; margin: 0 0 24px; border-radius: 9px; font-size: 13px; }
border: none; .error-notice { border: 1px solid rgba(255,107,104,.25); color: #ffb1ae; background: rgba(255,107,104,.09); }
background: transparent; .onboarding-notice { flex-wrap: wrap; border: 1px solid rgba(62,166,255,.25); color: var(--text); background: var(--accent-soft); }
cursor: pointer; .onboarding-notice > span { flex: 1; min-width: 190px; }
border-radius: 6px; .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; }
.category-nav button.active { @keyframes spin { to { transform: rotate(360deg); } }
background: var(--accent-bg, #eee); .mobile-category-nav { display: none; }
font-weight: 600; .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); }
.content { .video-thumbnail img, .video-thumbnail-placeholder { width: 100%; height: 100%; display: block; object-fit: cover; }
flex: 1; .video-thumbnail-placeholder { background: linear-gradient(135deg, #232323, #303030); }
min-width: 0; .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; }
.channel-list { .video-title:hover { color: var(--accent); }
list-style: none; .video-meta { display: flex; flex-wrap: wrap; gap: 2px 7px; margin-top: 7px; color: var(--muted); font-size: 12px; line-height: 1.4; }
padding: 0; .video-channel { color: #c8c8c8; }
margin: 0; .video-meta span + span::before { content: '·'; margin-right: 7px; color: var(--subtle); }
display: grid; .video-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: auto; padding-top: 13px; }
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); .video-actions .button-link, .video-actions .button-secondary { min-height: 36px; padding: 0 11px; }
gap: 12px; .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); }
.channel-card { .download-badge.error { color: var(--error); background: rgba(255,107,104,.13); }
display: flex; .download-badge.downloading { color: var(--warning); background: rgba(245,166,35,.11); }
align-items: flex-start; .download-progress { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--warning); }
gap: 10px; .download-action-error { color: var(--error); font-size: 12px; }
border: 1px solid #ddd; .load-more { display: flex; margin: 34px auto 0; }
border-radius: 8px; .empty-state { max-width: 520px; margin: clamp(45px, 8vh, 100px) auto; padding: 30px; text-align: center; color: var(--muted); }
padding: 8px; .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); }
.channel-card img { .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; }
width: 40px; .skeleton-thumbnail { aspect-ratio: 16 / 9; border-radius: 11px; }
height: 40px; .skeleton-line { height: 13px; border-radius: 5px; width: 70%; margin: 8px 0; }
border-radius: 50%; .skeleton-line.wide { width: 93%; }
object-fit: cover; .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; }
.channel-info { .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); }
flex: 1; .input-search:focus-within { border-color: var(--accent); }
min-width: 0; .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; }
.channel-title { .category-nav::-webkit-scrollbar, .mobile-category-nav::-webkit-scrollbar { display: none; }
font-weight: 500; .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; }
color: inherit; .category-nav button:hover { background: var(--hover); color: var(--text); }
text-decoration: none; .category-nav button.active { background: var(--accent-soft); border-color: rgba(62,166,255,.55); color: var(--accent); }
display: block; .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; }
.channel-title:hover { .local-filter-nav::-webkit-scrollbar { display: none; }
text-decoration: underline; .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); }
.badge { .local-filter-nav span { font-size: 11px; color: var(--subtle); }
font-size: 12px; .local-filter-nav a.active span { color: var(--accent); }
color: #999; .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; }
.unsubscribe-button { .channel-info { flex: 1; min-width: 0; }
font-size: 12px; .channel-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; min-height: 23px; }
padding: 2px 8px; .channel-title { font-size: 15px; font-weight: 650; text-decoration: none; }
margin-left: 8px; .channel-title:hover { color: var(--accent); }
border: 1px solid #ccc; .badge { font-size: 11px; color: var(--subtle); }
border-radius: 10px; .badge-new { padding: 2px 8px; border-radius: 999px; background: rgba(255,107,104,.13); color: var(--error); font-weight: 650; white-space: nowrap; }
background: transparent; .unsubscribe-button { margin-left: auto; min-height: 28px; border: 0; border-radius: 6px; padding: 0 8px; color: var(--subtle); background: transparent; font-size: 11px; }
cursor: pointer; .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; }
.unsubscribe-error { .chip:hover { color: var(--text); }
font-size: 12px; .removable-chip:hover { border-color: var(--error); color: var(--text); }
margin-top: 4px; .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; }
.channel-categories { .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); }
display: flex; .category-popover strong { padding: 3px 7px 7px; font-size: 13px; color: var(--text); }
flex-wrap: wrap; .category-popover-empty { padding: 8px; font-size: 12px; color: var(--muted); }
gap: 4px; .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; }
margin-top: 6px; .category-checkbox:hover { background: var(--hover); }
align-items: center; .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; }
.chip { .popover-create button { display: grid; place-items: center; width: 36px; height: 36px; border: 0; border-radius: 6px; background: var(--accent); color: #081521; }
font-size: 12px; .channel-loading { display: grid; gap: 1px; max-width: 1000px; }
background: #eee; .channel-skeleton { display: flex; align-items: center; gap: 15px; height: 83px; border-bottom: 1px solid var(--border); }
border-radius: 12px; .channel-skeleton span { width: 48px; height: 48px; border-radius: 50%; }
padding: 2px 8px; .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); }
.chip-edit { .video-page { max-width: 1320px; }
font-size: 12px; .player-wrapper { position: relative; width: 100%; aspect-ratio: 16 / 9; overflow: hidden; border-radius: 11px; background: #000; }
padding: 2px 8px; .player-wrapper iframe, .player-wrapper video { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: #000; }
border-radius: 12px; .video-page-source { margin: 11px 0 22px; color: var(--muted); font-size: 12px; }
border: 1px dashed #aaa; .video-detail-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 25px; }
background: transparent; .video-page-title { font-size: clamp(21px, 2vw, 29px); line-height: 1.32; margin-bottom: 9px; }
cursor: pointer; .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); }
.category-popover-wrapper { .video-page-categories { display: flex; flex-wrap: wrap; gap: 7px; margin: 18px 0 24px; }
position: relative; .video-page-description { border-top: 1px solid var(--border); padding-top: 24px; margin-top: 24px; }
display: inline-block; .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-popover { .category-form { display: flex; gap: 10px; max-width: 630px; margin-bottom: 28px; }
position: absolute; .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; }
top: calc(100% + 4px); .category-form input:focus, .category-edit-form input:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
left: 0; .category-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--border); }
z-index: 10; .category-row { display: flex; align-items: center; gap: 14px; min-height: 70px; border-bottom: 1px solid var(--border); }
background: #fff; .reorder-buttons { display: flex; flex-direction: column; gap: 0; }
border: 1px solid #ccc; .reorder-buttons .icon-button { width: 31px; height: 28px; }
border-radius: 8px; .category-row-label { display: flex; align-items: center; gap: 13px; flex: 1; min-width: 0; }
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); .category-name { overflow: hidden; text-overflow: ellipsis; font-size: 14px; font-weight: 650; }
padding: 8px; .category-count { color: var(--muted); font-size: 12px; white-space: nowrap; }
display: flex; .category-row-actions { display: flex; gap: 3px; }
flex-direction: column; .category-row-actions .danger-hover:hover { color: var(--error); }
gap: 4px; .category-edit-form { flex: 1; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
font-size: 13px; .modal-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(0,0,0,.72); }
min-width: 160px; .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); }
max-height: 240px; .confirm-dialog h2 { margin-bottom: 10px; }
overflow-y: auto; .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; }
.category-popover-empty { .settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
color: #999; .settings-panel { min-width: 0; padding: 23px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
white-space: nowrap; .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; }
.category-checkbox { .panel-icon { display: grid; place-items: center; width: 39px; height: 39px; flex: none; border-radius: 10px; background: var(--accent-soft); color: var(--accent); }
display: flex; .settings-detail { display: flex; justify-content: space-between; gap: 15px; padding: 12px 0; border-top: 1px solid var(--border); font-size: 13px; }
align-items: center; .settings-detail span { color: var(--muted); }
gap: 6px; .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); }
.categories-screen { .settings-help { color: var(--muted); font-size: 13px; line-height: 1.5; }
max-width: 600px; .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%); }
.back-link { .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); }
display: inline-block; .connect-card .brand-mark { margin: 0 auto 22px; width: 52px; height: 40px; }
margin-bottom: 8px; .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; }
.category-form { .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; }
display: flex; .app-loading h1 { font-size: 24px; }
gap: 8px; .app-loading button { min-height: 42px; padding: 0 16px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface); color: var(--text); }
margin-bottom: 16px; @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)); } }
.category-form input { @media (max-width: 900px) {
flex: 1; .topbar { gap: 14px; padding: 0 18px; }
padding: 8px; .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); }
.category-list { .app-sidebar.open { visibility: visible; transform: translateX(0); }
list-style: none; .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); }
padding: 0; .drawer-backdrop { display: block; position: fixed; z-index: 70; inset: 0; border: 0; padding: 0; background: rgba(0,0,0,.64); }
margin: 0; body.drawer-open { overflow: hidden; }
display: flex; .main-content { margin-left: 0; }
flex-direction: column; .page { padding: 28px 28px 65px; }
gap: 8px; .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; }
.category-row { .mobile-category-nav a.active { border-color: rgba(62,166,255,.55); background: var(--accent-soft); color: var(--accent); }
display: flex; }
align-items: center; @media (max-width: 620px) {
gap: 10px; .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; }
border: 1px solid #ddd; .brand { font-size: 19px; }
border-radius: 8px; .brand-mark { width: 31px; height: 25px; }
padding: 8px 12px; .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; }
.reorder-buttons { .sync-indicator { width: 40px; padding: 0; justify-content: center; }
display: flex; .sync-indicator span { display: none; }
flex-direction: column; .header-settings { display: none; }
} .page { padding: 24px 16px 58px; }
.page-heading { align-items: flex-start; gap: 13px; margin-bottom: 23px; }
.reorder-buttons button { .page-heading h1 { font-size: 27px; }
border: none; .refresh-button { width: 42px; height: 42px; flex: none; overflow: hidden; font-size: 0; padding: 0; }
background: transparent; .refresh-button svg { flex: none; }
cursor: pointer; .mobile-category-nav { margin-left: -16px; margin-right: -16px; padding-left: 16px; padding-right: 16px; }
line-height: 1; .video-grid { display: grid; grid-template-columns: 1fr; gap: 28px; }
} .video-thumbnail { border-radius: 9px; }
.video-info { padding: 10px 2px 0; }
.category-name { .video-actions { padding-top: 11px; }
flex: 1; .video-actions .button-link, .video-actions .button-secondary, .download-badge { min-height: 44px; }
font-weight: 500; .channels-toolbar { align-items: stretch; }
} .manage-link { width: 43px; flex: none; overflow: hidden; font-size: 0; padding: 0; }
.manage-link svg { flex: none; }
.category-count { .channel-card { gap: 11px; padding: 16px 0; }
font-size: 12px; .channel-card > img, .channel-avatar-placeholder { width: 42px; height: 42px; }
color: #999; .unsubscribe-button { margin-left: 0; }
} .category-popover { left: auto; right: -5px; }
.category-checkbox, .chip, .chip-edit { min-height: 44px; }
.category-edit-form { .category-nav button, .mobile-category-nav a { min-height: 44px; }
flex: 1; .local-filter-nav a { min-height: 44px; }
display: flex; .video-detail-head { flex-direction: column; gap: 9px; }
gap: 8px; .player-wrapper { border-radius: 0; margin-left: -16px; width: calc(100% + 32px); }
} .video-page-source { margin-top: 9px; }
.settings-grid { grid-template-columns: 1fr; }
.video-grid { .settings-panel { padding: 18px; }
list-style: none; .category-form { flex-wrap: wrap; }
padding: 0; .category-form input { flex-basis: 100%; }
margin: 0; .category-row { gap: 8px; }
display: grid; .reorder-buttons .icon-button { width: 40px; height: 40px; }
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); .category-row-label { flex-wrap: wrap; gap: 5px 10px; }
gap: 16px; .category-count { flex-basis: 100%; margin-left: 21px; }
} .category-edit-form input { flex-basis: 100%; }
.sync-popover { right: -7px; max-width: calc(100vw - 30px); }
.video-card { }
display: flex; @media (min-width: 621px) and (max-width: 699px) { .video-grid { grid-template-columns: 1fr; } }
flex-direction: column; @media (pointer: coarse) {
border: 1px solid #ddd; .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; }
border-radius: 8px; .sidebar-badge { display: inline-flex; align-items: center; }
overflow: hidden; .reorder-buttons .icon-button { width: 40px; height: 40px; }
} .popover-create input, .popover-create button { height: 44px; }
.popover-create button { width: 44px; }
.video-thumbnail { }
position: relative; @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } }
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;
}

View file

@ -1,55 +1,40 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Route, Routes } from 'react-router-dom' import { Link, Navigate, Route, Routes } from 'react-router-dom'
import { getAuthStatus } from './api/client' import { 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({ const { data, isLoading, isError } = useQuery({ queryKey: ['auth-status'], queryFn: getAuthStatus })
queryKey: ['auth-status'],
queryFn: getAuthStatus,
})
if (isLoading) { if (isLoading) return <div className="app-loading"><span className="brand-mark">▶</span><p>Открываем MyTube…</p></div>
return ( if (isError) return <div className="app-loading"><h1>Не удалось открыть MyTube</h1><p>Проверь подключение к серверу и обнови страницу.</p><button onClick={() => window.location.reload()}>Повторить</button></div>
<div className="app"> if (!data?.authenticated) return <Connect />
<p>Загрузка...</p>
</div>
)
}
if (isError) { return <AppShell>
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="/categories" element={<Categories />} /> <Route path="/settings" element={<Settings email={data.email} />} />
<Route path="/settings/categories" element={<Categories />} />
<Route path="/categories" element={<Navigate to="/settings/categories" replace />} />
<Route path="*" element={<div className="empty-state"><h1>Страница не найдена</h1><Link to="/">Вернуться к ленте</Link></div>} />
</Routes> </Routes>
</div> </AppShell>
)
} }
export default App export default App

View file

@ -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: { 'Content-Type': 'application/json', ...(options.headers ?? {}) }, headers: options.body != null ? { 'Content-Type': 'application/json', ...(options.headers ?? {}) } : options.headers,
...options, ...options,
}) })
if (!res.ok) { if (!res.ok) {
@ -39,6 +39,10 @@ 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'
} }
@ -54,9 +58,11 @@ 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) {
@ -64,12 +70,13 @@ export function getChannel(channelId: number) {
} }
export function listChannels( export function listChannels(
params: { search?: string; categoryId?: number; uncategorized?: boolean } = {}, params: { search?: string; categoryId?: number; uncategorized?: boolean; subscribed?: 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}`)
} }
@ -91,6 +98,7 @@ 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() {
@ -199,13 +207,36 @@ export function recheckLocal(youtubeVideoId: string) {
} }
export function getFeed( export function getFeed(
params: { categoryId?: number; uncategorized?: boolean; channelId?: number; cursor?: string } = {}, params: {
categoryId?: number
uncategorized?: boolean
channelId?: number
downloaded?: boolean
search?: string
cursor?: string
limit?: number
newOnly?: boolean
} = {},
) { ) {
const qs = new URLSearchParams() 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')
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -0,0 +1,103 @@
import { useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom'
import { getSyncStatus, listCategories } from '../api/client'
import { formatRelativeTime } from '../utils/format'
import Icon from './Icon'
function SyncIndicator() {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const { data } = useQuery({
queryKey: ['sync-status'], queryFn: getSyncStatus,
refetchInterval: (query) => query.state.data?.videos.running || query.state.data?.subscriptions.running ? 2000 : 60_000,
})
useEffect(() => {
if (!open) return
const close = (event: MouseEvent) => { if (!ref.current?.contains(event.target as Node)) setOpen(false) }
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [open])
const running = data?.videos.running || data?.subscriptions.running
const failed = data?.videos.status === 'failed' || data?.subscriptions.status === 'failed'
const recent = [data?.videos.finished_at, data?.subscriptions.finished_at].filter((value): value is string => Boolean(value)).sort().at(-1)
const label = running ? 'Обновление...' : failed ? 'Ошибка синхронизации' : recent ? `Обновлено ${formatRelativeTime(recent)}` : 'Синхронизация'
return <div className="sync-wrap" ref={ref}>
<button type="button" className={`sync-indicator ${failed ? 'is-error' : ''}`} onClick={() => setOpen(!open)} aria-expanded={open} aria-label={label}>
<Icon name={failed ? 'alert' : running ? 'refresh' : 'check'} size={17} className={running ? 'spin' : ''} />
<span>{label}</span>
</button>
{open && <div className="sync-popover" role="status">
<strong>Синхронизация</strong>
<p>Подписки: {data?.subscriptions.running ? 'обновляются' : data?.subscriptions.finished_at ? formatRelativeTime(data.subscriptions.finished_at) : 'ещё не обновлялись'}</p>
<p>Видео: {data?.videos.running ? 'обновляются' : data?.videos.finished_at ? formatRelativeTime(data.videos.finished_at) : 'ещё не обновлялись'}</p>
{failed && <p className="error">Последнее обновление завершилось ошибкой. Подробности в настройках.</p>}
</div>}
</div>
}
function Sidebar({ close }: { close: () => void }) {
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
const categories = categoriesQuery.data ?? []
const links = [
{ to: '/', icon: 'home' as const, text: 'Все видео', end: true },
{ to: '/local', icon: 'server' as const, text: 'На сервере' },
]
return <nav className="sidebar-nav" aria-label="Основная навигация">
<div className="sidebar-group">
{links.map((item) => <NavLink key={item.to} to={item.to} end={item.end} onClick={close} className={({ isActive }) => `sidebar-link ${isActive ? 'active' : ''}`}><Icon name={item.icon} size={19} /><span>{item.text}</span></NavLink>)}
</div>
<div className="sidebar-group sidebar-categories">
<div className="sidebar-heading">Категории</div>
{categories.map((category) => <div key={category.id} className="sidebar-category-row">
<NavLink to={`/category/${category.id}`} onClick={close} className={({ isActive }) => `sidebar-link category-link ${isActive ? 'active' : ''}`}><span className="category-dot" /><span className="truncate">{category.name}</span></NavLink>
{category.new_videos_count > 0 && <Link to={`/category/${category.id}?new=1`} onClick={close} className="sidebar-badge sidebar-badge-link" aria-label={`${category.new_videos_count} новых видео в категории «${category.name}»`}>{category.new_videos_count}</Link>}
</div>)}
{categories.length === 0 && !categoriesQuery.isLoading && <p className="sidebar-hint">Пока нет категорий</p>}
<Link to="/settings/categories" onClick={close} className="sidebar-link sidebar-add"><Icon name="plus" size={18} /><span>Новая категория</span></Link>
</div>
<div className="sidebar-group sidebar-bottom">
<NavLink to="/channels" onClick={close} className={({ isActive }) => `sidebar-link ${isActive ? 'active' : ''}`}><Icon name="users" size={19} /><span>Каналы</span></NavLink>
<NavLink to="/settings" onClick={close} className={({ isActive }) => `sidebar-link ${isActive ? 'active' : ''}`}><Icon name="settings" size={19} /><span>Настройки</span></NavLink>
</div>
</nav>
}
function SearchForm({ initialValue }: { initialValue: string }) {
const [search, setSearch] = useState(initialValue)
const navigate = useNavigate()
return <form className="header-search" role="search" onSubmit={(event) => { event.preventDefault(); navigate(search.trim() ? `/search?q=${encodeURIComponent(search.trim())}` : '/') }}>
<Icon name="search" size={19} />
<input aria-label="Поиск по видео и каналам" placeholder="Поиск видео и каналов" maxLength={200} value={search} onChange={(event) => setSearch(event.target.value)} />
{search && <button type="submit" aria-label="Искать"><Icon name="chevron" size={18} /></button>}
</form>
}
export default function AppShell({ children }: { children: React.ReactNode }) {
const [drawerOpen, setDrawerOpen] = useState(false)
const drawerCloseRef = useRef<HTMLButtonElement>(null)
const location = useLocation()
const initialSearch = location.pathname === '/search' ? new URLSearchParams(location.search).get('q') ?? '' : ''
useEffect(() => {
document.body.classList.toggle('drawer-open', drawerOpen)
if (drawerOpen) drawerCloseRef.current?.focus()
const onEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') setDrawerOpen(false) }
if (drawerOpen) document.addEventListener('keydown', onEscape)
else document.removeEventListener('keydown', onEscape)
return () => { document.body.classList.remove('drawer-open'); document.removeEventListener('keydown', onEscape) }
}, [drawerOpen])
return <div className="app-shell">
<header className="topbar">
<button type="button" className="icon-button mobile-menu" aria-label="Открыть меню" onClick={() => setDrawerOpen(true)}><Icon name="menu" /></button>
<Link to="/" className="brand" aria-label="MyTube — все видео"><span className="brand-mark"><Icon name="play" size={18} /></span><span>My<span className="brand-accent">Tube</span></span></Link>
<SearchForm key={location.pathname + location.search} initialValue={initialSearch} />
<div className="topbar-actions"><SyncIndicator /><Link to="/settings" className="icon-button header-settings" aria-label="Настройки"><Icon name="settings" /></Link></div>
</header>
{drawerOpen && <button type="button" className="drawer-backdrop" aria-label="Закрыть меню" onClick={() => setDrawerOpen(false)} />}
<aside className={`app-sidebar ${drawerOpen ? 'open' : ''}`}>
<div className="drawer-header"><span>Меню</span><button ref={drawerCloseRef} type="button" className="icon-button" aria-label="Закрыть меню" onClick={() => setDrawerOpen(false)}><Icon name="close" /></button></div>
<Sidebar close={() => setDrawerOpen(false)} />
</aside>
<main className="main-content" id="main-content">{children}</main>
</div>
}

View file

@ -6,24 +6,31 @@ 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 }: Props) { function CategoryNav({ categories, value, onChange, allCount, uncategorizedCount }: Props) {
return ( return (
<nav className="category-nav"> <nav className="category-nav" aria-label="Фильтр каналов по категориям">
<button className={value === 'all' ? 'active' : ''} onClick={() => onChange('all')}> <button type="button" aria-pressed={value === 'all'} className={value === 'all' ? 'active' : ''} onClick={() => onChange('all')}>
Все Все
{allCount != null && <span className="category-nav-count">{allCount}</span>}
</button> </button>
<button className={value === 'uncategorized' ? 'active' : ''} onClick={() => onChange('uncategorized')}> <button type="button" aria-pressed={value === 'uncategorized'} className={value === 'uncategorized' ? 'active' : ''} onClick={() => onChange('uncategorized')}>
Без категории Без категории
{uncategorizedCount != null && <span className="category-nav-count">{uncategorizedCount}</span>}
</button> </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>

View file

@ -2,7 +2,8 @@ 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 { setChannelCategories, unsubscribeChannel } from '../api/client' import { createCategory, setChannelCategories, unsubscribeChannel } from '../api/client'
import Icon from './Icon'
interface Props { interface Props {
channel: ChannelDto channel: ChannelDto
@ -11,6 +12,7 @@ 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()
@ -22,7 +24,9 @@ function ChannelCard({ channel, categories }: Props) {
} }
} }
document.addEventListener('mousedown', handleClickOutside) document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside) const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('keydown', handleEscape)
return () => { document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleEscape) }
}, [open]) }, [open])
const mutation = useMutation({ const mutation = useMutation({
@ -41,6 +45,7 @@ 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'] })
}, },
}) })
@ -51,6 +56,15 @@ function ChannelCard({ channel, categories }: Props) {
}, },
}) })
const createMutation = useMutation({
mutationFn: (name: string) => createCategory(name),
onSuccess: (category) => {
setNewName('')
queryClient.invalidateQueries({ queryKey: ['categories'] })
mutation.mutate([...channel.category_ids, category.id])
},
})
function toggleCategory(categoryId: number) { 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)) {
@ -61,15 +75,14 @@ function ChannelCard({ channel, categories }: Props) {
mutation.mutate(Array.from(current)) mutation.mutate(Array.from(current))
} }
const assignedNames = categories.filter((c) => channel.category_ids.includes(c.id)).map((c) => c.name) const assignedCategories = categories.filter((c) => channel.category_ids.includes(c.id))
return ( return (
<li className="channel-card"> <li className="channel-card">
{channel.thumbnail_url && <img src={channel.thumbnail_url} alt={channel.title} />} {channel.thumbnail_url ? <img src={channel.thumbnail_url} alt="" loading="lazy" /> : <span className="channel-avatar-placeholder">{channel.title.charAt(0).toUpperCase()}</span>}
<div className="channel-info"> <div className="channel-info">
<Link to={`/channels/${channel.id}/videos`} className="channel-title"> <div className="channel-head"><Link to={`/channels/${channel.id}/videos`} className="channel-title">{channel.title}</Link>
{channel.title} {channel.new_videos_count > 0 && <span className="badge badge-new" title="Недавно вышедшие видео">{channel.new_videos_count} новых</span>}
</Link>
{!channel.subscribed && <span className="badge">отписан</span>} {!channel.subscribed && <span className="badge">отписан</span>}
{channel.subscribed && ( {channel.subscribed && (
<button <button
@ -80,26 +93,24 @@ function ChannelCard({ channel, categories }: Props) {
} }
}} }}
disabled={unsubscribeMutation.isPending} disabled={unsubscribeMutation.isPending}
> >Отписаться</button>
Отписаться
</button>
)} )}
</div>
{unsubscribeMutation.isError && ( {unsubscribeMutation.isError && (
<p className="error unsubscribe-error">{(unsubscribeMutation.error as Error).message}</p> <p className="error">{(unsubscribeMutation.error as Error).message}</p>
)} )}
<div className="channel-categories"> <div className="channel-categories">
{assignedNames.map((name) => ( {assignedCategories.map((category) => (
<span key={name} className="chip"> <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>
{name}
</span>
))} ))}
<div className="category-popover-wrapper" ref={popoverRef}> <div className="category-popover-wrapper" ref={popoverRef}>
<button className="chip-edit" onClick={() => setOpen((v) => !v)}> <button type="button" className="chip-edit" onClick={() => setOpen((v) => !v)} aria-expanded={open} aria-label={`Выбрать категории для канала ${channel.title}`}>
+ Категория <Icon name="plus" size={15} /> Категория
</button> </button>
{open && ( {open && (
<div className="category-popover"> <div className="category-popover" role="group" aria-label="Категории канала">
<strong>Категории</strong>
{categories.length === 0 && <div className="category-popover-empty">Сначала создай категорию</div>} {categories.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">
@ -111,6 +122,11 @@ 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>

View file

@ -2,6 +2,7 @@ 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']
@ -12,9 +13,10 @@ const STATUS_LABELS: Record<string, string> = {
interface Props { interface Props {
video: FeedVideoDto video: FeedVideoDto
showDelete?: boolean
} }
function DownloadButton({ video }: Props) { function DownloadButton({ video, showDelete = false }: 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 --
@ -64,44 +66,47 @@ function DownloadButton({ video }: Props) {
if (status === 'completed') { if (status === 'completed') {
return ( return (
<span className="download-inline-actions"> <span className="download-inline-actions">
<span className="download-badge completed">✓ На сервере</span> <span className="download-badge completed"><Icon name="check" size={15} /> На сервере</span>
<button {showDelete && <button className="button-quiet"
onClick={() => { onClick={() => {
if (confirm('Удалить скачанную копию с сервера?')) { if (confirm('Удалить скачанную копию с сервера?')) {
deleteMutation.mutate() deleteMutation.mutate()
} }
}} }}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
> >Удалить копию</button>}
Удалить {deleteMutation.isError && (
</button> <span className="download-badge error" title={(deleteMutation.error as Error).message}>
{(deleteMutation.error as Error).message}
</span>
)}
</span> </span>
) )
} }
if (status === 'downloading') { if (status === 'downloading') {
return <span className="download-badge">{percent ?? 0}%</span> return <span className="download-badge downloading"><span className="download-progress" style={{ width: `${Math.min(100, Math.max(0, percent ?? 0))}%` }} />Скачивается {percent ?? 0}%</span>
} }
if (status === 'queued' || status === 'postprocessing') { if (status === 'queued' || status === 'postprocessing') {
return <span className="download-badge">{STATUS_LABELS[status]}</span> return <span className="download-badge downloading">{STATUS_LABELS[status]}</span>
} }
if (status === 'failed') { if (status === 'failed') {
return ( return (
<span className="download-error"> <span className="download-error">
<span className="download-badge error">Ошибка</span> <button className="button-secondary" onClick={() => downloadMutation.mutate()} disabled={downloadMutation.isPending}>
<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 (
<button onClick={() => downloadMutation.mutate()} disabled={downloadMutation.isPending}> <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>
) )
} }

View file

@ -0,0 +1,32 @@
import type { SVGProps } from 'react'
export type IconName = 'menu' | 'close' | 'play' | 'download' | 'home' | 'folder' | 'server' | 'users' | 'settings' | 'search' | 'refresh' | 'plus' | 'check' | 'chevron' | 'arrow' | 'alert' | 'logout' | 'youtube' | 'edit' | 'trash' | 'up' | 'down'
const paths: Record<IconName, React.ReactNode> = {
menu: <><path d="M4 6h16M4 12h16M4 18h16" /></>,
close: <><path d="M6 6l12 12M18 6L6 18" /></>,
play: <><path d="m8 5 11 7-11 7V5Z" /></>,
download: <><path d="M12 3v12m-4-4 4 4 4-4M4 17v3h16v-3" /></>,
home: <><path d="m3 10 9-7 9 7v10H3V10Z" /><path d="M9 20v-7h6v7" /></>,
folder: <><path d="M3 5h7l2 2h9v12H3V5Z" /></>,
server: <><rect x="3" y="4" width="18" height="7" rx="2" /><rect x="3" y="13" width="18" height="7" rx="2" /><path d="M7 7.5h.01M7 16.5h.01" /></>,
users: <><circle cx="9" cy="8" r="3" /><path d="M3 20v-2a6 6 0 0 1 12 0v2H3ZM17 5a3 3 0 0 1 0 6m1 4a5 5 0 0 1 3 5" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l-1.86 1.86A1.7 1.7 0 0 0 16 18.4a1.7 1.7 0 0 0-1.5 1.6h-5A1.7 1.7 0 0 0 8 18.4a1.7 1.7 0 0 0-1.88.34l-1.86-1.86A1.7 1.7 0 0 0 4.6 15 1.7 1.7 0 0 0 3 13.5v-3A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.88l1.86-1.86A1.7 1.7 0 0 0 8 5.6 1.7 1.7 0 0 0 9.5 4h5A1.7 1.7 0 0 0 16 5.6a1.7 1.7 0 0 0 1.88-.34l1.86 1.86A1.7 1.7 0 0 0 19.4 9a1.7 1.7 0 0 0 1.6 1.5v3a1.7 1.7 0 0 0-1.6 1.5Z" /></>,
search: <><circle cx="10.8" cy="10.8" r="6.8" /><path d="m16 16 5 5" /></>,
refresh: <><path d="M20 7v5h-5M4 17v-5h5" /><path d="M5 9a8 8 0 0 1 14-2l1 5M4 12l1 5a8 8 0 0 0 14-2" /></>,
plus: <><path d="M12 4v16M4 12h16" /></>,
check: <><path d="m4 12 5 5L20 6" /></>,
chevron: <><path d="m9 6 6 6-6 6" /></>,
arrow: <><path d="m14 6-6 6 6 6M8 12h13" /></>,
alert: <><path d="M12 3 2 21h20L12 3ZM12 9v5m0 3h.01" /></>,
logout: <><path d="M10 4H4v16h6M14 8l4 4-4 4M9 12h9" /></>,
youtube: <><rect x="2" y="5" width="20" height="14" rx="4" /><path d="m10 9 5 3-5 3V9Z" /></>,
edit: <><path d="m4 16 11-11 4 4L8 20H4v-4ZM13 7l4 4" /></>,
trash: <><path d="M4 7h16M9 7V4h6v3M6 7l1 14h10l1-14M10 11v6m4-6v6" /></>,
up: <><path d="m6 15 6-6 6 6" /></>,
down: <><path d="m6 9 6 6 6-6" /></>,
}
export default function Icon({ name, size = 20, ...props }: SVGProps<SVGSVGElement> & { name: IconName; size?: number }) {
return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props}>{paths[name]}</svg>
}

View file

@ -25,7 +25,6 @@ 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!}
@ -35,7 +34,7 @@ function Player({ video }: Props) {
}} }}
/> />
</div> </div>
<div className="video-page-source">● Локальная копия · mediaVM</div> <div className="video-page-source">Источник: локальная копия</div>
</> </>
) )
} }

View file

@ -1,7 +1,8 @@
import { Link } from 'react-router-dom' import { Link, useLocation } 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
@ -10,28 +11,27 @@ 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} className="video-thumbnail"> <Link to={href} state={{ from }} onClick={rememberScroll} className="video-thumbnail" aria-label={`Смотреть: ${video.title}`}>
{video.thumbnail_url ? ( {video.thumbnail_url ? (
<img src={video.thumbnail_url} alt={video.title} /> <img src={video.thumbnail_url} alt="" loading="lazy" />
) : ( ) : (
<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} className="video-title" title={video.title}> <Link to={href} state={{ from }} onClick={rememberScroll} className="video-title" title={video.title}>
{video.title} {video.title}
</Link> </Link>
<div className="video-meta"> <div className="video-meta"><span className="video-channel">{video.channel.title}</span><span>{formatRelativeTime(video.published_at)}</span></div>
{video.channel.title} · {formatRelativeTime(video.published_at)}
</div>
<div className="video-actions"> <div className="video-actions">
<Link to={href} className="button-link"> <Link to={href} state={{ from }} onClick={rememberScroll} className="button-link"><Icon name="play" size={16} /> Смотреть</Link>
Смотреть
</Link>
<DownloadButton video={video} /> <DownloadButton video={video} />
</div> </div>
</div> </div>

View file

@ -1,111 +1,32 @@
:root { :root {
--text: #6b6375; font-family: Inter, Roboto, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--text-h: #08060d; color: #f1f1f1;
--bg: #fff; background: #0f0f0f;
--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;
-moz-osx-font-smoothing: grayscale; --bg: #0f0f0f;
--surface: #181818;
@media (max-width: 1024px) { --surface-raised: #202020;
font-size: 16px; --hover: #292929;
} --border: #303030;
} --text: #f1f1f1;
--muted: #aaa;
@media (prefers-color-scheme: dark) { --subtle: #777;
:root { --accent: #3ea6ff;
--text: #9ca3af; --accent-soft: rgba(62, 166, 255, .14);
--text-h: #f3f4f6; --success: #4bc467;
--bg: #16171d; --success-soft: rgba(75, 196, 103, .13);
--border: #2e303a; --warning: #f5a623;
--code-bg: #1f2028; --error: #ff6b68;
--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; }

View file

@ -1,145 +1,45 @@
import { useState } from 'react' import { useEffect, useRef, 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 { import { createCategory, deleteCategory, listCategories, reorderCategories, updateCategory } from '../api/client'
createCategory, import Icon from '../components/Icon'
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 invalidate = () => { const createMutation = useMutation({ mutationFn: createCategory, onSuccess: () => { setNewName(''); invalidate() } })
queryClient.invalidateQueries({ queryKey: ['categories'] }) const updateMutation = useMutation({ mutationFn: ({ id, name }: { id: number; name: string }) => updateCategory(id, name), onSuccess: () => { setEditingId(null); invalidate() } })
queryClient.invalidateQueries({ queryKey: ['channels'] }) const deleteMutation = useMutation({ mutationFn: deleteCategory, onSuccess: () => { setDeletingId(null); invalidate() } })
} 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((c) => c.id) const ids = categories.map((category) => category.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">
return ( <Link to="/settings" className="back-link"><Icon name="arrow" size={17} /> Настройки</Link>
<div className="categories-screen"> <div className="page-heading"><div><p className="eyebrow">Моя лента</p><h1>Категории</h1><p className="page-subtitle">Создавай темы и меняй порядок в меню.</p></div></div>
<Link to="/" className="back-link"> <form className="category-form" onSubmit={(event) => { event.preventDefault(); if (newName.trim()) createMutation.mutate(newName.trim()) }}><label className="sr-only" htmlFor="category-name">Название новой категории</label><input id="category-name" placeholder="Название новой категории" value={newName} onChange={(event) => setNewName(event.target.value)} /><button type="submit" className="button-primary" disabled={!newName.trim() || createMutation.isPending}><Icon name="plus" size={17} /> Создать</button></form>
← Каналы {createMutation.isError && <p className="error" role="alert">Не удалось создать категорию. Возможно, такое имя уже есть.</p>}
</Link> {reorderMutation.isError && <p className="error" role="alert">Не удалось изменить порядок категорий.</p>}
<h1>Категории</h1> {categoriesQuery.isLoading && <div className="channel-loading">{Array.from({ length: 3 }, (_, index) => <div key={index} className="channel-skeleton"><span /><i /></div>)}</div>}
{categoriesQuery.isError && <div className="empty-state" role="alert"><h2>Не удалось загрузить категории</h2><button className="button-primary" onClick={() => categoriesQuery.refetch()}>Повторить</button></div>}
<form {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>}
onSubmit={(e) => { {updateMutation.isError && <p className="error" role="alert">Не удалось переименовать категорию.</p>}
e.preventDefault() {!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>}
if (newName.trim()) createMutation.mutate(newName.trim()) {deleting && <div className="modal-backdrop" onClick={() => setDeletingId(null)} onKeyDown={(event) => { if (event.key === 'Escape') setDeletingId(null) }}><div className="confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="delete-category-title" aria-describedby="delete-category-description" onClick={(event) => event.stopPropagation()}><h2 id="delete-category-title">Удалить «{deleting.name}»?</h2><p id="delete-category-description">Каналы останутся в подписках. Они просто больше не будут относиться к этой категории.</p>{deleteMutation.isError && <p className="error" role="alert">Не удалось удалить категорию.</p>}<div className="dialog-actions"><button ref={cancelDeleteRef} className="button-secondary" onClick={() => setDeletingId(null)}>Отмена</button><button className="button-danger" onClick={() => deleteMutation.mutate(deleting.id)} disabled={deleteMutation.isPending}>Удалить категорию</button></div></div></div>}
}} </section>
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

View file

@ -1,56 +1,26 @@
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) }) const channelQuery = useQuery({ queryKey: ['channel', id], queryFn: () => getChannel(id), enabled: valid })
const feedQuery = useInfiniteQuery({ queryKey: ['feed', 'channel', id], queryFn: ({ pageParam }) => getFeed({ channelId: id, limit: 20, cursor: pageParam }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, enabled: valid })
const 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 ( return <section className="page">
<div className="feed-screen"> <Link to="/channels" className="back-link"><Icon name="arrow" size={17} /> Каналы</Link>
<header> <div className="page-heading"><div><p className="eyebrow">Видео канала</p><h1>{channelQuery.data?.title ?? 'Канал'}</h1><p className="page-subtitle">{subscriberLabel ?? 'Последние ролики из твоих подписок.'}</p></div></div>
<div> {(channelQuery.isError || feedQuery.isError || !valid) && <div className="empty-state" role="alert"><h2>Не удалось открыть канал</h2><Link to="/channels" className="button-primary">К списку каналов</Link></div>}
<Link to="/channels" className="back-link"> {feedQuery.isLoading && <div className="video-grid">{Array.from({ length: 6 }, (_, index) => <div className="video-card" key={index}><div className="skeleton-thumbnail" /><div className="video-info"><div className="skeleton-line wide" /><div className="skeleton-line" /></div></div>)}</div>}
← Каналы {!feedQuery.isLoading && !feedQuery.isError && items.length > 0 && <ul className="video-grid">{items.map((video) => <VideoCard key={video.youtube_video_id} video={video} />)}</ul>}
</Link> {!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><h2>Пока нет видео</h2><p>Обнови ленту, чтобы получить новые ролики канала.</p></div>}
<h1>{channelQuery.data?.title ?? 'Канал'}</h1> {feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
</div> </section>
</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

View file

@ -1,106 +1,49 @@
import { useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { getSyncStatus, listCategories, listChannels, logout, syncSubscriptions } from '../api/client' import { getSyncStatus, listCategories, listChannels, syncSubscriptions } from '../api/client'
import ChannelCard from '../components/ChannelCard' import 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: () => queryFn: () => listChannels({ subscribed: true, categoryId: typeof filter === 'number' ? filter : undefined, uncategorized: filter === 'uncategorized' }),
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 filteredChannels = useMemo(() => { const syncMutation = useMutation({ mutationFn: syncSubscriptions, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['channels'] }); queryClient.invalidateQueries({ queryKey: ['sync-status'] }) } })
const data = channelsQuery.data ?? [] const finishedAt = syncStatusQuery.data?.subscriptions.finished_at
if (!search.trim()) return data const previousFinished = useRef(finishedAt)
const needle = search.trim().toLowerCase() useEffect(() => {
return data.filter((c) => c.title.toLowerCase().includes(needle)) if (finishedAt && previousFinished.current !== finishedAt) queryClient.invalidateQueries({ queryKey: ['channels'] })
}, [channelsQuery.data, search]) previousFinished.current = finishedAt
}, [finishedAt, queryClient])
const syncStatusQuery = useQuery({ const allChannels = allChannelsQuery.data ?? []
queryKey: ['sync-status'], const uncategorizedCount = allChannels.filter((channel) => channel.category_ids.length === 0).length
queryFn: getSyncStatus,
refetchInterval: (query) => (query.state.data?.subscriptions.running ? 1500 : false),
})
const syncMutation = useMutation({
mutationFn: syncSubscriptions,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] })
queryClient.invalidateQueries({ queryKey: ['sync-status'] })
},
})
const logoutMutation = useMutation({
mutationFn: logout,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['auth-status'] })
},
})
const running = syncStatusQuery.data?.subscriptions.running
const categories = categoriesQuery.data ?? [] 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 ( return <section className="page channels-screen">
<div className="channels-screen"> <div className="page-heading"><div><p className="eyebrow">Мои подписки</p><h1>Каналы</h1><p className="page-subtitle">Распределяй подписки по темам, чтобы лента была удобнее.</p></div><button className="button-secondary refresh-button" onClick={() => syncMutation.mutate()} disabled={running || syncMutation.isPending}><Icon name="refresh" size={17} className={running ? 'spin' : ''} />{running ? 'Обновляется' : 'Обновить'}</button></div>
<header> {syncMutation.isError && <div className="notice error-notice" role="alert"><Icon name="alert" size={18} />Не удалось запустить обновление подписок.</div>}
<h1>Каналы</h1> {syncStatusQuery.data?.subscriptions.status === 'failed' && <div className="notice error-notice" role="status"><Icon name="alert" size={18} />Последнее обновление подписок завершилось ошибкой.</div>}
<div className="actions"> <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>
<Link to="/">Лента</Link> <CategoryNav categories={categories} value={filter} onChange={setFilter} allCount={allChannels.length} uncategorizedCount={uncategorizedCount} />
<Link to="/categories">Категории</Link> {channelsQuery.isLoading && <div className="channel-loading" aria-label="Загрузка каналов">{Array.from({ length: 6 }, (_, index) => <div key={index} className="channel-skeleton"><span /><i /></div>)}</div>}
<button onClick={() => syncMutation.mutate()} disabled={running}> {channelsQuery.isError && <div className="empty-state" role="alert"><h2>Не удалось загрузить каналы</h2><button className="button-primary" onClick={() => channelsQuery.refetch()}>Повторить</button></div>}
{running ? 'Синхронизация...' : 'Обновить сейчас'} {!channelsQuery.isLoading && !channelsQuery.isError && filteredChannels.length > 0 && <ul className="channel-list">{filteredChannels.map((channel) => <ChannelCard key={channel.id} channel={channel} categories={categories} />)}</ul>}
</button> {!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>}
<button onClick={() => logoutMutation.mutate()}>Выйти</button> </section>
</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

View file

@ -1,17 +1,19 @@
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-screen"><div className="connect-card">
<h1>MyYouTube</h1> <span className="brand-mark"><Icon name="play" size={26} /></span>
<p>Персональная лента YouTube-подписок с категориями.</p> <h1>Твоя лента, твои темы</h1>
<p>Подключи YouTube, чтобы видеть видео своих подписок и распределять каналы по категориям. Доступен только твой аккаунт.</p>
{authError && <p className="error">{authErrorMessage(authError)}</p>} {authError && <p className="error">{authErrorMessage(authError)}</p>}
<button onClick={startGoogleAuth}>Подключить YouTube</button> <button className="button-primary" onClick={startGoogleAuth}><Icon name="youtube" size={18} /> Подключить YouTube</button>
</div> </div></div>
) )
} }

View file

@ -1,102 +1,110 @@
import { useMutation, useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useRef } from 'react'
import { Link } from 'react-router-dom' import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getFeed, getSyncStatus, listCategories, logout, syncVideos } from '../api/client' import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom'
import CategoryNav, { type CategoryFilter } from '../components/CategoryNav' import { getFeed, getSavedCounts, getSyncStatus, listCategories, listChannels, syncVideos } from '../api/client'
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 [filter, setFilter] = useState<CategoryFilter>('all') const location = useLocation()
const { categoryId } = useParams<{ categoryId: string }>()
const [searchParams] = useSearchParams()
const search = location.pathname === '/search' ? searchParams.get('q')?.trim() ?? '' : ''
const categoryNumber = categoryId && /^\d+$/.test(categoryId) ? Number(categoryId) : undefined
const isLocal = location.pathname === '/local'
const isUncategorized = location.pathname === '/uncategorized'
const newOnly = categoryNumber !== undefined && searchParams.get('new') === '1'
const localCategoryRaw = isLocal ? searchParams.get('category') : null
const localCategory = localCategoryRaw && /^\d+$/.test(localCategoryRaw) ? Number(localCategoryRaw) : undefined
const localUncategorized = isLocal && searchParams.get('uncategorized') === 'true'
const queryClient = useQueryClient() const queryClient = useQueryClient()
const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories }) const categoriesQuery = useQuery({ queryKey: ['categories'], queryFn: listCategories })
const categories = categoriesQuery.data ?? [] const category = categoriesQuery.data?.find((item) => item.id === categoryNumber)
const savedCountsQuery = useQuery({ queryKey: ['feed', 'saved-counts'], queryFn: getSavedCounts, enabled: isLocal })
const countQuery = useQuery({
queryKey: ['channels', isUncategorized ? 'uncategorized-count' : 'all-for-counts'],
queryFn: () => listChannels({ subscribed: true, uncategorized: isUncategorized }),
enabled: isUncategorized || (!categoryNumber && !isLocal && !search),
})
const feedQuery = useInfiniteQuery({ const feedQuery = useInfiniteQuery({
queryKey: ['feed', filter], queryKey: ['feed', location.pathname, search, localCategory, localUncategorized, newOnly],
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) => getFeed({
getFeed({ categoryId: isLocal ? localCategory : categoryNumber,
categoryId: typeof filter === 'number' ? filter : undefined, uncategorized: isUncategorized || localUncategorized,
uncategorized: filter === 'uncategorized', downloaded: isLocal,
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'], queryKey: ['sync-status'], queryFn: getSyncStatus,
queryFn: getSyncStatus, refetchInterval: (query) => query.state.data?.videos.running ? 2000 : 60_000,
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: () => { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['sync-status'] }); queryClient.invalidateQueries({ queryKey: ['feed'] }) },
queryClient.invalidateQueries({ queryKey: ['feed'] })
queryClient.invalidateQueries({ queryKey: ['sync-status'] })
},
}) })
const title = search ? `Поиск: ${search}` : isLocal ? 'На сервере' : isUncategorized ? 'Без категории' : categoryId ? category?.name ?? 'Категория' : 'Все видео'
const logoutMutation = useMutation({ const subtitle = newOnly ? 'Только новые видео' : search ? 'Результаты в вашей ленте' : isLocal ? 'Видео, которые вы сохранили для просмотра' : isUncategorized ? `${countQuery.data?.length ?? '…'} каналов пока без категории` : category ? `${category.channel_count} каналов в категории` : `${countQuery.data?.length ?? '…'} подписок в вашей ленте`
mutationFn: logout,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['auth-status'] })
},
})
const running = syncStatusQuery.data?.videos.running
const items = feedQuery.data?.pages.flatMap((page) => page.items) ?? [] 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 ( return <section className="page">
<div className="feed-screen"> <div className="page-heading">
<header> <div><p className="eyebrow">Моя лента</p><h1>{title}</h1><p className="page-subtitle">{subtitle}{newOnly && <> · <Link to={`/category/${categoryNumber}`}>Показать все</Link></>}</p></div>
<h1>Лента</h1> {!isLocal && !search && <button type="button" className="button-secondary refresh-button" onClick={() => syncMutation.mutate()} disabled={syncMutation.isPending || syncStatusQuery.data?.videos.running}><Icon name="refresh" size={17} className={syncStatusQuery.data?.videos.running ? 'spin' : ''} />{syncStatusQuery.data?.videos.running ? 'Обновляется' : 'Обновить'}</button>}
<div 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>
</header> <nav className="mobile-category-nav" aria-label="Категории">
<Link to="/" className={location.pathname === '/' ? 'active' : ''}>Все</Link>
{syncMutation.isError && <p className="error">{(syncMutation.error as Error).message}</p>} <Link to="/uncategorized" className={isUncategorized ? 'active' : ''}>Без категории</Link>
{syncStatusQuery.data?.videos.status === 'failed' && ( <Link to="/local" className={isLocal ? 'active' : ''}>На сервере</Link>
<p className="error">Последняя синхронизация видео завершилась ошибкой: {syncStatusQuery.data.videos.error}</p> {categoriesQuery.data?.map((item) => <Link key={item.id} to={`/category/${item.id}`} className={categoryNumber === item.id ? 'active' : ''}>{item.name}</Link>)}
)} </nav>
{isLocal && <nav className="local-filter-nav" aria-label="Фильтр сохранённых видео">
<div className="layout"> <Link to="/local" className={!localCategory && !localUncategorized ? 'active' : ''}>Все <span>{savedCountsQuery.data?.all ?? '…'}</span></Link>
<CategoryNav categories={categories} value={filter} onChange={setFilter} /> <Link to="/local?uncategorized=true" className={localUncategorized ? 'active' : ''}>Без категории <span>{savedCountsQuery.data?.uncategorized ?? '…'}</span></Link>
{categoriesQuery.data?.map((item) => <Link key={item.id} to={`/local?category=${item.id}`} className={localCategory === item.id ? 'active' : ''}>{item.name} <span>{savedCountsQuery.data?.categories[String(item.id)] ?? 0}</span></Link>)}
<div className="content"> </nav>}
{feedQuery.isLoading && <p>Загрузка...</p>} {syncMutation.isError && <div className="notice error-notice" role="alert"><Icon name="alert" size={18} />Не удалось запустить обновление видео.</div>}
{feedQuery.isError && <p className="error">Не удалось загрузить ленту</p>} {syncStatusQuery.data?.videos.status === 'failed' && <div className="notice error-notice" role="status"><Icon name="alert" size={18} />Последнее обновление видео завершилось ошибкой. Попробуйте позже.</div>}
{location.pathname === '/' && (countQuery.data?.length ?? 0) > 0 && categoriesQuery.data?.length === 0 && <div className="notice onboarding-notice"><Icon name="folder" size={19} /><span>Каналы уже в ленте. Разложи их по темам, чтобы быстрее находить видео.</span><Link to="/channels">Настроить категории <Icon name="chevron" size={15} /></Link></div>}
<ul className="video-grid"> {hasInvalidCategory || (categoryId && categoriesQuery.data && !category) ? <div className="empty-state"><h2>Категория не найдена</h2><Link className="button-primary" to="/">Ко всем видео</Link></div> : <>
{items.map((video) => ( {feedQuery.isLoading && <ul className="video-grid">{Array.from({ length: 8 }, (_, index) => <VideoSkeleton key={index} />)}</ul>}
<VideoCard key={video.youtube_video_id} video={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>}
))} {!feedQuery.isLoading && !feedQuery.isError && items.length > 0 && <ul className="video-grid">{items.map((video) => <VideoCard key={video.youtube_video_id} video={video} />)}</ul>}
</ul> {!feedQuery.isLoading && !feedQuery.isError && items.length === 0 && <div className="empty-state"><span className="empty-icon"><Icon name={syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running ? 'refresh' : isLocal ? 'server' : search ? 'search' : 'folder'} size={28} /></span><h2>{syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running ? 'Загружаем новые видео' : search ? 'Ничего не найдено' : isLocal ? 'Пока нет сохранённых видео' : isUncategorized ? 'Все каналы распределены' : 'Здесь пока нет видео'}</h2><p>{syncStatusQuery.data?.subscriptions.running || syncStatusQuery.data?.videos.running ? 'Подписки и лента обновляются. Видео появятся здесь автоматически.' : search ? 'Попробуй другой запрос.' : isLocal ? 'Нажми «Скачать» у понравившегося ролика, и он появится здесь.' : 'Добавь каналы в категорию или обнови ленту.'}</p>{!search && !isLocal && !syncStatusQuery.data?.subscriptions.running && !syncStatusQuery.data?.videos.running && <Link className="button-primary" to="/channels">Перейти к каналам</Link>}</div>}
{feedQuery.hasNextPage && <button className="button-secondary load-more" onClick={() => feedQuery.fetchNextPage()} disabled={feedQuery.isFetchingNextPage}>{feedQuery.isFetchingNextPage ? 'Загрузка…' : 'Показать ещё'}</button>}
{!feedQuery.isLoading && items.length === 0 && ( </>}
<p>Видео пока нет. Нажми "Обновить сейчас" или проверь, есть ли каналы в этой категории.</p> </section>
)}
{feedQuery.hasNextPage && (
<button
className="load-more"
onClick={() => feedQuery.fetchNextPage()}
disabled={feedQuery.isFetchingNextPage}
>
{feedQuery.isFetchingNextPage ? 'Загрузка...' : 'Показать ещё'}
</button>
)}
</div>
</div>
</div>
)
} }
export default Feed export default Feed

View file

@ -0,0 +1,29 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link, useNavigate } from 'react-router-dom'
import { getHealth, getSyncStatus, logout, startGoogleAuth, syncSubscriptions, syncVideos } from '../api/client'
import { formatRelativeTime } from '../utils/format'
import Icon from '../components/Icon'
function Settings({ email }: { email: string | null }) {
const queryClient = useQueryClient()
const navigate = useNavigate()
const statusQuery = useQuery({ queryKey: ['sync-status'], queryFn: getSyncStatus, refetchInterval: 30_000 })
const healthQuery = useQuery({ queryKey: ['health'], queryFn: getHealth, staleTime: 0 })
const subscriptionsMutation = useMutation({ mutationFn: syncSubscriptions, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['sync-status'] }) })
const videosMutation = useMutation({ mutationFn: syncVideos, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['sync-status'] }) })
const logoutMutation = useMutation({ mutationFn: logout, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['auth-status'] }); navigate('/') } })
const subscriptions = statusQuery.data?.subscriptions
const videos = statusQuery.data?.videos
return <section className="page settings-screen">
<div className="page-heading"><div><p className="eyebrow">Аккаунт и сервисы</p><h1>Настройки</h1><p className="page-subtitle">Подключения и обновление вашей ленты.</p></div></div>
<div className="settings-grid">
<section className="settings-panel"><div className="settings-panel-heading"><span className="panel-icon"><Icon name="youtube" size={21} /></span><div><h2>YouTube</h2><p>Подписки и новые видео</p></div></div><div className="settings-detail"><span>Аккаунт</span><strong>{email ?? 'Неизвестен'}</strong></div><div className="settings-detail"><span>Статус</span><strong className="success-text"><span className="status-dot" /> Подключён</strong></div><div className="settings-detail"><span>Подписки обновлены</span><strong>{subscriptions?.finished_at ? formatRelativeTime(subscriptions.finished_at) : 'Ещё не обновлялись'}</strong></div><div className="settings-actions"><button className="button-secondary" onClick={() => subscriptionsMutation.mutate()} disabled={subscriptionsMutation.isPending || subscriptions?.running}><Icon name="refresh" size={16} />Обновить подписки</button><button className="button-quiet" onClick={startGoogleAuth}>Переподключить</button></div>{subscriptionsMutation.isError && <p className="error" role="alert">Не удалось запустить обновление.</p>}</section>
<section className="settings-panel"><div className="settings-panel-heading"><span className="panel-icon"><Icon name="server" size={21} /></span><div><h2>Локальные видео</h2><p>Сохранённые копии</p></div></div><div className="settings-detail"><span>Соединение</span><strong className={healthQuery.data?.metube === 'ok' ? 'success-text' : 'error'}>{healthQuery.isLoading ? 'Проверяем…' : healthQuery.data?.metube === 'ok' ? 'Доступно' : 'Недоступно'}</strong></div><p className="settings-help">Скачанные видео доступны во вкладке «На сервере».</p><div className="settings-actions"><button className="button-secondary" onClick={() => healthQuery.refetch()} disabled={healthQuery.isFetching}><Icon name="refresh" size={16} />Проверить соединение</button><Link className="button-quiet" to="/local">Открыть видео</Link></div></section>
<section className="settings-panel"><div className="settings-panel-heading"><span className="panel-icon"><Icon name="refresh" size={21} /></span><div><h2>Синхронизация</h2><p>Видео в вашей ленте</p></div></div><div className="settings-detail"><span>Видео обновлены</span><strong>{videos?.finished_at ? formatRelativeTime(videos.finished_at) : 'Ещё не обновлялись'}</strong></div><div className="settings-detail"><span>Состояние</span><strong className={videos?.status === 'failed' ? 'error' : ''}>{videos?.running ? 'Обновляются' : videos?.status === 'failed' ? 'Ошибка' : videos?.status === 'completed' ? 'Успешно' : 'Ещё не запускалась'}</strong></div><div className="settings-actions"><button className="button-secondary" onClick={() => videosMutation.mutate()} disabled={videosMutation.isPending || videos?.running}><Icon name="refresh" size={16} />Обновить видео</button></div>{videosMutation.isError && <p className="error" role="alert">Не удалось запустить обновление.</p>}</section>
<section className="settings-panel"><div className="settings-panel-heading"><span className="panel-icon"><Icon name="folder" size={21} /></span><div><h2>Категории</h2><p>Организация каналов</p></div></div><p className="settings-help">Переименовывай категории и меняй их порядок.</p><div className="settings-actions"><Link className="button-secondary" to="/settings/categories"><Icon name="folder" size={16} />Управлять категориями</Link></div></section>
</div>
<button className="button-quiet logout-button" onClick={() => logoutMutation.mutate()} disabled={logoutMutation.isPending}><Icon name="logout" size={17} /> Выйти из аккаунта</button>
</section>
}
export default Settings

View file

@ -1,73 +1,31 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link, useParams } from 'react-router-dom' import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
import { getVideo } from '../api/client' import { 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 ?? '/') }
const videoQuery = useQuery({ 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>
queryKey: ['video', youtubeVideoId], 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>
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">
return ( <button className="back-link" onClick={back}><Icon name="arrow" size={17} /> Назад к ленте</button>
<div className="video-page"> <Player key={video.youtube_video_id} video={video} />
<Link to="/" className="back-link"> <div className="video-detail-head"><div><h1 className="video-page-title">{video.title}</h1><p className="video-page-meta"><Link to={`/channels/${video.channel.id}/videos`}>{video.channel.title}</Link><span>·</span><span>{formatRelativeTime(video.published_at)}</span>{duration && <><span>·</span><span>{duration}</span></>}</p></div><DownloadButton video={video} showDelete /></div>
← Лента {video.categories.length > 0 && <div className="video-page-categories">{video.categories.map((category) => <Link key={category.id} to={`/category/${category.id}`} className="chip">{category.name}</Link>)}</div>}
</Link> {video.description && <section className="video-page-description"><h2>Описание</h2><p>{video.description}</p></section>}
</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

View file

@ -9,6 +9,13 @@ 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 {

View file

@ -0,0 +1,29 @@
"""drop access_token_expires_at
Revision ID: 0007_drop_access_expiry
Revises: 0006_channel_sub_id
Create Date: 2026-09-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0007_drop_access_expiry"
down_revision: Union[str, None] = "0006_channel_sub_id"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Dead column: access tokens are now cached in-process (google_oauth.py)
# with their own expiry, so this was written but never read.
op.drop_column("oauth_credentials", "access_token_expires_at")
def downgrade() -> None:
op.add_column(
"oauth_credentials",
sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=True),
)

View file

@ -0,0 +1,26 @@
"""channels.subscriber_count
Revision ID: 0008_channel_subscriber_count
Revises: 0007_drop_access_expiry
Create Date: 2026-09-17
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0008_channel_subscriber_count"
down_revision: Union[str, None] = "0007_drop_access_expiry"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Subscriber count comes from YouTube channels.list (statistics part);
# hidden counts stay NULL.
op.add_column("channels", sa.Column("subscriber_count", sa.BigInteger(), nullable=True))
def downgrade() -> None:
op.drop_column("channels", "subscriber_count")

4
opencode.json Normal file
View file

@ -0,0 +1,4 @@
{
"$schema": "https://opencode.ai/config.json",
"default_agent": "orchestrator"
}

View file

@ -1,9 +1,12 @@
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
@ -78,3 +81,146 @@ 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"]

View file

@ -1,10 +1,13 @@
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
@ -29,6 +32,19 @@ 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"
@ -118,3 +134,64 @@ 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

View file

@ -1,3 +1,5 @@
from datetime import datetime, timedelta, timezone
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@ -5,6 +7,7 @@ from app.core.auth_dependency import require_session
from app.db import get_db from app.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
@ -86,3 +89,54 @@ 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

View file

@ -235,6 +235,101 @@ 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")

View file

@ -9,7 +9,9 @@ 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
@ -66,6 +68,19 @@ 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)
@ -109,6 +124,182 @@ 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)

View file

@ -1,3 +1,6 @@
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
@ -32,7 +35,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: f"UU{cid}" for cid in ids}, lambda creds, ids: {cid: {"uploads_playlist_id": f"UU{cid}", "subscriber_count": 12345} for cid in ids},
) )
result = sync.sync_subscriptions(db_session) result = sync.sync_subscriptions(db_session)
@ -45,6 +48,7 @@ def test_sync_subscriptions_idempotent_and_unsubscribes(monkeypatch, db_session)
assert [c.youtube_channel_id for c in channels] == ["chanA", "chanB"] assert [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(
@ -81,6 +85,46 @@ 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:
@ -106,12 +150,14 @@ def _seed_channel(db_session, youtube_channel_id="chanA", uploads_playlist_id="U
return channel return channel
def test_sync_videos_adds_and_updates(monkeypatch, db_session): def test_sync_videos_adds_new_videos(monkeypatch, db_session):
channel = _seed_channel(db_session) 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, "fetch_playlist_video_ids", lambda creds, playlist_id, max_results: ["vid1"] sync.youtube_client,
"fetch_playlist_video_ids_incremental",
lambda creds, playlist_id, known_ids, max_results, stop_threshold: ["vid1"],
) )
monkeypatch.setattr( monkeypatch.setattr(
sync.youtube_client, sync.youtube_client,
@ -133,12 +179,23 @@ def test_sync_videos_adds_and_updates(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",
@ -146,29 +203,142 @@ def test_sync_videos_adds_and_updates(monkeypatch, db_session):
{ {
"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 Updated", "title": "Video One",
"description": "d2", "description": "d",
"thumbnail_url": "t2", "thumbnail_url": "t",
"published_at": "2026-09-10T12:00:00Z", "published_at": "2026-09-10T12:00:00Z",
"duration_iso8601": "PT6M", "duration_iso8601": "PT5M",
} }
], ],
) )
result2 = sync.sync_videos(db_session) first = sync.sync_videos(db_session)
assert result2["videos_added"] == 0 assert first["videos_added"] == 1
assert result2["videos_updated"] == 1
videos = db_session.query(Video).all() # Second run: vid1 is now known, so the incremental fetch reports nothing
assert len(videos) == 1 # new and no details are requested at all (existing videos are not
assert videos[0].title == "Video One Updated" # metadata-refreshed by design).
details_calls = []
monkeypatch.setattr(
sync.youtube_client,
"fetch_playlist_video_ids_incremental",
lambda creds, playlist_id, known_ids, max_results, stop_threshold: [],
)
monkeypatch.setattr(
sync.youtube_client,
"fetch_videos_details",
lambda creds, ids: details_calls.append(list(ids)) or [],
)
second = sync.sync_videos(db_session)
assert second["videos_added"] == 0
assert second["videos_updated"] == 0
assert details_calls == [[]]
assert db_session.query(Video).count() == 1
def test_sync_videos_passes_known_ids_and_backfill_settings(monkeypatch, db_session):
channel = _seed_channel(db_session)
db_session.add_all(
[
Video(
youtube_video_id="known1",
channel_id=channel.id,
title="Known 1",
published_at=datetime(2026, 9, 1, tzinfo=timezone.utc),
youtube_url="https://www.youtube.com/watch?v=known1",
),
Video(
youtube_video_id="known2",
channel_id=channel.id,
title="Known 2",
published_at=datetime(2026, 9, 1, tzinfo=timezone.utc),
youtube_url="https://www.youtube.com/watch?v=known2",
),
]
)
db_session.commit()
captured = {}
def fake_incremental(creds, playlist_id, known_ids, max_results, stop_threshold):
captured.update(known_ids=known_ids, max_results=max_results, stop_threshold=stop_threshold)
return ["vid1"]
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: object())
monkeypatch.setattr(sync.youtube_client, "fetch_playlist_video_ids_incremental", fake_incremental)
monkeypatch.setattr(
sync.youtube_client,
"fetch_videos_details",
lambda creds, ids: [
{
"youtube_video_id": "vid1",
"youtube_channel_id": channel.youtube_channel_id,
"title": "Video One",
"description": "d",
"thumbnail_url": "t",
"published_at": "2026-09-10T12:00:00Z",
"duration_iso8601": "PT5M",
}
],
)
result = sync.sync_videos(db_session)
assert result["videos_added"] == 1
assert captured["known_ids"] == {"known1", "known2"}
assert captured["max_results"] == settings.videos_backfill_cap
assert captured["stop_threshold"] == settings.videos_known_stop_threshold
def test_sync_videos_backfill_cap_ingests_all_candidates(monkeypatch, db_session):
channel = _seed_channel(db_session)
cap = settings.videos_backfill_cap
new_ids = [f"vid{i}" for i in range(cap)]
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: object())
monkeypatch.setattr(
sync.youtube_client,
"fetch_playlist_video_ids_incremental",
lambda creds, playlist_id, known_ids, max_results, stop_threshold: new_ids,
)
details_calls = []
monkeypatch.setattr(
sync.youtube_client,
"fetch_videos_details",
lambda creds, ids: details_calls.append(list(ids))
or [
{
"youtube_video_id": vid,
"youtube_channel_id": channel.youtube_channel_id,
"title": f"Title {vid}",
"description": "d",
"thumbnail_url": "t",
"published_at": "2026-09-10T12:00:00Z",
"duration_iso8601": None,
}
for vid in ids
],
)
result = sync.sync_videos(db_session)
assert result["videos_added"] == cap
assert result["videos_updated"] == 0
assert set(details_calls[0]) == set(new_ids)
assert db_session.query(Video).count() == cap
def test_sync_videos_skips_unknown_channel(monkeypatch, db_session): 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(sync.youtube_client, "fetch_playlist_video_ids", lambda creds, playlist_id, max_results: []) monkeypatch.setattr(
sync.youtube_client,
"fetch_playlist_video_ids_incremental",
lambda creds, playlist_id, known_ids, max_results, stop_threshold: [],
)
monkeypatch.setattr( monkeypatch.setattr(
sync.youtube_client, sync.youtube_client,
"fetch_videos_details", "fetch_videos_details",
@ -189,3 +359,211 @@ 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

159
tests/test_sync_trigger.py Normal file
View file

@ -0,0 +1,159 @@
import json
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from app.services import sync_trigger
def _now():
return datetime.now(timezone.utc)
def test_is_videos_sync_due_without_finished_at():
assert sync_trigger.is_videos_sync_due(None, _now(), 2) is True
def test_is_videos_sync_due_after_idle():
finished = (_now() - timedelta(hours=3)).isoformat()
assert sync_trigger.is_videos_sync_due(finished, _now(), 2) is True
def test_is_videos_sync_due_at_exact_threshold():
finished = (_now() - timedelta(hours=2)).isoformat()
assert sync_trigger.is_videos_sync_due(finished, _now(), 2) is True
def test_is_videos_sync_due_before_idle():
finished = (_now() - timedelta(hours=1)).isoformat()
assert sync_trigger.is_videos_sync_due(finished, _now(), 2) is False
def test_is_videos_sync_due_unparseable_finished_at():
assert sync_trigger.is_videos_sync_due("not-a-date", _now(), 2) is True
assert sync_trigger.is_videos_sync_due(12345, _now(), 2) is True # non-str garbage
def test_is_videos_sync_due_naive_finished_at():
naive = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=3)
assert sync_trigger.is_videos_sync_due(naive.isoformat(), _now(), 2) is True
def test_extract_finished_at():
assert sync_trigger._extract_finished_at(None) is None
assert sync_trigger._extract_finished_at("not json") is None
assert sync_trigger._extract_finished_at(json.dumps(["a"])) is None
assert sync_trigger._extract_finished_at(json.dumps({"status": "running", "finished_at": None})) is None
assert (
sync_trigger._extract_finished_at(json.dumps({"status": "completed", "finished_at": "2026-09-10T12:00:00+00:00"}))
== "2026-09-10T12:00:00+00:00"
)
class _FakeDB:
def __init__(self):
self.closed = False
def close(self):
self.closed = True
def _patch_threads(monkeypatch, started):
monkeypatch.setattr(
sync_trigger,
"threading",
SimpleNamespace(Thread=lambda target=None, daemon=None: started.append({"target": target, "daemon": daemon})),
)
def test_maybe_trigger_skips_when_sync_running(monkeypatch):
started = []
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: True)
monkeypatch.setattr(
sync_trigger,
"SessionLocal",
lambda: (_ for _ in ()).throw(AssertionError("DB must not be touched while running")),
)
_patch_threads(monkeypatch, started)
sync_trigger.maybe_trigger_videos_sync()
assert started == []
def test_maybe_trigger_skips_when_not_due(monkeypatch):
started = []
db = _FakeDB()
raw = json.dumps({"status": "completed", "finished_at": (_now() - timedelta(hours=1)).isoformat()})
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: False)
monkeypatch.setattr(sync_trigger, "SessionLocal", lambda: db)
monkeypatch.setattr(sync_trigger, "get_setting", lambda session, key: raw)
_patch_threads(monkeypatch, started)
sync_trigger.maybe_trigger_videos_sync()
assert started == []
assert db.closed is True
def test_maybe_trigger_starts_thread_when_due_never_synced(monkeypatch):
started = []
db = _FakeDB()
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: False)
monkeypatch.setattr(sync_trigger, "SessionLocal", lambda: db)
monkeypatch.setattr(sync_trigger, "get_setting", lambda session, key: None)
_patch_threads(monkeypatch, started)
sync_trigger.maybe_trigger_videos_sync()
assert len(started) == 1
assert started[0]["target"] == sync_trigger._run_videos_sync
assert started[0]["daemon"] is True
assert db.closed is True
def test_maybe_trigger_starts_thread_when_due_after_idle(monkeypatch):
started = []
db = _FakeDB()
raw = json.dumps({"status": "completed", "finished_at": (_now() - timedelta(hours=5)).isoformat()})
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: False)
monkeypatch.setattr(sync_trigger, "SessionLocal", lambda: db)
monkeypatch.setattr(sync_trigger, "get_setting", lambda session, key: raw)
_patch_threads(monkeypatch, started)
sync_trigger.maybe_trigger_videos_sync()
assert len(started) == 1
assert started[0]["daemon"] is True
def test_maybe_trigger_never_raises_when_db_unavailable(monkeypatch):
started = []
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: False)
monkeypatch.setattr(
sync_trigger,
"SessionLocal",
lambda: (_ for _ in ()).throw(RuntimeError("db down")),
)
_patch_threads(monkeypatch, started)
sync_trigger.maybe_trigger_videos_sync() # must not raise
assert started == []
def test_run_videos_sync_swallows_sync_in_progress(monkeypatch):
closed = []
monkeypatch.setattr(
sync_trigger,
"SessionLocal",
lambda: SimpleNamespace(close=lambda: closed.append(True)),
)
monkeypatch.setattr(
sync_trigger.sync,
"sync_videos",
lambda db: (_ for _ in ()).throw(sync_trigger.sync.SyncInProgress("busy")),
)
sync_trigger._run_videos_sync() # must not raise
assert closed == [True]

View file

@ -182,16 +182,49 @@ 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, metube_job_id: calls.setdefault("id", metube_job_id) or {"status": "ok"}, lambda self, youtube_url: calls.setdefault("youtube_url", youtube_url) or {"status": "ok"},
) )
monkeypatch.setattr("app.services.metube_client.MeTubeClient.check_media", lambda self, url: False)
resp = client.delete(f"/api/videos/{video.youtube_video_id}/download") 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
assert calls["id"] == "vid1.vid1" # MeTube's queue/done stores are keyed by URL, not by the id it reports
# over Socket.IO -- passing the wrong one is exactly the bug this
# regression test guards against (it silently no-ops on MeTube's side).
assert calls["youtube_url"] == video.youtube_url
db_session.refresh(job) 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"

View file

@ -115,6 +115,160 @@ 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,
@ -194,13 +348,48 @@ def test_fetch_uploads_playlists_batches(monkeypatch):
200, 200,
{ {
"items": [ "items": [
{"id": "chanA", "contentDetails": {"relatedPlaylists": {"uploads": "UUchanA"}}}, {
{"id": "chanB", "contentDetails": {"relatedPlaylists": {"uploads": "UUchanB"}}}, "id": "chanA",
"contentDetails": {"relatedPlaylists": {"uploads": "UUchanA"}},
"statistics": {"subscriberCount": "12345"},
},
{
"id": "chanB",
"contentDetails": {"relatedPlaylists": {"uploads": "UUchanB"}},
"statistics": {"hiddenSubscriberCount": True},
},
{"id": "chanC", "contentDetails": {"relatedPlaylists": {"uploads": "UUchanC"}}},
{"id": "chanD", "contentDetails": {"relatedPlaylists": {}}, "statistics": {"subscriberCount": "42"}},
] ]
}, },
) )
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([response])) monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([response]))
result = youtube_client.fetch_uploads_playlists(_credentials(), ["chanA", "chanB"]) result = youtube_client.fetch_uploads_playlists(_credentials(), ["chanA", "chanB", "chanC", "chanD"])
assert result == {"chanA": "UUchanA", "chanB": "UUchanB"} assert result == {
"chanA": {"uploads_playlist_id": "UUchanA", "subscriber_count": 12345},
"chanB": {"uploads_playlist_id": "UUchanB", "subscriber_count": None},
"chanC": {"uploads_playlist_id": "UUchanC", "subscriber_count": None},
"chanD": {"uploads_playlist_id": None, "subscriber_count": 42},
}
def test_fetch_uploads_playlists_subscriber_count_not_an_int_is_none(monkeypatch):
response = FakeResponse(
200,
{
"items": [
{
"id": "chanA",
"contentDetails": {"relatedPlaylists": {"uploads": "UUchanA"}},
"statistics": {"subscriberCount": "not-a-number"},
}
]
},
)
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([response]))
result = youtube_client.fetch_uploads_playlists(_credentials(), ["chanA"])
assert result == {"chanA": {"uploads_playlist_id": "UUchanA", "subscriber_count": None}}

File diff suppressed because it is too large Load diff